debugpy:DAP 远程调试、子进程与路径映射
debugpy 把 Python 进程暴露为 DAP 调试端点
debugpy 是需要单独安装和版本管理的调试适配器。它在目标 Python 进程中打开 DAP 会话,让 IDE 设置断点、检查变量和控制执行;监听地址、连接方向、子进程注入与源码路径映射都会改变安全和证据边界。
远程调试端口应默认绑定回环并通过受控隧道访问,不能把 0.0.0.0 监听当作团队协作入口。会话结束后还要证明监听端口、注入配置、临时凭据和调试进程已经退出。
debugpy 的启动、等待与连接方向
最小本地入口让 debugpy 监听回环,并在客户端连接前暂停目标启动:
python -m debugpy \
--listen 127.0.0.1:5678 \
--wait-for-client \
app.py没有 --wait-for-client 时程序会立即运行,不会自动停在第一行。目标是模块时使用 -m,参数放在模块名后:
python -m debugpy --listen 127.0.0.1:5678 -m your_package.worker --queue demo--listen 表示目标进程打开服务端口等待客户端;--connect host:port 表示目标主动连接一个正在等待的客户端。网络策略只允许目标出站、无法让工作站进入目标网络时,后者可能更合适,但客户端监听地址、访问控制和审计仍要受控。二者是连接方向,不是安全等级。
为了让 CLI 实验留下可诊断证据,可以先创建专用目录并启用日志:
mkdir -p .debug/debugpy-logs
python -m debugpy \
--listen 127.0.0.1:5678 \
--wait-for-client \
--log-to .debug/debugpy-logs \
app.py启动后,目标应保持等待,回环地址出现监听;DAP 客户端连接并设置断点后,断点应变为已验证,继续执行才进入业务代码。日志目录应出现 debugpy*.log,但具体文件名和数量随组件、版本与子进程变化,不能写死。客户端断开、目标结束后,端口应消失。
反向实验去掉 --wait-for-client 再启动短脚本。预期脚本可能在客户端完成连接前就结束,断点不会命中;这证明“监听已创建”与“业务会等待调试”是两件事。恢复等待参数后重新验证,不要靠把脚本改成长睡眠掩盖连接时序。
attach 到既有进程时,权限与副作用一起进入
debugpy 可把调试服务注入现有 Python 进程:
python -m debugpy --listen 127.0.0.1:5678 --pid <pid>先用平台工具核对 PID、用户、启动时间、命令行和解释器。Linux 的 ptrace_scope、容器 capability 与 PID namespace,macOS 的调试授权,Windows 的完整性级别都可能阻止注入。收到 permission denied 时应申请对目标进程的短时最小权限,或在同安全域的隔离副本复现;全局降低主机安全策略、给普通容器永久增加 SYS_PTRACE 或直接使用特权容器,都会把一次调试扩大成持续攻击面。
注入不是零影响操作。它会向活进程加载代码、建立线程与套接字,并允许客户端暂停线程和执行表达式。长时间持锁、心跳与请求超时可能在断点期间继续累积。共享环境应先摘流或选择隔离副本,设置暂停预算与退出条件,再执行 attach。
正向验收应同时看到四项:注入命令成功,目标回环端口监听,DAP 会话关联到正确 PID,授权断点在该进程中命中。反向验收使用无权限身份附加同一类测试进程,预期被平台拒绝;若越权身份也能成功,先修权限边界,再推广工具。
退出时先让目标继续并断开客户端,但不要假设 DAP disconnect 会卸载已经注入的 debugpy 或关闭监听。对 --pid 注入的长寿命进程,若 5678 仍由目标持有,公开 API 没有一个可以从外部保证安全卸载全部注入组件的通用“detach 后复原”动作;需要彻底移除入口时,应在既定维护窗口重启隔离副本。退出证明要同时检查端口 owner、目标健康状态和日志结尾,不能只看 UI 已关闭。
子进程调试的关键是配置时点
Python Web server、任务队列和 multiprocessing 经常由父进程创建真正执行代码的 child。只在父进程设置断点,可能观察到启动器而错过 worker。debugpy 支持子进程调试,但策略必须在子进程创建前生效。
用下面的程序验证父子边界:
import multiprocessing as mp
import os
import time
def child(value):
result = value * 2
print("child", os.getpid(), result, flush=True)
if __name__ == "__main__":
print("parent", os.getpid(), flush=True)
process = mp.Process(target=child, args=(21,))
process.start()
process.join()
time.sleep(30)通过 launch 启动时,DAP 客户端可在配置中决定 subProcess;完整 IDE 配置应由团队编辑器模板维护。对于已经 attach 的服务端,若要关闭自动子进程注入,必须在客户端连接前传入:
python -m debugpy \
--listen 127.0.0.1:5678 \
--configure-subProcess False \
--pid <parent-pid>代码嵌入模式也要在 listen() 或客户端连接前调用 debugpy.configure(subProcess=False)。客户端连上后再改,可能已经错过早期 child 的注入时机。
正向实验启用子进程调试,在 child() 内设置断点;客户端应出现与 child PID 对应的会话。断点停在赋值完成后才能同时读到 value == 21 和 result == 42;若停在 result = value * 2 这一行执行前,result 尚未绑定,不能把 NameError 误判为子进程调试失败。反向实验在连接前设置 --configure-subProcess False,父进程仍可调试,但 child 不应建立调试会话,也不应产生该 child 的 debugpy adapter 日志。普通业务输出中的 child PID 仍会出现,它不能作为“已注入”证据。若两个实验结果相同,检查进程是 fork、spawn 还是外部 exec,核对配置是否在首个客户端连接之前生效,并为每个 PID 分开读日志。
启用自动子进程调试会增加连接、日志和暂停面。高并发 worker 池可能一次创建大量会话,影响启动和客户端可用性。项目模板应默认覆盖最小进程集合,并提供明确开关;排查结束恢复默认策略,删除每个子进程产生的 debugpy 日志。
路径映射错了,断点会成为空心证据
远程主机或容器中的源码路径与工作站通常不同。DAP 客户端用 pathMappings 把 localRoot 映射到目标的 remoteRoot;映射方向、路径大小写、符号链接或容器挂载点错误时,典型现象是断点空心或未验证,客户端停在不存在的远端路径,日志出现路径翻译失败。
最低限度的诊断信息是:本地源码根、远端源码根、两端提交、目标解释器实际导入文件。目标侧可以执行:
python -c "import your_package; print(your_package.__file__)"客户端侧核对同一模块来自当前工作区,并比较提交。localRoot 必须指向工作站源码,remoteRoot 必须指向目标进程看到的源码;pathMappings 是 DAP 客户端配置,不是 debugpy --listen 的 CLI 参数。VS Code Python 调试文档提供了远程 attach 与映射示例,完整 launch.json 应留在编辑器家族和团队模板中维护。
例如工作站打开 ${workspaceFolder},容器把同一提交挂载到 /workspace/app,attach 配置中的关键部分应是:
{
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 5678
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/workspace/app"
}
]
}remoteRoot 取自目标解释器实际导入路径,不取 Dockerfile 的 WORKDIR 猜测值,也不取宿主机 bind mount 的源路径。镜像中若安装的是 wheel,module.__file__ 可能落在 site-packages;此时映射工作区源码并不能证明两者内容相同,应先比对 wheel 版本、制品摘要或源码提交,再决定是调试已安装副本还是改用同身份的可编辑构建。
正向实验把本地根映射到目标实际工作目录,在 child() 设置断点;验收证据是断点变为已验证,并停在与目标同一提交的函数。反例把 remoteRoot 临时改成不存在的目录,保留未验证断点和 debugpy 日志中的路径证据;随后恢复正确映射并重新命中。不要通过在远端复制另一份源码“修复”断点,那会让显示内容与进程实际导入文件继续分叉。
若路径正确但断点仍不命中,检查模块是否已在 attach 前执行完、是否由另一 worker 导入、是否加载了打包后的副本、代码是否被生成或覆盖。先用 module.__file__ 和 PID 证明执行对象,再调整断点;反复改行号只会掩盖身份问题。
容器场景还要把“能连端口”和“能附加进程”拆开验证。由 debugpy 启动目标时,优先让容器内服务监听 127.0.0.1:5678,再使用能够进入该网络命名空间并访问其回环地址的受控 exec、平台端口转发或 SSH 隧道;普通宿主端口发布通常转发到容器接口,未必能抵达只监听容器回环的进程,不能把发布规则存在当成链路成立。不要为了省一步把 5678 直接暴露到宿主所有接口。使用 --pid 注入时,调试进程还必须看见目标 PID,并拥有平台允许的跟踪权限;端口转发成功却报 No such process,优先检查 PID namespace,报 permission denied 则检查目标用户与 ptrace/capability,不能把两类失败都归因于防火墙。
远程调试端口只绑定回环
debugpy 只给端口时默认绑定 127.0.0.1;显式使用 0.0.0.0 会监听所有接口。官方文档警告,任何能连接 debugpy 端口的人都可能在被调试进程中执行任意代码。因此远程目标应只监听回环:
python -m debugpy \
--listen 127.0.0.1:5678 \
--wait-for-client \
your_app.py工作站建立 SSH 本地转发:
ssh -N -o ExitOnForwardFailure=yes \
-L 5678:127.0.0.1:5678 user@debug-host-N 只建立转发,ExitOnForwardFailure=yes 会让本地端口占用或转发建立失败以非零状态结束;若 SSH 没有保持运行,不能继续把本地 5678 当成本次受控入口。DAP 客户端连接工作站的 127.0.0.1:5678。debugpy 本身不是带 TLS、认证和多租户隔离的远程管理服务;SSH 身份、主机密钥、访问审批和会话审计承担安全边界。需要经过跳板机时,用组织支持的 ProxyJump 或访问代理,仍然不要把调试端口直接暴露到业务网段。
安全反例应在隔离网络验证:目标保持回环监听,在另一台无 SSH 隧道的主机设置目标的管理地址后执行:
TARGET_ADDR="${TARGET_ADDR:?set TARGET_ADDR to the target management address}"
if nc -vz -w 3 "$TARGET_ADDR" 5678; then
echo "unexpected: debugpy is directly reachable" >&2
exit 1
else
echo "expected: direct connection was refused or timed out"
fi运行前由实验者把 TARGET_ADDR 设置为目标的管理地址。这里外层 Shell 预期返回 0,但只有因为 nc 连接失败才算通过;若 nc 成功,脚本显式返回 1。nc 的超时参数在不同实现中可能不同,执行前先看本机 nc -h;Windows 可用 Test-NetConnection $env:TARGET_ADDR -Port 5678 并要求 TcpTestSucceeded 为 False。随后只有持有 SSH 权限并成功建立隧道的工作站,才应通过本地端口完成 DAP initialize 与 attach。若远端直接可达,检查进程是否误绑 0.0.0.0、容器端口是否发布到宿主、Service 或防火墙是否扩大了范围;修复后重复越权连接,直到直连失败而隧道内 DAP 握手成功。
--wait-for-client 会阻塞业务启动。它只属于人工触发的诊断入口,必须设置时限和回滚,不得长期写进普通服务命令、健康检查或自动扩容模板。会话结束后关闭 SSH 隧道,并检查 ss -ltnp、Get-NetTCPConnection 或平台等价命令,确认工作站转发已消失;目标由 CLI 启动且随诊断任务结束时,目标监听也应消失。若是 --pid 注入的长寿命进程,监听可能继续存在,应按前述退出策略重启隔离副本,不能把客户端断开冒充端口回收。
把项目接入做成默认关闭的诊断能力
本地开发可在依赖组中锁定 debugpy,并提供独立启动脚本;生产依赖若不需要远程调试,就不要为了“以后可能用”默认安装。一个较窄的代码嵌入入口应由显式环境开关触发、只绑定回环,并保持默认不等待客户端:
import os
def enable_debugpy_if_requested() -> None:
if os.getenv("ENABLE_DEBUGPY") != "1":
return
import debugpy
debugpy.configure(subProcess=False)
debugpy.listen(("127.0.0.1", 5678))
enable_debugpy_if_requested()这段代码刻意不调用 wait_for_client(),避免普通启动无限阻塞。若某次诊断必须等待,应由外层启动脚本设置明确超时和失败语义,而不是在应用线程中永久等待。ENABLE_DEBUGPY 只能由受控部署参数打开,不能接受用户请求或未认证管理接口动态设置。
项目脚本应同时记录解释器、debugpy 版本、提交、PID 和脱敏启动模式;禁止输出完整环境变量、命令行 secret 或变量快照。日志写入 .debug/debugpy-logs 这类已忽略目录,容量受限,任务结束即清理。虚拟环境、容器和远端主机使用同一依赖锁,但仍需分别验证平台 attach 权限。
本地 pdb 断点也要有退出门禁。提交前扫描 breakpoint()、pdb.set_trace()、pdb.post_mortem() 和 debugpy.listen();允许保留的诊断钩子必须默认关闭、有 owner、有测试覆盖,普通请求无法触发。测试与日志规范由各自工具链负责,这里只保证运行时调试入口不会意外进入常规路径。
退出会话后核对端口、进程和临时配置
先让 IDE 正常 disconnect,再检查目标进程是否仍需要继续运行。通过系统端口工具确认监听已消失,删除只为本次诊断加入的启动参数、环境变量和路径映射;若使用了 SSH 或 Kubernetes 端口转发,同时关闭隧道并核对审计记录。无法证明端口和注入状态已退出时,不能把会话标记为完成。
