Docker容器退出码137与OOMKilled的排查方法(docker inspect定位、内存限额调整与JVM容器感知)

容器跑着跑着自己就没了,docker ps -a里状态写着Exited (137),应用日志干干净净,最后一行还是正常输出,没有任何异常堆栈。这种死法跟代码关系不大,137这个数字本身就把原因说了一半。

137 = 128 + 9,也就是进程收到了SIGKILL。这个信号不能被捕获、不能被忽略,应用层留不下痕迹,所以日志平静是正常的。在容器里发这个信号的,绝大多数时候是内核的OOM killer:容器内存撞上了限额,内核挑进程杀掉回收内存。剩下的少数是docker kill、编排平台的健康检查把容器重启了,或者宿主机自己内存见底。

动手之前先把这个结论钉死。之前碰到过一台机器上运维看137就认定是内存问题,把限额从1g加到4g,结果第二天照样挂——真正的原因是K8s的liveness探针超时后被kill,跟内存一点关系没有。

一步确认:看容器的OOM标记

docker自己就记着这个状态,不用猜:

docker inspect api --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} restart={{.RestartCount}}'

OOMKilled=true基本就是实锤。oom=false而exit=137,那SIGKILL来自别处,往下翻到最后一节。顺带说一句,{{.RestartCount}}这个数如果一直在涨,说明容器已经陷在重启循环里了,这时候先把重启策略摘掉才能拿到干净的现场:

docker update --restart=no api
docker wait api

二步:翻内核日志

内核杀进程会留记录,里面有被杀瞬间的内存占用、进程名、RSS大小,这是判断"到底超了多少"最直接的依据:

dmesg -T | grep -i -E 'out of memory|killed process'
journalctl -k --since '30 min ago' | grep -i -E 'oom-kill|killed process'

输出里形如Killed process 12345 (java) total-vm:4194304kB, anon-rss:1048576kB。anon-rss那一项是真金白银的匿名内存,不含page cache。Ubuntu 22.04、24.04,这类较新的发行版统一走cgroup v2,日志里还会带oom_score_adj和被杀进程所属的cgroup路径,能一路指到具体容器。

三步:看真实用量,别被MEM USAGE骗了

docker stats --no-stream api

这里有个坑:docker stats的MEM USAGE把page cache算进去了。一个读了大量文件的容器,缓存能占好几百兆,看着快撞线了,但缓存是可回收的,内核缺内存时先丢缓存而不是杀进程。实测下来更准的做法是直接读cgroup文件,cgroup v2下:

docker exec api cat /sys/fs/cgroup/memory.max
docker exec api cat /sys/fs/cgroup/memory.current
docker exec api cat /sys/fs/cgroup/memory.events

memory.events里的oom_kill是个计数器,数值大于0就说明这个cgroup确实发生过OOM杀;max与oom两个计数分别代表撞线次数和触发OOM的次数。CentOS 7.9这类老系统是cgroup v1,路径换成/sys/fs/cgroup/memory/memory.limit_in_bytes与memory.usage_in_bytes,文件名不同,意思一样。

限额确实给小了

用量曲线是平的、只是数值高,那就是限额的锅。按峰值再留三成余量,顺手把swap关掉让行为可预测:

docker run -d --name api --memory=2g --memory-swap=2g --restart=on-failure:3 api:1.4.2

--memory-swap给得和--memory一样大等于禁用交换。compose里的写法分两代:老版本的mem_limit: 2g在compose 2.24、2.29、2.32,这些新版本上仍然认,但推荐走标准的限制段:

services:
  api:
    image: api:1.4.2
    deploy:
      resources:
        limits:
          memory: 2g

别用"去掉内存限制"这招解决。不限额的容器一旦泄漏,会把宿主机连同上面所有服务一起拖垮,那个故障面比单个容器重启大得多。

运行时不知道自己在容器里

这是最常被忽略的一类。旧版JVM按宿主机总内存算堆:宿主机64G,照默认四分之一开出16G的堆,而容器限额只有2G,于是还没用到自己的堆上限就先被内核杀了。Java是从2018年的8u191开始默认打开容器感知(-XX:+UseContainerSupport),OpenJDK 11.0.22、17.0.10、21.0.2,这些版本都没问题。

稳妥的做法是不让JVM自己算,直接给百分比,同时给非堆部分留够位置——元空间、线程栈、JIT代码缓存、直接缓冲区都不在堆里,加起来常常还要再占两三成:

JAVA_TOOL_OPTIONS='-XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0'

验一下容器里实际生效的堆上限:

docker exec api java -XX:+PrintFlagsFinal -version | grep -i maxheapsize

Node这条线上是同一个毛病,老生代默认按宿主机内存走,得显式封顶:

NODE_OPTIONS=--max-old-space-size=1536

Node 18.20、20.11、22.3,这几个版本的默认堆策略照样看主机内存,跑在容器里一律建议锁死。

内存一直在涨

如果docker stats的曲线斜着往上、重启后从低位重新爬,那就是泄漏,加限额只把挂掉的时间往后推。这时候该抓堆快照:Java用jmap -dump:format=b,file=/tmp/heap.hprof 1再docker cp出来分析,Python上tracemalloc够用,Node用--heapsnapshot-signal=SIGUSR2发信号导出。见过最典型的一次是连接池没设上限,每来一个请求建一条连接,跑四个小时准时被杀,曲线平直得像尺子画出来的。

137但不是OOM的那几种

OOMKilled=false时按下面几条挨个排除。

1、docker kill或者编排系统的探活把容器杀了。K8s里用kubectl describe pod看Last State那段,Reason: Error配Exit Code: 137多半是探针干的;

2、宿主机自身内存耗尽。这时候被杀的往往不是占用最大的那个容器,dmesg里能看到谁当了替罪羊。给关键容器加--oom-score-adj=-500可以降低被挑中的概率;

3、退出码看错了。139是SIGSEGV(128+11),常见于JNI或者native库;143是SIGTERM(128+15),正常停止或滚动更新就是这个信号,压根不是故障。这三个数字挨着,看走眼很正常。

改完记得压一轮

限额改动一定要压测之后再定。当初有次把限额从1g提到2g,第一天没事,第三天业务量上来又被杀,最后定到3g才算稳住。凭感觉拍一个数基本拍不准,峰值乘以1.3只是起点,不是答案。

数据截至2026年9月,Docker Engine 24.0、25.0、26.1、27.3,各版本在内存限制与OOM标记上的行为一致;cgroup v1与v2的区别只在读取路径,不影响上面这些判断。