Docker 容器中 Puppeteer 僵尸进程排查与修复

文章目录

引言

在 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 内核的僵尸进程回收规则:

  1. 进程死亡后,内核保留其进程表项,等待父进程 waitpid() 签收
  2. 只有父进程能签收kill 对僵尸无效(它已经死了)
  3. 父进程死亡时,子进程变成孤儿,内核强制把孤儿判给 PID 1(reparent 规则)
  4. 孤儿随后死亡 → 变僵尸 → 只能等 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 为什么能收割

收割需要两样东西同时成立:

  1. 资格:必须是孤儿进程的父进程(PID 1 自动获得)
  2. 意愿 :程序主动执行 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 的职责问题

  1. Chrome 子进程孤儿化不可避免(旧版 headless chrome 任何关闭路径都不协调子进程)
  2. 孤儿死后变僵尸,只能由 PID 1 收割
  3. node 当 PID 1 不收割塞过来的孤儿 → 僵尸累积
  4. 换成 sh 当 PID 1,waitpid(-1) 自动收割 → 僵尸归零

一行 entrypoint 配置,替换 PID 1 进程类型,问题彻底解决。这套思路同样适用于任何"容器内频繁 fork 子进程"的场景(Chromium、Java、C 系程序等)。

相关推荐
骊城英雄1 小时前
彩笔运维勇闯机器学习--逻辑回归
运维·机器学习·逻辑回归
孪生质数-1 小时前
AI 应用实践篇——让大模型真正开始工作
linux·运维·服务器·人工智能·深度学习·语言模型·llama
Splashtop高性能远程控制软件2 小时前
连锁零售多门店远程运维指南:从故障响应到批量运维的四项能力
运维·零售·远程控制·splashtop
瞬间&永恒~2 小时前
【MySQL】 InnoDB 锁等待排查与并发压测实验
运维·数据库·mysql
潘正翔2 小时前
k8s进阶_Harbor镜像仓库
git·云原生·容器·kubernetes·gitee·github
江湖有缘2 小时前
Docker实战 | 使用Docker部署Donetick任务与家务管理应用
运维·docker·容器
流星白龙2 小时前
【Docker】4.NameSpace空间隔离实战
java·运维·docker
有续技术3 小时前
哈斯 (Haas) 机床 IP、端口号配置内容总结
运维·服务器
小张同学a.3 小时前
LAMP架构2
linux·运维·网络·架构·负载均衡