我给容器化的服务做了优雅停机、代码里明明监听了 SIGTERM 信号,可每次 kubectl 删 Pod 它都不优雅退出、非要硬等三十秒被强杀,我对着代码反复确认信号处理逻辑没问题,最后才发现根子在 Dockerfile 那行用 shell 形式写的 CMD、我的应用压根不是容器里的 1 号进程
这是一次让我把 Docker 里"CMD 用 shell 形式还是 exec 形式"这件事,从"不就是写法不同、效果一样吗",重新理解成"它决定了我的应用是不是容器里的 1 号进程、收不收得到停止信号"的事故。我给容器化的服务做了优雅停机,代码里明明监听了 SIGTERM。可每次 kubectl 删 Pod,它都不优雅退出、非要硬等三十秒被强杀。我对着代码反复确认信号处理逻辑没问题,最后才发现:根子在 Dockerfile 那行用 shell 形式写的 CMD——我的应用,压根就不是容器里的 1 号进程。这篇就把这次"优雅停机代码写了却从不生效"的事故,从头到尾复盘一遍。
故障现场:监听了 SIGTERM,却每次都被硬等超时强杀
我的服务需要优雅停机:收到停止信号时,先停止接收新请求、把手头的请求处理完、关好连接,再退出。我在代码里老老实实注册了 SIGTERM 的处理函数,本地直接跑、手动 kill 进程,优雅停机逻辑完美触发。我很满意,打包成镜像、部署上了 K8s。
可线上一发布更新,问题就来了:每次滚动更新或 kubectl delete pod,Pod 都要卡足整整 30 秒才消失——那正是 K8s 的 terminationGracePeriodSeconds 默认值。也就是说,K8s 发了 SIGTERM 后,我的服务毫无反应,K8s 等了 30 秒没等到它自己退出,只好发 SIGKILL 把它强杀。我的优雅停机逻辑,一次都没触发过。我先怀疑是不是信号处理代码有 bug,本地反复测都正常;又怀疑 K8s 没发 SIGTERM,查文档确认它就是先发 SIGTERM。直到我进到容器里 ps -ef 一看,才恍然大悟——容器里的 1 号进程(PID 1)不是我的应用,而是 /bin/sh -c "java -jar app.jar" 这个 shell!我的 Java 应用是它的子进程(PID 不是 1)。而容器收到的 SIGTERM,只会发给 PID 1 那个进程;那个 shell 收到 SIGTERM 后,既不会把信号转发给我的应用、自己也不响应,于是 SIGTERM 就石沉大海,我的应用根本没收到、自然不会优雅退出。
# 我的 Dockerfile: 用了 shell 形式的 CMD
CMD java -jar app.jar
# 这等价于: CMD ["/bin/sh", "-c", "java -jar app.jar"]
# 结果: 容器里 PID 1 是 /bin/sh, 我的 java 是它的子进程
# 容器内 ps -ef 看到的:
# PID 1: /bin/sh -c java -jar app.jar ← 1 号进程是 shell!
# PID 7: java -jar app.jar ← 我的应用, 不是 1 号
# K8s/docker stop 发 SIGTERM → 只发给 PID 1(那个 sh)
# sh 收到 SIGTERM: 不转发给子进程、自己也不退 → 信号丢失
# 我的 java 应用: 压根没收到 SIGTERM → 优雅停机逻辑不触发
# 等 30 秒 grace period → K8s 发 SIGKILL → 强杀(连 java 一起杀)
# 现象: 优雅停机代码写了, 却从不生效, 每次都硬等超时被强杀
问题被钉死在这个认知错位上:我以为"容器启动我的应用"就等于"我的应用是容器的主进程、直接接收容器的信号",但 shell 形式的 CMD,会先启动一个 /bin/sh 作为 PID 1、再由它启动我的应用作为子进程;而容器的停止信号(SIGTERM)只发给 PID 1。那个 shell 既不转发信号、也不响应,我的应用就被隔在了信号之外。我精心写的优雅停机逻辑没有错,它只是从来没等到过那个该触发它的信号——信号在 PID 1 那个 shell 那里就断了。我以为我把应用放到了能直接听见指令的位置,其实中间隔了一个不传话、也不动作的 shell。我对着话筒喊话,可话筒根本没接到我的应用,而是接到了一个又聋又哑的中间人。
第一件事:想明白容器里 PID 1 的特殊地位,和 shell 形式 CMD 的代价
把这次事故彻底想清楚,关键是理解在容器(以及 Linux)里,PID 1 是一个有特殊职责的位置:容器的停止信号(docker stop/K8s 删 Pod 发的 SIGTERM)只发给 PID 1;同时 PID 1 还负责"收养并回收孤儿/僵尸子进程"。而 Dockerfile 的 CMD(和 ENTRYPOINT)有两种写法:exec 形式 CMD ["java","-jar","app.jar"] 会让你的应用直接成为 PID 1;shell 形式 CMD java -jar app.jar 则等价于 ["/bin/sh","-c","java -jar app.jar"],让 shell 成为 PID 1、你的应用沦为它的子进程。
这就是为什么"同样能把应用跑起来"的两种写法,在停止行为上天差地别:exec 形式下,应用是 PID 1,容器的 SIGTERM 直接送到应用手里,优雅停机逻辑正常触发;shell 形式下,PID 1 是那个 sh -c 启动的 shell——而这个默认的 shell 通常不会把收到的信号转发给子进程,也不会主动响应 SIGTERM,于是信号就卡在 PID 1 这里、传不到真正干活的应用。更糟的是,shell 当 PID 1 还往往不履行"回收僵尸进程"的职责,容易积累僵尸进程。关键认知是:容器里的"1 号进程"是信号和进程管理的唯一入口和总管,谁占了这个位置、谁就得承担这些职责;让一个不胜任的 shell 占着它,这些职责就没人履行了。
# 正解1: 用 exec 形式 CMD/ENTRYPOINT, 让应用直接成为 PID 1
CMD ["java", "-jar", "app.jar"]
# 容器内 ps: PID 1 就是 java 应用本身
# → SIGTERM 直达应用 → 优雅停机逻辑正常触发 ✓
# 正解2: 确实需要 shell(如要展开环境变量), 用 exec 让应用顶替 shell 成 PID 1
CMD ["sh", "-c", "exec java -jar app.jar"]
# exec 会用 java 进程【替换】掉当前 shell 进程, java 因此成为 PID 1 ✓
# 正解3: 用一个真正的 init 作 PID 1, 负责转发信号 + 回收僵尸
# Docker: docker run --init ... (内置 tini)
# Dockerfile: ENTRYPOINT ["/tini", "--"] 然后 CMD ["java","-jar","app.jar"]
# tini/dumb-init 作 PID 1, 把 SIGTERM 转发给子进程、并回收僵尸 ✓
# 验证: 进容器 ps -ef / ps -o pid,comm, 确认 PID 1 是你的应用或 init,
# 而不是一个光秃秃的 /bin/sh -c
想通这一层,我才明白自己错在哪:我只关心"CMD 能不能把应用启动起来",而完全没意识到"用哪种写法,决定了谁是 PID 1、信号能不能到达我的应用"。shell 形式看起来更简洁、也确实能把应用跑起来,但它悄悄在我的应用和容器信号之间,插了一个不传话的 shell 当 PID 1。我的优雅停机代码从设计到实现都没问题,却败在了一个"它根本收不到信号"的部署细节上。应用能不能优雅退出,不只取决于它有没有写优雅退出的逻辑,更取决于那个该触发它的信号,到底有没有真的送到它手上。
第二件事:正解——让应用直接当 PID 1,或用 init 转发信号
找到根因,正解就清晰了:要让容器的 SIGTERM 真正送到应用手里,就得保证应用(或一个会转发信号的 init)是 PID 1——用 exec 形式 的 CMD/ENTRYPOINT 让应用直接成为 PID 1;非用 shell 不可时(比如要展开环境变量)就在 shell 里用 exec 让应用顶替 shell;或者用 tini/dumb-init(docker run --init)作 PID 1,由它负责转发信号和回收僵尸进程。
# 错误: shell 形式, PID 1 是不转发信号的 sh, 应用收不到 SIGTERM
CMD java -jar app.jar
# 正解1(首选): exec 形式, 应用直接当 PID 1, SIGTERM 直达
ENTRYPOINT ["java", "-jar", "app.jar"]
# 正解2: 需要 shell 处理变量/管道时, 用 exec 让应用替换 shell 成 PID 1
CMD ["sh", "-c", "exec java -Xmx${MAX_HEAP} -jar app.jar"]
# ↑ 关键是 exec: 没有它, java 是 sh 的子进程, 信号又断了
# 正解3: 用 init 进程作 PID 1, 转发信号 + 回收僵尸(多进程场景尤其需要)
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["java", "-jar", "app.jar"]
# 或运行时: docker run --init your-image / K8s 用 shareProcessNamespace 等
# K8s 侧配套: 给优雅停机留足时间, 并配 preStop 兜底
apiVersion: v1
kind: Pod
spec:
terminationGracePeriodSeconds: 30 # 给应用收尾留够时间(按实际调)
containers:
- name: app
lifecycle:
preStop:
exec:
# 收到停止前先 sleep 几秒, 让流量摘除(等 endpoint 同步), 再停
command: ["sh", "-c", "sleep 5"]
# 配合: 应用收到 SIGTERM 后停止接新请求、处理完存量、关连接、退出
这套做法的精髓,是确保"容器停止信号的唯一收件人(PID 1)",要么就是我的应用本身,要么是一个会忠实把信号转发给我的应用的 init——绝不能是一个收了信号却不传话的裸 shell。exec 形式让应用直达 PID 1,是最简单的根治;sh -c "exec ..." 里的 exec 关键字,会用应用进程替换掉 shell、从而继承 PID 1;tini 这类 init 则专门干"转发信号 + 回收僵尸"的脏活。配上 K8s 的 grace period 和 preStop,优雅停机才真正闭环。不是只写优雅停机的逻辑,而是要确保那个触发它的信号,有一条畅通的路真正抵达应用。
【让容器优雅停机真正生效, 我现在认死的几条】
1. 容器停止信号(SIGTERM)只发给 PID 1, 别的进程收不到
2. shell 形式 CMD(CMD java ...)= sh 当 PID 1, 应用是子进程, 收不到信号
3. exec 形式 CMD(CMD ["java",...])= 应用直接当 PID 1, 信号直达(首选)
4. 必须用 shell 时, 用 exec 让应用替换 shell: sh -c "exec java ..."
5. 多进程/需回收僵尸: 用 tini/dumb-init 或 docker run --init 作 PID 1
6. 进容器 ps -ef 确认 PID 1 是应用或 init, 不是裸 /bin/sh -c
7. K8s 配 terminationGracePeriodSeconds + preStop, 给收尾和摘流量留时间
第三件事:其他"信号/职责被错误的中间层挡住"的同类坑
顺着"该收到信号/该履职的没收到、被一个不合适的中间层挡住了"这条线,我把同类的坑都排查了一遍:
第一个,用启动脚本(entrypoint.sh)拉起应用却不 exec。脚本最后 java -jar app.jar 而非 exec java ...,脚本(sh)是 PID 1、应用是子进程,信号同样到不了应用。
第二个,僵尸进程不被回收。shell 当 PID 1 通常不收割孤儿子进程,容器内僵尸越积越多,占满进程表。用 init(tini)作 PID 1 才会回收。
第三个,nohup/& 后台拉起应用,主进程先退出。用后台方式起应用,前台进程(PID 1)退出导致容器直接退出,应用还没起来或被一起带走。
第四个,多进程塞一个容器、没人当 init。一个容器跑多个进程又没有合适的 init,信号管理和回收全乱;要么拆成多容器,要么放个 init 进程统管。
第四件事:shell 形式 vs exec 形式 CMD——一张对照表
我把 Dockerfile 的两种 CMD 写法摆在一起对比,核心看"谁是 PID 1、信号到不到得了应用":
| 维度 | shell 形式 CMD app | exec 形式 CMD ["app"] |
|---|---|---|
| 实际执行 | /bin/sh -c "app" | 直接执行 app |
| PID 1 是谁 | /bin/sh | app 本身 |
| SIGTERM 发给谁 | shell(不转发) | app(直达) |
| 优雅停机 | 失效, 应用收不到信号 | 正常触发 |
| 回收僵尸进程 | shell 通常不回收 | app 一般也不(需 init) |
| 变量展开($VAR) | 支持 | 不支持(除非自己 sh -c exec) |
看清这张表,写法就有定论了:默认用 exec 形式让应用直接当 PID 1、信号直达;只有确实需要 shell 展开变量时才用 shell,且必须 sh -c "exec app";多进程或需回收僵尸再加 tini 这类 init。我这次踩坑,就是图简洁用了 shell 形式,让一个不转发信号的 sh 当了 PID 1,把 SIGTERM 挡在了应用门外。两种写法都能启动应用,但在"信号能否抵达"上判若云泥。
第五件事:我曾经对容器 PID 1 与信号想当然的几个误区
这次事故也把我对容器进程模型的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| CMD 启动应用, 应用就是容器主进程 | shell 形式下 PID 1 是 sh, 应用只是它的子进程 |
| 容器的 SIGTERM 会发给我的应用 | 只发给 PID 1; 不是 PID 1 就收不到 |
| shell 形式和 exec 形式效果一样 | 谁当 PID 1、信号能否到达, 完全不同 |
| 优雅停机代码写了就一定会生效 | 信号到不了应用, 代码再对也不触发 |
| Pod 删除卡 30 秒是 K8s 慢 | 是应用没响应 SIGTERM, 被等到超时强杀 |
这些误区的根子是同一个:我只关注了"应用有没有被启动起来",而忽略了"应用在容器进程树里处于什么位置、能不能收到本该给它的信号"。一个进程能不能响应停止信号,前提是它得真的收到那个信号;而在容器里,这取决于它是不是 PID 1、或有没有一个会转发信号的 init。我把"写了响应逻辑"当成了"一定能响应",却漏掉了"信号要先能送达"这个更底层的前提。把"我准备好了应对"当成"我一定会被通知",而忽略了通知的链路本身可能断在中途,是这类"逻辑没问题却不生效"事故的共同根源。
第六件事:写 Dockerfile、排查"优雅停机不生效/Pod 删除卡住"时,我现在的自检习惯
现在每当我写 Dockerfile、或排查"容器优雅停机不触发、Pod 删除总卡满 grace period",我都会先按这张图问自己:
这张图的精髓,是"容器 SIGTERM 只发给 PID 1;先确认 PID 1 是应用或会转发信号的 init,信号才到得了应用,优雅停机逻辑才有机会触发"。设计就用 exec 形式 CMD 让应用当 PID 1、或加 tini 作 init、排查就进容器 ps -ef 看 PID 1 是不是裸 sh、信号是不是断在那。这套习惯,让我从"写了优雅停机就放心"变成了"先确认信号能不能送到应用"——核心始终是:在容器(以及 Linux)里 PID 1 是一个有特殊职责的位置:容器的停止信号(docker stop 或 K8s 删 Pod 发的 SIGTERM)只发给 PID 1,同时 PID 1 还负责收养并回收孤儿/僵尸子进程;而 Dockerfile 的 CMD/ENTRYPOINT 有两种写法,exec 形式 CMD ["java","-jar","app.jar"] 会让应用直接成为 PID 1、容器的 SIGTERM 直达应用、优雅停机逻辑正常触发,shell 形式 CMD java -jar app.jar 则等价于 ["/bin/sh","-c","java -jar app.jar"]、让 shell 成为 PID 1 而应用沦为它的子进程,这个默认 shell 通常不会把收到的信号转发给子进程、也不主动响应 SIGTERM,于是信号卡在 PID 1 传不到真正干活的应用、应用永远收不到 SIGTERM、优雅停机代码再正确也从不触发、最终被等到 grace period 超时后 SIGKILL 强杀,而且 shell 当 PID 1 还往往不回收僵尸进程;正解是确保应用本身(exec 形式)或一个会转发信号的 init(tini/dumb-init、docker run --init)成为 PID 1,非用 shell 不可时用 sh -c "exec app" 让应用进程替换掉 shell 从而继承 PID 1,再配 K8s 的 terminationGracePeriodSeconds 和 preStop 给收尾与摘流量留时间;关键是应用能否优雅退出不只取决于它有没有写优雅退出逻辑,更取决于那个该触发它的信号有没有真的送到它手上。
我立下的几条规矩
这场"优雅停机代码写了却从不生效"的事故,换来了我写容器配置时,刻进骨子里的几条铁律:
- 容器停止信号(SIGTERM)只发给 PID 1,别的进程收不到。
- shell 形式 CMD = sh 当 PID 1、应用是子进程,信号到不了应用。
- 默认用 exec 形式 CMD/ENTRYPOINT,让应用直接当 PID 1、信号直达。
- 必须用 shell 时,用 exec 让应用替换 shell:sh -c "exec app"。
- 多进程/需回收僵尸,用 tini/dumb-init 或 docker run --init 作 PID 1。
- 进容器 ps -ef 确认 PID 1 是应用或 init,而不是裸 /bin/sh -c。
- 配 terminationGracePeriodSeconds + preStop,给收尾和摘流量留时间。
附:我现在容器优雅停机的"exec 形式 + tini + preStop"完整骨架
这是我现在做容器优雅停机固定套的骨架——把这次踩坑的教训(应用当 PID 1、信号直达、收尾留时间、摘流量)固化成一套配置,让"优雅停机不生效"那种坑再不会埋进部署:
# Dockerfile: 用 tini 作 PID 1 转发信号+回收僵尸, 应用用 exec 形式
FROM eclipse-temurin:21-jre
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
COPY app.jar /app/app.jar
# tini 当 PID 1, 负责把 SIGTERM 转发给 java、并回收僵尸进程
ENTRYPOINT ["/usr/bin/tini", "--"]
# exec 形式; 即使没有 tini, 应用也会是 tini 的直接子进程并收到转发的信号
CMD ["java", "-jar", "/app/app.jar"]
# 自检命令(构建后跑一下确认):
# docker run -d --name t your-image
# docker exec t ps -o pid,comm # PID 1 应是 tini, java 是其子进程
# docker stop t && docker logs t # 应看到应用打印"收到SIGTERM, 优雅停机中..."
# K8s Deployment: grace period + preStop 摘流量, 与应用 SIGTERM 处理配套
spec:
template:
spec:
terminationGracePeriodSeconds: 45 # 留够: preStop + 处理存量请求
containers:
- name: app
image: your-image
lifecycle:
preStop:
exec:
# 先睡几秒等 Service/Ingress 把本 Pod 摘出 endpoint, 再让应用收尾
command: ["sh", "-c", "sleep 8"]
readinessProbe: # 配合: 停机时探针失败 → 不再被分配新流量
httpGet: { path: /healthz, port: 8080 }
// 应用侧: 注册 SIGTERM/关闭钩子, 真正做优雅收尾(信号现在到得了它了)
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log.info("收到 SIGTERM, 优雅停机中...");
server.stopAcceptingNewRequests(); // 1) 停止接新请求
server.awaitInflightRequests(30); // 2) 等存量请求处理完(带上限)
pool.shutdown(); // 3) 关线程池/连接池等资源
log.info("优雅停机完成, 退出");
}));
这套骨架把我这次的教训钉死在了配置里:用 tini 作 PID 1 负责转发 SIGTERM 和回收僵尸、应用用 exec 形式 启动从而能直接收到信号、K8s 配 足够的 grace period 和 preStop sleep 先摘流量再收尾、应用侧 注册关闭钩子做停止接客→等存量→关资源的真正收尾,并用 ps 自检 PID 1、docker stop 验证日志确认链路通。这样,从 K8s 发出 SIGTERM 到应用优雅退出的整条链路都打通了,而不再是当初那个"信号被裸 shell 截在门外、应用浑然不觉被强杀"的局面。把"同时打通送达链路与处理逻辑、先确认信号真的抵达再谈如何应对"这个道理,沉淀成优雅停机的固定骨架,这是我对这次"每次都被强杀的 Pod"最实在的交代——毕竟,再周到的收尾准备,也得先让那声"该收摊了"的招呼,真正传到干活的人耳朵里。
写在最后
回头看,这场由"shell 形式 CMD"引发的"优雅停机从不生效"事故,真正教给我的,远不止"把 CMD 改成 exec 形式"这一个技巧。它让我对"一个系统要正确响应外部的指令,不仅取决于它内部'有没有准备好应对'的逻辑,更取决于那条'指令送达它的链路'是不是通畅;我们常常把全部心力放在打磨'应对逻辑'上,却默认'指令一定会送到',而真正让应对失效的,往往是那条我们从未审视过的、断在中途的送达链路",有了一次刻骨的体会。我栽跟头,是因为我把"我写好了响应停止信号的逻辑"当成了"我一定能响应停止信号",却漏掉了"这个信号要先能真正送达我的应用"这个更底层的前提——我把全部注意力放在了"收到 SIGTERM 后该怎么优雅地收尾"上,反复确认这段逻辑完美无缺;我从没想过去问"这个 SIGTERM,到底能不能到达我的应用?"——我默认它一定会到;可容器的世界里,信号只发给 PID 1,而我那行图简洁的 shell 形式 CMD,悄悄塞了一个不传话的 shell 当 PID 1,把信号截在了应用门外;于是我精心准备的应对,因为根本没收到触发它的信号,而沦为一段永远沉睡的代码。这让我领悟到一个关于"应对能力与送达链路"的深刻认知:任何"响应外部事件/指令"的能力,都由两个环节串联而成:一是"事件/指令能否真正送达"(链路),二是"送达之后能否正确处理"(逻辑);只有两者都通,响应才成立,任何一环断了,整条链就失效;而人的注意力天然偏向后者——"处理逻辑"是我们亲手写的、看得见摸得着、有成就感的部分,于是我们反复打磨它;前者——"送达链路"却常常被当成理所当然的背景、被默认为"肯定通的",从而成为整条链上最少被审视、却最容易悄然断裂的一环;尤其当链路中间隔着一些"看似透明、实则会拦截或不转发的中间层"(代理、包装脚本、网关、默认的壳)时,指令会在我们意想不到的地方就被吞掉,而我们还在徒劳地打磨那段永远等不到触发的应对逻辑。这给了我一种看待"一切'系统该响应某个外部信号却没响应'之事"时的清醒:每当我发现一个本该响应某指令的系统毫无反应时,要追问"问题是出在'它不会处理',还是出在'指令压根没送到它'?这条从指令发出到它接收的链路上,有没有一个中间层把指令拦下、吞掉、没转发"——先验证"信号到底有没有送达",再去怀疑"处理逻辑对不对",别只盯着应对逻辑而忽略了送达链路;"同时打通送达链路与处理逻辑、先确认指令真的抵达再谈如何应对",是做对容器优雅停机、也是让一切系统正确响应外部指令的关键。认清容器 SIGTERM 只发给 PID 1、shell 形式 CMD 让应用收不到信号、要用 exec 形式或 init 让信号直达——这,是我用一次"优雅停机从不生效、Pod 总被强杀"的事故,换来的、关于 DevOps、也关于如何同时守住应对逻辑与送达链路的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写 Dockerfile 的 CMD 时,先停一秒想想"这样写,我的应用会是 PID 1 吗?SIGTERM 到得了它吗?",并默认用 exec 形式,那我对着那个"每次都卡满 30 秒才被强杀"的 Pod 排查的大半天,就值了。
—— 别看了 · 2026