KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 信号与优雅停机:停止是谁的命令,信号发给了谁 — keel 龙骨
这一章回答:容器停止时信号经过哪几跳到达你的进程?为什么写在应用里的优雅退出逻辑可能一次都没被执行?
这一章回答:容器停止时信号经过哪几跳到达你的进程?为什么写在应用里的优雅退出逻辑可能一次都没被执行?
第 04 章说了"编排层怎么把服务组织起来",其中留了一个尾巴:healthcheck 判为 unhealthy 时容器不会被重启。这一章处理它的对偶问题——容器真的要停下来时,谁通知你的进程,以及你的进程有没有机会把正在进行的工作做完。
这是本课里"本地绝不会暴露、生产必踩"的典型问题:本地你按 Ctrl-C,或者在终端里杀了进程,一切都"看起来正常";而生产上容器是被编排系统停止的,走的是另一条路径。
现场
一次滚动发布。发布完成后有人发现有三笔订单的状态停在"处理中",日志里各自只写到一半,没有任何异常堆栈。
查容器的终止记录,看到:
api container exited with code 137
而应用代码里明明有优雅退出的逻辑:
process.on('SIGTERM', () => {
console.log('SIGTERM received, draining');
server.close(() => process.exit(0));
});
排查时的争论是"137 是不是 OOM"。不是——137 = 128 + 9,9 是 SIGKILL 的编号。所以真正的问题是:为什么进程收到了 SIGKILL,而不是先收到 SIGTERM 走正常退出? 那段回调里的 SIGTERM received, draining 一次都没出现在日志里。
一、停止不是一个动作,是三段时序
编排层停止一个容器的流程是固定三段:
| 段 | 动作 | 能不能被应用影响 |
|---|---|---|
| 第一段 | 向容器内的 PID 1 发送 STOPSIGNAL(默认 SIGTERM) |
能被处理:应用可以捕获并做排空 |
| 第二段 | 等待一段超时窗口 | 能被配置:--stop-timeout / stop_grace_period / stop_timeout |
| 第三段 | 窗口耗尽仍在运行 → 发送 SIGKILL |
不能被捕获、不能被忽略、不能被阻塞 |
第三段是内核行为:SIGKILL 无可商量。所以所有"优雅"的机会都在第二段的时间窗口里。这也是为什么"137"这个退出码的含义不是"被杀",而是"它没能在那段窗口里自己退出来"——它是结果,不是原因。
二、PID 1:容器里的第一个进程有特殊规则
容器里的第一个进程是 PID 1。这个位置在内核里有一条特殊规则:
对 PID 1,未注册处理器的信号,其默认动作不会被应用。
翻译成实用结论:
- 一个普通进程收到
SIGTERM且没注册处理器时,默认动作是终止; - 但 PID 1 收到
SIGTERM且没注册处理器时,默认动作被忽略——它不会退出。
这条规则的存在理由很合理:PID 1 通常是一个 init 系统,它不该因为收到一个信号就死掉,否则整个容器就没有父亲进程了。但后果是:在容器里,你的应用必须自己注册信号处理器,否则它会一直等到被 SIGKILL。
这也是"容器里的 PID 1 不该是一个不转发信号的 shell" 这个常见建议的来源——下一节。
三、谁收到信号:CMD 的形式决定了这件事
CMD(以及 ENTRYPOINT)有两种写法,它们的执行契约不同:
CMD ["node", "dist/index.js"] # exec 形式:数组 → PID 1 就是 node
CMD node dist/index.js # shell 形式:字符串 → 实际执行 /bin/sh -c "node dist/index.js"
第二种写法多了一层 shell,于是 PID 1 变成了 /bin/sh,而你的应用是它的子进程。默认情况下 sh 不会把收到的信号转发给子进程,所以:
编排层发 SIGTERM → PID 1 = /bin/sh 收到 → sh 不做转发,也不自己退出
→ 你的 node 进程从头到尾没收到任何信号
→ 等待窗口耗尽 → SIGKILL → 退出码 137
这就是现场那个 bug 的根因。 代码里那段 SIGTERM 回调是正确的,它只是从来没被触发过——信号停在了上一层。
证据:解析结果里,命令是数组还是字符串
本机的 compose.yml 里,migrate 服务的命令写成 JSON 数组:
migrate:
image: orders-api:${APP_TAG:-dev}
command: ["node", "dist/migrate.js"]
docker compose config 把它解析成(本机实测):
migrate exec 形式(数组): ["node","dist/migrate.js"]
数组形态 = exec 形式。 如果写成字符串 command: node dist/migrate.js,解析结果会是一个字符串,那就落进了 shell 形式。所以"我这份配置用的是哪种形式"有一个确定的检查方法,不靠读文件时眼睛扫一遍。
同一份实验材料里的两个 Dockerfile,CMD 都写成了 JSON 数组:
Dockerfile.single:11:CMD ["node", "src/index.js"]
Dockerfile.multi:30:CMD ["node", "dist/index.js"]
这是刻意的:它们要能被 SIGTERM 直接送达。作为对照,Dockerfile.multi 里的 HEALTHCHECK 用的是 shell 形式(HEALTHCHECK ... CMD node -e "...")——这在健康检查的场景里是可以接受的,因为健康检查只看退出码,不涉及信号传递(第 03 章第五节)。
完整链路
把三段时序、PID 1 规则与两种 CMD 形式画在一起:
flowchart TD
A["① docker stop api<br/>或发布时的滚动停止"] --> B["② 向容器内 PID 1 发送 STOPSIGNAL<br/>(默认 SIGTERM)"]
B --> FORM{"③ CMD 是哪种形式?"}
FORM -->|"exec 形式:JSON 数组"| APP["PID 1 = 应用进程本身"]
FORM -->|"shell 形式:字符串"| SH["PID 1 = /bin/sh -c ..."]
SH --> CH["应用是 sh 的子进程"]
CH -.->|"sh 不转发信号<br/>SIGTERM 停在这一层"| LOST["应用从未收到 SIGTERM<br/>优雅退出逻辑一次都没跑"]
APP --> H{"④ 应用注册了<br/>SIGTERM 处理器吗?"}
H -->|"没有注册"| IGN["PID 1 的默认终止动作被内核忽略<br/>进程不退出"]
H -->|"已注册"| DRAIN["⑤ 排空:停止接受新连接<br/>→ 等存量请求完成<br/>→ 释放连接池等资源<br/>→ exit 0"]
DRAIN --> OK["⑦ 退出码 0<br/>在途请求已处理完"]
IGN --> WAIT
LOST -.-> WAIT
B --> WAIT{"⑥ 等待窗口<br/>默认 10s<br/>stop_grace_period 可覆盖"}
WAIT -->|"应用已自行退出"| OK
WAIT -->|"窗口耗尽"| KILL["发送 SIGKILL<br/>不可捕获、不可忽略"]
KILL --> CRASH["⑦ 退出码 137<br/>在途请求被切断"]
读懂这张图的关键是两条通向失败的路:
- 第 ③ 跳走右边的分支 → 信号根本没到应用(现场那个 bug);
- 第 ③ 跳走对、但第 ④ 跳"没有注册处理器" → PID 1 规则让进程不退出,最后仍被
SIGKILL。
两条路的终点都是第 ⑦ 跳的 137 与"在途请求被切断",但根因完全不同。这就是为什么"看到 137 就怀疑 OOM"会把人带偏:退出码只说结果,图上第 ③、④、⑥ 跳才是要找的地方。
四、应用侧:排空该做什么,按什么顺序
写进应用的 SIGTERM 处理器应该按固定顺序做四件事:
process.on('SIGTERM', () => {
console.log('SIGTERM received, draining');
// ① 停止接受新连接(listener 关掉,正在排队的请求不再进来)
server.close(() => {
// ③ 存量请求都完成后,释放资源
db.end(() => process.exit(0)); // ④ 以 0 退出 → 编排层知道这是"正常停止"
});
// ② 在此之后到来的连接会被拒绝或超时——这也是为什么要先让 LB 摘流量
});
| 步骤 | 做什么 | 不做会怎样 |
|---|---|---|
| ① 停止接收 | 关闭监听套接字 | 新请求持续进来,永远排不空 |
| ② 等存量完成 | 等正在处理的请求结束 | 在途请求被切断(现场那三笔订单) |
| ③ 释放资源 | 关连接池、flush 缓冲、提交事务 | 数据丢失或半提交 |
④ exit 0 |
用正常退出码结束 | 编排层会把它当成异常退出,触发告警或重启策略 |
两个常见错法值得单独点出来:
- 收到信号立刻
process.exit(0):退出码是"正常"的,但所有在途请求被瞬间切断。退出码好看不代表没有损失。 - 只关 listener、不等回调就结束:
server.close()是异步的,它的回调才是"存量都处理完了"的信号。不等回调就退出,等于没排空。
还有一件不在应用里、但同样关键的事:先让上游把流量摘掉,再发信号。 如果没有这一步,应用在第 ① 步关掉监听之后,负载均衡层可能还在往它转发请求,那些请求会拿到连接错误。所以完整的停机序列是上游摘流量 → 发 SIGTERM → 排空 → 退出,三层的时间必须彼此兼容——下一节。
五、时间预算:编排层的窗口必须覆盖应用的排空时间
这是本章最容易被忽略、影响又最大的一处。停机的总时间预算由三层叠成:
① 上游摘流量所需时间(LB 停止转发,或健康检查连续失败到被摘除)
↓ 必须 ≤
② 应用自己的排空窗口(等存量请求完成 + 释放资源)
↓ 必须 ≤
③ 编排层的等待窗口(docker stop 默认 10s / stop_grace_period / stop_timeout)
↓ 超过则
SIGKILL(第 ⑦ 跳的 137)
本机实验文件里,api 显式写了等待窗口(docker compose config 实测):
api stop_grace_period = 10m0s
db stop_grace_period = (未设置)
migrate stop_grace_period = (未设置)
对照 Dockerfile.multi 里显式的 STOPSIGNAL SIGTERM,这里能读出完整的意图:信号用 SIGTERM,等待窗口给到 10 分钟。
由这条链条能推出一条很难在事后发现的结论:
如果编排层的等待窗口短于应用的排空窗口,应用里那个更长的等待配置永远不会被执行到。
举例:应用里设了 600 秒的排空窗口(一个正在跑的长任务要跑完),而编排层没改默认值、10 秒就 SIGKILL——那 600 秒这个数字在生产里从未生效过。它在本地也测不出来,因为本地你不太可能等满 10 秒去观察。这类配置错误的特征是"三层分别在三个文件里,每一层单独看都对"。
三个可执行的判断:
- 三层的值必须显式写出来,并且写在一处可核对的地方(例如运维文档的一张表),别指望三处巧合地兼容。
- 排查思路要按层次走:应用说自己排空了但没排空 → 先看第 ③ 层(编排层窗口是不是不够),再看第 ② 层(应用有没有真等回调),最后看第 ① 层(上游摘流量了吗)。
docker stop的默认窗口是 10 秒,对"等一个长任务结束"这类需求明显不够。这个默认值来自 CLI/引擎侧,需要用--stop-timeout(docker run)或stop_grace_period(compose 服务级)覆盖。
STOPSIGNAL 这一项也值得说明:它声明该发哪个信号,默认就是 SIGTERM。显式写它的价值不在改变行为,而在把契约写下来——让读这份 Dockerfile 的人知道应用是按"收到 SIGTERM 后开始排空"设计的,而不是指望某个信号之外的机制。
(K8s 里对应的字段是 terminationGracePeriodSeconds,语义与这里的等待窗口一致——这也是本课与下一门 K8s 课之间最直接的继承点。)
生产边界
| 教学替身 | 真实替换点 | 要注意什么 |
|---|---|---|
docker stop / docker compose down |
编排系统的滚动更新、缩容、驱逐 | 停机路径不同(docker stop、compose down、节点驱逐)但三段时序与 PID 1 规则相同;逐条确认每种路径用的窗口 |
单机 compose 的 stop_grace_period: 10m |
K8s 的 terminationGracePeriodSeconds + preStop 钩子 |
K8s 还有"从 Endpoints 摘除"这一跳,第 ① 层的延迟更容易变成主导项 |
| 应用里自查排空日志 | 用连接排空指标 + 停机耗时分布做长期观测 | 只看单次日志会漏掉"偶发长任务"那部分样本;要按 p95/p99 而不是平均值评估窗口 |
| 示例中几十行的 HTTP 服务 | 有长任务、有连接池、有消息消费者的真实服务 | 消费者/worker 的排空比 HTTP 复杂:要停止拉新消息、把已拉取的处理完、再提交位点 |
动手
- 检查你项目里所有
CMD/ENTRYPOINT:是不是 JSON 数组(exec 形式)?有 shell 形式的,改成数组; - 检查应用有没有注册
SIGTERM处理器,以及它是否按"停止接收 → 等存量 → 释放 →exit 0"的顺序做; - 把三层的等待时间写下来核对:上游摘流量时间 / 应用排空时间 / 编排层窗口,确认单调递增;
- 判断标准:能说出"我的服务从收到信号到进程结束,最长需要多久",并确认编排层窗口大于它;
- 完成标志:停一次容器,日志里能看到排空记录,且退出码是 0 而不是 137(若为 137,按图上的第 ③、④、⑥ 跳逐个排查)。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
把 CMD 从 exec 形式改成 shell 形式 |
停止时日志里还有没有排空那行 | 信号停在 shell 层,应用从未收到(第 ③ 跳走错分支) |
去掉应用里的 SIGTERM 处理器 |
进程是否自行退出、退出码 | PID 1 的默认终止动作被忽略 → 只能等 SIGKILL |
把应用里的排空回调 server.close(cb) 改成立即 process.exit(0) |
在途请求的完成率 | 退出码是 0,但存量请求被切断——退出码好看不等于无损 |
把 stop_grace_period 改得比应用排空时间短 |
退出码 | 应用侧的长等待永不生效(窗口耗尽即 SIGKILL) |
| 让一个 worker 在处理消息时收到停止信号 | 消息是否被处理完、位点是否提交 | 消费者的排空需要"停止拉取 → 处理完在途 → 提交位点"三步 |
自测
- 容器停止的三段时序分别是什么?哪一段是应用可以影响的?
SIGKILL为什么不能被捕获? exit code 137由什么算出?为什么它只是结果而不是原因?- "PID 1 的默认终止动作被忽略"这条内核规则,对"我在容器里跑一个没注册信号处理的程序"意味着什么?
CMD ["node","a.js"]与CMD node a.js在信号传递上的差别是什么?有什么命令可以在不启动容器的情况下判断一份配置用的是哪种形式?- 应用里设的排空窗口比编排层的等待窗口更长,会发生什么?为什么这个错误在本地很难被发现?
- 完整的停机序列包含"上游摘流量"这一步。为什么它必须排在发
SIGTERM之前?
现在能解释什么
- 停止是三段时序(TERM → 等待 → KILL),所有"优雅"的机会都在中间那段窗口里。
- 容器里 PID 1 的信号语义是特殊的:未注册处理器的信号,默认动作被忽略。
CMD的 exec 形式让应用直接成为 PID 1,shell 形式会插入一个不转发信号的 shell——这是"优雅退出从未执行"的最常见根因。- 排空必须按"停止接收 → 等存量 → 释放资源 →
exit 0"的顺序;退出码 0 不等于没有切断请求。 - 停机时间预算是三层的(上游摘流量 / 应用排空 / 编排层窗口),编排层窗口短于应用排空时间时,后者永远不生效。
- 退出码 137 是结果,排查要回到图上第 ③、④、⑥ 跳。
下一章把镜头转到"已经跑起来但行为不对"的容器:怎么查、exit 137 之外的异常怎么读,以及哪些问题根本不该用容器解决。