容器里的 PID 1 与僵尸进程——dumb-init / tini / k8s pause 全解
容器里的 PID 1 与僵尸进程——dumb-init / tini / k8s pause 全解
本文把"容器里 PID 1 为什么是个问题、僵尸进程怎么来的、dumb-init/tini 怎么兜、k8s pause 和 shareProcessNamespace 怎么协作"一次讲清。读完你能回答面试官"为什么容器里 Python/Java 当 PID 1 会漏僵尸"“dumb-init 和 tini 干了什么”"k8s pause 容器是不是 PID 1"“shareProcessNamespace 开不开有啥区别”。
前置:fork/exec/wait、僵尸 vs 孤儿的基础见本人《操作系统接口》篇。本文只讲容器场景。
一、僵尸进程快速回顾(容器视角)
子进程 exit 后内核不能立刻释放 task_struct——父进程可能要查退出码,于是子进程留一个"僵尸"状态(Z):代码/数据/堆栈都释放了,只剩 task_struct 和退出码等父进程 wait 收尸。父进程 wait/waitpid 收掉才彻底回收。
僵尸的两个来源,容器场景都常见:
- 父进程没 wait:应用 spawn 了子进程却忘了
wait(比如 Pythonsubprocess.Popen不communicate/wait)。 - 父进程先死,子进程被 init 收养:孤儿被 reparent 到 PID 1,PID 1 有义务
wait它们——如果 PID 1 也不 wait,僵尸就永远留着。
裸 OS 里 PID 1 是 systemd/init,专门负责收尸,所以孤儿不会成永久僵尸。容器里 PID 1 是你的应用,问题就从这开始。
二、容器里 PID 1 的两宗罪
容器(Docker/containerd)默认给每个容器一个独立 PID namespace,你 ENTRYPOINT 跑的进程就是该 namespace 的 PID 1。PID 1 在 Linux 里有两个特殊职责,普通应用都不胜任:
罪一:不收僵尸
PID 1 有义务 wait 所有 reparent 到它的孤儿。但 Python / Node / Java / Bash 这些运行时当 PID 1 时:
- 它们不是 init,主循环不调
waitpid收养孤儿。 - 子进程死了、或更深的孙进程被 reparent 上来,没人收 → 僵尸永久堆积,占 PID、耗 task_struct。
- 最典型:容器里跑个 shell 脚本 spawn 一堆子任务,子任务再 fork,僵尸越积越多,最终 PID 耗尽(容器 PID 上限通常几万~几十万),新进程 fork 失败 → 服务卡死。
这就是为什么"容器里跑 bash 当 entrypoint"是反模式——bash 不收孤儿僵尸、不转发信号,经典坑。
罪二:信号不工作
PID 1 在内核里有个特殊待遇:很多信号(SIGTERM/SIGINT/SIGUSR1 等)默认 handler 被屏蔽——因为内核认为 init 不该被随便一个信号打死。后果:
docker stop/kubectl delete发 SIGTERM 给 PID 1,如果你应用没显式装 signal handler,SIGTERM 被忽略→ 等不到优雅退出,只能等超时后 SIGKILL 硬杀(默认 10s)。- SIGINT(Ctrl-C)同样无效。
- 普通进程能靠默认 handler 响应的信号,PID 1 必须自己显式处理。
Python/Node 的 signal 模块要主动注册才有 handler;Java 的 shutdown hook 依赖 SIGTERM 被接收。当它们是 PID 1 且没正确处理时,优雅关闭直接失效——这是生产里"pod 删除慢、要等 10s 才死"的常见根因。
面试金句:“容器里你的应用就是 PID 1,但 PID 1 有两个应用管不好的特殊职责:收 reparent 上来的孤儿僵尸、显式处理被内核屏蔽默认 handler 的信号。普通运行时(Python/Node/Java/Bash)当 PID 1 会漏僵尸 + 收不到 SIGTERM 优雅退出。”
三、dumb-init / tini:一个合格的 PID 1
解法是在应用和 PID 1 之间插一个小 init:让 dumb-init/tini 当 PID 1,你的应用当它的子进程(PID 2+)。这个小 init 专职做 PID 1 该做的两件事。
3.1 dumb-init(Yelp 出品)
dumb-init 是个极小的 C 程序,当 PID 1 时:
- 收僵尸:装 SIGCHLD handler,主动
waitpid(-1, ..., WNOHANG)循环收所有 reparent 上来的孤儿和自己的子进程僵尸。 - 转发信号:收到信号转发给直接子进程(默认)或整个进程组(
--single-child反向控制);子进程退出码作为 dumb-init 自己的退出码透传。
1 | dumb-init (PID 1) ──fork──► 你的应用 (PID 2) |
效果:你的应用不再是 PID 1,享受"普通进程待遇"——信号默认 handler 恢复生效、孤儿有人收。dumb-init 是个"信号代理 + 僵尸清道夫"。
3.2 tini(k8s 生态主力)
tini 同思路,更小更专注(~10KB),作者 Thomas Steenbergen,被 Docker / k8s 广泛集成:
- 收僵尸:reap 所有 reparent 上来的进程。
- 信号转发:把信号转给直接子进程,子进程退出后 tini 用相同码退出。
-s选项:显式杀整个进程组而非只直接子。
Docker 17.04+ 内置可选支持(dockerd --init,runtime 层自动注入 tini 作 PID 1);k8s 里常见做法是在镜像 entrypoint 套 tini。
3.3 dumb-init vs tini 怎么选
- 都解决"PID 1 漏僵尸 + 信号不工作",核心一致。
- tini 是 k8s/Docker 生态默认,集成好(runtime 注入、镜像里
ENTRYPOINT ["tini", "--"])。 - dumb-init 历史更早、Yelp 出品,信号组行为控制更细(
--single-child)。 - 实务:k8s 用 tini 居多;两者二选一即可,别同时用(嵌套 init 没意义)。
面试金句:“dumb-init/tini 是个微型 PID 1 init,专职两件事:转发信号给应用、收 reparent 上来的僵尸。让应用从 PID 1 退到 PID 2,恢复普通进程的信号默认 handler + 有人收尸。k8s 生态默认 tini。”
四、容器内复现:会漏僵尸的几种典型姿势
| 入口 | 是否漏僵尸 | 信号能否优雅 | 说明 |
|---|---|---|---|
python app.py 直接 PID 1 |
会(孤儿没人收) | Python 需显式 signal handler;否则 SIGTERM 被屏蔽 | 最常见坑 |
node app.js PID 1 |
会 | Node 对 SIGTERM 有默认行为但 PID 1 下要小心 | 同上 |
java -jar app.jar PID 1 |
会 | shutdown hook 依赖收到 SIGTERM | 同上 |
bash -c "..." PID 1 |
严重(bash 不收孤儿、不转发) | SIGTERM 打到 bash 不转给子进程 | 反模式之王 |
tini -- python app.py |
否(tini 收) | tini 转发 → Python 收到 SIGTERM | 正解 |
dumb-init python app.py |
否 | 转发 | 正解 |
一个高频血案:Dockerfile
ENTRYPOINT bash -c 'exec "$@"'/sh -c当 PID 1,shell 不转发信号、不收僵尸 →kubectl delete等 10s 才 SIGKILL、僵尸堆积。解法是ENTRYPOINT ["tini","--"]exec 形式(不用 shell),或 runtime--init。
五、k8s 视角:pause 容器与 shareProcessNamespace
k8s pod 不是单个容器,而是一组容器共享网络/IPC namespace,由一个 pause 容器(infra container) 持有这些 namespace 当锚。pause 容器的代码极简,核心就是:
1 | // pause 容器大致逻辑 |
5.1 默认:各容器独立 PID namespace(shareProcessNamespace: false)
默认 pod 内每个容器有自己的 PID namespace。pause 容器只是持网络/IPC ns 的锚,不是你应用容器的 PID 1——你应用容器里仍是各自独立 PID 1。所以:
- pause 在这个默认模式下不帮你收应用容器的僵尸。
- 每个应用容器仍要自己解决 PID 1 问题(tini/dumb-init 或应用自带 init 行为)。
5.2 shareProcessNamespace: true:pod 共享一个 PID namespace,pause 是 PID 1
开 shareProcessNamespace: true 后,pod 所有容器共享一个 PID namespace,pause 容器就是这个共享 namespace 的 PID 1。于是:
- pause 专职收整个 pod 的孤儿僵尸——任何容器里被 reparent 上来的孤儿都归 pause 收。
- 应用进程在各自容器里是 PID >1,信号默认 handler 恢复(不再受 PID 1 信号屏蔽)。
- 这等价于"pod 级 tini",由 k8s/容器运行时提供,不用改镜像。
1 | shareProcessNamespace: true 时: |
5.3 开不开 shareProcessNamespace 的取舍
| 默认(false) | shareProcessNamespace: true | |
|---|---|---|
| PID namespace | 每容器独立 | pod 共享一个 |
| PID 1 | 各容器自己 | pause(pod 级) |
| 僵尸回收 | 各容器自理(要 tini 或应用自收) | pause 兜底全 pod |
| 隔离 | 强(容器间进程互不可见) | 弱(互相能 ps/信号到对方进程) |
| 适用 | 大多数 pod(强隔离) | sidecar 需要给主进程发信号、或想让 init 兜底僵尸 |
实务:大多数场景用默认 + 镜像里 tini(强隔离 + 自包含);需要 sidecar 直接给主进程发信号/共享 PID 才开 shareProcessNamespace(此时 pause 兜底僵尸,省去每容器 tini)。两者别叠加(pod 级 pause + 容器级 tini 没必要,除非真有特殊需求)。
5.4 k8s 还有一个选项
io.kubernetes.cri.container-runtime/ Docker--init:容器运行时自动注入 tini 作 PID 1(容器级,不开 pod 共享也能用)。最省心的"镜像不用改也能修 PID 1"开关。- Dockerfile 里
ENTRYPOINT ["tini","--","python","app.py"](exec 形式,不用 shell):镜像自包含,跨运行时可移植。
面试金句:“k8s 默认每容器独立 PID ns,pause 只持网络/IPC 不收应用僵尸——要靠镜像里的 tini 或运行时 --init。开 shareProcessNamespace 后 pause 才是 pod 共享 PID ns 的 PID 1,兜底全 pod 僵尸。多数场景用默认+镜像 tini;需 sidecar 跨容器信号/共享 PID 才开共享。”
六、怎么排查与最佳实践
6.1 排查容器僵尸
1 | # 进容器看僵尸 |
6.2 best practice 清单
- exec 形式 ENTRYPOINT:
ENTRYPOINT ["tini","--","python","app.py"],不用 shell 形式(shell 形式会插入sh -c当 PID 1,转发与收尸全坏)。 - 应用 spawn 子进程务必 wait:
subprocess用communicate()/wait();别Popen后不管。 - 应用装 SIGTERM handler:即便有 tini 转发,应用也要响应 SIGTERM 做优雅关闭(关连接、刷盘、exit 0),否则 tini 转发了你也不接、还是等超时 SIGKILL。
- 设合理 terminationGracePeriodSeconds:默认 30s,复杂优雅关闭要调大;别依赖默认 SIGKILL。
- k8s 选其一:镜像 tini(自包含、跨运行时)/ 运行时
--init(不改镜像)/shareProcessNamespace: true(pod 级 pause 兜底,需弱隔离副作用)。多数用镜像 tini。 - 别让 bash/sh 当 PID 1:脚本型镜像用
tini -- bash -c '...'或set -e+ 显式 exec。 - Java/Go 注意:Go 二进制当 PID 1 也要
signal.Notify收 SIGTERM;Java 用Runtime.getRuntime().addShutdownHook+ 确认 JVM 收到 SIGTERM(容器里有时要-XX:+UseContainerSupport之外的信号处理)。
七、面试速答清单
Q1:容器里为什么应用直接当 PID 1 会漏僵尸?
PID 1 有义务
waitreparent 上来的孤儿。但 Python/Node/Java/Bash 这些运行时不是 init,主循环不waitpid收养孤儿——子进程死了或被 reparent 上来没人收,僵尸永久堆积,占 PID 直到耗尽、服务卡死。bash 当 PID 1 尤其严重(不收僵尸也不转发信号)。
Q2:dumb-init / tini 解决了什么?原理?
让 dumb-init/tini 当 PID 1、应用当它的子进程(PID 2+)。它专职两件事:① 装 SIGCHLD handler 主动
waitpid收所有 reparent 上来的孤儿和子进程僵尸;② 转发信号给应用(默认给直接子进程、可给进程组)。应用退到 PID 2 恢复普通进程待遇——信号默认 handler 生效、孤儿有人收。tini 是 k8s 生态默认。
Q3:为什么容器 PID 1 收不到 SIGTERM、优雅退出失效?
Linux 对 PID 1 屏蔽了 SIGTERM/SIGINT 等的默认 handler(内核认为 init 不该被随便打死),PID 1 必须显式装 handler 才响应。应用当 PID 1 且没显式处理 → SIGTERM 被忽略 →
kubectl delete等不到优雅退出、超时 SIGKILL。tini 转发 + 应用自装 handler 才能优雅关闭。
Q4:k8s pause 容器是不是 PID 1?收不收僵尸?
取决于 shareProcessNamespace。默认 false:每容器独立 PID ns,pause 只持网络/IPC ns 当锚、不是应用容器的 PID 1、不收应用僵尸——要靠镜像 tini 或运行时 --init。开 true:pod 共享一个 PID ns,pause 就是这个 ns 的 PID 1,专职收全 pod 孤儿僵尸(代码就是 sigaction 收 SIGCHLD + pause 死循环持 ns)。
Q5:shareProcessNamespace 开不开怎么选?
多数用默认(false)+ 镜像 tini:强隔离、自包含、跨运行时可移植。需要 sidecar 给主进程发信号/共享 PID ns 时才开 true:此时 pause 兜底全 pod 僵尸、省每容器 tini,代价是容器间进程互相可见、隔离弱。两者别叠加(pod pause + 容器 tini 没必要)。
Q6:bash 当 ENTRYPOINT 有什么坑?
ENTRYPOINT bash -c '...'让sh -c当 PID 1:sh 不转发信号给子进程(SIGTERM 打到 sh 不传给应用)、不收孤儿僵尸 → 删除等 10s SIGKILL + 僵尸堆积。解法用 exec 形式ENTRYPOINT ["tini","--","python","app.py"]不走 shell,或tini -- bash -c '...'。
Q7:怎么排查容器僵尸?
kubectl exec <pod> -- ps -eo pid,ppid,stat,cmd | grep ' Z '看僵尸;cat /proc/1/comm看 PID 1 是不是 tini/dumb-init(好)还是 python/node/bash(坏)。生产配 runtime--init或镜像 tini 从根上防。
Q8:pod 删除慢、要等 10s 才死是什么原因?
典型是 PID 1 没正确响应 SIGTERM:要么应用当 PID 1 且没装 signal handler(SIGTERM 被屏蔽)、要么 bash 当 PID 1 不转发。k8s 发 SIGTERM 后等 terminationGracePeriodSeconds(默认 30s,老默认 10s)超时 SIGKILL。修法:tini 转发 + 应用装 SIGTERM handler 优雅 exit。
八、一张图收口
1 | 裸机: systemd(PID1) ──收僵尸──► 孤儿不残留 |
主线一句话:容器把你应用推到 PID 1,而 PID 1 要收 reparent 上来的孤儿僵尸、要显式处理被内核屏蔽的信号——普通运行时(Python/Node/Java/Bash)两件都不胜任,于是漏僵尸 + 收不到 SIGTERM。dumb-init/tini 当微型 PID 1 专职收僵尸+转发信号,k8s 默认靠镜像 tini 或运行时 --init,开 shareProcessNamespace 时 pause 容器才成 pod 级 PID 1 兜底全 pod 僵尸。 把 PID 1 两宗罪、tini/dumb-init 原理、pause 与 shareProcessNamespace 讲顺,容器僵尸这关就稳了。
参考资料
- Yelp dumb-init: https://github.com/Yelp/dumb-init
- tini: https://github.com/krallin/tini
- k8s: Pod Lifecycle / Container Lifecycle Hooks /
shareProcessNamespace文档 - Kubernetes pause 容器源码(kubernetes/pkg/api/v1/pod.go 与 pause 镜像 main.c)
- Linux man:
pid_namespaces(7)、signal(7)、prctl(2)(PR_SET_PDEATHSIG/PR_SET_CHILD_SUBREAPER) - 与本人《操作系统接口(fork/exec/wait)》篇交叉对照


