最近在搞GPU服务器的模型调试项目,几个同事共用一台装Ubuntu 22.04的机器,每人通过VS Code Remote-SSH连上去,各自在Rootless Docker里跑容器。白天大家各跑各的,晚上一走,容器还在那里占着显存和CPU,第二天来了发现资源被前一天的任务占着,又得手动 docker stop。领导说:“搞个自动清理呗,人走了就停掉。”
我当时觉得这还不简单?写个脚本扔进 ~/.bash_logout,等用户注销时 sleep 600 秒再 docker stop 不就完了?结果一上手就被现实教育了。
第一个坑:物理断网根本不走注销流程
我在终端手动敲 exit,10分钟后容器确实停了,完美。但第二天一早合上笔记本走人,晚上回来检查——容器还活着。当时我就纳闷:说好的优雅退出呢?
查了一圈发现,.bash_logout 只有在用户执行 exit 或正常关闭shell时才会触发。如果你是直接合上电脑,本质上属于物理断网,SSH会话是被强杀的。服务器端的 /etc/ssh/sshd_config 里面 ClientAliveInterval 默认是0(没开),所以它完全依赖底层的TCP KeepAlive去判定连接状态。这个机制本身有一套参数组合——比如 tcp_keepalive_time、keepalive_probes 等——综合下来,实际观测到的断连判定窗口大约在15到30分钟。等系统反应过来,你的sshd进程已经被强制销毁了,里面的 .bash_logout 根本来不及执行。
第二个坑:后台守护进程连不上Docker
既然退出时触发不靠谱,那换成“持续监控”总行了吧?我写了个简单的Bash循环,每10秒检查一下当前用户的sshd进程数量,如果为0就累计时间,超过10分钟就执行 docker stop。然后用 nohup 丢到后台。
测试时发现,脚本确实能检测到断连,也尝试执行 docker ps -q,但返回的是空结果,不会执行stop。手动在终端里跑 docker ps 一切正常,后台脚本就不行。
问题出在环境变量上。Rootless Docker模式下,每个用户通过SSH登录时,系统会分配 XDG_RUNTIME_DIR,指向 /run/user/。而通过 nohup 启动的后台进程,脱离了用户的Login Shell上下文,拿不到这些变量,Docker客户端就去默认的 /var/run/docker.sock 找,要么没权限要么找不到文件。
定位这个问题有个很直接的办法:在正常终端里 echo $XDG_RUNTIME_DIR 看输出,再用 env | grep -E "DOCKER|XDG" 确认关键变量。然后你就知道该往脚本里硬编码什么了。
最终方案:硬编码环境变量 + bashrc自启动
知道了原因,解决起来就简单了:在守护进程脚本开头硬编码那两个环境变量。
bash
export XDG_RUNTIME_DIR=/run/user/1002
export DOCKER_HOST=unix:///run/user/1002/docker.sock
然后把脚本放在用户根目录,叫 ~/.gpu_cleanup_daemon.sh。逻辑就是:每10秒检查一次sshd进程数,>0重置计时器,=0累计时间,超过10分钟就执行 docker stop 并把自己杀掉。
为了让这个脚本自动启动,我在 ~/.bashrc 里加了启动逻辑——只保证一个实例,用 pgrep -u $USER -f gpu_cleanup_daemon.sh 判断,不存在则用 nohup 和 disown 启动。
同时还搞了几个别名方便切换:
- gpu-keep-on:创建一个 ~/.skip_gpu_cleanup 文件,进入“常驻模式”(比如跑长时间训练时)。
- gpu-keep-off:删除文件,恢复自动清理。
- gpu-status:查看当前模式。
这样每个用户登录时就自动启动守护进程,完成清理后进程自动销毁,不产生僵尸。实际用下来,容器停止的时间大约是:TCP超时(15~30分钟)+ 脚本缓冲(10分钟),虽然看起来慢了几十分钟,但正好相当于双缓冲冗余——你临时走开还有重连机会,不至于断个网回来发现训练被杀了。
几点感触
说实话,最让我觉得有意思的是Rootless Docker的环境变量丢失这个问题。平时在终端里跑命令没感觉,但切换到后台进程就忘了上下文,这种“隐形依赖”特别容易踩坑。好在守护进程脚本里硬编码一下就解决了,而且因为Rootless Docker只影响当前用户空间,docker stop 不会误杀别人,安全性放心。
另外,资源占用真的可以忽略不计——一个Bash脚本每10秒 sleep 一次,几乎不占CPU,内存也才几百KB。对比它省下来的GPU显存,非常值。
延伸思考
类似的“让机器替你干活”的思路在很多地方都用得上。比如服务器上的内存压力管理,可以配置systemd-oomd通过PSI指标在内存紧张时自动杀掉指定服务,避免整机卡死;或者前端项目的CI/CD部署,用Cloudflare Pages配合GitHub Actions,推代码自动构建发布,连服务器都不需要管。
仔细想想,systemd-oomd要在service文件里声明 ManagedOOMMemoryPressure=kill、Cloudflare Pages要在环境变量里注入 NODE_VERSION——本质上都是在解决“进程上下文用什么信息来做决策”的问题,跟我这次硬编码那两个Docker环境变量是同一回事。资源自动化管理的不同维度,最终都绕不开配置注入和上下文依赖这两个核心点。
如果你也在做类似的自清理方案,建议从触发时机(物理断网 vs 正常退出)和进程上下文(后台 vs 登录shell)两个角度先想清楚,否则很容易像我一样绕一圈。有什么好办法欢迎来聊,互相踩坑互相救。