一、一个真实的痛点场景
去年大促前,我们一个核心交易 Pod 突然开始频繁被 OOMKilled。很诡异:内存监控没满、CPU 也不高,但 Pod 就是周期性地被干掉重启。进去一查 ps aux,好家伙——两百多个 [defunct],而且数量每天以十几个的速度在涨。
最离谱的一次,我想手动清理:kubectl exec 进容器执行 kill -9 <pid>,结果一点反应都没有。它们的进程状态是 Z(zombie),而 kill -9 对僵尸进程本身就是无效的——僵尸早已“死”了,只剩一个进程表壳等着父进程来收尸。
另一个场景更常见:每次发版滚动更新,前端监控里总有一段时间的 502。我们明明设置了 terminationGracePeriodSeconds: 30,应用却“不配合”优雅退出。kubectl delete pod 之后要老老实实等满 30 秒才被 SIGKILL 强杀,这段空窗期进来的请求直接断,用户侧看到的就是一串 502。
还有同事用 kubectl exec 进容器 kill -9 想干掉某个卡死的子进程,结果父进程没死,子进程变成僵尸,PID 一直挂着不释放,日积月累把 PID 表撑爆。
这些现象,根子全在 PID 1 身上。
二、一句话点透原理
Linux 内核给 PID 1(容器的 1 号进程)规定了两个特殊职责:
- 孤儿进程的收养与僵尸进程的
wait() 收割:任何父进程先于子进程退出,子进程会被内核自动过继给 PID 1。如果 PID 1 不去 wait() 回收这些子进程,它们就会变成永远的僵尸。
- 没有默认信号处理:对于普通进程,SIGTERM/SIGINT 的默认动作是“终止进程”;但内核规定 PID 1 默认会忽略 SIGTERM 和 SIGINT,除非进程自己
signal() 注册了 handler。
如果你的 ENTRYPOINT 是 sh -c "xxx" 或者一个 bash 脚本,问题就来了:shell 既不会把收到的信号转发给子进程,也不会去 wait() 收割子进程。于是两件事同时发生——
- 信号丢失:K8s 发 SIGTERM 给 PID 1(也就是那个 shell),shell 直接忽略,子进程收不到,优雅关闭不生效;
- 僵尸堆积:shell 跑起来的子进程退出后无人
wait(),全变成 [defunct] 挂在那里。
一句话总结:容器里 PID 1 不是普通进程,它是“收养院院长 + 信号处理员”,你用 shell 当 PID 1 等于让一个不干活的人当院长。
三、实战步骤
3.1 查看僵尸进程
在宿主机或容器里都能看:
# 容器内直接看 Z 状态
ps -eo pid,ppid,stat,comm | grep -w Z
# 不想进容器,kubectl 直接看
kubectl exec <pod> -- ps aux | awk '$8 ~ /Z/'
# 看僵尸数量汇总
kubectl exec <pod> -- ps aux | awk '$8 ~ /Z/' | wc -l
如果输出一大片 [defunct],说明 PID 1 根本没在收割。
3.2 通过 /proc 定位元凶
僵尸本身没内存,看不了它的命令。但可以通过 /proc 找到它的父进程是谁:
# 假设僵尸 pid 是 1234
cat /proc/1234/status | grep -E 'State|PPid'
# 看它的父进程在干什么
PPID=$(cat /proc/1234/status | awk -F'\t' '/PPid/{print $2}')
echo "父进程 PID: $PPID"
cat /proc/$PPID/cmdline | tr '\0' ' '; echo
定位到父进程后基本就能判断:是不是你那个 sh -c 跑出来的。
3.3 复现:一段会产出僵尸的最小例子(错误写法)
Dockerfile(错误示范):
FROM alpine:3.19
COPY start.sh /start.sh
RUN chmod +x /start.sh
# 注意:这里用 shell 形式,PID 1 是 /bin/sh
ENTRYPOINT ["/bin/sh", "-c", "/start.sh"]
start.sh(错误示范):
#!/bin/sh
# 后台跑一个会频繁退出的子进程,但 shell 不会 wait 它
while true; do
( sleep 1; exit 0 ) & # 这个子进程退出后变成僵尸
sleep 0.5
done
构建运行后,kubectl exec 进去 ps aux | awk '$8 ~ /Z/' 就会看到僵尸越积越多。这就是线上事故的微缩版。
3.4 修复方案 1:用 exec 替换 shell
核心思想:让真正干活的进程成为 PID 1,而不是 shell。在 shell 脚本里用 exec 把当前 shell 进程“替换”成目标进程。
Dockerfile 里两种 CMD 写法的区别:
# ❌ shell 形式:实际执行的是 /bin/sh -c "nginx -g 'daemon off;'"
# nginx 是 sh 的子进程,不是 PID 1,收不到 SIGTERM
CMD nginx -g 'daemon off;'
# ✅ exec 形式:docker 直接 exec 调起 nginx 作为 PID 1
CMD ["nginx", "-g", "daemon off;"]
当 ENTRYPOINT 必须是一个脚本时,脚本最后一行用 exec:
ENTRYPOINT ["/app/start.sh"]
start.sh:
#!/bin/sh
# 做一些前置准备(比如渲染配置、等待依赖)
echo "preparing..."
# 关键:用 exec 把 nginx 替换掉当前 shell 成为 PID 1
exec nginx -g 'daemon off;'
exec 之后 shell 进程被 nginx 覆盖,nginx 既是 PID 1 又能正确处理 SIGTERM,僵尸和优雅退出两个问题同时解决。
3.5 修复方案 2:使用 tini / dumb-init
如果你的容器里确实需要多进程(比如同时跑应用 + 日志采集 sidecar 逻辑、或者应用会 fork 子进程),单靠 exec 不够,需要一个真正的 init 来当 PID 1,负责转发信号和收割僵尸。最常用的是 tini。
完整 Dockerfile(带 tini):
FROM alpine:3.19
# 下载静态编译的 tini
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /usr/bin/tini
RUN chmod +x /usr/bin/tini
COPY app /app/app
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
# tini 作为 PID 1,-- 后面接真正要跑的命令
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/start.sh"]
tini 会做三件事:把 SIGTERM/SIGINT 转发给子进程组;子进程退出后负责 wait() 收割僵尸;子进程被杀后正确把退出码传出来(这样 K8s 才能拿到正确的 exit code)。
K8s 侧也可以不用改镜像,直接在 command 里包一层:
spec:
containers:
- name: app
image: myapp:latest
command: ["/usr/bin/tini", "--"]
args: ["java", "-jar", "/app/app.jar"]
注意:K8s 自带的 pause 容器本身带 init 能力,但业务容器默认没有。想在业务容器里用 tini,要么打进镜像,要么在 command 显式指定。
dumb-init 是另一个等价选择,用法类似:ENTRYPOINT ["/usr/bin/dumb-init", "--"]。
3.6 修复方案 3:K8s 原生 shareProcessNamespace
K8s 提供了一个官方手段:spec.shareProcessNamespace: true。开启后,Pod 内所有容器共享同一个 PID namespace,K8s 会注入一个真正的 pause 进程作为 PID 1,负责信号转发和僵尸收割。
spec:
shareProcessNamespace: true
containers:
- name: app
image: myapp:latest
- name: sidecar
image: my-sidecar:latest
坑点:
- 开启后,
kubectl exec 进容器会看到其他容器的进程,进程视图不再隔离,排查时容易混淆;
- 某些依赖
/proc 只读隔离的安全场景(比如 seccomp、某些只读根文件系统配置)可能受影响;
- 它解决的是“有 init 来 reap”,但不会改变你应用自己的信号处理——如果应用本身没注册 SIGTERM handler,依然不会优雅退出,只是僵尸有人收了。
所以 shareProcessNamespace 适合“只想有人收僵尸,应用本身信号没问题”的场景,治标不治本。
3.7 完整的 Pod YAML 示例
一个同时兼顾优雅关闭、僵尸收割、滚动更新的标准写法:
apiVersion: v1
kind: Pod
metadata:
name: trade-app
spec:
shareProcessNamespace: true
terminationGracePeriodSeconds: 30
containers:
- name: app
image: myapp:latest
# 用 dumb-init 包一层,确保 PID 1 会转发信号 + 收割僵尸
command: ["/usr/bin/dumb-init", "--"]
args: ["java", "-jar", "/app/app.jar"]
lifecycle:
preStop:
# 先优雅摘流量再停进程,避免滚动更新瞬间 502
exec:
command: ["/bin/sh", "-c", "sleep 5"]
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
resources:
limits:
memory: "1Gi"
preStop 里的 sleep 5 给 LB 摘流量留了缓冲窗口,配合 terminationGracePeriodSeconds: 30,发版时基本不再有 502。
3.8 验证信号是否真的生效
写完别急着上线,先用这几条命令验证:
# 进容器直接给 PID 1 发 SIGTERM,看应用是否优雅退出
kubectl exec <pod> -- kill -TERM 1
# 观察容器退出日志 / 应用是否打印了 shutdown hook
kubectl logs <pod> --previous
# 用 grace-period 模拟删除,观察是否真的在 SIGTERM 后退出而非被 SIGKILL
kubectl delete pod <pod> --grace-period=30
kubectl get pod <pod> -w # 看是否 30s 内平滑退出
如果 kill -TERM 1 之后进程立刻退出且日志里有“shutting down / closing pool”之类,说明信号链路通了。
四、生产环境注意事项
- sidecar 容器同样有 PID 1 问题:不要以为只有主容器要注意。sidecar(日志采集、agent)如果也是
sh -c 启动,它自己也会堆积僵尸,只是大家不太关注。
- Java 应用用
sh -c java -jar 的代价:这是最常见的新手写法。SIGTERM 发到 shell 被吞,JVM 收不到,连接池、线程池、注册中心注销全都不执行,滚动更新必现 5xx。务必 exec java -jar 或上 tini。
- init 系统与 supervisor 的取舍:需要多进程时才上 tini/dumb-init。如果你强行在容器里跑
systemd,不仅体积大、启动慢,还常常因为容器里缺少 cgroup 权限而报错,容器里用 tini 比用 systemd 轻量且可靠得多。
- 多进程容器必须上 tini:只要你的容器里会 fork 子进程(fork 模型的 web server、带 cron 的镜像、应用自己 spawn 子命令),PID 1 必须是 init,否则僵尸必堆积。
- 僵尸进程不受 OOM/内存 limit 影响:僵尸不占内存,K8s 的
memory limit 管不到它,但它占 PID 和内核 task_struct 资源。所以靠内存告警是发现不了僵尸问题的,要靠僵尸数量监控。
pid.max / --pids-limit 打满会导致 fork 失败:容器有 PID 上限(Docker 默认视宿主机,kubectl 也有 pid.max 概念)。僵尸把 PID 表占满后,fork: cannot allocate memory 错误就会出现——即使内存还剩一大半。
kubectl exec 进去跑的进程,父进程是容器里的 shell 而非 PID 1,你在 exec 里启的临时进程退出后,如果 exec 的 shell 不退出,也会短暂变僵尸,所以 exec 调试完记得退出 shell。
五、踩坑实录(3 个真实案例)
坑 1:shell 脚本跑 Java,发版 5xx 雪崩
某次上线后,滚动更新期间下游监控到大量 5xx。排查发现 Dockerfile 里 ENTRYPOINT ["/start.sh"],脚本内容是:
#!/bin/sh
java -jar /app/app.jar # 注意没有 exec
SIGTERM 发给 PID 1(shell),shell 忽略,JVM 收不到。等到 30s 被 SIGKILL 强杀,HikariCP 连接池、注册中心注销、Kafka 消费者 rebalance 全都没执行。强杀瞬间,下游一大批请求因为连接被粗暴掐断返回 5xx。改成 exec java -jar 后,JVM 成为 PID 1,SIGTERM 一到位就跑 shutdown hook,5xx 归零。
坑 2:cron + rsyslog 多进程,半年攒 8000 僵尸
有个运维工具镜像,容器里同时跑 cron 调度任务 + rsyslog 收集日志,ENTRYPOINT 是个 sh -c "cron && rsyslogd"。因为没有任何 init 进程,cron 派生的任务进程退出后无人 wait(),rsyslog 的子线程同理。半年下来,这个 Pod 里悄悄攒了 8000 多个僵尸。直到某天突发任务量上涨,新进程 fork: cannot allocate memory 直接起不来,整个工具失灵。加一行 tini 做 PID 1 后,僵尸数量长期稳定在 0。
坑 3:Python python app.py 直接当 PID 1,更新请求中断
一个 Python 服务,Dockerfile 写 CMD ["python", "app.py"]。Python 进程确实是 PID 1,但它没有注册 SIGTERM handler。K8s 滚动更新发 SIGTERM,Python 作为 PID 1 默认忽略该信号,继续干活,直到 30s 后被 SIGKILL。这 30s 内进来的请求全部中断,客户端报超时。修复有两步:一是给 Python 注册信号 handler(signal.signal(signal.SIGTERM, graceful_shutdown));二是即便忘了注册,也用 tini 兜底——tini 在转发 SIGTERM 后会等待子进程退出,并把超时的子进程 SIGKILL,行为可控。
六、排查 Checklist
- [ ]
kubectl exec <pod> -- ps aux | awk '$8 ~ /Z/' 看是否有僵尸
- [ ] 统计僵尸数量:
kubectl exec <pod> -- ps aux | awk '$8 ~ /Z/' | wc -l
- [ ] 看 PID 1 是什么:
kubectl exec <pod> -- ps -p 1 -o pid,comm,args
- [ ] 确认 PID 1 是不是
sh / bash / dash
- [ ] 如果是 shell,检查 Dockerfile 是否用了 shell 形式的
CMD nginx -g ...
- [ ] 检查脚本最后一行是否用了
exec "$@" 或 exec <命令>
- [ ] 进容器
kill -TERM 1,看应用是否优雅退出
- [ ]
kubectl delete pod --grace-period=30,观察是否等到 30s 被强杀
- [ ] 看应用日志是否有 shutdown hook 执行痕迹
- [ ] 检查是否多进程容器且没用 tini/dumb-init
- [ ] 检查
pid.max / --pids-limit 是否接近上限
- [ ] 看
dmesg 或应用日志里有没有 fork: cannot allocate memory
- [ ] 确认 Java/Python 应用是否注册了 SIGTERM handler
- [ ] 检查 Pod 是否配置了
preStop 摘流量缓冲
七、面试加餐:3 道高频题
Q1:容器里的 PID 1 有什么特殊之处?
要点:① 内核规定 PID 1 负责收养孤儿进程并 wait() 收割僵尸;② PID 1 默认忽略 SIGTERM/SIGINT,普通进程的默认终止动作对 PID 1 不适用;③ 所以 PID 1 既要有信号 handler,又要能 reap 子进程,否则会出现优雅关闭失效 + 僵尸堆积。
Q2:为什么 kill -9 杀不掉僵尸进程?
要点:僵尸进程已经终止,只是保留了进程表项(task_struct)等待父进程 wait() 回收。它没有可执行代码,kill -9 无的放矢——信号根本没地方投递。唯一正解是杀掉/修复它的父进程,让父进程去 wait(),或者让 PID 1 来收养并收割。
Q3:如何让容器里的应用优雅退出?
要点:① Dockerfile 用 exec 形式(CMD ["x"])或脚本里 exec 替换,让应用成为 PID 1;② 多进程容器用 tini/dumb-init 作 PID 1 转发信号;③ 应用自身注册 SIGTERM handler 做资源释放;④ K8s 侧配 preStop 摘流量 + 合理 terminationGracePeriodSeconds;⑤ 验证 kill -TERM 1 和应用 shutdown 日志。
七点五、实战延伸:把僵尸数量纳入监控
光会手动排查还不够,僵尸是慢性毒药,必须靠监控早发现。最轻量的做法是在容器里加一个 sidecar 或定时脚本,把僵尸数暴露成指标:
# 一个最简单的探针脚本,配合 node-exporter 的 textfile collector
#!/bin/sh
Z=$(ps -e -o stat | grep -c Z)
echo "container_zombie_count $Z" > /var/lib/node_exporter/zombie.prom
如果你想更省事,直接用 kubectl 在集群外巡检所有 Pod 的僵尸数量,做成每日报表:
for p in $(kubectl get pods -n prod -o name); do
z=$(kubectl exec -n prod "$p" -- ps aux 2>/dev/null | awk '$8 ~ /Z/' | wc -l)
echo "$p zombie=$z"
done | awk '$NF>0'
一旦某个 Pod 僵尸数持续不为 0 且缓慢上涨,基本可以判定它的 PID 1 没在干活,赶紧回去查 ENTRYPOINT。
七点六、FAQ 高频疑问
Q:用了 tini 是不是就高枕无忧了?
不是。tini 解决了“信号转发”和“僵尸收割”,但应用自身的优雅逻辑仍要自己写。如果应用没注册 SIGTERM handler,tini 转发了信号,应用依然无动于衷,最后还是被 SIGKILL。tini 是兜底,不是免死金牌。
Q:僵尸进程会吃内存吗?
不会吃 RSS 内存,它早已释放了几乎所有资源,只保留一个极小的 task_struct 和 PID。所以它逃得过 memory limit,但逃不过 PID 上限和内核 slab 占用,攒多了照样让 fork 失败。
Q:docker stop 和 kubectl delete 发的信号一样吗?
本质都是先发 SIGTERM,宽限期过了再发 SIGKILL。差别在宽限期:docker stop 默认 10 秒,terminationGracePeriodSeconds 默认 30 秒。如果你的优雅关闭需要更久,记得调大这个值,并配合 preStop 留缓冲。
八、总结
容器里的 PID 1 不是普通进程,它身兼“孤儿收养 + 僵尸收割 + 信号分发”三职。大多数人踩坑,都是因为随手写了个 sh -c 或者 shell 脚本当 ENTRYPOINT,让一个“不转发信号、不收割僵尸”的 shell 当了 1 号进程。结果就是:发版等 30 秒被强杀、滚动更新 502、僵尸进程越积越多直到 fork 失败。
行动建议(Dockerfile 规范):
- 永远用 exec 形式写
CMD / ENTRYPOINT,脚本最后一行务必 exec "$@";
- 只要容器里有多进程,立刻上
tini 或 dumb-init,别裸跑;
- 应用层务必注册
SIGTERM handler,别把锅全甩给 PID 1;
- 把“僵尸数量监控 + PID 用量监控”加进告警,内存告警发现不了僵尸问题;
- 发版前用
kill -TERM 1 和 kubectl delete --grace-period=30 真实验证一遍优雅退出。
把这五条当成发布前 checklist,PID 1 的坑基本就与你无缘了。