先给结论:容器内存故障分两种——「OOMKilled」是响的那种,「内存消失」是不响的那种。不响的更常见也更恶心:监控全绿、dmesg 干净、只有延迟在涨。八成「内存消失」的根因是「你看的那本账,和内核杀你时用的那本账,不是同一本」。本文给一套五步排查手册,每步都带具体命令和判断阈值,可直接抄进故障手册。
一、现象:线上最常见的三种「内存事故」
近两年我经手的内存故障,按症状分三类,先列数字:
A 类:OOMKilled,但监控全绿。 K8s 1.28.9,Java 17 服务,requests=limits=2Gi,-Xmx1536m。2 小时窗口内 OOMKilled 5 次;同时 Grafana 的 working_set 峰值只有 1.28Gi(limit 的 64%),JVM 堆最高没超过 1.1Gi。「监控说 64%」「内核说 100%+」——两个都是事实。
B 类:内存没爆,延迟爆了。 4C/2Gi 的 Java 服务,P50 稳定 12ms,P99 某次从 40ms 拉到 220ms,持续约 20 分钟后自行恢复。dmesg 无 OOM,GC 日志无长 STW(最长 Young GC 28ms),内存水位离 limit 还有 14% 余量。当时怀疑下游抖动折腾了 40 分钟才定位。
C 类:同一个文件,第二次读比第一次慢。 一个 1.2GB 的参考 CSV,首次读 30ms,再读 2.5s,且反复横跳。无 OOM、无 page allocation failure,只是 page cache 被全局内存压力回收掉了。
三种症状、三种机制,但排查路径是同一件事:先对账本,再看机制。
二、原理:你和内核在用两本不同的账
所有「内存消失」的根子可以归成一句话:你看的水位不是内核杀你时用的账本。
三本账:
| 账本 | 谁看 | 账里有什么 | |---|---|---| | 应用自报账 | jstat / runtime.MemStats / heap dump | 只有堆,或只有 Go heap size | | cgroup 账 | memory.current / memory.peak | 匿名页 + 未回收的 file cache + swap 占用 | | 节点/监控账 | kubectl top、working_set_bytes | working set(已扣掉可回收 page cache) |
关键词是 file cache:它是「可回收但还没回收」的部分。内核算你要不要死时用的是 cgroup 账(含它),你看监控用的是 working_set 账(已把它扣掉)。两本账的差值,就是「消失的内存」。
再叠加三个「静默机制」:
- Reclaim:内存紧张时内核先回收 page cache 和 swap,不动你的匿名页。所以症状是「读盘变慢、延迟涨」,而不是「OOM」。
- memory.high 背压:cgroup v2 的软限,超了不杀你,而是让你在分配时 sleep——代价用 CPU 和延迟支付,内存账本纹丝不动。这是 B 类事故的直接凶手。
- PSI(Pressure Stall Information):内核替你回收内存、线程被强制脱机的实时计数器。这是 OOM 之前少数几个「看得见」的信号之一。
所以排查心智是:别问「谁用了内存」,问「内核按哪本账结算的、对我施加了多少软压力」。
三、做法:五步对账(命令可直接抄)
假设故障 Pod 的 cgroup v2 路径为 /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/cri-containerd-<id>.scope(容器内 cat /proc/self/cgroup 可直接拿到)。
第 1 步:读内核自己的账
CG=/sys/fs/cgroup/kubepods.slice/.../cri-containerd-<id>.scope
cat $CG/memory.current # 内核结算用的账
cat $CG/memory.peak # 真实峰值(单调不减,采样不会漏)
cat $CG/memory.events # oom_kill / max 计数器
判断阈值:memory.peak - memory.current 差值 > 10%,说明存在毛刺,15s 间隔的 Prometheus 大概率漏采;memory.events 里 max 有计数而 oom_kill 为 0 → 内核触顶后做了回收,你没感知,是 B 类。
第 2 步:对两本账的差
cat $CG/memory.stat | grep -E '^(file|anon|swap)'
file(page cache)占 memory.current 的 30% 以上且持续增长 → A 类或 C 类,差值在 file cache 里。下一步判断是应用正当持有(审计留存文件)还是读盘毛刺残留。
第 3 步:看 PSI,判断是否正在被「软压」
cat $CG/memory.pressure
# some=... avg10=... avg60=... total=...
# full=...(同字段)
判断阈值:full 是累计量,必须取速率——隔 10s 采两次,full 增量 / 10s > 5%,说明内核正在对你背压,CPU 和延迟代价正被你的线程支付。这一步是 B 类定位的关键。
第 4 步:对应用层预算
- JVM:
jstat -gcutil <pid> 1000 5,核对-Xmx与容器 limit 的关系。JDK 11+ 的 MaxRAMPercentage 默认只有 25%——删掉 -Xmx 后 16G 容器只给你 4G 堆,GC 频率能飙 5~10 倍。 - Go:
runtime.MemStats看 HeapAlloc 与 RSS 的关系;GOMEMLIMIT 是软提示,GC 按 heap size 触发而非 RSS,「limit 4Gi 却 OOMKilled」多半是这两本账没对齐。 - 通用:堆外内存(Netty DirectBuffer、glibc arena、mmap)都计入 cgroup 账,但应用自报账看不见。
第 5 步:上翻一层看节点
free -h
cat /proc/pressure/memory
journalctl -u kubelet | grep -i eviction
Pod 账本全绿,但节点 memory.available 低于 kubelet 驱逐阈值(默认 memory.available<100Mi)时,驱逐排序按 QoS + (usage - request),不是「谁用得最多」——死的未必是最重的那个。
四、坑与反直觉
kubectl top不是内核结算账。 它是 working_set,page cache 已扣掉。「top 说 64%」和「内核说 100%+」可以同时为真。- 15s 采样对 OOM 是结构性失明。 OOM 是阶跃事件,你采到的是「被杀前最后一个 15s」。
memory.peak是唯一不会漏采的真峰值,值得单独采集。 - memory.high 背压的代价是 CPU,不是内存。 看到「CPU 莫名涨 20% + P99 翻倍 + 内存账本没动」,先怀疑内核在替你 reclaim 并让你 sleep,别急着翻代码。
- PSI 的
full是累计量,不是百分比。 直接当百分比读会错一个量级,必须两次采样取速率。 - 驱逐不是「杀最重的」。 排序按 QoS 类 + (usage - request),requests 写小一档,Burstable Pod 就排到队首。
- GOMEMLIMIT / -Xmx 对内核都是「软」的。 它们只约束应用自报账,cgroup 账是另一本。三本账三套数字,能杀你的只有一本。
五、结论与可复用清单
一页清单(建议贴进故障 SOP):
- [ ]
memory.peakvsmemory.current:有没有毛刺? - [ ]
memory.events:是max还是oom_kill? - [ ]
memory.stat:file占比多少? - [ ]
memory.pressure:full速率是否 > 5%/10s? - [ ] 应用预算:-Xmx / GOMEMLIMIT / 堆外,是否与 limit 对过账?
- [ ] 节点:
memory.availablevs kubelet 驱逐阈值? - [ ] dmesg / journalctl:有无 page allocation failure?
五步走下来,我经手的「内存消失」类故障 90% 能定位;剩下 10% 通常在 tmpfs(如 emptyDir medium:Memory)和 cgroup v1/v2 混账这类边角,可顺着我引用的文章线索查。
最后留个开放问题想听大家聊:你们团队有没有把「软压力」当成独立故障类别? 我认为是——memory.high 背压和 PSI full 和 OOM 一样是「事故」,因为代价正被用户延迟支付;但业界的告警几乎都写在 OOM 和 working_set 上。有没有人已经把「PSI 速率」或「memory.high 触顶」写进告警规则?阈值怎么定、误报率多少?欢迎带真实案例来,请附上服务类型和规格,不然没法对比。