容器内存限额与OOM:判定链路与取证

一、从一次「内存看着正常但服务被杀」的事故说起

一台 2G 内存的小机器,跑着 Nginx + PHP-FPM + MySQL,跑了两年一直正常。某天开始,每天早上九点左右页面会出现几次 502,每次持续几十秒,日志里有 upstream timed out。登上去看 free -m,available 还有 400MB,top 里也没有哪个进程特别大。第一反应是「不可能是内存问题」。

直到 dmesg -T | grep -i oom 打出一行 Out of memory: Killed process 12345 (php-fpm),时间正好是每天九点前后。也就是说:系统在「整体内存看起来还够」的情况下,杀掉了单个进程。这不是内核算错了,而是两种「内存不足」被混为一谈:整机可用内存不够,和某个 cgroup 里该进程所属的限额不够,是两个完全不同的判断路径。

这篇文章讲清楚四件事:容器里 OOM 的判定链路到底怎么走、为什么 free 和 docker stats 经常给出矛盾的结论、常见内存用量统计为什么和限额对不上、以及怎么设置限额才能既不 OOM 又不浪费。全部基于 cgroup v2(现代发行版默认),cgroup v1 会单独标出差异。

二、判定链路:两次检查,两个不同的杀手

理解 OOM 的关键是记住:内核在两条不同的路径上做内存回收,失败时的处置不一样。

第一次检查:全局内存。当整机内存和 swap 加起来不足以满足分配请求,内核先做全局回收(丢 page cache、回收 slab、尝试 swap),如果仍不够,触发全局 OOM killer,从所有进程里挑一个分数最高的杀掉。这个分数由 oom_score 决定,和进程的内存占用、运行时长、是否特权有关。

第二次检查:cgroup 限额。如果进程在某个 cgroup 里(systemd 服务默认都在,容器里必然在),并且这个 cgroup 设了 memory.max,那么该 cgroup 内的内存用量触顶时,内核会先把回收限制在这个 cgroup 内部:它不能去动别的 cgroup 的 page cache,只能在这个组的匿名页(真正的进程内存)和文件页里找。匿名页是回收不掉的(除非有 swap),所以一旦组内的匿名页本身就接近限额,回收必然失败,于是触发 cgroup 级 OOM killer,只在这个组里杀进程。

# 判定是哪一种 OOM:看内核日志里的关键字
dmesg -T | grep -iE 'out of memory|oom-kill|cgroup out of memory' | tail -20

# 全局 OOM 的特征:有 "Killed process" 但没有 cgroup 约束提示
# cgroup OOM 的特征:日志里带 "Memory cgroup out of memory" 或 "oom_memcg"

# 查看进程当前的 OOM 分数(0~1000,越高越先被杀)
for p in $(pgrep -f php-fpm | head -5); do
  echo "$p $(cat /proc/$p/oom_score) $(cat /proc/$p/comm)"
done

区分这两者非常重要,因为修法完全不同:全局 OOM 说明整机容量或整体配置有问题,可能需要降并发、加 swap、减服务;cgroup OOM 说明这个组的限额和这个组的实际需求不匹配,只要调限额或调该服务的并发即可,机器本身没问题。

三、为什么 free 和容器统计给出矛盾的结论

这里有个反直觉的事实:cgroup 级别的回收是「局部」的,它不能回收本组之外的缓存。设想一台机器 8G 内存,跑了三个容器各限 2G。某个容器里 PHP 进程把匿名页堆到 1.9G,文件页(读过的静态文件、模板缓存)占了 300M,加起来 2.2G 超过限额。此时内核会在这个组内回收那 300M 文件页,成功,不会 OOM。

但如果这个容器的匿名页本身就到了 2.0G,那么无论回收多少次文件页都救不了:匿名页无法被回收(没有 swap 时),限额触顶,OOM 必然发生。而同一时刻 free -m 可能显示整机 available 还有 3G——因为另外两个容器只用了 1G,剩下的全是空闲内存。空闲内存在别的地方,但这个组用不了,因为限额是硬的。

反过来也成立:docker stats 显示的 MEM USAGE 往往是 memory.current 减掉 inactive_file 的值,这个值可能远低于 memory.current。所以就出现了那个经典矛盾:stats 显示用了 1.2G/2G 很健康,但进程还是被杀了——因为 memory.current 已经到 2.0G,差额全是被内核视为「可回收但没来得及回收」的文件页,而真正触发 OOM 的那一瞬内核需要立刻拿到内存,回收来不及,于是杀进程。

# cgroup v2 下真正决定「会不会 OOM」的数字
CG=/sys/fs/cgroup/system.slice/php-fpm.service
echo "current: $(cat $CG/memory.current)"
echo "max:     $(cat $CG/memory.max)"
echo "anon:    $(grep -E '^anon ' $CG/memory.stat)"
echo "file:    $(grep -E '^file ' $CG/memory.stat)"
echo "inactive_file: $(grep -E '^inactive_file ' $CG/memory.stat)"
echo "峰值:    $(cat $CG/memory.peak 2>/dev/null)"
echo "事件:    $(cat $CG/memory.events)"

memory.events 是排查 OOM 最有用的文件,它记录了几个计数器:low、high、max(触顶次数)、oom(真正触发 OOM 的次数)、oom_kill(被杀进程数)。如果 max 在持续增长而 oom_kill 是 0,说明限额经常触顶但靠回收活下来了,这是一个「快到极限」的明确预警,比等到 OOM 发生再查有价值得多。可以把 max 的增长做成告警。

四、常见的内存用量统计为什么和限额对不上

排查时最常遇到的问题是:「我们设了 1G 限额,但进程 RSS 加起来只有 600M,为什么还会 OOM?」这里有四个统计口径的差异,每一个都能解释掉一部分差额。

差异一:RSS 会重复计算共享内存。同一份共享库、同一份 mmap 的共享内存段,会在每个映射它的进程的 RSS 里各算一次。十个 PHP-FPM 子进程共享 100M 的 opcache 共享内存,ps 里每个子进程 RSS 都含这 100M,加起来多算了 900M。而 cgroup 只算一次。所以要判断限额够不够,永远看 cgroup 的数字,不要把进程 RSS 相加。

差异二:memory.stat 的分项之和不等于 memory.current。除了 anon、file,还有 kernel、slab、sock、shmem 等若干项。shmem(包括 tmpfs 和共享内存)在很多版本里同时计入 anon 和 file 的一部分,所以简单相加会重复。正确的做法是直接看 memory.current,需要拆解时用 memory.stat 里的 anon 和 file 两个大头,其余的当余量。

差异三:页缓存算在 cgroup 里,但它是可以回收的。一个程序大量读文件,file 项会持续增长到限额附近,此时 memory.current 看起来很满,但只要 anon 远低于限额,就不会 OOM。判断风险的正确指标是 anon 与 memory.max 的比例,而不是 memory.current 与 max 的比例。如果 anon 已经到限额的 90%,那才是真正的危险信号。

差异四:子 cgroup 的用量会计入父 cgroup。如果用 systemd 的 slice 结构(system.slice 下有多个 service),父 slice 的 memory.current 是子项之和。给父 slice 设了限额,而某个子服务突增,会导致整个 slice 触顶,OOM 时可能杀掉旁边那个「无辜」的服务。这一点在多服务小机上特别常见。

# 一次性看清一个 cgroup 的真实构成
CG=/sys/fs/cgroup/system.slice/php-fpm.service
awk '{printf "%-16s %8.1f MB\n", $1, $2/1048576}' $CG/memory.stat \
  | grep -E '^(anon|file|kernel|slab|sock|shmem|inactive_file|active_file) '

# 树状查看所有 cgroup 的用量与限额
systemd-cgtop -m --iterations=1 2>/dev/null | head -15

五、限额怎么设:从「拍一个整数」到「按实测留余量」

大多数人的限额是照着「这台机器有 2G,那就设 1G」这么拍的。更靠谱的做法是按实测峰值乘一个系数,并区分「硬限」和「软限」。

第一步是拿到真实峰值。如果服务已经在跑,用 memory.peak(需要内核 5.19+)最直接;老内核就得靠采样。采样要覆盖业务高峰,至少连续一周:

# 老内核:每分钟采一次 anon,连续记录
CG=/sys/fs/cgroup/system.slice/php-fpm.service
while true; do
  a=$(awk '$1=="anon"{print $2}' $CG/memory.stat)
  echo "$(date +%F_%H:%M) $a" >> /var/log/mem-anon.log
  sleep 60
done
# 一周后看峰值与 P95
sort -k2 -n /var/log/mem-anon.log | awk '{a[NR]=$2} END {print "max",a[NR],"p95",a[int(NR*0.95)]}'

第二步:硬限设为实测峰值的 1.3 到 1.5 倍,软限(memory.high)设为峰值的 1.1 倍左右。软限的作用是「超过就限速回收,而不是杀进程」——超出 memory.high 后内核会让这个组的分配变慢(通过直接回收和 throttle),给应用一个自我调节的机会。这是 cgroup v2 相比 v1 最实用的改进之一,很多 OOM 事故其实用 soft limit 就能避免。

# systemd 服务:写进 unit 覆盖文件
systemctl edit php-fpm
# 内容:
# [Service]
# MemoryHigh=1150M
# MemoryMax=1500M
# MemorySwapMax=512M
# OOMPolicy=continue
systemctl daemon-reload && systemctl restart php-fpm

三个参数的含义值得逐个说清楚:

  • MemoryHigh:软限。超过后内核优先回收该组内存并限制分配速率,不杀进程。适合作为「预警线」。
  • MemoryMax:硬限。超过且回收失败就 OOM。这是真正的天花板。
  • MemorySwapMax:该组能用的 swap 上限。设 0 表示完全不给 swap,设一个小值(如硬限的 1/3)可以让冷页换出去,避免因瞬时峰值被杀。
  • OOMPolicy=continue:OOM 杀掉一个子进程后,systemd 是否继续尝试重启。默认值在某些版本上是 stop,会让整个服务停掉;对 PHP-FPM 这种「子进程可以死」的服务,continue 通常更合适。

第三步是把限额和并发对上。PHP-FPM 的内存基本等于「子进程数 × 单进程平均内存 + opcache 共享内存」。如果 pm.max_children = 50 而单个子进程峰值 40MB,那就是 2G,无论限额设多少都会 OOM。调整顺序应该是:先量单进程峰值,再按内存上限反算子进程数,最后才设 cgroup 限额。反过来(先设限额再看为什么被杀)是本末倒置。

# 量单进程峰值:观察一段时间内各子进程 RSS 的最大值
ps -o rss= -C php-fpm | sort -n | tail -1 | awk '{printf "单进程峰值 %.1f MB\n", $1/1024}'
# 结合最大子进程数估算理论峰值
grep -E '^pm.max_children' /etc/php/*/fpm/pool.d/www.conf

六、OOM 发生之后的现场取证

OOM 的一个特点是「现场的进程已经不存在了」,所以取证必须靠事先留下的记录。四样东西按顺序看:

  1. memory.events 的 oom_kill 计数:确认确实是 OOM,而不是服务自己崩了。
  2. dmesg -T 里的 OOM 段:里面有被杀进程的名字、PID、总内存、以及所有进程的 rss 排行。这个排行非常有价值,能看到「是谁把内存吃光了」。
  3. 被杀进程的 memory.peak 与 memory.max:如果 peak 和 max 几乎相等,说明是稳稳地撞上天花板,不是瞬时尖峰。
  4. 同一时段的应用日志:OOM 前通常有几秒的「分配失败」或「超时」,能对上就说明因果关系成立。
dmesg -T | grep -A 25 'Memory cgroup out of memory' | tail -40
cat /sys/fs/cgroup/system.slice/php-fpm.service/memory.events
journalctl -u php-fpm --since "今天 08:50" --until "今天 09:10" --no-pager | grep -iE 'error|fatal|memory' | head -20

如果 dmesg 已经滚掉了(重启后常见),就只剩 memory.events 的计数和 journal 的日志。这也是为什么建议把 memory.events 的 oom_kill 做成监控指标——它是 OOM 发生后唯一还在的证据,而且永不丢失。

七、六个常见误区

误区一:加 swap 就能避免 OOM。swap 只是把匿名页换出去,如果工作集的匿名页本身就超过 MemoryMax,换出去也仍然算在组的内存里(swap 页仍在 cgroup 记账范围内),OOM 照旧。swap 对「冷页长期不用」有用,对「工作集超限」无用。

误区二:把 vm.overcommit_memory 调成 2 能解决 OOM。这个是控制「是否允许超量分配虚拟内存」的,调成 2 会让大块 malloc 直接失败(应用可能启动不了),和 cgroup OOM 没有关系。不要动它,除非有明确的应用需求(比如 Redis 的 fork 场景)。

误区三:drop_caches 能预防 OOM。丢缓存只会让 memory.current 暂时下降,anon 一点没变,OOM 风险不变,还白白浪费了缓存带来的性能。判断风险看 anon,不看 current,这一点前面已经说过,但踩的人最多。

误区四:给 cgroup 设了 max 就不需要监控了。恰恰相反,设了 max 等于给系统加了一条「到点就杀」的硬线,更需要监控 max 事件和 anon 占比。没有监控的硬限只是把「慢」变成了「突然死」。

误区五:用 docker run -m 设限额后就不用管了。容器的 memory 限额等价于 memory.max,但默认不设 memory.high。而且如果宿主机本身也紧张,容器内的 OOM 和宿主机的 OOM 会一起出现,两个层面的日志要分别查。

误区六:看到 OOM 就加大限额。加大限额是最快的止血手段,但如果不查清「anon 为什么涨上去」,下次只是把事故推迟几天。至少要回答一个问题:这次的内存增长是缓慢爬升(泄漏)还是瞬时冲高(并发突发或大请求)?前者要改代码或降并发,后者才适合调限额。

八、把判断固化成三条日常检查

日常不需要天天盯着,但有三条检查值得固定下来,加起来一分钟:

  • 每天:memory.events 的 oom_kill 是否比昨天增长。增长就查 dmesg 与 journal。
  • 每周:关键 cgroup 的 anon 峰值与 MemoryMax 的比值。超过 85% 就该评估扩容或降并发。
  • 每月:max 与 high 事件计数的增长率。持续增长说明软限正在频繁工作,配置已不匹配业务。
for cg in $(find /sys/fs/cgroup/system.slice -maxdepth 1 -name '*.service' -type d); do
  n=$(basename "$cg")
  [ -f "$cg/memory.max" ] || continue
  max=$(cat "$cg/memory.max"); cur=$(cat "$cg/memory.current")
  anon=$(awk '$1=="anon"{print $2}' "$cg/memory.stat")
  ok=$(awk '$1=="oom_kill"{print $2}' "$cg/memory.events")
  [ "$max" = "max" ] && continue
  awk -v n="$n" -v a="$anon" -v m="$max" -v k="$ok" \
    'BEGIN{printf "%-28s anon=%6.0fM max=%6.0fM 占比=%5.1f%% oom_kill=%s\n", n, a/1048576, m/1048576, a/m*100, k}'
done

九、小结

容器里 OOM 和整机 OOM 是两条完全不同的判定路径,混在一起就会得出「内存明明够却还是被杀」这种看起来矛盾的结论。记住三件事就够了:判断风险看 anon 占 MemoryMax 的比例,而不是看 free 或 docker stats;memory.events 是 OOM 的唯一长期证据,要监控它;限额必须从实测峰值反推,并且和 PHP-FPM 的并发、单进程内存这三个数字对齐,先设限额再查原因永远是本末倒置。

Last modification:November 5th, 2026 at 08:27 pm

Leave a Comment