一、现象与影响面:退出码是进程的遗言
容器”莫名消失”的标准现场:docker ps -a 里躺着一排 Exited (137);或者服务每天凌晨定点死一次;或者 docker stop 一次要卡十秒才退。进程退出前没来得及说的,都写在退出码里——先读懂它,再决定动不动手。
反模式一:看到 Exited 就 up -d 拉起来接着跑。重启消灭现场,”偶发死亡”和”定点死亡”是两种完全不同的病,被搅在一起就都治不了。退出码、inspect 状态、内核日志三样东西两分钟看完,先取证。
排查主线四层:读退出码(语义对号)→ docker inspect(状态字段)→ dmesg(内核目击证词)→ 应用层(日志与信号处理)。一层有结论就停,没有就往下走。
二、第一层:读懂退出码——128+N 规则
Linux 的约定:进程被编号为 N 的信号杀死,退出码就是 128+N。Docker 如实上报这个数字,所以退出码表一半是信号表:
| 退出码 | 语义 | 常见来源 |
|---|---|---|
| 0 | 正常退出 | 程序自己 exit(0),或优雅退出流程走完 |
| 1 | 应用通用错误 | 未捕获异常、代码里主动 exit(1)——最高频的”应用自己的锅” |
| 125 | Docker 本身报错 | run 参数非法、容器创建失败,应用可能根本没跑起来 |
| 126 | 命令找到但不可执行 | 忘了 chmod +x、shebang 指错解释器 |
| 127 | 命令不存在 | CMD/ENTRYPOINT 拼错、镜像里没装、PATH 不对 |
| 130 | 128+2,SIGINT | 终端 Ctrl+C 打断 |
| 134 | 128+6,SIGABRT | C/C++ 的 abort() 或断言失败 |
| 137 | 128+9,SIGKILL | OOM killer、docker kill、人为 kill -9——本文主角之一 |
| 139 | 128+11,SIGSEGV | 段错误,多为原生库崩溃 |
| 143 | 128+15,SIGTERM | docker stop 与编排系统的正常停止流程——本文另一位主角 |
# 宿主机上
docker ps -a --format '{{.Names}} {{.Status}}'
1. api Exited (137) 2 minutes ago
1. worker Exited (143) About an hour ago
docker logs --tail 100 api # 死前最后一屏应用日志,先扫一眼有没有堆栈
分诊:退出码 1 → 直接进应用日志,本文帮不上太多;125/126/127 → 检查启动命令与镜像,问题在容器配置不在运行时;137/139/143 → 继续往下。
三、第二层:docker inspect——137 有两种死法
退出码只能说明”被什么等级的暴力对待过”,inspect 补充”谁干的”:
# 宿主机上;format 一行读全关键字段
docker inspect api --format 'Code={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}} Started={{.State.StartedAt}} Finished={{.State.FinishedAt}}'
1. Code=137 OOMKilled=true Error= Started=... Finished=...
docker inspect api --format '{{.RestartCount}}' # 编排系统拉起过几次
| 读数组合 | 结论 | 下一步 |
|---|---|---|
| 137 + OOMKilled=true | cgroup 限额 OOM:容器吃破自己的 --memory 被内核杀 |
内存专项,见延展阅读 OOM 一篇 |
| 137 + OOMKilled=false | 用户态有人动用 SIGKILL:docker kill、脚本 kill -9,或宿主全局 OOM 殃及 | dmesg 区分,进第四节 |
| 143 | 收到 SIGTERM 后退出;本应优雅收尾却秒退,多半没处理信号 | 对照 stop 流程,进第五节 |
| 139 | 原生代码段错误 | dmesg 找 segfault 现场,进第四节 |
FinishedAt 减 StartedAt 是存活时长,这个数字自带信息量:启动几秒就死是启动崩溃循环,去日志看第一屏;稳定运行几天后死是运行期触发(内存爬到限额、定时任务、外部 kill)。RestartCount 不为零说明上面还有自动重启在兜底——别被”服务还在跑”骗了,它可能正处在崩溃循环里。
这层有结论就停;OOMKilled=false 的 137 和 139,往下找内核。
四、第三层:dmesg——内核的目击证词
内核动手时一定留记录。以下命令在宿主机上执行:
# 宿主机上
dmesg -T | grep -iE 'killed process|oom|segfault|general protection'
1. Out of memory: Killed process 4837 (node) ... ← 全局 OOM 的标准句式
1. traps: app[1234] general protection ip:... sp:... in libc... ← 段错误现场
journalctl -k --since "-24 hours" | grep -iE 'oom|segfault' # dmesg 环形缓冲被冲掉时的兜底
三种结论:日志出现 “Out of memory: Killed process” 且不带 Memory cgroup 字样,是宿主全局 OOM 殃及容器——真凶是整机内存预算,不是这个容器(它只是 rss 最大被挑中);出现 segfault 或 general protection,则 139 实锤,嫌疑集中在原生依赖(C 扩展、cgo、JNI),二分法回滚依赖版本比盯着地址猜快得多;什么都没有,说明 SIGTERM 或 SIGKILL 来自用户态(脚本、编排、人),查运维操作记录与编排配置。
这层走完,137 的来源必然水落石出;143 与”该优雅退出却没退”的场景,进应用层。
五、第四层:应用与信号——为什么 docker stop 总卡十秒
先补一个流程事实:docker stop 先发 SIGTERM,等默认 10 秒,还没退就补 SIGKILL。所以 stop 卡十秒、最后以 137 收场,等于应用宣告”我没处理 SIGTERM”。
最常见的根因是 PID 1 问题:容器里只有 PID 1 能直接收到 docker 发的信号。shell 形式写的 CMD(如 CMD npm start)实际执行的是 /bin/sh -c "npm start",PID 1 是 sh,它不转发信号,node 进程永远不知道要退场,十秒后被补刀。Dockerfile 的 exec 形式让应用自己坐上 PID 1:
# 反例:sh 占着 PID 1,SIGTERM 到 sh 就断了,node 收不到
CMD npm start
1. 正例:exec 形式,node 自己是 PID 1,信号直达
CMD ["node", "server.js"]
1. 兜底:镜像改不了时,--init 让 Docker 挂一个会转发信号的 init 进程当 PID 1
docker run --init myimage
反模式二:以为应用坐上 PID 1 就万事大吉。坐上 PID 1 只是”收到信号”,优雅退出还要自己写:先停止接新请求,再等在途请求走完,最后自己以 0 退出。以 Node 为例——注意 Node 的机制:一旦你自己注册了 SIGTERM 处理器,默认行为(直接以 128+信号号 退出)就被替换,process.exit 要自己调:
// server.js —— 优雅退出:先停止接客,再收拾桌子,最后自己退场
const http = require('http');
const server = http.createServer((req, res) => {
// 模拟在途请求:50ms 后才应答
setTimeout(() => res.end('ok'), 50);
});
let draining = false;
function shutdown(signal) {
if (draining) return;
draining = true;
console.log(`${signal} received, closing server...`);
// server.close:不再接受新连接,等存量请求处理完再回调
server.close(() => {
console.log('all connections drained, exit 0');
process.exit(0); // 自己处理了退出,就自己给 0,别留 143
});
// 兜底:keep-alive 连接可能一直挂着,超时强退,避免被 docker stop 的 SIGKILL 补刀
setTimeout(() => process.exit(0), 8000).unref();
// Node 18.2+ 可顺手掐掉空闲连接,让 drain 阶段更快结束
if (server.closeIdleConnections) server.closeIdleConnections();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.listen(3000);
两个工程细节:compose 里给清理耗时长的服务配 stop_grace_period(默认 10 秒,数据库类进程往往不够);优雅退出后以 0 退出,监控才不会把正常发布当成事故——退出码是给机器看的信号,别浪费。
六、根因清单:按出现频率排序
| 根因 | 退出码特征 | 一行验证 |
|---|---|---|
| cgroup 限额 OOM | 137,OOMKilled=true | docker inspect 看 OOMKilled |
| 应用未捕获异常 | 1,日志有堆栈 | docker logs 最后一屏 |
| entrypoint 拼错或文件缺失 | 127 | logs 第一屏找 not found |
| 忘 chmod +x | 126 | logs 找 permission denied |
| SIGTERM 不处理被补刀 | stop 卡十秒后 137 | 掐表计时,对照第五节自查 |
| 宿主全局 OOM 殃及 | 137,OOMKilled=false,dmesg 有全局 Killed | dmesg -T 找 oom 字样 |
| 人为或脚本 kill -9 | 137,OOMKilled=false,dmesg 无记录 | 查操作记录与定时脚本 |
| 原生库段错误 | 139 | dmesg -T 找 segfault |
七、预防
| 措施 | 做什么 | 边界 |
|---|---|---|
| exec form 全员化 | 镜像评审盯 CMD/ENTRYPOINT 写法 | 改不了的旧镜像用 --init 兜底 |
| 优雅退出标配 | 长驻服务一律注册 SIGTERM 处理 | 长连接类应用 drain 逻辑复杂得多,十秒未必够 |
| stop_grace_period 按需调 | 按清理耗时的实测值给 | 太长会拖慢发布与缩容 |
| 退出事件监控 | cron 巡检各容器 inspect 的 OOMKilled 与 ExitCode,异常对接告警 | 第一次事故永远靠人发现,监控为第二次而生 |
这套走法收拢成一句:先读码(128+N 对号入座),再问 Docker(inspect 的 OOMKilled 与存活时长),再问内核(dmesg 目击证词),最后问应用(信号处理与日志)。顺序反了就会在应用日志里空手而归——SIGKILL 不给被杀者写遗言的机会。
延展阅读:137 的内存专项(限额怎么给、泄漏怎么抓)见《OOM 与内存泄漏的定位》;性能类故障先分诊是 CPU 还是别的,见《CPU 100% 的定位路径》;容器跑在什么样的部署形态里,见《多节点集群架构规划》。
版本核实(以官方文档为准):128+N 为 shell 信号退出码的稳定约定;Docker 保留的 125/126/127 语义、docker stop 默认 10 秒宽限、–init、exec 与 shell 两种 CMD 形式的信号行为均为长期稳定文档;Node 默认 SIGTERM 行为(以 128+信号号 退出,注册处理器后需自行调用 process.exit)出自 Node 官方文档,closeIdleConnections 自 Node 18.2 起提供。以上随版本演进,以 Docker 与 Node 官方文档为准。