容器里的 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 收掉才彻底回收。

僵尸的两个来源,容器场景都常见:

  1. 父进程没 wait:应用 spawn 了子进程却忘了 wait(比如 Python subprocess.Popencommunicate/wait)。
  2. 父进程先死,子进程被 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 时:

  1. 收僵尸:装 SIGCHLD handler,主动 waitpid(-1, ..., WNOHANG) 循环收所有 reparent 上来的孤儿和自己的子进程僵尸。
  2. 转发信号:收到信号转发给直接子进程(默认)或整个进程组(--single-child 反向控制);子进程退出码作为 dumb-init 自己的退出码透传。
1
2
3
4
dumb-init (PID 1) ──fork──► 你的应用 (PID 2)
│ │ fork 子任务...
│ 收到 SIGTERM ──转发──► │
│ 收到 SIGCHLD ──waitpid── 收僵尸

效果:你的应用不再是 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
2
3
4
5
6
7
// pause 容器大致逻辑
int main() {
// 1. 装信号 handler 收僵尸
sigaction(SIGCHLD, ...); // waitpid 收 reparent 上来的孤儿
// 2. 永远 pause, 持住 namespace
for (;;) 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
2
3
4
5
shareProcessNamespace: true 时:
pause (PID 1, 整 pod 共享) ── 收所有孤儿僵尸
├── 容器A 应用 (PID x)
├── 容器B 应用 (PID y)
└── sidecar (PID z)

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
2
3
4
5
6
7
8
9
10
# 进容器看僵尸
kubectl exec <pod> -- ps -eo pid,ppid,stat,cmd | grep -E ' Z '

# 或在容器内
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'
ps -A -ostat,pid,ppid,cmd | grep -e '^[Zz]'

# 看容器 PID 1 是谁(是不是 tini/dumb-init, 还是应用本体)
kubectl exec <pod> -- cat /proc/1/comm
# 输出 tini / dumb-init = 好; 输出 python/node/bash = 漏僵尸风险

6.2 best practice 清单

  1. exec 形式 ENTRYPOINTENTRYPOINT ["tini","--","python","app.py"],不用 shell 形式(shell 形式会插入 sh -c 当 PID 1,转发与收尸全坏)。
  2. 应用 spawn 子进程务必 waitsubprocesscommunicate()/wait();别 Popen 后不管。
  3. 应用装 SIGTERM handler:即便有 tini 转发,应用也要响应 SIGTERM 做优雅关闭(关连接、刷盘、exit 0),否则 tini 转发了你也不接、还是等超时 SIGKILL。
  4. 设合理 terminationGracePeriodSeconds:默认 30s,复杂优雅关闭要调大;别依赖默认 SIGKILL。
  5. k8s 选其一:镜像 tini(自包含、跨运行时)/ 运行时 --init(不改镜像)/ shareProcessNamespace: true(pod 级 pause 兜底,需弱隔离副作用)。多数用镜像 tini。
  6. 别让 bash/sh 当 PID 1:脚本型镜像用 tini -- bash -c '...'set -e + 显式 exec。
  7. Java/Go 注意:Go 二进制当 PID 1 也要 signal.Notify 收 SIGTERM;Java 用 Runtime.getRuntime().addShutdownHook + 确认 JVM 收到 SIGTERM(容器里有时要 -XX:+UseContainerSupport 之外的信号处理)。

七、面试速答清单

Q1:容器里为什么应用直接当 PID 1 会漏僵尸?

PID 1 有义务 wait reparent 上来的孤儿。但 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
2
3
4
5
6
7
8
9
10
裸机:      systemd(PID1) ──收僵尸──► 孤儿不残留
容器默认: 你的应用(PID1) ──不收孤儿/信号被屏蔽──► 僵尸堆积 + 删除要等超时
└ 解法1: tini/dumb-init 当 PID1 ──fork──► 应用(PID2)
收 SIGCHLD→waitpid 收僵尸; 转发 SIGTERM→应用
└ 解法2: 运行时 --init(自动注入 tini)
k8s pod:
默认 shareProcessNamespace:false → 各容器独立 PID ns, pause 只持 netns
→ 各容器要自带 tini(镜像/运行时)
shareProcessNamespace:true → pod 共享一个 PID ns, pause 是 PID1
→ pause 兜底全 pod 僵尸; 应用退到 PID>1 信号恢复

主线一句话:容器把你应用推到 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)》篇交叉对照