容器被杀之前:Linux 内存账本与 cgroup v2 的 OOM 现场

一个每个月都会重演的场景

docker stats 显示某个容器用了 600MB,上限 1GB,看上去还有富余。第二天早上进程没了,退出码 137,日志里干干净净,只有 dmesg 里孤零零一行 OOM。

这不是玄学,是两本账没对上:docker stats 显示的是 working set,而内核判死刑看的是另一个数。把这件事搞清楚,比加内存有用得多。

free 的两列,本来就不是一回事

$ free -h
               total        used        free      shared  buff/cache   available
Mem:            62Gi        18Gi       2.1Gi       411Mi        43Gi        41Gi
Swap:           8.0Gi          0B       8.0Gi

free 只剩 2.1Gi,available 还有 41Gi。差在哪?buff/cache。Linux 会把暂时用不到的内存拿去做页缓存——读过的文件留在内存里,下次不用再走磁盘。这部分在缺内存时可以被回收,所以 /proc/meminfo 里的 MemAvailable 才是「还能给新进程用多少」。

盯着 free 做告警,等于给自己安排一份每天误报的日报。真正要盯的是 available,或者更直接的:cgroup 里的 memory.current。

不过「可回收」也不等于「立刻能回收」。脏页得先写回磁盘;tmpfs 和共享内存虽然算在 buff/cache 里,却没有后备文件,压根回收不掉;slab 里也有一部分是不可回收的内核对象。所以 available 是个估计值,不是承诺。

容器里看到的数字,基本都是错的

/proc/meminfo 没有做命名空间隔离。在一个限制 2GB 的容器里执行 free -h,它会老老实实告诉你宿主机有 62GB。

于是所有「容器内读 meminfo 判断要不要重启」的脚本,判断依据全是别人的内存。要看自己的账,得去 cgroup v2:

# 宿主机上(容器 ID 前 12 位即可)
cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/memory.current
cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/memory.max

# 容器内部,路径更简单
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.stat

memory.current 里到底装了什么

memory.stat 把账目摊开给你看,几个关键项:

  • anon:匿名页,进程的堆、栈、malloc 出来的东西。不换出就回收不了;
  • file:页缓存,包括 tmpfs。大部分可回收,但 tmpfs 那部分特殊;
  • inactive_file / active_file:LRU 的两个分区,前者是「最近没被碰过、随时可以扔」的;
  • slab、kernel_stack:内核侧的开销,容器里通常不大,但句柄泄漏时会悄悄涨。

现在可以解释开头那个矛盾了:docker stats 报的「内存使用」是 memory.current - inactive_file,也就是 working set——它有意把「随时能扔的页缓存」扣掉了。而内核判 OOM,看的是 memory.current 和 memory.max,一分不减。

于是就有了那个经典错位:看板显示 600M / 1G 很健康,实际 memory.current 已经贴着 1G,只差一次大文件读取。

OOM 是 cgroup 级别的事

进程申请内存时,如果 memory.current 顶到 memory.max,内核先做一轮回收:扔 file 页、把 anon 页换到 swap。回收不动,才触发 cgroup OOM,在这个 cgroup 里挑一个 oom_score 最高的进程杀掉。

容器通常还开着 memory.oom.group=1,意思是整组一起杀。所以现象是「整个服务没了」,而不是「挂了一个 worker 自动重启」——很多人第一次遇到会觉得莫名其妙。

cat /sys/fs/cgroup/system.slice/docker-<ID>.scope/memory.events
# low 0
# high 0
# max 12
# oom 3
# oom_kill 2

这五个计数比任何日志都诚实。max 非零说明撞过上限的边(可能回收成功了,没死),oom_kill 才是真的杀过。

顺带说清退出码:137 = 128 + 9(SIGKILL),143 = 128 + 15(SIGTERM)。137 不必然是 OOM——docker stop 等 10 秒没退,也会补一发 SIGKILL。要区分,就去看 oom_kill 有没有涨、dmesg 有没有留痕。

memory.high:比一刀切更体面的刹车

memory.max 是墙,撞上去就是死。memory.high 是减速带:超过它,内核开始限流并加紧回收,进程会变慢,但通常不会死。对 Java、Node 这种「缓存一大堆对象、自己又不太愿意还」的运行时,这比直接给个更大的上限有效。

# systemd unit
MemoryHigh=1G
MemoryMax=1.5G

# docker compose(reservations 映射到 memory.low,limits 映射到 memory.max)
deploy:
  resources:
    limits:
      memory: 1536M
    reservations:
      memory: 1G

注意 compose 目前没有直接对应 memory.high 的字段。要么用 systemd 起容器,要么直接把值写进 cgroup 文件——重启后失效,得配个 oneshot 服务补上。

语言运行时还有另一本账

容器被 OOM,很多时候不是因为业务真的需要那么多内存,而是因为运行时把「上限」理解错了。

  • JVM:-Xmx 只管堆。Metaspace、线程栈、直接内存(NIO、Netty)都在堆外,加起来能比堆还大。容器里用 -XX:MaxRAMPercentage=75 让它读 cgroup 限制,别再手写固定值。
  • Go:1.19 之后有 GOMEMLIMIT,但它是软限制——超过以后 GC 会更卖力,不是禁止分配。真到顶还是会被杀。
  • Node.js:--max-old-space-size 只限老生代,Buffer 和 off-heap 不在里面。这是 Node 服务在容器里被莫名杀掉的头号原因。

现场排查的五步

  1. dmesg -T | grep -i "killed process":谁被杀、RSS 多大、当时系统什么样,一行看完;
  2. memory.events:确认是 OOM 还是别的死法,oom_kill 涨了才算实锤;
  3. memory.peak:cgroup v2 会记录历史峰值。这个数比 memory.current 有用一百倍,因为它包含了你没蹲到的那个尖峰;
  4. memory.stat 看结构:anon 占大头是真吃内存,file 占大头只是页缓存,处理方式完全不同;
  5. memory.pressure 的 full avg10:这个值高,说明所有进程都在等内存,不是某一个进程的问题。
# 一次看全
cd /sys/fs/cgroup/system.slice/docker-<ID>.scope
grep -E '^(anon|file|slab) ' memory.stat
cat memory.peak memory.events memory.pressure

OOM 不是内存不够,是账没算对

加内存能解决一阵子,但同一套代码换个流量曲线还会再撞一次。真正管用的是三条习惯:给每个容器都设 memory.max(哪怕设得很宽松,也不要不设);把 memory.peak 和 oom_kill 接进监控,而不是只看当前用量;以及搞清自己那门语言的运行时到底在哪分配内存。

最后一句实话:swap 不是洪水猛兽。有 swap 的时候,内核能把 anon 页换出去,很多「OOM 崩掉」会退化成「变慢一会儿」。小内存机器上开 zram 更划算——用 CPU 换内存,比加一条内存条便宜得多。

参考来源

延伸阅读

© 版权声明
THE END
喜欢就支持一下吧
点赞15 分享
评论 抢沙发
头像/SUENCA
欢迎您留下宝贵的见解!
提交
头像/SUENCA

昵称

取消
昵称表情代码图片快捷回复

    暂无评论内容