容器显示 Exited (137) 时,最常见的误判是直接得出“内存不够”。137 只表示进程最终收到了 SIGKILL:它可能来自容器内存上限触发的 OOM,也可能来自宿主机内存压力、人工执行 docker kill,或编排系统强制终止。正确做法是先把退出码、Docker 状态、cgroup 计数和事发前内存曲线连成一条证据链,再决定扩容还是修应用。
适用范围与先给结论
本文适用于 Docker Engine 与使用 Linux cgroup 的容器环境。V2CE 复现实验使用 Docker 24.0.7、Compose 2.40.0、cgroup v2 和 node:22.20.0-alpine,为实验容器设置 48 MiB 硬限制,并禁止额外 swap 空间。
判断顺序应当是:
- 确认容器实际退出时间、退出码和是否被 Docker 标记为
OOMKilled。 - 确认生效的内存上限,而不是只看 Compose 文件。
- 检查 cgroup v2 的
memory.events,区分达到上限与真正发生 OOM kill。 - 把容器证据与宿主机内核日志、监控时间线对齐。
只有退出码 137,没有 OOMKilled=true 或其他同时段证据,不能单独证明 OOM。
第一步:保存容器退出状态
先不要立即删除或重新创建故障容器。重建会丢失原容器的状态和 cgroup 上下文。用容器 ID 检查完整状态:
docker inspect --format \
'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{json .State.Error}} finished={{.State.FinishedAt}}' \
my-container
docker inspect my-container > /tmp/my-container-inspect.json
docker logs --timestamps --since 30m my-container > /tmp/my-container.log 2>&1
ExitCode=137 对应 128 + 9,也就是被 SIGKILL 终止。OOMKilled=true 是更直接的容器级证据,但仍要继续确认限制值和宿主机情况,尤其是在节点整体内存紧张时。
第二步:确认真正生效的内存限制
不要假定配置文件里的值已经应用到当前容器。读取 Docker 保存的 HostConfig:
docker inspect --format \
'memory={{.HostConfig.Memory}} memory_swap={{.HostConfig.MemorySwap}} oom_kill_disable={{.HostConfig.OomKillDisable}}' \
my-container
这里的数值单位是字节。也可以用下面的命令观察运行时用量与限制;故障很快时应由监控系统连续采样,而不是事后只执行一次:
docker stats --no-stream my-container
Docker 官方把 --memory 定义为硬限制。进程越过可用范围且无法回收时,内核可能终止容器内进程。不要通过关闭 OOM killer 来“修复”问题;这会把风险从单个容器转移到宿主机。
第三步:读取 cgroup v2 的 OOM 计数
在 cgroup v2 环境中,容器对应目录下的 memory.events 会记录内存事件。目录布局随 systemd、cgroup driver 和 Docker 版本变化,因此生产环境应先从容器 PID 或现有监控确定 cgroup 路径,不要把某个固定路径写死到所有机器。
container_id=$(docker inspect --format '{{.Id}}' my-container)
find /sys/fs/cgroup -type f -name memory.events -path "*${container_id}*" -print
sudo cat /sys/fs/cgroup/.../memory.events
关注以下计数:
max:内存使用尝试超过memory.max的次数;oom:cgroup 内发生 OOM 的次数;oom_kill:OOM killer 实际终止进程的次数。
计数是累计值,应保存故障前后的差值或至少记录采样时间。容器退出后 cgroup 目录可能很快消失,所以最好由节点监控持续采集。
第四步:排除另外两类 137
人工或自动化发送 SIGKILL
docker kill 默认发送 SIGKILL,也会产生 137,但通常不会让 State.OOMKilled 变成 true。检查部署平台事件、审计日志、定时任务和运维操作记录。若应用没有在优雅停止期限内退出,编排系统也可能在超时后发送 SIGKILL;这时应检查停止信号与终止宽限期。
宿主机整体 OOM
当节点整体内存耗尽时,内核可能选择某个容器进程作为牺牲对象。把容器结束时间与内核日志对齐:
journalctl -k --since "30 minutes ago" --no-pager | grep -Ei 'out of memory|killed process|oom'
dmesg -T | grep -Ei 'out of memory|killed process|oom'
如果同一时间多个容器异常、节点可用内存接近零,处理范围就不能只局限于单个容器限制。
修复:先控制工作集,再调整上限
修复目标不是让“137 消失”,而是让正常峰值工作集与容器上限之间保留可解释的余量。按以下顺序执行:
- 从监控中确定稳定工作集、峰值、增长速率和并发量。
- 检查应用自身的堆上限、缓存、队列、批处理大小和并发数。应用堆之外还存在 native memory、线程栈、共享库和页缓存,不能把应用堆设置成与容器限制完全相同。
- 若工作集合理但限制过低,再逐级提高
--memory,并为节点保留系统与其他服务的空间。 - 若内存持续增长,先定位泄漏或无界队列;自动重启只能降低中断时间,不能替代修复。
改动后在与生产相同的并发和数据规模下做压力复核,至少观察一个完整业务高峰。确认 oom_kill 不再增长、延迟没有因频繁回收显著恶化,并验证停止和滚动发布仍可正常完成。
调整前应保存原镜像、应用内存参数、容器的 Memory/MemorySwap 和节点容量基线。若调整后出现节点内存压力、延迟恶化或其他工作负载被挤压,应恢复原限制与应用参数并使用上一版镜像回滚;不能为了保住单个容器而把 OOM 风险转移到整台节点。
V2CE 复现实验记录
实验容器被设置为 48 MiB 内存和 48 MiB memory+swap 上限。Node.js 进程每 100 毫秒分配 4 MiB 并写入内存。Docker 最终记录 OOMKilled=true、ExitCode=137;在进程退出前采样到的 memory.events 为 max 19、oom 1、oom_kill 1。
随后在同样 48 MiB 限制下运行有界工作负载。应用只分配 8 MiB 工作集,输出完成信息并以 0 退出。实验结束后 Compose 网络和容器均被清理。这证明本实验中的 137 来自受控 cgroup OOM,而不是人工发送 SIGKILL;同时说明限制本身不是故障,越过限制的工作集才是触发条件。
复核清单
- 保存原容器的 inspect、日志和结束时间;
- 确认
ExitCode与OOMKilled,不只看管理面板文案; - 确认当前容器真正生效的
Memory与MemorySwap; - 保存
memory.events差值和宿主机内核日志; - 调整后用真实负载复测,并观察完整高峰期。