用 docker inspect 提取容器 OOMKilled 状态并区分退出码 137

容器突然消失,docker ps -a 里状态显示 Exited (137),运维第一反应往往是「被 OOM 杀了」。但这个判断在多数情况下是错的:退出码 137 只说明进程收到了 SIGKILL(信号 9,128+9=137),而 SIGKILL 的来源至少有四种——cgroup 内存超限被内核 OOM killer 选中、docker stop 超时后

容器突然消失,docker ps -a 里状态显示 Exited (137),运维第一反应往往是「被 OOM 杀了」。但这个判断在多数情况下是错的:退出码 137 只说明进程收到了 SIGKILL(信号 9,128+9=137),而 SIGKILL 的来源至少有四种——cgroup 内存超限被内核 OOM killer 选中、docker stop 超时后 daemon 补刀、docker kill 手动发送、容器内 PID 1 自己 kill -9 了子进程。只有第一种才是真正的 OOMKilled。

更麻烦的是,docker inspect 里的 State.OOMKilled 字段并不总是可靠:它在某些 Docker 版本与 cgroup v2 环境下会漏报,而 State.ExitCode 又无法区分信号来源。本文给出一套可直接落地的方案:用 docker inspect 的 --format 模板一次性提取关键字段,再结合 cgroup 内存事件计数与内核日志交叉验证,把「137」拆成可归因的几类,最后封装成可放进 cron 的巡检脚本。

先看清楚 docker inspect 到底给了什么

不要凭记忆写模板,先把一个已退出容器的完整 JSON 打出来看结构。docker inspect 返回的是数组,所以格式化时必须带索引:

docker inspect -f '{{json .State}}' my-container | jq .

典型输出(Docker 24.x / 26.x,字段顺序可能不同):

{
  "Status": "exited",
  "Running": false,
  "Paused": false,
  "Restarting": false,
  "OOMKilled": true,
  "Dead": false,
  "Pid": 0,
  "ExitCode": 137,
  "Error": "",
  "StartedAt": "2024-05-11T02:13:44.512873901Z",
  "FinishedAt": "2024-05-11T02:47:09.118342255Z"
}

几个必须记住的点:

  • State.OOMKilled 是布尔值,但它由 Docker daemon 在容器退出时根据 cgroup 的 memory.events(cgroup v2)或 memory.oom_control(cgroup v1)推断,属于事后推断,不是内核直接告诉它的。
  • State.ExitCode 是进程退出状态。Linux 上「被信号 N 杀死」的进程,退出码记为 128+N,所以 SIGKILL 对应 137,SIGTERM(15)对应 143,SIGSEGV(11)对应 139。
  • State.Error 在 OOM 场景下通常为空字符串,别指望它给线索;它更多用于 daemon 启动失败(如 exec format error、no such file or directory)。
  • HostConfig.Memory 与 HostConfig.MemorySwap 是判断「有没有设限」的关键,没设限的容器不可能被 cgroup OOM 杀,只能是全局内存耗尽或手动 kill。

把这些字段一次性抽出来:

docker inspect -f \
'{{.Name}} | status={{.State.Status}} | exit={{.State.ExitCode}} | oom={{.State.OOMKilled}} | mem={{.HostConfig.Memory}} | swap={{.HostConfig.MemorySwap}} | finished={{.State.FinishedAt}}' \
my-container

输出示例:

/my-container | status=exited | exit=137 | oom=true | mem=536870912 | swap=1073741824 | finished=2024-05-11T02:47:09.118342255Z

注意 .Name 带前导斜杠,做报表时要 sed 's#^/##' 去掉,否则脚本里做字符串匹配容易踩坑。

批量提取所有容器的 OOM 与退出码

单容器调试用上面的模板即可;线上巡检要遍历全部容器。这里有两个坑:容器名可能含空格(虽然不推荐,但 docker run --name "a b" 是合法的),以及 docker inspect 的 {{.Name}} 在模板里遍历时不能直接用 range 取顶层数组,必须让 shell 先展开。

稳妥写法:

#!/usr/bin/env bash
set -euo pipefail

docker ps -aq | while read -r cid; do
  docker inspect -f \
'{{.Name}}|{{.State.Status}}|{{.State.ExitCode}}|{{.State.OOMKilled}}|{{.HostConfig.Memory}}|{{.State.FinishedAt}}' \
    "$cid"
done | sed 's#^/##' | column -t -s '|'

想让脚本在容器很多时不那么慢,可以用一次 docker inspect 传多个 ID 配合 Go 模板遍历,减少 daemon 往返:

docker inspect -f \
'{{range .}}{{.Name}}|{{.State.Status}}|{{.State.ExitCode}}|{{.State.OOMKilled}}|{{.HostConfig.Memory}}{{"\n"}}{{end}}' \
$(docker ps -aq)

这里 {{range .}} 遍历的就是 inspect 返回的数组本身,每个元素是一个容器对象。注意模板里换行用 {{"\n"}},直接敲回车在部分 Docker 版本会被模板引擎吞掉,导致所有容器挤成一行。

用 jq 做结构化输出,便于接告警

--format 模板适合人看,接 Prometheus、企业微信、钉钉告警时用 JSON 更省事:

docker inspect $(docker ps -aq) | jq -r '
  .[] |
  select(.State.Status == "exited") |
  [ (.Name | ltrimstr("/")),
    .State.ExitCode,
    .State.OOMKilled,
    (.HostConfig.Memory / 1024 / 1024 | floor),
    .State.FinishedAt
  ] | @tsv
'

如果机器上没装 jq(很多精简镜像宿主机确实没有),用 apt-get install -y jq 或直接退回模板方案,不要为了一个字段引入 Python 依赖。

把 137 拆开:四种来源的判别方法

这是全文的核心。退出码 137 本身没有信息量,必须结合 OOMKilled、内存限制、cgroup 计数、内核日志四路证据交叉判断。

情形一:cgroup 内存超限,真 OOM

特征组合:OOMKilled=true + ExitCode=137 + HostConfig.Memory > 0。这是最典型的容器 OOM。此时可以进一步确认是「用量触顶」还是「瞬时尖峰」:

# cgroup v2 宿主机
cat /sys/fs/cgroup/system.slice/docker-<full-container-id>.scope/memory.events

# cgroup v1 宿主机
cat /sys/fs/cgroup/memory/docker/<full-container-id>/memory.failcnt

memory.events 里 oom_kill 1 就实锤了;memory.failcnt 大于 0 表示有过分配失败计数。容器退出后 cgroup 目录通常会被 daemon 清理,所以这项检查只对仍在运行或被 --rm=false 保留的容器有效,事后追溯要靠内核日志。

其中 <full-container-id> 是 64 位完整 ID,来自 docker inspect -f '{{.Id}}' my-container,短 ID 在 cgroup 路径里匹配不到。

情形二:docker stop 超时被补刀

特征组合:OOMKilled=false + ExitCode=137。这是最常被误判成 OOM 的情况。docker stop 先发 SIGTERM,等 --time(默认 10 秒)后如果进程没退,daemon 发 SIGKILL,退出码就是 137,但 OOMKilled 是 false。

验证方法:看 State.FinishedAt 与容器启动参数的时间差,以及 Config.StopSignal、Config.StopTimeout:

docker inspect -f 'stopsignal={{.Config.StopSignal}} stoptimeout={{.Config.StopTimeout}}' my-container

如果应用没有正确响应 SIGTERM(比如 shell 形式的 CMD "java -jar app.jar" 导致 PID 1 是 shell 而非 java,信号不会转发给子进程),就会稳定地每次 stop 都 137。修复方式是用 exec 形式的 CMD ["java", "-jar", "app.jar"],或在入口脚本里 exec java -jar app.jar。

情形三:docker kill 手动发送

docker kill 默认发 SIGKILL,docker kill -s SIGTERM 发 15。docker inspect 无法直接告诉你「是谁发的 kill」,只能靠审计:Docker daemon 日志(journalctl -u docker)里会有 API 调用记录,或直接查操作审计系统。这也解释了为什么 OOMKilled=false + 137 时不要急着甩锅给内存。

情形四:容器内进程自杀或被杀

如果容器里跑了个 supervisor,子进程被 kill -9,容器主进程(PID 1)收到子进程退出后自己 exit 137,同样呈现 137 + OOMKilled=false。这种情况下容器内 /var/log 或应用日志会有痕迹,而宿主机内存毫无压力。

把四类判别浓缩成一张表,巡检时直接对照:

OOMKilled ExitCode HostConfig.Memory 内核日志 oom-kill 判定
true 137 > 0 有 cgroup 内存超限(真 OOM)
false 137 任意 无 stop 超时 / 手动 kill / 进程自杀
true 137 0 有 宿主机全局内存耗尽,非容器限额
false 143 任意 无 正常收到 SIGTERM 退出
false 1 任意 无 应用自身报错退出,查应用日志

内核日志交叉验证:dmesg 与 journalctl

OOMKilled 字段是 daemon 推断的,遇到「明明内存超了却报 false」的漏报,得回到内核日志。dmesg 里 OOM killer 的日志形如:

[1234567.890123] Memory cgroup out of memory: Killed process 12345 (java) total-vm:8388608kB, anon-rss:2097152kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:4096kB oom_score_adj:0

Memory cgroup out of memory 是 cgroup 限流触发的关键词;如果是宿主机全局耗尽,开头会变成 Out of memory: Killed process(没有 cgroup 前缀)。命令:

# 内核环形缓冲区(CentOS 7 / 老内核)
dmesg -T | grep -E 'Out of memory|oom-kill|oom_reaper'

# systemd 宿主机更稳妥,dmesg 可能被 dmesg_restrict 限制
journalctl -k --since "2 hours ago" | grep -E 'Memory cgroup out of memory|oom-kill'

常见报错:在部分云主机上执行 dmesg 返回 dmesg: read kernel buffer failed: Operation not permitted,这是内核参数 kernel.dmesg_restrict=1 所致。解决方式是 sysctl -w kernel.dmesg_restrict=0(需 root,且部分发行版重启后失效,要写进 /etc/sysctl.d/),或改用 journalctl -k 绕过。

要按容器 ID 精确定位,日志里通常没有容器 ID,只有被杀的进程名和 PID。可以配合 docker inspect -f '{{.State.Pid}}' 在容器运行时记下 PID,或引入 cgroup.event_control 监听。生产上更实用的做法是让脚本在容器退出后立刻抓 docker events 的时间戳,用时间窗口和 dmesg -T 对齐。

可落地的巡检脚本

下面这个脚本做了四件事:遍历所有容器、筛出 137 的、区分真 OOM 与伪 137、输出到终端并可选写入日志文件。依赖 bash、docker,不依赖 jq。

#!/usr/bin/env bash
# oom-audit.sh —— 容器 OOM 巡检
# 用法: ./oom-audit.sh [--only-oom]
set -uo pipefail

ONLY_OOM=0
[[ "${1:-}" == "--only-oom" ]] && ONLY_OOM=1

LOG_DIR=/var/log/container-oom
mkdir -p "$LOG_DIR"
REPORT="$LOG_DIR/report-$(date +%F).log"

printf '%-32s %-8s %-6s %-6s %-8s %-10s %s\n' \
  NAME STATUS EXIT OOM HASLIMIT MEM_MB JUDGE | tee -a "$REPORT"
printf '%s\n' "$(printf '%.0s-' {1..96})" | tee -a "$REPORT"

docker ps -aq | while read -r cid; do
  line=$(docker inspect -f \
    '{{.Name}}|{{.State.Status}}|{{.State.ExitCode}}|{{.State.OOMKilled}}|{{.HostConfig.Memory}}' \
    "$cid" 2>/dev/null) || continue

  name=$(echo "$line"  | cut -d'|' -f1 | sed 's#^/##')
  status=$(echo "$line"| cut -d'|' -f2)
  exitc=$(echo "$line" | cut -d'|' -f3)
  oom=$(echo "$line"   | cut -d'|' -f4)
  mem=$(echo "$line"   | cut -d'|' -f5)

  # 只看 137,且只统计已退出容器
  [[ "$status" != "exited" ]] && continue
  [[ "$exitc"  != "137"    ]] && continue

  if [[ "$mem" -gt 0 ]]; then
    mem_mb=$(( mem / 1024 / 1024 ))
    haslimit=yes
  else
    mem_mb=0
    haslimit=no
  fi

  if [[ "$oom" == "true" && "$haslimit" == "yes" ]]; then
    judge="真OOM(cgroup超限)"
  elif [[ "$oom" == "true" && "$haslimit" == "no" ]]; then
    judge="OOM(宿主机级)"
  else
    judge="伪137(stop超时/手动kill)"
  fi

  [[ "$ONLY_OOM" -eq 1 && "$judge" == "伪137(stop超时/手动kill)" ]] && continue

  printf '%-32s %-8s %-6s %-6s %-8s %-10s %s\n' \
    "$name" "$status" "$exitc" "$oom" "$haslimit" "$mem_mb" "$judge" | tee -a "$REPORT"
done

运行效果:

NAME                             STATUS   EXIT   OOM    HASLIMIT MEM_MB     JUDGE
------------------------------------------------------------------------------------------------
order-api-1                      exited   137    true   yes       1024       真OOM(cgroup超限)
cron-worker                      exited   137    false  yes       512        伪137(stop超时/手动kill)
redis-cache                      exited   137    true   no        0          OOM(宿主机级)

几个工程细节值得说明:

  1. set -uo pipefail 但不开 set -e。因为容器被删、权限不足时 docker inspect 会非零退出,开 -e 会让整个循环中断,这里用 || continue 兜住。
  2. docker ps -aq 默认包含未启动的(created)和已退出的,正好覆盖巡检需求;如果想缩小范围,用 docker ps -aq --filter status=exited。
  3. mem 字段是字节字符串,可能超出 bash 整数范围(> 2^63 才溢出,实际不会),但 HostConfig.Memory = 0 表示不限制,这个分支必须写,否则 mem_mb 计算无意义。
  4. 日志按天切分到 /var/log/container-oom/,接 logrotate 时配 daily + rotate 14 即可。

挂到 cron 每小时跑一次:

17 * * * * root /opt/scripts/oom-audit.sh --only-oom >> /var/log/container-oom/cron.log 2>&1

定位到真 OOM 之后怎么处理

脚本只能告诉你「是 OOM」,不能替你调参。真正 OOM 的容器按以下顺序排查:

  1. 确认是不是内存限额太低:docker inspect -f '{{.HostConfig.Memory}}' 换算成 MB,和业务预期峰值对比。Java 应用尤其要注意堆外内存——-Xmx 设成限额的 90% 必然 OOM,因为 JVM 元空间、线程栈、Direct Buffer 都没算进去,经验值是 -Xmx 取限额的 60%~75%。
  2. 区分 RSS 与 page cache。memory.current 包含 file(页缓存),如果文件缓存占比高,加大限额往往不是最优解,应该调 --memory-reservation 做软限制,让内核在压力下回收缓存。
  3. 看 docker stats 的历史曲线(需要外部采集),确认是稳态超限还是尖峰。尖峰可以考虑 --oom-kill-disable 配合 --memory-reservation,但不建议在关键服务上禁用 OOM killer,那会导致整机卡死而不是单容器重启。
  4. 重启策略:--restart=on-failure:5 只对非零退出码生效,137 属于 on-failure 范畴,会被拉起;但如果是持续 OOM,会形成重启风暴,务必加上次数上限。

一个补充手段是在 compose 或 docker run 里显式加上交换内存限制,避免 swap 让 OOM 判定变形:

services:
  order-api:
    image: registry.local/order-api:1.8.3
    mem_limit: 1g
    memswap_limit: 1g      # 与 mem_limit 相等,等于禁用 swap
    mem_reservation: 768m   # 软限制,压力下优先回收
    oom_kill_disable: false
    restart: on-failure:5

memswap_limit 必须 ≥ mem_limit,否则 docker run 直接报 Minimum memoryswap limit should be larger than memory limit 并拒绝启动,这是很常见的报错。

小结

退出码 137 是「SIGKILL 被杀」,不是「OOM」的同义词。可靠判定必须走三步:先用 docker inspect --format 拿 OOMKilled、ExitCode、HostConfig.Memory 三个字段,再用 cgroup 的 memory.events 或 dmesg/journalctl -k 交叉验证,最后才下结论。把这三步封装成脚本按小时巡检,能显著减少把 docker stop 超时误判成内存事故的返工。

FAQ

为什么容器退出码是 137,但 OOMKilled 是 false?

因为 137 只代表收到 SIGKILL。最常见的是 docker stop 在 --time(默认 10 秒)内没等到进程退出,daemon 补发 SIGKILL;其次是 docker kill 手动操作;再次是 PID 1 是 shell、不转发 SIGTERM 给子进程导致每次 stop 都超时。检查 Config.StopSignal 与 Config.StopTimeout,并把 CMD 改成 exec 形式。

OOMKilled 是 true,但 ExitCode 不是 137 而是 1,怎么回事?

OOM killer 杀的是容器内的某个子进程而非 PID 1,主进程检测到子进程消失后自行 exit 1,于是容器退出码是 1,而 daemon 仍从 cgroup 观察到 oom_kill 计数变化,标记 OOMKilled=true。这种情况下要查的是被杀的子进程,逻辑得从应用日志入手。

HostConfig.Memory 是 0,还能被 OOM 杀吗?

能,但那是宿主机级内存耗尽,不是 cgroup 限额触发。判定方法是看 dmesg 里日志前缀:有 Memory cgroup out of memory 是容器限额,只有 Out of memory: Killed process 是全局。后者说明整机内存吃紧,要考虑的不只是单个容器的限额,而是节点容量规划。

容器已经删了,还能不能事后判断是不是 OOM?

docker inspect 对已删除容器无能为力。此时唯一的证据是内核日志:journalctl -k | grep -E 'Memory cgroup out of memory',按容器被杀的时间点对齐。所以建议在容器上保留 --log-driver=json-file 并在编排层做好事件采集,或者不要随手 docker rm,先 inspect 存证再删。

cgroup v1 和 v2 下提取内存事件的路径不同,脚本怎么写兼容?

用 stat -fc %T /sys/fs/cgroup 判断:返回 cgroup2fs 走 /sys/fs/cgroup/.../memory.events 读 oom_kill 行;返回 tmpfs 走 /sys/fs/cgroup/memory/docker/<id>/memory.failcnt。cgroup v2 下容器 cgroup 路径通常形如 /sys/fs/cgroup/system.slice/docker-<64位id>.scope,v1 下是 /sys/fs/cgroup/memory/docker/<64位id>,注意短 ID 匹配不到目录。

用 --oom-kill-disable 关掉 OOM killer 是不是就不会 137 了?

不是好主意。禁用后 cgroup 内存超限时内核不会杀进程,但会阻塞分配(v2 下挂起在 memory.max 的 reclaim 上),表现为容器假死、请求大量超时,比被杀更难排查。且该参数在 cgroup v2 上行为受限。正确做法是调 mem_reservation 软限制 + 合理的 mem_limit,而不是关掉保护。

脚本报 docker: 'inspect' requires at least 1 argument 是什么原因?

宿主机上没有任何容器时 docker ps -aq 输出为空,docker inspect "" 就会报这个错。在命令前加 [[ -z "$(docker ps -aq)" ]] && exit 0,或给 docker inspect 的参数加判空保护即可。