找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖
Claude、GPT 海外模型 API 接入云原生前端项目实战教程50G互联网架构师面试指南
大模型全栈开发课程企业级DevOps全栈实践零基础产品经理就业课程

6135

积分

0

好友

777

主题
发表于 5 天前 | 查看: 1| 回复: 0

一、一个真实的痛点场景

去年大促前,我们一个核心交易 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 号进程)规定了两个特殊职责:

  1. 孤儿进程的收养与僵尸进程的 wait() 收割:任何父进程先于子进程退出,子进程会被内核自动过继给 PID 1。如果 PID 1 不去 wait() 回收这些子进程,它们就会变成永远的僵尸。
  2. 没有默认信号处理:对于普通进程,SIGTERM/SIGINT 的默认动作是“终止进程”;但内核规定 PID 1 默认会忽略 SIGTERM 和 SIGINT,除非进程自己 signal() 注册了 handler。

如果你的 ENTRYPOINTsh -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”之类,说明信号链路通了。

四、生产环境注意事项

  1. sidecar 容器同样有 PID 1 问题:不要以为只有主容器要注意。sidecar(日志采集、agent)如果也是 sh -c 启动,它自己也会堆积僵尸,只是大家不太关注。
  2. Java 应用用 sh -c java -jar 的代价:这是最常见的新手写法。SIGTERM 发到 shell 被吞,JVM 收不到,连接池、线程池、注册中心注销全都不执行,滚动更新必现 5xx。务必 exec java -jar 或上 tini。
  3. init 系统与 supervisor 的取舍:需要多进程时才上 tini/dumb-init。如果你强行在容器里跑 systemd,不仅体积大、启动慢,还常常因为容器里缺少 cgroup 权限而报错,容器里用 tini 比用 systemd 轻量且可靠得多
  4. 多进程容器必须上 tini:只要你的容器里会 fork 子进程(fork 模型的 web server、带 cron 的镜像、应用自己 spawn 子命令),PID 1 必须是 init,否则僵尸必堆积。
  5. 僵尸进程不受 OOM/内存 limit 影响:僵尸不占内存,K8s 的 memory limit 管不到它,但它占 PID 和内核 task_struct 资源。所以靠内存告警是发现不了僵尸问题的,要靠僵尸数量监控。
  6. pid.max / --pids-limit 打满会导致 fork 失败:容器有 PID 上限(Docker 默认视宿主机,kubectl 也有 pid.max 概念)。僵尸把 PID 表占满后,fork: cannot allocate memory 错误就会出现——即使内存还剩一大半。
  7. 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 stopkubectl delete 发的信号一样吗?

本质都是先发 SIGTERM,宽限期过了再发 SIGKILL。差别在宽限期:docker stop 默认 10 秒,terminationGracePeriodSeconds 默认 30 秒。如果你的优雅关闭需要更久,记得调大这个值,并配合 preStop 留缓冲。

八、总结

容器里的 PID 1 不是普通进程,它身兼“孤儿收养 + 僵尸收割 + 信号分发”三职。大多数人踩坑,都是因为随手写了个 sh -c 或者 shell 脚本当 ENTRYPOINT,让一个“不转发信号、不收割僵尸”的 shell 当了 1 号进程。结果就是:发版等 30 秒被强杀、滚动更新 502、僵尸进程越积越多直到 fork 失败。

行动建议(Dockerfile 规范)

  1. 永远用 exec 形式写 CMD / ENTRYPOINT,脚本最后一行务必 exec "$@"
  2. 只要容器里有多进程,立刻上 tinidumb-init,别裸跑;
  3. 应用层务必注册 SIGTERM handler,别把锅全甩给 PID 1;
  4. 把“僵尸数量监控 + PID 用量监控”加进告警,内存告警发现不了僵尸问题;
  5. 发版前用 kill -TERM 1kubectl delete --grace-period=30 真实验证一遍优雅退出。

把这五条当成发布前 checklist,PID 1 的坑基本就与你无缘了。




上一篇:PageSpeed 移动端 58 分:一行 @import 拖垮首屏的排查与修复
下一篇:OpenAI Codex 沙箱两度被突破:Accomplish 披露两个高危逃逸漏洞
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-21 02:00 , Processed in 1.249479 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表