尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

VSCode 远程 Docker 容器调试与断点命中指南

VSCode 远程 Docker 容器调试与断点命中指南 代码在远程服务器上、进程跑在 Docker 容器里、你手上只有一台笔记本和一个 VSCode 窗口——这大概是很多团队在服务器资源集中化之后每个开发都要面对的组合。把 VSCode 连上远程服务器里的容器然后在容器内打断点单步调试听起来只是装两个插件的事真正动手才会发现坑分布在三个不同层面上SSH 认证、容器进程模型、还有调试器的监听地址。这篇就把这三个层面拆开讲从链路原理到可抄的配置再到我自己反复踩过的几个坑目标是让你照着走一遍就能在容器里点住断点。适合已经会用 Docker 基本命令、但没试过远程容器调试的后端、算法、嵌入式方向的开发者也适合需要给团队搭一套统一开发环境的人。1. 从本地窗口到容器进程这条链路到底分成几段很多人一上手就去找哪个插件能连容器其实更值得先搞清楚的是你的按键和你的断点分别走了哪条路。链路一乱排错就变成瞎猜。1.1 三个角色和四段通道这套环境里其实有三个独立的角色本地机器只负责渲染 VSCode 的界面、处理键盘输入、显示终端和调试面板。它不跑你的业务代码也不跑语言服务器。远程服务器宿主机跑着 sshd 和 Docker 守护进程是容器真正的物理载体。所有镜像、容器、卷都在它的文件系统里。容器你的代码、运行时、依赖以及一个被临时塞进去的 VSCode Server 都在这儿。把它们串起来的是四段不同的通道。第一段是本地到宿主机的 SSH 连接这一段决定了你能不能打开窗口第二段是宿主机到容器的进程与文件访问通道通常由 Docker 命令或容器内的 sshd 提供第三段是 VSCode 的界面进程和远端 server 之间的消息通道它复用第一条 SSH 连接第四段是调试器和被调试进程之间的那条额外 TCP 或标准输入输出通道。为什么要费力区分这四段因为故障表现和故障层级是一一对应的。第一段断了你连窗口都开不出来报错直接是连接超时或认证失败第三段断了表现是扩展装不上、终端卡住而第四段断了表现非常有欺骗性——编辑器连得好好的文件能改能存终端也正常唯独断点是灰的或者打上去不命中。绝大多数人第一次卡住都是在第四段上折腾了半天插件设置实际上问题出在调试进程只监听了容器内的回环地址。1.2 为什么不干脆把工程挪到本地跑遇到连接问题最省事的想法是我在本地装个 Docker把代码拉下来跑不就行了。这个方案在两种情况下会立刻失效一是本地机器是 Windows 且没开虚拟化支持Docker Desktop 直接起不来报的错就是那句经典的虚拟化支持未检测到二是工程依赖的数据集、模型权重、专用加速卡都在服务器那边本地根本复现不了。更现实的理由还有磁盘和算力。一个包含几万张小文件的训练数据集同步到本地可能是几个小时的事而服务器上它就在那儿放着。所以正确思路不是把环境搬到本地而是把编辑器送到环境里去。理解了这一点后面所有配置的取舍就都有了判断标准能在远端完成的就不要往本地搬。1.3 两条能用得住的接入路线把编辑器送进容器业内常用的两条路线如下对比项路线 A容器内跑 sshdSSH 直连容器路线 B先 SSH 进宿主机再 attach 到容器是否需要改镜像需要镜像里得有 openssh-server 和密钥不需要任何运行中的容器都能接需要暴露的端口至少一个 SSH 端口调试端口另算只要宿主机的 SSH 端口多容器 / compose 编排每个容器都要配置较繁琐天然支持一次配好挂多个服务容器重启后配置留在镜像里重建也还在重新 attach 一次即可典型适用场景长期存在的固定开发容器微服务、临时容器、不想动镜像我自己的习惯是如果是给团队做长期统一的开发镜像走路线 A一次配好后任何人拿到镜像就能直连如果是接手别人的工程、容器已经在跑但里面什么都没有走路线 B五分钟就能进去。两条路线的调试配置基本一致真正的差别在于第 4 节的监听地址那一节——路线 A 里调试端口必须额外映射出来路线 B 里 VSCode 会自动帮你转发容器端口。下面两节分别把这两条路走通。2. 容器得先长成能被连进去的样子容器不是虚拟机它的设计哲学是一个进程干一件事进程结束容器就结束。这个特性决定了第一次尝试远程连接时最典型的失败docker run -d ubuntu:22.04之后docker ps里什么都看不到。2.1 PID 1 必须是一个不主动退出的进程容器的生命周期跟着 1 号进程走。你run一个基础镜像时不带任何命令它执行完默认命令就退出即使你带上bash没有分配伪终端的情况下 bash 也会立刻读到 EOF 然后退出。所以想让容器活着必须让 1 号进程是一个前台常驻的服务。对路线 A 来说最自然的做法就是让 sshd 当前台进程CMD [/usr/sbin/sshd, -D]-D的意思是不要 fork 到后台就待在前台。这是很多人第一次封装 SSH 镜像时漏掉的参数漏掉之后容器启动几秒就没了日志里还看不出任何异常因为 sshd 正常 fork 完就退出了容器跟着结束。如果你暂时不想动镜像可以先用sleep infinity顶着再docker exec进去手动起 sshd。但我必须提醒这种方式在容器重启后就失效了只能用来临时验证别写进正式流程。2.2 端口和卷的规划最好一次定死端口方面宿主机上 22 通常是系统自己 sshd 占着的所以容器内的 22 一般映射到宿主机的高位端口比如 2222。调试端口是否要映射取决于你走哪条路线路线 ASSH 端口2222和调试端口比如 Python 的 5678、Node 的 9229、Java 的 5005都得显式映射出来。路线 B只映射 SSH 端口就够了容器里监听的其他端口会由 VSCode 自动探测并转发到本地。卷方面最关键的是把代码目录和工作目录挂进去。还有一个容易被忽略的卷容器内用户的~/.vscode-server目录。VSCode Server 有好几百兆每次重建容器都重新下载一次加上扩展的安装时间一次能浪费十几分钟。把它挂成一个命名卷或者映射到宿主机的一个目录里后续重建就能秒进。2.3 一份可以照着改的镜像与编排文件下面这份 Dockerfile 的重点不在命令本身而在几个容易写错的细节创建一个 UID 与宿主机开发者一致的非 root 用户、预置公钥、调整 sshd 配置允许公钥登录。FROM python:3.11-slim RUN apt-get update apt-get install -y --no-install-recommends \ openssh-server sudo git curl procps \ rm -rf /var/lib/apt/lists/* # 建一个 uid 与宿主机开发者一致的用户避免挂载目录权限错乱 ARG DEV_UID1000 RUN useradd -m -u ${DEV_UID} -s /bin/bash dev \ echo dev ALL(ALL) NOPASSWD:ALL /etc/sudoers.d/dev # 预置公钥省去每次手动 ssh-copy-id USER dev RUN mkdir -p /home/dev/.ssh chmod 700 /home/dev/.ssh COPY --chowndev:dev id_ed25519.pub /home/dev/.ssh/authorized_keys RUN chmod 600 /home/dev/.ssh/authorized_keys USER root RUN mkdir -p /run/sshd # 容器内不需要密码登录关掉能减少一种排错干扰 RUN sed -i s/#PasswordAuthentication yes/PasswordAuthentication no/ /etc/ssh/sshd_config EXPOSE 22 CMD [/usr/sbin/sshd, -D]/run/sshd这个目录必须在启动前存在否则 sshd 会因为找不到特权分离目录而拒绝启动报错信息还比较绕。这是 Debian 系镜像里的一个固定坑。对应的编排文件可以这样写把代码目录、vscode-server 目录都挂上同时固定容器名services: devbox: build: context: . args: DEV_UID: 1000 container_name: devbox ports: - 2222:22 - 5678:5678 # Python debugpy走路线 A 时才需要 volumes: - ./workspace:/workspace - vscode-server:/home/dev/.vscode-server working_dir: /workspace restart: unless-stopped volumes: vscode-server:这里用命名卷而不是绑定挂载来存 vscode-server是刻意的它的读写非常碎放在宿主机目录上容易出现大量的 inode 变更命名卷的开销小得多。3. VSCode 这一侧的连接配置与认证排错容器准备好了接下来是本地的配置。这一段的核心不是装插件而是把密钥和连接参数搞对以及在报错时能分清是哪一类认证失败。3.1 用别名把连接参数固化下来~/.ssh/config是这一整套流程里性价比最高的一个文件。写一次后面所有工具都受益Host devbox HostName 10.0.0.21 User dev Port 2222 IdentityFile ~/.ssh/id_ed25519 ServerAliveInterval 30 ServerAliveCountMax 6写完之后终端里ssh devbox一条命令就能进VSCode 的 Remote-SSH 面板里也会直接出现这个名字不用每次填 IP 和端口。ServerAliveInterval这两个参数值得加上长时间挂着调试会话时中间网络设备经常会悄悄回收空闲连接加了心跳能明显减少过一会儿就掉线的现象。密钥用 ed25519 就够ssh-keygen -t ed25519 -C devdevbox ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 2222 dev10.0.0.213.2 两种permission denied根本不是一回事这是最常见的报错但很多人混在一起看。它们背后的原因完全不同报错原文实质含义优先检查的地方Permission denied (publickey)服务端不接受你提供的公钥公钥是否在 authorized_keys 里、权限位、sshd 是否禁用了公钥认证Permission denied, please try again服务端在用密码认证且密码不对是否连到了宿主机而不是容器、sshd 是否允许密码、密码是否设置过Connection refused对端没有进程监听该端口容器是否活着、端口是否映射、sshd 是否真的起来了Connection timed out网络层不通或防火墙丢弃地址是否正确、安全组与防火墙规则第二种报错最容易被误判。它出现时你的公钥认证流程根本没有被触发服务端是在向你要密码——这通常意味着你连到的其实是宿主机的 sshd而不是容器里那个。原因往往是端口映射没生效或者HostName和Port组合起来指向了别的地方。判断方法很简单在本地执行ssh -v -p 2222 dev10.0.0.21看 verbose 输出里服务端返回的 banner 和认证方式列表如果只列了 password就说明对面没读你的公钥。公钥认证失败时按这个顺序查准没错宿主机侧权限~/.ssh必须是 700authorized_keys必须是 600并且属主是登录用户本人。多一个可写位sshd 就会直接忽略这个文件而且是静默忽略。容器内的家目录权限如果/home/dev本身是 777同样会被拒。sshd_config里的PubkeyAuthentication、AuthorizedKeysFile是否被改动过。SELinux 环境下复制进去的密钥文件可能缺安全上下文需要恢复一下。提示排查权限问题时别只看当前用户注意authorized_keys里面的那把公钥是不是你本地正在用的那一把。切过一次密钥、换过一台机器很容易出现本地有私钥、远端有公钥但不是一对的情况表现和完全没配一样。3.3 把窗口 attach 到正在运行的容器路线 B 的操作全在命令面板里完成步骤不多但有个前提很容易被忽略先用 Remote-SSH 打开宿主机确保左下角显示的是远端主机名。在远端窗口里安装 Dev Containers 扩展注意是装在远端不是本地。按CtrlShiftP执行Dev Containers: Attach to Running Container。在列表里选中目标容器VSCode 会重新开一个窗口并在容器内下载 server。前提就是这个窗口必须是 Remote-SSH 窗口。如果你在本地窗口里执行这条命令它会去连本地机器的 Docker而本地往往什么都没装于是列表是空的。这个细节看起来很小但它导致的困惑非常多——有人明明容器在跑列表却一直不显示。另外Linux 上非 root 用户要能在列表里看到容器得能访问 Docker 的套接字通常是把用户加入 docker 组重新登录一次生效。不加的话命令执行后可能直接报权限相关的错误。3.4 用 devcontainer.json 把配置沉淀下来如果这条路要长期走把所有参数写进devcontainer.json比每次手动 attach 靠谱得多。基于 compose 的写法大致是这样{ name: my-devbox, dockerComposeFile: ../docker-compose.yml, service: devbox, workspaceFolder: /workspace, remoteUser: dev, customizations: { vscode: { extensions: [ ms-python.python, ms-python.debugpy ], settings: { files.watcherExclude: { **/node_modules/**: true, **/.git/objects/**: true } } } } }remoteUser这一项建议一定要显式写。不写的话默认用 root于是所有新建文件都是 root 属主回到宿主机上你就编不了也删不掉需要sudo收拾时间长了很烦。4. 让断点真正命中调试器的监听地址是决定性因素配置到这一步编辑器已经能连进容器文件也能正常读写。接下来的问题几乎只有两个调试进程有没有监听对地址、以及源码路径对不对得上。4.1 Pythondebugpy 的两种接入姿势Python 这边我推荐直接用 debugpy它的两种模式对应两种使用习惯。第一种是 attach 模式让程序自己带调试器启动python -m debugpy --listen 0.0.0.0:5678 --wait-for-client train.py--wait-for-client会让程序在启动处暂停等你把调试器接上去再继续适合调试启动阶段的逻辑。第二种是 listen 模式由程序等待调试器连接适合脚本很短、或者由外部调度器拉起的情况VSCode 侧配置成request: listen。不管哪种模式0.0.0.0这个绑定地址是整篇文章里最值得记住的一个细节。debugpy 默认只监听容器内的 127.0.0.1也就是容器自己的回环接口。当 VSCode 通过 Remote-SSH 做端口转发时转发是在宿主机的网络命名空间里发起的它看到的是一个独立网络栈里的容器容器回环地址上的监听对它来说是不可见的。结果就是端口转发列表里空空如也断点怎么都打不上而你在容器里curl 127.0.0.1:5678又是通的。这个现象特别能误导人因为从容器内部看一切正常。对应的调试配置如下{ name: Python: 附加到容器, type: debugpy, request: attach, connect: { host: 127.0.0.1, port: 5678 }, pathMappings: [ { localRoot: ${workspaceFolder}, remoteRoot: /workspace } ], justMyCode: true }justMyCode建议先保持 true。容器里的 site-packages 往往非常庞大关掉之后第一次命中断点会慢到让你怀疑人生。4.2 Node、Java、C 的关键参数对照其他语言的思路完全一样差别只在参数拼写。我把常用的几种整理成一张表方便对照语言 / 运行时启动参数默认端口必须注意的点Python (debugpy)--listen 0.0.0.0:56785678默认绑回环必须显式改Node.jsnode --inspect0.0.0.0:9229 app.js9229--inspect-brk会在首行暂停Java-agentlib:jdwptransportdt_socket,servery,suspendn,address*:50055005老版本 JDK 不支持*需用0.0.0.0C / Cgdbserver 0.0.0.0:1234 ./app1234需要配套的 gdb 与源码路径映射Java 这里有个版本差异值得留意JDK 9 之后address支持*:5005这种写法来监听所有网卡而 JDK 8 得写成0.0.0.0:5005写错的话进程直接起不来日志里给的是参数解析错误。C 走 gdbserver 时VSCode 侧的配置要指定远程调试器地址可以理解为让本地的调试前端去连远端的调试后端{ name: C: 远程 gdbserver, type: cppdbg, request: launch, program: /workspace/build/app, miDebuggerServerAddress: 127.0.0.1:1234, miDebuggerPath: /usr/bin/gdb, cwd: /workspace }4.3 路径对不上的时候断点会变成灰色空圈断点显示成一个灰色空心圆圈悬停提示未绑定断点这是路径映射出问题的典型信号。原因分两种一种是你用路线 B通过 Remote-SSH 打开宿主机的代码目录然后再 attach 到容器。这时 VSCode 看到的本地路径是宿主机的/home/you/project而进程实际在容器里的/workspace两者必须靠pathMappings搭桥。另一种是用 Dev Containers 的 attach 方式打开的窗口此时窗口的根目录本身就是容器内的路径${workspaceFolder}已经等于/workspace再加一层 pathMappings 反而会把映射搞乱。所以 pathMappings 不是写上更保险写错了照样不命中。判断方法很简单看 VSCode 左侧资源管理器的路径提示或者直接看设置里remote.SSH.remotePlatform显示的上下文。还有一种更隐蔽的情况容器里的代码是通过COPY拷进去的一份副本而你在宿主机上编辑的是另一份。断点文件内容对不上行号偏移调试器绑定位置就会漂移。这种问题只能靠把代码目录挂载进去解决别依赖镜像里的副本。5. 环境跑起来之后怎么让它别天天出状况连接和断点都通了接下来是长期使用的问题。这部分经验基本都是从又出问题了里攒出来的。5.1 vscode-server 的持久化与磁盘占用前面提过要把~/.vscode-server挂出来这里补充一下它的实际影响VSCode Server 本体加上 Python、C、Java 这几个大型扩展一个容器占掉一到两个 GB 是很正常的。如果不挂载每次docker compose up --build之后都要重新下载一遍几十秒到几分钟不等而且内网环境下载失败率不低。另一个容易被忽视的点是版本升级。VSCode 客户端升级之后会要求远端 server 版本匹配于是重新下载一份。挂载的卷里会同时留下好几个历史版本目录用久了体积会膨胀。隔一段时间进容器du -sh ~/.vscode-server看一眼超过五六个 GB 就可以清理旧版本目录了。5.2 大仓下的文件监听与索引绑定挂载的目录下node_modules、__pycache__、构建产物这类文件数量动辄上万。VSCode 默认会对它们建立文件监听容器的 inotify 句柄很容易被打满表现是改文件之后热重载不触发、状态栏一直转圈、甚至整个窗口卡住。在容器的devcontainer.json或远端设置里加排除规则是最直接的解法{ files.watcherExclude: { **/node_modules/**: true, **/.git/objects/**: true, **/dist/**: true, **/__pycache__/**: true }, search.followSymlinks: false }如果数据显示监听句柄还是不够可以在容器启动时通过sysctl或者直接改宿主机的/proc/sys/fs/inotify/max_user_watches提高上限。这两处是分开的宿主机上的值管宿主机进程容器里的值管容器内进程改一边不一定够。5.3 权限问题几乎都来自 UID 不对齐挂载目录里出现root root的文件是这套环境里最高频的日常烦恼。根源就是容器内的用户 ID 和宿主机上的开发者 ID 不一致。三种处理方式按推荐顺序排构建镜像时用ARG传入宿主机的 UID创建同号用户也就是第 2 节那份 Dockerfile 的做法。在 compose 里用user: ${UID}:${GID}覆盖运行用户前提是镜像里得有对应的家目录和权限。实在不行进容器用chown修一次但这是治标。顺带说一句remoteUser写成 root 的容器里git提交记录会带着 root 的身份评审时看着挺别扭改回来还要重写历史。5.4 常见故障的快速定位表把上面所有经验压缩成一张表出问题的时候从上往下扫一遍基本能定位到层现象最可能的层级验证方式处理方向完全连不上宿主机网络 / 认证ssh -v devbox看 verbose 输出停在哪一步连上了但不是容器SSH 端口映射在会话里执行hostname确认映射端口与容器内 22 的对应关系窗口能开但扩展装不上VSCode Server 通道看远端日志中的下载错误检查容器到外网的访问与磁盘空间端口转发列表为空监听地址容器内ss -tlnp调试进程是否绑定了 0.0.0.0断点是灰色空心圈路径映射对比两边文件路径补全或去掉 pathMappings改文件不触发热重载文件监听看状态栏索引进度加 watcherExclude提高 inotify 上限重启后要重新下载 server卷未持久化看~/.vscode-server是否存在挂命名卷6. 从零到断点命中我实际走的八步前面是分层拆解这里给一条完整的、我自己每次搭新环境都会复用的顺序。按这个顺序做每一步都有明确的成功判据不会出现全都配完了但不知道哪一步错了的情况。第一步本地生成密钥并写入~/.ssh/config判据是ssh devbox能直接进容器并且hostname返回容器 ID 的前几位。第二步确认容器内ss -tlnp能看到 22 和调试端口在监听且调试端口绑的是0.0.0.0而不是127.0.0.1。这一步用三十秒能省掉后面半小时的困惑。第三步挂载代码目录和~/.vscode-server在容器内用watch -n1 ls -l /workspace | head -3的方式确认文件属主是你的用户而不是 root。第四步用 Remote-SSH 打开容器里的/workspace等 VSCode 在容器内把 server 装完左下角显示容器名。第五步装语言扩展和 debugpy 之类的调试适配器注意装在远端。第六步用python -m debugpy --listen 0.0.0.0:5678 --wait-for-client拉起进程看端口转发面板里有没有自动出现 5678。没有的话先回去查第二步。第七步写好launch.json把pathMappings按当前打开方式决定写或不写然后按 F5 附加。第八步随便在一个函数里打一个断点看它变成实心红点然后触发那条代码路径。红了就说明绑定成功命中了就说明整条链路通了。最后分享一个小技巧这套环境里我习惯在容器启动脚本里加一行把调试端口和 sshd 状态打到日志里的命令容器一起来就能在docker logs里看到22 在听、5678 在听不用再 exec 进去查。搭环境这件事把判断依据前置比事后排错省力得多。
返回列表