容器跑着跑着自己就没了,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的区别只在读取路径,不影响上面这些判断。