上周有个客户找我,说他们的容器化订单系统跑在 4 核 8G 的云主机上,其中一个跑支付回调的容器昨天被 OOM Killer 直接干掉了,进程连日志都没来得及刷完。奇怪的是,这套服务在测试环境跑了半年没出过事,一上生产就崩。我登上去一看,配置里明明白白写着内存限制 512M,日志里也确实是 Memory cgroup out of memory: Killed process,可排查方向一开始全跑偏了我第一反应也是「是不是 Java 堆没设好」,查了一圈堆参数发现全是默认值,跟容器限额一点关系都没有。
真正的原因藏在 cgroup 的两行参数里:内存上限设了,swap 上限没设。这一篇就把容器内存限额这套东西从头捋一遍,包括为什么宿主机还有 4G 空闲内存容器却被杀、怎么在几分钟内确认是不是被 OOM、以及 memory.max 和 memory.swap.max 到底该怎么配才不出事。
想一边调参数一边盯着服务器内存曲线的话,可以试试这台服务器上的 Rayon(Linux 服务器监控工具):它能实时看 CPU、内存、磁盘和网络的占用曲线,配合 docker stats 一起用,容器哪一刻冲上红线基本一眼就能看出来,调限额参数的时候比反复敲命令翻日志省事很多。

先确认:容器到底是不是被 OOM 杀的
别一看到服务挂了就说内存不够。被 OOM Kill 和普通崩溃在应用层的表现很像进程突然消失,没有 core dump,没有异常栈,Java 应用甚至不会抛任何错误,直接 137 退出。所以第一件事是去翻内核日志,命令是 dmesg -T | grep -i -E "oom|killed process"。
如果日志里出现类似下面这行,就说明是 cgroup 触发的 OOM,而不是宿主机整体内存耗尽:
Memory cgroup out of memory: Killed process 12345 (java) total-vm:8000000kB, anon-rss:520000kB
关键在 total-vm 和 anon-rss 这两个值:total-vm 是虚拟内存总量,anon-rss 是真实常驻内存。很多运维习惯盯着 total-vm 说「才用了 800M 怎么会超 512M」,其实限额看的是 cgroup 记账的内存,包括 page cache 在内的 anon-rss 加上一部分文件缓存,跟 free 命令看到的完全不是一回事。真正该看的字段是 memory.events 里的 max 计数:
cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/memory.events
max 47 # 被卡在上限的次数oom 12 # 真正触发 OOM Kill 的次数oom_kill 12
如果 oom_kill 大于 0,就实锤了。顺手再看一眼 memory.stat,重点关注 anon、file、slab 三项,能判断到底是进程真吃多了,还是 page cache 被算进来了。

为什么宿主机还有空余内存,容器还是被杀
这是最容易把人绕进去的一点。容器内存限额走的是 cgroup,不是「宿主机还剩多少就分你多少」的动态逻辑。限额是硬红线,触线就杀,不看宿主机余量。宿主机当时还有 4G 空闲,但这个容器自己蹭到了 512M 上限,内核就会直接干掉它。
另外还有一个隐藏项经常被忽略:cgroup v2 的 memory 记账里包含了 page cache。如果容器里跑的服务有大量文件写入(比如日志、缓存文件、临时文件),这些页缓存算在容器的 memory.current 里。曾经有个客户把 Nginx 的 access log 打开又不做轮转,日志文件本身没多大,但 page cache 撑爆了 512M 的限额,Nginx 被 OOM Kill 的时候日志里一句错误都没有,非常有迷惑性。
还有个反直觉的点:cgroup v2 下,限制内存默认是允许用 swap 的。也就是说 memory.max=512M 并不意味着「容器最多用 512M 物理内存」,它只管内存部分,超过之后内核会尝试把匿名页换到 swap 里去;只有当 swap 也用满,才真正触发 OOM。这就带出了这次事故的根因。
memory.max 和 memory.swap.max 是两码事
我们可以用 docker run 直接复现这个坑:
docker run -d --memory=512m --name=pay-callback your-image
这条命令只设了物理内存上限 512M,swap 上限是无限的。后果是:容器内存涨到 512M 后,内核开始把匿名页往 swap 里换,容器看起来「卡了」但没死;等到 swap 也吃满,才会 OOM。这种「先卡后死」的表现最容易误判成性能问题,排查方向直接跑偏。
反过来,如果显式设置了 --memory-swap 且它等于 --memory,含义是完全禁用 swap,这会暴露另一个坑:容器一点缓存都不能用,内存涨到 512M 立刻被杀。这两种极端都不对。
在 cgroup v2 里对应的两个文件是:
/sys/fs/cgroup/<路径>/memory.max # 内存上限/sys/fs/cgroup/<路径>/memory.swap.max # swap 上限(默认 max,即不限)/sys/fs/cgroup/<路径>/memory.high # 软限,超过后开始节流回收,不会立刻杀
memory.high 是这套机制里最被低估的一项。它不杀进程,只是让内核加大回收力度、限制分配速度,给应用一个「你快超了」的信号。对于内存用量有波动的业务,配了 memory.high 之后,尖峰会被压平,而不是直接触发 OOM。

一套我自己在用的限额配置
现在我给业务容器配限额基本遵循三段式,供参考:
第一步,先量峰值再定限额。不看监控就拍脑袋写 512M 是在赌运气。先让容器以不限额的方式跑满一个业务高峰周期,用 docker stats 或者 cAdvisor 记录 P99 内存占用,再按「P99 值的 1.5 倍」来定 memory.max。留的那 50% 余量是留给 GC 峰值和 page cache 波动的。
第二步,swap 上限跟着内存上限一起设。我一般写 --memory=512m --memory-swap=768m,也就是内存 512M、swap 额外给 256M。这样内存到顶时还有一小段缓冲期可以换页,不至于瞬间暴毙;如果这台机器上 swap 本身不充裕,我就直接 --memory-swap=512m 禁用 swap,宁可早点 OOM 也不要整机被拖垮一个容器把宿主机拖垮,比一个容器自己死掉危害大得多。
第三步,配上 memory.high 做缓冲。Docker 原生对 memory.high 的支持要看版本,如果 compose 文件里不方便写,可以直接进 cgroup 目录写:
echo $((400*1024*1024)) > /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/memory.high
写进去之后注意观察 memory.events 里 high 计数,如果这个数在涨,说明应用长期贴着软限跑,要么调限额,要么回去看代码里是不是有内存泄漏。

几个容易踩的细节
其一,容器内的 JVM 不感知 cgroup 限额。JDK 8 及以前的版本读不到容器内存上限,默认按物理机的 8G 去算堆,直接配 -Xmx8g 是自杀行为。JDK 10 之后引入了 UseContainerSupport(1.8u191 之后 backport 了一部分),能识别 cgroup 限额,但默认堆大小依然是物理内存的 1/4,同样不准确。手动指定 -Xmx 并留出 200~300M 给元空间、线程栈和 native buffer,是最省心的做法。
其二,--oom-kill-disable 慎用。这个参数能让容器内的进程免于被 OOM 杀掉,但内存真耗尽时进程会卡死而不是退出,表现成「服务没响应但 CPU 为零」,排查起来更痛苦。除非是那种必须保数据的任务,否则不建议开。
其三,容器被杀了不等于状态没了。记得配置重启策略,比如 --restart=unless-stopped,同时保证写入的数据在容器外,用 volume 挂出来,不然容器重建数据全丢,这就从「内存调优」变成「数据丢失」了。
写在最后
回头看这次事故,最讽刺的地方是:整套限额配置只差了一行 swap 上限,代价是生产环境崩了两次、订单回调积压了 4 小时。容器内存限额这个东西,坑不在于它难,而在于它「看起来配了等于配了」很多参数不写就有默认值,默认值又和人的直觉不一致。真正花时间的地方在于知道每个参数管什么、知道去哪儿看证据。把这三件事搞明白了,OOM 这类问题基本就变成可观测、可预防的了。


喜欢
顶
难过
囧
围观
无聊



