
文章目录引言一、问题现象二、排查过程代码层三条路全军覆没三、根因分析僵尸进程为什么没人管四、最终方案把 PID 1 换成 sh4.1 docker-compose 配置4.2 配置逐项说明4.3 原理sh 为什么能收割五、部署与验证六、注意事项七、总结引言在 Docker 容器里跑 PuppeteerHeadless Chrome做 PDF 导出、网页截图跑一段时间后容器里的僵尸进程Z越积越多kill杀不掉docker restart重启后清空但跑几轮又满每次触发导出任务就会新增几个 chrome 僵尸进程最终可能拖垮系统PID 耗尽、内存统计虚高排查了很久试遍了代码层的各种关闭方式都不行最后发现根因根本不在代码层而在容器的 PID 1 进程类型上。本文记录完整排查过程与最终方案。一、问题现象每次导出任务结束后容器内出现 chrome 僵尸进程并永久残留1 0 S node ← PID 1node 26 1 S node ← 服务 86 1 Z chrome ← 僵尸永远残留杀不掉 87 1 Z chrome只有重启容器才能清理但下一轮导出又会产生。容器长期运行后僵尸进程数量持续累积。二、排查过程代码层三条路全军覆没先怀疑是 Puppeteer 关闭方式不对试了三种代码层方案全部失败方案结果puppeteerbrowser.close()主进程暴毙抛弃子进程 → 孤儿 → 僵尸 ❌先杀 chrome 子进程再关父进程chrome 主进程不收割被杀的子进程监控证据子进程变 Z 后 PPID 仍为主进程❌给主进程发 SIGTERM 优雅退出旧版 headless chrome 收到 SIGTERM 立即退出不协调子进程 ❌结论代码层无解。无论怎么关闭 chrome其子进程必然孤儿化孤儿死后只有 PID 1 能收割而 PID 1node不收割。三、根因分析僵尸进程为什么没人管先复习 Linux 内核的僵尸进程回收规则进程死亡后内核保留其进程表项等待父进程waitpid()签收只有父进程能签收kill对僵尸无效它已经死了父进程死亡时子进程变成孤儿内核强制把孤儿判给 PID 1reparent 规则孤儿随后死亡 → 变僵尸 → 只能等 PID 1 签收本容器的问题链chrome 子进程 → 父进程chrome 主进程退出 → 孤儿 → 内核把孤儿判给 PID 1node → 孤儿死亡 → 变 Z → 等 PID 1 签收 → node 只回收自己 spawn 的子进程有句柄的不回收被塞过来的孤儿 → 僵尸永远残留一句话node 当 PID 1 有资格但不干活——不回收被塞过来的孤儿。四、最终方案把 PID 1 换成 shshbusybox ash具备收割能力收到子进程死亡信号SIGCHLD时执行waitpid(-1)收割所有死掉的子进程包括被内核塞过来的孤儿。4.1 docker-compose 配置services:puppeteer-app:privileged:trueimage:your-registry/puppeteer-service:latestcontainer_name:puppeteer-apphostname:puppeteer-appentrypoint:[sh,-c,trap kill $$p TERM INT; \$$\ p$$!; wait $$p,--]command:[yarn,start]restart:alwaysnetworks:-app-network4.2 配置逐项说明配置项作用entrypoint: [sh, ...]容器 PID 1 换成 sh会收割僵尸的进程trap kill $p TERM INT登记信号转发docker stop时把信号转给服务进程保证优雅停机$ p$!把启动命令command 内容后台拉起并记住服务进程 PIDwait $psh 挂起值班服务不死 sh 不走等待期间自动收割所有死掉的子进程僵尸--占位符给sh -c的$0占位保证$正确展开command: [yarn, start]镜像原始启动命令。必须显式写docker-compose 1.22 覆盖 entrypoint 时会丢弃镜像自带 CMD表现为容器 CMDnull不写则服务起不来$$双写compose 变量插值转义$$被 compose 还原为单个$。不双写会报错The p variable is not set4.3 原理sh 为什么能收割收割需要两样东西同时成立资格必须是孤儿进程的父进程PID 1 自动获得意愿程序主动执行waitpid收割PID 1 是谁内核塞孤儿给它主动收割吗结果node是强制否只收自己 spawn 的僵尸累积sh是强制是SIGCHLD 时waitpid(-1)全收僵尸归零sh 的收割是出厂自带能力POSIX shell 标准功能不需要任何额外配置。五、部署与验证# ① 修改 docker-compose.yml见 4.1# ② 重建容器配置变化会自动 recreatedocker-composeup-dpuppeteer-app# ③ 确认 PID 1 是 shdockerexecpuppeteer-appcat/proc/1/comm# ④ 确认服务正常dockerexecpuppeteer-appps-opid,ppid,comm|head-10# 预期PID 1 sh下面有 node/yarn 服务进程# ⑤ 监控验证120 秒自动退出避免残留dockerexecpuppeteer-appsh-ci0; while [ $i -lt 120 ]; do ps -o pid,ppid,stat,comm | grep -E chrome|node | head -6; echo --- $(date %T); sleep 1; i$((i1)); done验证标准触发一次导出任务chrome 子进程孤儿化变 Z 后1~2 秒内被 sh 收割消失进程归零。六、注意事项不要用无限循环监控while true终端 CtrlC 中断后容器内的 sh 会残留不退出越积越多。用限时版120 秒自动退出残留的监控 sh 可用docker exec puppeteer-app sh -c kill pid清理不影响服务日常docker-compose stop可正常优雅停机trap 负责转发信号本方案不修改 Dockerfile、不重建镜像、不杀任何正常进程只改变 PID 1 的进程类型若将来升级 docker-compose 到 v2可直接改用官方init: true效果相同、配置更简单init字段需要 docker-compose 1.24七、总结Puppeteer 在 Docker 容器中的僵尸进程问题本质不是 Puppeteer 的关闭方式问题而是 PID 1 的职责问题Chrome 子进程孤儿化不可避免旧版 headless chrome 任何关闭路径都不协调子进程孤儿死后变僵尸只能由 PID 1 收割node 当 PID 1 不收割塞过来的孤儿 → 僵尸累积换成 sh 当 PID 1waitpid(-1)自动收割 → 僵尸归零一行entrypoint配置替换 PID 1 进程类型问题彻底解决。这套思路同样适用于任何容器内频繁 fork 子进程的场景Chromium、Java、C 系程序等。