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