CI 上有个任务超时,脚本里写了 kill $PID,日志显示"已终止",然后流水线卡在最后一步——因为那个 mvn 派生的 java 还在跑,还占着 8080。另一头,docker stop 之后容器要等满 10 秒才退出,落盘的文件少了最后几行。
两次都是同一件事没想清楚:终止不是一个动作,是一条传递链。信号只送达你点名的那个 PID;之后是它自己决定要不要往下传、传什么、什么时候传。链上任何一环不转发,终止就停在那一环,而所有工具都认为"已经发了"。
这篇把这条链拆成三段:信号发给谁、谁负责优雅、以及什么时候不该优雅。
1. 信号只到你点名的那一个进程
kill 1234 把 SIGTERM 发给 PID 1234,仅此一个。如果 1234 是个 shell,它派生的子进程收不到任何东西——这不是 bug,是 POSIX 的默认语义。
| 写法 | 送达对象 | 什么时候用 |
|---|---|---|
kill 1234 |
单个进程 | 目标是独立进程 |
kill -TERM -1234 |
进程组 1234(负号) | 脚本/作业,要连子进程一起 |
kill -- -$$ |
当前 shell 所在的进程组 | 脚本退出前清理自己派生的全部 |
pkill -f pattern |
匹配到的所有进程 | 清理散落的同族进程 |
timeout 30 cmd |
命令本身,30 秒后 TERM | 给单条命令设上限 |
第三行是脚本里最常用也最少人写的一个。区别在于:kill 1234 之后你的脚本可以正常退出,而子进程变成孤儿被 init 收养,继续占端口、继续写日志、继续吃 CPU。
// 反例与正例的差别:只有后者会一起终止
$ bash -c 'sleep 300 & echo $$; wait' & # 假设输出 4242
$ kill 4242 # sleep 300 仍在,成了孤儿
$ bash -c 'sleep 300 & echo $$; wait' &
$ kill -TERM -4242 # sleep 与 bash 一起收到 TERM
// 脚本里更稳的写法:trap 里对自己整个进程组发信号
$ trap 'kill -TERM -- -$$' EXIT
2. 三层终止:谁负责"优雅"
"优雅停机"不是应用的独角戏,它由三层分担,缺一层就会出现"进程没了、状态没清"。
| 层 | 它做的那一步 | 不做会怎样 |
|---|---|---|
| 编排层(systemd / docker / k8s) | 先摘流量,再发 TERM,超时后 KILL | 还在收请求就被杀,用户看到 502 |
| 进程管理器(shell / tini / 应用主线程) | 把 TERM 转给子进程 | 子进程成孤儿,占着端口 |
| 应用 | 停接新活、等在途、释放资源 | 连接泄漏、临时文件残留、消息重复消费 |
三层的顺序不能颠倒。最常见的错误是"编排层直接 SIGKILL,应用却没机会释放",于是下次启动时数据库连接数打满、或者消费者组里有未提交的 offset。
// 一次正确的停机时序(30 秒宽限)
t=0 编排层: 从 LB 摘除 → 停止接受新连接
t=0 编排层: 发 SIGTERM 给 PID 1
t=0 PID 1: 转发给 worker
t=1 应用: 健康检查转 not-ready,连接池开始 drain
t=1..20 应用: 等在途请求结束(最长 20s)
t=20 应用: 关闭连接池、flush 缓冲、提交 offset
t=21 应用: exit 0 → 退出码 0,编排层认为"干净"
// 若 30s 未退:SIGKILL → 退出码 137,所有清理都丢了
3. 容器里那道最经典的墙:PID 1 不转发信号
容器里的 PID 1 有个特殊行为:它不会为没有 handler 的信号执行默认动作。也就是说,如果镜像的入口是 CMD java -jar app.jar 之外的 CMD sh -c "java -jar app.jar",那么 PID 1 是 sh——sh 收到 SIGTERM 后既不退出也不转发,于是 docker stop 只能等满 10 秒再用 SIGKILL。
| 入口写法 | PID 1 | docker stop 的行为 |
|---|---|---|
CMD ["java","-jar","app.jar"] |
java | 正常收 TERM,优雅退出 |
CMD java -jar app.jar(shell form) |
sh | 不转发,等满 10 秒后 KILL |
ENTRYPOINT ["/tini","--"] + 上面 |
java | tini 转发并回收僵尸 |
CMD ["sh","-c","exec java -jar app.jar"] |
java(被 exec 替换) | 正常 |
第三行是标准修法:装一个 init(tini / dumb-init),它负责转发信号与回收孤儿。第四行是"不改镜像也能修"的写法——exec 让 shell 被 java 替换,PID 1 就变成 java 了。
// 两种等价修法,选一种即可
// 修法 A:exec 替换
CMD ["sh", "-c", "exec java -jar /app.jar --spring.profiles.active=prod"]
// 修法 B:加 init,顺带解决僵尸回收
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["java", "-jar", "/app.jar", "--spring.profiles.active=prod"]
// 注意 tini 必须是 ENTRYPOINT 的第一个参数,否则它自己也会变成中间层
4. 退出码是停机质量的读数
137 与 143 不是两个随便的数字,它们等于 128 + 信号编号。读这两个数就能判断"停机是否体面"。
| 退出码 | 含义 | 通常说明 |
|---|---|---|
| 0 | 正常退出 | 应用自己决定退出,清理已完成 |
| 143 | 128 + 15(SIGTERM) | 收到 TERM 后退出,未捕获则默认终止 |
| 137 | 128 + 9(SIGKILL) | 被强杀,宽限期用完或 OOM |
| 130 | 128 + 2(SIGINT) | Ctrl-C |
| 1 / 2 | 应用自定义 | 配置错误、启动失败 |
关键区分在 143 与 137 之间:143 是"我收到了,我退了";137 是"没等我处理"。CI 日志里频繁出现 137,说明某处的宽限期不够,或者进程卡在了不可中断的等待里。
// 判断一个容器为什么退出:先看码,再看最后一条日志
$ docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' weblog-backend-blue
137 false # 不是 OOM,是宽限期用尽被 KILL
$ docker inspect -f '{{.State.Error}}' weblog-backend-blue
// 若 OOMKilled=true,问题在内存,不在停机逻辑——别改 StopTimeout
5. 应用侧该做什么:三件固定的事
应用收到 SIGTERM 之后要做的不是"立刻退出",而是三件按顺序的事。很多人只做第三件(退出),于是前两件变成了副作用。
| 顺序 | 动作 | 时长预算 | 不做会怎样 |
|---|---|---|---|
| 1 | 健康检查转 not-ready | < 1 s | 摘流量前还收新请求,502 |
| 2 | 等在途请求完成 | 5–20 s | 响应截断、客户端重试放大流量 |
| 3 | 释放资源(池、锁、文件、offset) | < 2 s | 连接泄漏、重复消费、临时文件堆积 |
第二项的时长必须小于编排层的宽限期,否则第 3 项永远没机会跑。这三件事的预算加起来要小于 docker stop --time 或 systemd 的 TimeoutStopSec,这是一个纯算术问题,不是态度问题。
// 顺序错了就白写:先摘流量,再等,再释放
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
readiness.set(false); // 1. 让 LB 把本实例摘掉
sleep(2000); // 给 LB 一点传播时间
pool.awaitQuiescence(15, SECONDS); // 2. 等在途请求
dataSource.close(); redis.close(); // 3. 释放资源,最后一步
}));
// 陷阱:shutdown hook 只有收到 TERM/INT 才跑,SIGKILL 直接跳过
6. 排错:五种"kill 了还在跑"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| kill 后子进程还在 | 只发给单 PID,未发进程组 | kill -TERM -PGID 或 trap 清理 |
端口仍被占,ss 看不到进程 |
进程在 TIME_WAIT / 属于别的 netns | ss -lntp + 查 netns |
| docker stop 等满 10 秒 | PID 1 是 shell,不转发 | exec 形式或 tini |
| 退出码 137 且 OOMKilled=false | 宽限期小于清理耗时 | 调 StopTimeout 或缩短等待 |
| 重启后连接数打满 | 上次 SIGKILL,池没关 | 修宽限期,别只加连接数 |
第三行的判别要精确:OOMKilled=false 的 137 是时间问题,OOMKilled=true 的 137 是内存问题。同一个退出码,两个完全不同的修法,混了就白折腾。
7. 边界:什么时候不该优雅
| 场景 | 为什么不该优雅 | 做法 |
|---|---|---|
| 进程死锁 / 无限重试 | 它在等一个永远不会来的东西 | 直接 SIGKILL |
| 内存已失控(RSS 暴涨) | 继续等只会拖垮同机其他服务 | SIGKILL + 事后取证 |
| 状态已不可信(写坏了数据) | 优雅退出会"固化"坏状态 | SIGKILL,人工介入 |
| 容器已失联(node NotReady) | 信号根本送不到 | 由编排层强制回收 |
判断口径一句话:优雅停机的前提是"进程还讲道理";一旦它不讲道理,SIGKILL 才是正确选项,而不是妥协。真正要设计的是"优雅失败"——保证被强杀之后,重启能自愈(幂等消费、连接池重建、临时文件带时间戳),而不是保证"永远能优雅"。
8. 自测
kill 1234与kill -TERM -1234的送达范围差别是什么?为什么脚本里更容易踩后者?- 停机链上的三层各负责哪一步?顺序颠倒会出现什么症状?
- 容器里 PID 1 为什么不转发 SIGTERM?两种标准修法分别是什么?
- 退出码 143 与 137 的语义差别是什么?137 的两种成因怎么区分?
- 应用侧三件事的时长预算必须满足什么不等式?为什么?
- 什么情况下 SIGKILL 才是正确选择?这时该补的是哪类设计?
9. 一句话总结
终止是一条传递链,不是一次点击:信号只送达点名的 PID,之后靠每一环主动转发——shell 要发给进程组、容器要用 exec 或 tini 让 PID 1 变成真正的应用、编排层要先摘流量再发 TERM。判据是退出码:143 是"收到了并退了",137 是"没等它处理",而 137 要再分一次 OOM 与超时。至于不该优雅的场景,正确的设计不是加长宽限期,而是让强杀之后的重启能自愈。