Skip to content

Commit 27b7c0b

Browse files
committed
Allow pyspy to attach in docker without SYS_PTRACE
1 parent bdc232a commit 27b7c0b

File tree

2 files changed

+74
-11
lines changed

2 files changed

+74
-11
lines changed

distributed_pyspy/distributed_pyspy.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import asyncio
2-
from contextlib import contextmanager
3-
import tempfile
2+
import logging
43
import os
54
import signal
6-
from typing import List, Optional, Iterable
7-
import logging
5+
import tempfile
6+
from contextlib import contextmanager
7+
from typing import Iterable, List, Optional, Union
88

99
import distributed
1010
from distributed.diagnostics import SchedulerPlugin
1111

12+
from .prctl import allow_ptrace
13+
1214
logger = logging.getLogger(__name__)
1315

1416

@@ -46,6 +48,7 @@ def __init__(
4648
self.pyspy_args.extend(extra_pyspy_args)
4749
self.proc = None
4850
self._tempfile = None
51+
self._run_failed_msg = None
4952

5053
def __repr__(self) -> str:
5154
return f"<{type(self).__name__} {self.pyspy_args}>"
@@ -69,6 +72,17 @@ async def start(self, scheduler):
6972
scheduler.handlers[self._HANDLER_NAME] = self._get_py_spy_profile
7073

7174
pid = os.getpid()
75+
76+
try:
77+
# Allow subprocesses of this process to ptrace it.
78+
# Since we'll start py-spy as a subprocess, it will be below the current PID
79+
# in the process tree, and therefore allowed to trace its parent.
80+
allow_ptrace(pid)
81+
except OSError as e:
82+
self._run_failed_msg = str(e)
83+
else:
84+
self._run_failed_msg = None
85+
7286
self.proc = await asyncio.create_subprocess_exec(
7387
"py-spy",
7488
"record",
@@ -88,9 +102,10 @@ async def _stop(self) -> Optional[int]:
88102
try:
89103
self.proc.send_signal(signal.SIGINT)
90104
except ProcessLookupError:
91-
logger.warning(
92-
f"py-spy subprocess {self.proc.pid} already terminated (it probably never ran?)."
93-
)
105+
msg = f"py-spy subprocess {self.proc.pid} already terminated (it probably never ran?)."
106+
if self._run_failed_msg:
107+
msg += "\nNOTE: " + self._run_failed_msg
108+
logger.warning(msg)
94109

95110
stdout, stderr = await self.proc.communicate() # TODO timeout
96111
retcode = self.proc.returncode
@@ -147,7 +162,7 @@ def start_pyspy_on_scheduler(
147162
"""
148163
client = client or distributed.worker.get_client()
149164

150-
async def _inject(dask_scheduler: distributed.Scheduler):
165+
async def _inject_pyspy(dask_scheduler: distributed.Scheduler):
151166
plugin = PySpyScheduler(
152167
output=output,
153168
format=format,
@@ -164,11 +179,11 @@ async def _inject(dask_scheduler: distributed.Scheduler):
164179
await plugin.start(dask_scheduler)
165180
dask_scheduler.add_plugin(plugin)
166181

167-
client.run_on_scheduler(_inject)
182+
client.run_on_scheduler(_inject_pyspy)
168183

169184

170185
def get_profile_from_scheduler(
171-
path: str, client: Optional[distributed.Client] = None
186+
path: Union[str, os.PathLike], client: Optional[distributed.Client] = None
172187
) -> None:
173188
"""
174189
Stop the current `PySpyScheduler` plugin, send back its profile data, and write it to ``path``.
@@ -188,7 +203,7 @@ async def _get_profile():
188203

189204
@contextmanager
190205
def pyspy_on_scheduler(
191-
output: str,
206+
output: Union[str, os.PathLike],
192207
format: str = "speedscope",
193208
rate: int = 100,
194209
subprocesses: bool = True,

distributed_pyspy/prctl.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Enable other processes to ptrace this one via the ``prctl`` system call.
3+
4+
Only needed on Ubuntu. See
5+
6+
* https://www.kernel.org/doc/Documentation/admin-guide/LSM/Yama.rst
7+
* https://wiki.ubuntu.com/SecurityTeam/Roadmap/KernelHardening#ptrace_Protection
8+
* https://man7.org/linux/man-pages/man2/prctl.2.html
9+
10+
Note that we could have used https://github.com/seveas/python-prctl, but since that package doesn't
11+
have prebuilt wheels, it would be more of a pain to install than making the one syscall ourselves via libc.
12+
"""
13+
import ctypes
14+
import ctypes.util
15+
from typing import Optional
16+
17+
# https://github.com/torvalds/linux/blob/master/include/uapi/linux/prctl.h#L155-L156
18+
PR_SET_PTRACER = 0x59616D61
19+
PR_SET_PTRACER_ANY = -1
20+
21+
22+
def allow_ptrace(pid: Optional[int] = None):
23+
"""
24+
Allow the PID to ptrace this process.
25+
26+
If ``pid`` is None, any process is allowed.
27+
"""
28+
libc = ctypes.CDLL(ctypes.util.find_library("c"))
29+
30+
try:
31+
prctl = libc.prctl
32+
except AttributeError:
33+
raise OSError(
34+
"Your OS does not support the `prctl` syscall (only available on Linux), "
35+
"so we can't automatically allow py-spy to run without elevated permissions.\n"
36+
"You'll need to run the scheduler with sudo access (or if in Docker, just `--cap-add SYS_PTRACE`).\n"
37+
"https://github.com/benfred/py-spy#when-do-you-need-to-run-as-sudo"
38+
)
39+
40+
prctl.argtypes = [
41+
ctypes.c_int,
42+
ctypes.c_ulong,
43+
ctypes.c_ulong,
44+
ctypes.c_ulong,
45+
ctypes.c_ulong,
46+
]
47+
48+
prctl(PR_SET_PTRACER, pid if pid is not None else PR_SET_PTRACER_ANY, 0, 0, 0)

0 commit comments

Comments
 (0)