Docker 与容器排查 列表全部文章
Docker 与容器排查

容器每 40 秒“重生”一次:Restarting、exit 137 与一场 restart always 引发的重启风暴

docker ps 里一个容器永远显示 Restarting,日志什么都没有。docker inspect 看 OOMKilled 与 ExitCode 137,配合 docker events、stats、dmesg 定位内存超限,讲清 restart policy 如何放大故障、-m 与 swap 的关系,附 exit code 速查表。

更多

周五下午 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,再决定去翻应用日志还是去翻内核日志:

① docker ps -a状态 Restarting (N) ② docker inspectState.ExitCode / OOMKilled ③ docker events谁在 die / oom / kill ExitCode 是多少? 1 / 2 / 其它非 0应用自己退出 → docker logs 137 (SIGKILL)OOMKilled 是 true 还是 false? 125 / 126 / 127启动命令本身有问题 true:超出容器 limit调大 -m / 修内存泄漏 false:别人杀的dmesg / 宿主 OOM / kill false 且 dmesg 有 oom 记录 → 宿主整体缺内存,不是容器 limit

三、动手:一步步把凶手揪出来

第 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 2143

RestartCount 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 本意是“高可用”,但对“确定性会挂”的故障,它只会:

  1. 把“一次明确的失败”变成“两千次模糊的失败”,监控图上像心电图,谁都觉得“好像时好时坏”;
  2. 每次启动都重新加载缓存、预热连接池、拉起 JVM,把宿主机 CPU 和磁盘 IO 也顺带打满,影响同机其他容器;
  3. 每次启动都写一遍启动日志——配合 json-file 日志不限大小,磁盘也会被顺带吃掉(下一篇会讲)。

下面这张图对比了重启风暴和“有上限的重启”是怎么演变的:

restart: always(确定性故障) 启动 45s 内存爆 等 1-2s 再启动 无限循环:3 天 2143 次,没人被通知 on-failure:5 + 告警(改造后) 启动 OOM 退出 重试 ≤5 停下 超过次数即停止,状态变 Exited,告警响起 要点 always / unless-stopped 适合“偶发崩溃”;对“必然再挂”的故障,只会把现场淹没 重启次数(RestartCount)与 OOMKilled 事件必须有监控,光看容器“Up”不够

3. 真正吃内存的是谁

确认 OOM 只是第一层;第二层是:它为什么要那么多内存?这次的服务是 Java,导出接口把整张表读进 List 再写 Excel,堆里同时压着几十万个对象;同时 -Xmx 被写成了 -Xmx512m——恰好等于容器 limit。而 JVM 除了堆,还有元空间、线程栈、直接内存、JIT 编译缓存……堆设满 limit,堆外内存再多吃一点,容器就先于 JVM 的 OutOfMemoryError 被杀了。(Java 在容器里的坑我单独写了一篇,这里只提结论:-Xmx 要明显小于容器 limit。)

五、止血与修复

止血:先让业务活下来,并且保留现场

  1. 先把风暴关掉,别让它继续折腾宿主机。先保存证据(上面的 inspect / events / dmesg 输出重定向到文件),再更新策略:
    $ docker update --restart=on-failure:5 report-export
    report-export
    docker update 可以在线修改重启策略,不需要重建容器。
  2. 临时把内存 limit 放宽,先让它能完成一次导出(内存和 swap 一起调,注意 --memory-swap 必须不小于 --memory):
    $ docker update --memory 2g --memory-swap 2g report-export
    report-export
    这里把 swap 设成和内存相等,等于禁用 swap:宁可快速失败,也不要“慢慢卡死”。生产上大多数服务不建议依赖 swap。
  3. 通知业务方:导出功能限流,只允许一个并发。

根治:三条腿

# 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
  1. 代码层:导出改成流式(分页读 + SXSSFWorkbook 落盘写),内存占用从“与行数成正比”降到“常数”;
  2. 配置层:limit 基于观测的峰值加 20%-30% 的余量,而不是拍脑袋;
  3. 监控层:对 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 看最后的报错
125docker 自身出错docker run 参数不对(如 flag 未定义、端口冲突),守护进程层面的问题
126命令无法执行文件存在但没有执行权限,或者根本不是可执行文件
127命令找不到ENTRYPOINT/CMD 路径写错,镜像里没装这个命令;alpine 里跑 bash 脚本也会遇到
137128+9,SIGKILLOOM Killer、docker kill、docker stop 超时强杀。看 OOMKilled
139128+11,SIGSEGV段错误,原生代码/JNI/so 库崩溃,需要 core dump 分析
143128+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。看清楚了再动手,周五才有可能准点下班。(那天我最终六点半走的,导出功能则是周一凌晨改完发的版。)

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人