周五下午 5 点 50 分,离下班还有十分钟,我已经把电脑包拎在手上。钉钉“叮”了一下:“报表导出服务又挂了,一会儿好一会儿不好,你看看?”后面跟了一个我最讨厌的表情:一个笑着的小人。
上机器一看,docker ps 里这个容器的状态栏是 Restarting (137) 12 seconds ago。它像个不肯下班的同事:每隔几十秒就“重生”一次,刚活过来就被人一刀带走。日志?空空如也。这篇把排查过程整理成备忘录——重点是怎么区分“被 OOM 杀了”“被人 kill 了”“自己崩了”,以及 restart: always 是如何把一个小毛病放大成“重启风暴”的。
一、现象:容器在“循环重生”
先看全局,别急着 docker restart(那是重启的重启,现场只会更乱):
$ docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}'
NAMES STATUS CREATED
report-export Restarting (137) 12 seconds ago 3 days ago
report-gateway Up 3 days 3 days ago
redis Up 3 days 3 days ago两个关键信息:
Restarting:说明这个容器配置了自动重启策略,而且正处于“退出 → 等待 → 再启动”的循环里;- 括号里的
137:上一次退出的 exit code。137 = 128 + 9,也就是进程被信号 9(SIGKILL)杀死。SIGKILL 不能被捕获、不能被忽略,应用来不及打一行日志、来不及优雅关闭。这就是“日志为空”的原因——它不是崩溃,是被处决。
凶手是谁?SIGKILL 的常见来源就三类:内核 OOM Killer、docker kill / docker stop 超时后的强杀、外部编排/脚本。下面按这个顺序排除。
二、排查路线图
我把“容器反复重启”的排查压成下面这张图,先看 ExitCode,再看 OOMKilled,再决定去翻应用日志还是去翻内核日志:
三、动手:一步步把凶手揪出来
第 1 步:docker inspect 看“死亡证明”
$ docker inspect report-export --format '{{json .State}}' | python3 -m json.tool
{
"Status": "restarting",
"Running": true,
"Paused": false,
"Restarting": true,
"OOMKilled": true,
"Dead": false,
"Pid": 0,
"ExitCode": 137,
"Error": "",
"StartedAt": "2026-09-25T09:41:12.318Z",
"FinishedAt": "2026-09-25T09:41:58.902Z"
}一次看全:OOMKilled: true 且 ExitCode: 137——这个容器是因为使用内存超过了它自己的 cgroup 限制,被内核杀掉的。另外从 StartedAt 到 FinishedAt 只活了 46 秒,这个数字后面有用:它说明每次启动后大约 45 秒内存就顶到了天花板。
只想要两个字段时用这条,写进脚本很方便:
$ docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}} {{.RestartCount}}' report-export
true 137 2143RestartCount 2143——三天里被“重生”了两千多次。这个数字放在监控上就是一根一路向上的斜线,可惜当时我们没配它的告警。
第 2 步:docker events 看时间线
docker events 能按事件类型过滤,正好看容器的一生:
$ docker events --since 10m --filter container=report-export \
--filter event=oom --filter event=die --filter event=start
2026-09-25T09:41:58.90 container oom 3f2a...c1 (image=demo/report-export:1.8.2, name=report-export)
2026-09-25T09:41:58.91 container die 3f2a...c1 (exitCode=137, image=demo/report-export:1.8.2, name=report-export, oomKilled=true)
2026-09-25T09:42:00.12 container start 3f2a...c1 (image=demo/report-export:1.8.2, name=report-export)
2026-09-25T09:42:46.55 container oom 3f2a...c1 (image=demo/report-export:1.8.2, name=report-export)
2026-09-25T09:42:46.56 container die 3f2a...c1 (exitCode=137, ..., oomKilled=true)规律一目了然:oom 事件紧跟着 die,再隔约 1-2 秒 start。先有 oom 事件,再有 die,就是 OOM 杀的铁证;如果只有 die 没有 oom,那大概率是 docker kill 或者外部脚本干的。
第 3 步:docker stats 看它是怎么“吃撑”的
容器每次只活 45 秒,抓不到稳定态,所以用不带 --no-stream 的实时刷新,盯着 MEM USAGE / LIMIT:
$ docker stats report-export --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.CPUPerc}}'
NAME MEM USAGE / LIMIT MEM % CPU %
report-export 118MiB / 512MiB 23.05% 35.12%
report-export 301MiB / 512MiB 58.79% 102.40%
report-export 467MiB / 512MiB 91.21% 99.87%
report-export 509.6MiB / 512MiB 99.53% 101.10%
report-export 0B / 0B 0.00% 0.00%内存 40 秒内从 118 MiB 一路飙升到 limit 上限,接着归零——那是内核动手的瞬间。容器 limit 是 512 MiB(当初为了“省资源”写下的),而这个报表服务一次导出 Excel 需要在内存里攒几十万行数据,明显不够。
第 4 步:去宿主机看内核的“口供”
内核 OOM 事件会写进内核日志,这才是最权威的一手证据:
$ dmesg -T | grep -iE 'memory cgroup out of memory|oom-kill|killed process' | tail -5
[Fri Sep 25 09:41:58 2026] memory: usage 524288kB, limit 524288kB, failcnt 8127
[Fri Sep 25 09:41:58 2026] Memory cgroup out of memory: Killed process 28744 (java) total-vm:3312448kB, anon-rss:517292kB, file-rss:9120kB, shmem-rss:0kB, UID:1000 ...
[Fri Sep 25 09:41:58 2026] oom_reaper: reaped process 28744 (java), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB注意 Memory cgroup out of memory 这一句——是cgroup 级别的 OOM(容器自己的 limit),而不是 Out of memory: Killed process 那种整机级别的 OOM。usage 524288kB, limit 524288kB 换算刚好 512 MiB,和我们配的 limit 吻合。
如果 docker inspect 里 OOMKilled 是 false,而 dmesg 里却有 OOM 记录,多半是宿主机整机内存耗尽,内核挑了一个进程杀;或者容器里的子进程被杀但 PID 1 存活。这种情况下调大容器 limit 没用,要去看整机的内存分配。
第 5 步:看它当初是怎么被“允许重启”的
$ docker inspect -f '{{.HostConfig.RestartPolicy.Name}} {{.HostConfig.Memory}} {{.HostConfig.MemorySwap}}' report-export
always 536870912 1073741824三个数字分别是:重启策略 always、内存 limit 536870912 字节(512 MiB)、MemorySwap 1073741824 字节(1 GiB)。后面根因部分会解释后两个的关系。
四、根因:limit 太小,加上 restart always 的“好心”
1. 内存 limit 是怎么起作用的
Docker 的 -m / --memory 直接映射到 Linux cgroup 的内存限制。容器里所有进程的内存(堆、栈、页缓存中不可回收的部分等)加起来一旦触碰上限,内核先尝试回收,回收不了就触发 cgroup OOM,杀掉容器内占用最大的进程。按官方文档,-m 最小值 6 MiB。
再说 swap。--memory-swap 表示“内存 + swap”的总量,容易搞错的几条规则:
- 只设
-m 512m,不设--memory-swap:容器可以再使用与-m同样大小的 swap(前提是宿主机开了 swap)——所以上面看到MemorySwap是 1 GiB,即 512 MiB 内存 + 512 MiB swap; --memory-swap等于--memory:不允许使用 swap,一旦超限直接 OOM;--memory-swap -1:swap 不限制,最多用到宿主机有多少给多少;--memory-swap必须和--memory一起设才有意义。
我们这次的情况是:内存吃满后先狂用 swap(CPU 100% 那几秒就是大量换页),swap 也塞满后才被杀。所以现象是“越来越慢,然后突然消失”,而不是“一下就没”。
2. restart policy 的“好心办坏事”
根据官方文档,重启策略有四种:
no(默认):不自动重启;on-failure[:max-retries]:只有退出码非 0 才重启,可以限制最大重试次数;always:不管怎么退出都重启(手动 stop 的除外,但 Docker 守护进程重启后又会拉起来);unless-stopped:和 always 类似,但如果容器是被手动停止的,即使守护进程重启也不会再拉起来。
官方文档还有一条容易被忽视的细节:策略只会在容器成功运行至少 10 秒之后才生效,用来防止“启动就失败”的容器无限重启(重启间隔也是逐步递增的,从很短开始翻倍)。但我们这个容器每次能活 45 秒,远超过 10 秒,所以在 Docker 看来它“是健康跑了一阵才挂的”,于是一直重启,退避间隔也一直很短。
这就是“重启风暴”的形状。restart: always 本意是“高可用”,但对“确定性会挂”的故障,它只会:
- 把“一次明确的失败”变成“两千次模糊的失败”,监控图上像心电图,谁都觉得“好像时好时坏”;
- 每次启动都重新加载缓存、预热连接池、拉起 JVM,把宿主机 CPU 和磁盘 IO 也顺带打满,影响同机其他容器;
- 每次启动都写一遍启动日志——配合 json-file 日志不限大小,磁盘也会被顺带吃掉(下一篇会讲)。
下面这张图对比了重启风暴和“有上限的重启”是怎么演变的:
3. 真正吃内存的是谁
确认 OOM 只是第一层;第二层是:它为什么要那么多内存?这次的服务是 Java,导出接口把整张表读进 List 再写 Excel,堆里同时压着几十万个对象;同时 -Xmx 被写成了 -Xmx512m——恰好等于容器 limit。而 JVM 除了堆,还有元空间、线程栈、直接内存、JIT 编译缓存……堆设满 limit,堆外内存再多吃一点,容器就先于 JVM 的 OutOfMemoryError 被杀了。(Java 在容器里的坑我单独写了一篇,这里只提结论:-Xmx 要明显小于容器 limit。)
五、止血与修复
止血:先让业务活下来,并且保留现场
- 先把风暴关掉,别让它继续折腾宿主机。先保存证据(上面的 inspect / events / dmesg 输出重定向到文件),再更新策略:
$ docker update --restart=on-failure:5 report-export report-exportdocker update可以在线修改重启策略,不需要重建容器。 - 临时把内存 limit 放宽,先让它能完成一次导出(内存和 swap 一起调,注意
--memory-swap必须不小于--memory): 这里把 swap 设成和内存相等,等于禁用 swap:宁可快速失败,也不要“慢慢卡死”。生产上大多数服务不建议依赖 swap。$ docker update --memory 2g --memory-swap 2g report-export report-export - 通知业务方:导出功能限流,只允许一个并发。
根治:三条腿
# docker-compose.yml(Compose v2 写法)
services:
report-export:
image: demo/report-export:1.8.3
restart: on-failure:5 # 不要无限重启
mem_limit: 2g # 也可用 deploy.resources.limits.memory
memswap_limit: 2g # 等于 mem_limit → 禁用 swap
environment:
JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=60" # 堆占 limit 的 60%,给堆外留空间
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 3s
retries: 3- 代码层:导出改成流式(分页读 +
SXSSFWorkbook落盘写),内存占用从“与行数成正比”降到“常数”; - 配置层:limit 基于观测的峰值加 20%-30% 的余量,而不是拍脑袋;
- 监控层:对
RestartCount增长、OOMKilled事件、容器内存使用率 > 85% 都做告警。可以用 cAdvisor 的container_oom_events_total,或者最粗暴的定时脚本:#!/bin/bash # 每分钟跑一次:发现最近 1 分钟内有 oom 事件就告警 docker events --since 1m --until 0s --filter event=oom --format '{{.Actor.Attributes.name}}' | sort -u
六、附:exit code 速查表
值班手册里我贴了这张表,看到数字先对号入座:
| Exit code | 含义 | 常见原因 / 下一步 |
|---|---|---|
| 0 | 正常退出 | 进程主动结束。如果预期常驻,检查 CMD 是不是后台进程导致 PID 1 退出 |
| 1 | 通用错误 | 应用异常退出,docker logs --tail 200 看最后的报错 |
| 125 | docker 自身出错 | docker run 参数不对(如 flag 未定义、端口冲突),守护进程层面的问题 |
| 126 | 命令无法执行 | 文件存在但没有执行权限,或者根本不是可执行文件 |
| 127 | 命令找不到 | ENTRYPOINT/CMD 路径写错,镜像里没装这个命令;alpine 里跑 bash 脚本也会遇到 |
| 137 | 128+9,SIGKILL | OOM Killer、docker kill、docker stop 超时强杀。看 OOMKilled |
| 139 | 128+11,SIGSEGV | 段错误,原生代码/JNI/so 库崩溃,需要 core dump 分析 |
| 143 | 128+15,SIGTERM | 收到优雅终止信号(docker stop)。若是常见的“被停止”,属正常;如果被反复触发,检查编排/健康检查 |
规律:128 + N 表示被信号 N 杀死;125-127 是 Docker/Shell 层面的“没跑起来”;其他数字是应用自己定义的。
顺便补一个易混淆点:docker stop 会先发 SIGTERM,等待 10 秒(默认)后发 SIGKILL。如果你的容器里 PID 1 是个 shell 脚本,而信号又没被转发给真正的应用,就会出现“每次停容器都要等 10 秒,最后 exit 137”的情况。这时 137 并不代表内存出了问题。
七、复盘:怎么让下一次不用在周五 5 点 50 分排查
| 做法 | 为什么 |
|---|---|
先看 docker inspect 的 OOMKilled 与 ExitCode,再看日志 | 137 的进程根本来不及写日志,翻应用日志是白费力气 |
用 docker events 看 oom → die → start 时间线 | 一眼分清是 OOM、手动 kill,还是自己崩溃 |
OOMKilled=false 时必看 dmesg | 可能是宿主整机 OOM 或子进程被杀,两种情况处理方式完全不同 |
重启策略优先 on-failure:N,常驻服务再配合外部编排 | 无限重启会掩盖故障、放大资源消耗 |
生产容器必须设 -m,并把 --memory-swap 设成与 -m 相等 | 没有 limit 会拖垮整机;允许 swap 会让故障从“快速失败”变成“慢性卡死” |
| limit 取“观测峰值 × 1.2~1.3”,JVM 堆只占 60%-75% | 堆外内存、线程栈、元空间不属于堆,但计入容器内存 |
监控 RestartCount 增量和 OOM 事件 | “容器是 Up 的”不代表健康,重启次数才是真相 |
一句话备忘:Restarting (137) →
docker inspect看OOMKilled→true调 limit / 修泄漏,false去dmesg和docker events找真凶;restart: always不是高可用,是把故障“续杯”。
后来我给同事总结:容器 137,就像半夜有人敲门,你别急着开门骂人,先看猫眼——门外是内存超限的 OOM,还是路过的 docker kill。看清楚了再动手,周五才有可能准点下班。(那天我最终六点半走的,导出功能则是周一凌晨改完发的版。)
参考资料
- Docker 官方文档:Resource constraints——
-m、--memory-swap、--memory-reservation、--oom-kill-disable的语义及 swap 取值规则。 - Docker 官方文档:Start containers automatically——四种重启策略(no / on-failure / always / unless-stopped)及“运行满 10 秒才生效”的说明。
- Docker 官方文档:docker run 参考——退出码 125 / 126 / 127 的定义。
- Netdata:Docker exit code 137 - OOMKilled or SIGKILL?——区分 OOMKilled true/false 的诊断命令、dmesg 与 cgroup 计数器位置。
一起聊聊