结论先行:
emptyDir.medium: Memory把卷的页计到「挂载它的那个容器」的 cgroupmemory.max里,而不是 Pod 或节点层面——你在应用里看 RSS 不涨、kubectl top看 Pod 内存不高,但内核已经在算这块 tmpfs 了。更反直觉的是,给这个卷加sizeLimit只限制卷自己的容量上限,不改变它计入哪个容器的内存账本,所以「加了 sizeLimit 就安全了」是一个不成立的假设。下面复盘一次真实的 OOMKilled。
一、现象:应用内存没涨,Pod 却被 OOMKilled
先交代规模。我们一个实时风控特征服务,Go 1.21,Kubernetes 1.28.9 + containerd 1.7.13,cgroup v2,节点 16C/64G。Pod 规格:主容器 limits.memory = 2Gi、requests = 1.5Gi,旁边一个 sidecar(metrics 采集,limit 256Mi)。
上周一次发布后,白天 QPS 从 1.2k 涨到 3.5k(一次营销活动),主容器开始每 6~8 分钟被 OOMKilled 一次,CrashLoopBackOff。诡异的地方:
- 应用侧
runtime.MemStats显示堆峰值稳定在 1.1GB,远没到 2Gi; kubectl top pod在 OOM 前一刻显示 Pod 内存 1.8Gi,"看着还有一点头";- 但
dmesg里的 oom_kill 记录,被杀的是主容器,且memory cgroup out of memory的memory.max = 2147483648。
真正的主角在 Pod spec 里躺了半年没人注意:
volumes:
- name: feat-cache
emptyDir:
medium: Memory
# 当时没有 sizeLimit
containers:
- name: main
volumeMounts:
- name: feat-cache
mountPath: /var/featcache
- name: sidecar
# 没挂这个卷
这个卷本来是给「特征热点缓存」用的 mmap 文件区,代码里往 /var/featcache/hot.bin 写一个 900MB 的 mmap 文件。
二、根因:tmpfs 页计入「挂载者容器」的 cgroup 内存账
机制拆开讲,这是最容易搞错的部分:
medium: Memory时,kubelet 在节点上给这个卷挂一块 tmpfs,然后mount --rbind进每个挂载了它的容器。- cgroup v2 的记账规则是:谁写入(谁通过自己 namespace 里的 mount 产生 page cache),谁的 cgroup
memory.current就涨。主容器写 hot.bin → 页计在主容器 cgroup → 直接吃掉它的memory.max。 - sidecar 没挂这个卷,所以它不记账——但如果你把卷同时挂给两个容器,两个容器都会各自记账一部分(按各自的写入),加起来还是落在 Pod 的两个 cgroup 里,节点总内存同样要兜底。
- 这就是「应用 RSS 不涨」的真相:Go 进程用 mmap + 只读映射访问 hot.bin,文件页不在进程 RSS 里,它在 page cache 里,page cache 归属 cgroup 内存账。你在进程里
MemStats看的是堆,看的是「进程」;内核 OOM 判的是「cgroup」——两个口径。
sizeLimit 的真相:sizeLimit 只是给这块 tmpfs 设一个 mount 时的 size= 上限(超过后 ENOSPC),它不改变记账归属,也不从主容器的 memory.max 里"划走"一块预算。换句话说,sizeLimit: 512Mi 的意思是"这个卷最多用 512Mi 内存",而这 512Mi 仍然算在主容器 2Gi 的预算里,不是额外的。
三、具体做法:一份能直接抄的清单
1. 内存预算要按「容器 + 它挂的内存卷」重新算
主容器的可用内存 = limits.memory − 它自己挂的 medium: Memory 卷的峰值实际写入(不是 sizeLimit,因为 sizeLimit 只是上限)。
我们这个案例的正确预算:
主容器 limits: 2 Gi
- 应用堆 + 栈 + runtime: 1.1 Gi
- feat-cache tmpfs 峰值: 0.9 Gi ← 之前漏算的
- page cache 未回收余量: ~0.2 Gi
= 2.2 Gi > 2 Gi ← 必然 OOM
修复二选一(我们选了 b,因为业务上不需要这个卷):
# a. 提 limit(不推荐,治标)
resources:
limits:
memory: "3Gi"
# b. 换掉内存卷(治本)
volumes:
- name: feat-cache
emptyDir: {} # 落盘到节点,走 page cache,可被回收
2. 用 crictl inspect 直接验证记账归属
排查时最有用的两条命令:
# 看容器 mount 里这块卷的真实 tmpfs 大小
crictl inspect <container-id> | jq '.info.runtimeSpec.mounts[] | select(.destination|contains("featcache"))'
# 输出里会有 "options": ["rprivate", "rw", "nosuid", "nodev", "noexec", "size=..."]
# 看这个 cgroup 的 memory.current 和 memory.max
cat /sys/fs/cgroup/<pod-cgroup>/<container-cgroup>/memory.current
cat /sys/fs/cgroup/<pod-cgroup>/<container-cgroup>/memory.max
# 如果 current ≈ max 而应用 RSS 只有 1.1G,差额基本就是 tmpfs 页
3. 加一条能落地的告警
memory.max 触发 OOM 是阶跃事件,Prometheus 15s 采集抓不到峰值。用 cgroup v2 的 PSI 提前 20~30s 预警(这条思路与社区另一位作者的经验一致,见引用):
# 容器级 PSI(cgroup v2 memory.pressure 的 full 字段)
- job: cgroup-psi
metrics_path: /metrics/cgroups
# 告警:full 压力 > 10% 持续 60s → 比 oom_kill 早 20~30s
4. 如果确实需要内存卷,把 sizeLimit 当「熔断」而不是「预算」
emptyDir:
medium: Memory
sizeLimit: "800Mi" # 熔断:超过就 ENOSPC,应用要能优雅降级
应用侧必须有 ENOSPC 的降级路径(写失败 → 走 fallback 到磁盘卷或远端缓存),否则 sizeLimit 触发时应用会直接报错崩溃,比 OOM 更难排查。
四、三个反直觉的点
sizeLimit≠ 额外预算。它只是给 tmpfs 加个size=上限,记账归属不变,仍然吃挂载者容器的memory.max。kubectl top pod会骗你。它显示的是 Pod cgroup 的memory.current,在 cgroup v2 + 某些内核版本下,tmpfs 页的统计口径和memory.max的口径不完全一致(尤其涉及 reclaimable page cache),你看到 1.8Gi 时真实可用可能已经 < 0.3Gi。medium: Memory不是"更快的磁盘",它是内存。把它当 scratch 空间用的团队,等于把一部分limits.memory挪到卷上,但没在预算里反映。
五、可复用清单
- 给 Pod 设
limits.memory时,显式列出:应用堆 + runtime + 每个容器挂载的medium: Memory卷峰值 + page cache 余量,加和必须 ≤ limit。 - 新增
medium: Memory卷时,必须同时评估:① 是否真的需要内存速度;② 挂载者容器的 limit 是否够;③ sizeLimit 是否作为熔断而非预算。 - 排查「OOMKilled 但应用内存没涨」时,第一步不是看堆,而是
crictl inspect找容器里所有 tmpfs mount,再看 cgroup 的memory.current与memory.max差额。 - 告警层:不要只盯
oom_kill,加 cgroup PSImemory pressure full > 10% 持续 60s作为前置预警。 - 治理层:
medium: Memory卷的引入应走变更评审,因为它实质上是内存预算的再分配,不是新增资源。
(本文与社区关于 /dev/shm 64MB 的讨论互补:那篇讲的是 runc 默认给容器的 /dev/shm 太小,本篇讲的是你自己主动声明的 emptyDir.medium: Memory 太"隐形",两者是 tmpfs 记账问题的两个入口。)