91% 用了三周没事,73% 那天死了容器

两份截然相反的报告。第一份:某节点内存使用率连续三周在 88%~93% 徘徊,业务零异常,告警被 SRE 习惯性静音。第二份:某天使用率 73%,一个容器 OOMKilled,重启循环,业务掉线。

第一台机器没病。那 90% 里的大头是 page cache——内核拿空闲内存缓存文件,需要时随时让出来,这是内存在干活,不是内存在漏。第二台机器的墙也不是物理内存:容器的生死线是 cgroup 的 memory.max,跟宿主机还剩多少百分点毫无关系。

结论先立住:使用率不是压力信号。压力是回收行为——在回收什么、回收得多疼——以及 kill 事件。诊断要顺着这两条找证据,而不是盯着百分比。

先把 free 读对,重点在 available

$ free -h
               total        used        free      shared     buff/cache   available
Mem:            62Gi        9.4Gi       2.1Gi       412Mi        50Gi        52Gi
Swap:          8.0Gi          0B       8.0Gi
  • buff/cache 主体是 page cache(文件页),内存紧张时优先回收它,代价是后续访问回盘重读。
  • available 对应 /proc/meminfo 的 MemAvailable,是内核"在不制造压力的前提下还能给出多少"的估计值,已经扣掉了收不回来的那部分。
  • used 高几乎说明不了任何问题。这台 used 9.4Gi 的机器,和一台 used 55Gi 但 available 依然充足的机器,健康程度没有差别。

一个准确但少有人知的细节:/proc/meminfo 的 Cached 字段是包含 tmpfs(Shmem)的,而 tmpfs 页没有 backing file,不能像普通文件页那样直接丢弃。日志、会话状态塞 /dev/shm 的部署里,“buff/cache 都可回收"这句口头禅不成立,估实际可回收量要把 Shmem 减掉:

grep -E 'MemTotal|MemFree|MemAvailable|^Cached|Shmem|SReclaimable|AnonPages|SwapTotal|SwapFree' /proc/meminfo

文件页和匿名页:回收的代价完全不对称

内存分两类,回收路径两条:

  • 文件页(page cache):read/mmap 进来的文件数据。干净页直接丢弃,脏页先写回再丢。丢的代价是 refault——下次访问回盘重读。
  • 匿名页:堆、栈。没有 backing store,回收必须 swap out。无 swap 的机器上匿名页事实不可回收,全部压力砸向文件页。

所以"物理机不配 swap 更稳"是个错觉。swap 是匿名页的泄压阀,拆掉它压力不会消失,只会换一身伪装:page cache 被反复挤出去又读回来,表现为读延迟劣化,而不是任何 swap 计数器报警。问题没走,只是更难认了。

内核用四条 LRU(active/inactive × file/anon)管理衰老:inactive 上再次被访问的页升级 active,active 上长期冷的页降级。/proc/vmstat 里能直接读这几条链的存量:

grep -E 'nr_active_file|nr_inactive_file|nr_active_anon|nr_inactive_anon|^pgmajfault|workingset_refault ' /proc/vmstat

workingset_refault 值得单独点名:它统计"刚被回收出去、立刻又被访问"的页数。它增长,说明 working set 已经装不进可用内存,缓存在空转。这是所有指标里最接近"压力"本质的一个,比任何使用率都诚实。

PSI:压力第一次变成可以直接读的量

/proc/pressure/memory(内核 4.20 起默认可用)长这样:

some avg10=0.42 avg60=0.18 avg300=0.07 total=125051993
full avg10=0.00 avg60=0.01 avg300=0.00 total=910322
  • some:窗口内至少有一个任务在等内存(分配被回收卡住)的时间占比。
  • full:所有非 idle 任务同时都在等内存。接近停摆的信号,持续非零就该立刻处理。
  • avg10/60/300 是过去 10/60/300 秒的窗口平均,total 是累计微秒数,适合做增量。

告警用 PSI 表达比使用率诚实得多:some avg10 > 10 持续 5 分钟,意味着延迟已经被回收持续拖累;full avg10 > 0,意味着整机接近瘫痪。而"使用率 92%“只说明内核在好好利用内存。

cgroup v2 下每个组有自己的 memory.pressure,容器粒度的压力不用再从整机数字里猜:

cat /sys/fs/cgroup/system.slice/docker-9f31c2a1b04e.scope/memory.pressure

major fault 与 si/so:回收已经咬到业务

  • major fault:缺页时数据根本不在内存,要回盘。业务侧体感是单请求偶发长尾。sar -B 的 majflt/s、/proc/vmstat 的 pgmajfault 是它的计数器。
  • si/so:swap 换入/换出速率。vmstat 1 下两列同时持续非零,是典型 thrash——换出去的页马上又被要回来。
$ vmstat 1 3
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 2  1 421888 2104312 142012 49821240   42   96  1280   512 24010 38102 68  4 25  3  0
 3  1 423004 2081100 142012 49788132   45  112  1401   498 25102 40188 67  5 24  4  0

si 与 so 同时非零、还跟 bi(块设备读入)一起抬:换出去的页正在被读回来,thrash 实锤。这份输出的价值在于它把 swap 和 IO 放在同一行里,因果不用拼接。

回收发生在哪条路径上,决定疼在谁身上。kswapd 是每个 node 的后台回收线程,低水位线触发,提前干活,让业务的分配不必等待;direct reclaim 发生在任务自己的分配路径上,扫描与回收的时间全部记在这次请求的延迟里。sar -B 的 pgscank/s 与 pgscand/s 正好对应这两条路:

$ sar -B 1 3
Linux 6.1.0 (node-7)       10/17/26        _x86_64  (96 CPU)

10:31:12    pgpgin/s pgpgout/s   fault/s  majflt/s  pgfree/s pgscank/s pgscand/s pgsteal/s    %vmeff
10:31:13       12.00    2401.00   3211.00     18.00   4201.00      0.00   1902.00   1855.00     97.53
10:31:14        0.00    2388.55   3188.12     21.00   4320.79      0.00   2110.89   2046.53     96.96

pgscank 恒为 0、pgscand 每秒两千:后台线程已经追不上分配速度,业务线程在分配路径上自己回收自己。把这份报表和同时间段的服务 p99 曲线叠在一起,因果基本就锁死了。

swappiness 顺带说清:它是文件页与匿名页回收之间的倾向权重,不是"用到百分之几才开始 swap”;调小只是更不情愿动匿名页,0 也不等于绝不换出(具体语义随内核版本还在变)。把它当旋钮微调之前,先用 PSI 确认真的有压力,再动它。

cgroup v2:容器有另一本账

OOMKilled 的判定从来不看宿主机的 free,只看容器自己的账本:

# 先定位容器主进程所在的 cgroup(v2 是统一层级)
$ cat /proc/$(docker inspect -f '{{.State.Pid}}' api)/cgroup
0::/system.slice/docker-9f31c2a1b04e.scope

$ CG=/sys/fs/cgroup/system.slice/docker-9f31c2a1b04e.scope
$ cat $CG/memory.max
536870912                        # 512MiB;无限额时这里是 "max"
$ cat $CG/memory.events
low 0
high 0
max 114                          # 触线后触发组内回收的次数
oom 3
oom_kill 1                       # 真正执行过 kill
$ grep -E '^(anon|file|slab|sock|shmem|pgmajfault|workingset_refault) ' $CG/memory.stat
anon 344061952                   # 堆等匿名内存
file 132120576                   # 记在这个组头上的 page cache
slab 28835840
shmem 0
pgmajfault 3411
workingset_refault 9812

判定链是:memory.current 逼近 memory.max → 组内回收(file 优先,代价见前文)→ 仍不满足 → oom_kill 加一,组内按打分选一个进程杀掉。docker inspect 的 State.OOMKilled、k8s 里 exit code 137 加 reason: OOMKilled,都是这条链的末端显示。“宿主机才用 73%,容器凭什么死"的答案永远一样:那道墙是 memory.max,不是 MemTotal。

两个补丁。其一,memory.current 不等于进程 RSS:page cache 记账给首次把它 fault 进来的组,容器里顺序读一个 2GiB 的文件,file 就涨 2GiB——没有泄漏也可能顶爆 limit。其二,memory.high 是软墙:超了不杀进程,而是让分配变慢,作为"先疼后死"的缓冲层,比只有 hard limit 的配置优雅得多。

k8s 节点上,Pod 的 cgroup 在 /sys/fs/cgroup/kubepods.slice 下按 QoS 分层(burstable、besteffort 各占一个子树),路径永远用 /proc//cgroup 反查,不要猜。诊断容器内存问题时把两级的数字并排读:整机 PSI 和 free 回答"节点有没有压力”,容器自己的 memory.stat 和 memory.events 回答"它是不是先撞墙的那个”。两个都看,才分得清"全局内存不足拖累所有人"和"单个容器超限被杀"。

案例:RSS 慢涨,page cache 在流血

一个 limit 512MiB 的 C++ 服务,症状很典型。memory.stat 的 anon 每天涨几十 MiB,两周从不到 200MiB 爬到 330MiB;file 从 160MiB 被挤到 80MiB。业务侧同步出现读 p99 从 8ms 爬到 40ms:热数据文件的 page cache 被自己进程的匿名页持续挤出,每次访问回盘,读放大。

anon 慢涨的来源是 jemalloc:释放的内存以 dirty/muzzy 状态留在 arena 里延迟归还内核,碎片率越高 RSS 越高。没有泄漏,但账本上的匿名页只增不减。处理分两步:

# compose:收紧 jemalloc 归还窗口,同时给 working set 留余量
services:
  api:
    environment:
      # 释放后更快归还内核;代价是更频繁的 madvise 系统调用
      MALLOC_CONF: "dirty_decay_ms:5000,muzzy_decay_ms:15000"
    deploy:
      resources:
        limits:
          memory: 768m   # 匿名页稳定后 file 有地方住,refault 自然回落

判断这类问题的标准动作是先问三句:anon 涨的是谁(pmap 或内存 profiler 定位)、file 跌到哪去了、refault 是否同步抬头——三问都能从 memory.stat 一张表里读出轮廓。

验证改动有没有效,盯三个量的增量而不是瞬时值:

CG=/sys/fs/cgroup/system.slice/docker-9f31c2a1b04e.scope
while sleep 60; do
  printf '%s anon=%dMiB file=%dMiB refault=%s oom_kill=%s\n' \
    "$(date +%T)" \
    "$(awk '/^anon /{print $2/1048576}' $CG/memory.stat)" \
    "$(awk '/^file /{print $2/1048576}' $CG/memory.stat)" \
    "$(awk '/^workingset_refault/{print $2}' $CG/memory.stat)" \
    "$(awk '/^oom_kill/{print $2}' $CG/memory.events)"
done

anon 走平、file 回升、refault 增速放缓,三件事同时发生才算修好。只盯 RSS 回落,会被 allocator 的下一次波动骗到。不想等两周再确认一遍因果,用 stress-ng 之类往同一个 cgroup 里灌匿名页,几分钟能在测试环境重演"file 回落、refault 抬头"的全过程。

常见坑点

  • 按使用率告警:page cache 计入 used,健康机器常年 85%+,狼来了喊三个月,真 OOM 那天没人信。改告 PSI 的 some/full 持续阈值,加上 memory.events 里 oom_kill 的增量。
  • 生产机上 drop_caches:丢掉的全是马上要用的 cache,等于主动制造读放大。这个开关只属于受控的测试环境。
  • OOM killer 杀的不一定是泄漏的那个:打分大致随 RSS 与 oom_score_adj 增长,大 RSS 的数据库、JVM 经常先死,真凶在旁边继续涨。事后先 dmesg -T | grep -iE 'killed process|oom-kill' 还原选择过程,而不是谁死了查谁。
  • 拿宿主机 free 解释容器为什么 OOM:两本账,见上一节。
  • si/so 恒为 0 就断定没有内存压力:无 swap 的机器上,压力全部伪装成 refault 和读延迟。
  • 看到 Cached 大就想"清理":cache 大是内存在做正事,只有 workingset_refault 增长才说明回收收得过头。

总结

排查顺序应该倒过来:先用事件(oom_kill、majflt、PSI)确认压力存在,再用 memory.stat 和 meminfo 定位压力落在谁头上——匿名页、文件页还是 slab——最后才谈动作:调 limit、修泄漏、动 swappiness。从使用率百分比出发的排查,起点就是一个被 page cache 污染过的数字,方向常常一开始就错了。

内存的真相不在百分比里,在回收的代价里。