jimyag's Blog

Kubernetes 内存指标不是同一个数字:从 cgroup v2 到 OOM 与 Eviction

一个 Pod 的内存使用量,可能同时出现在 kubectl top pod、Prometheus、进程的 RSS 和容器的 cgroup 文件里。它们经常不是同一个数字,但这不一定表示监控或应用有问题。

问题在于「内存使用量」没有脱离测量目的的唯一答案:进程 RSS 关心进程当前驻留的页面,cgroup 关心一组进程及其后代的资源账本,kubelet 还要用另一个口径判断节点是否应该驱逐 Pod。把这些数字直接比较,容易得到错误结论,例如「Working Set 还没到 Limit,为什么已经 OOMKilled」或「MemAvailable 很高,为什么 Pod 还是被 Evict」。

本文以 Linux cgroup v2 为前提,整理这些数字之间的关系,并给出一条排查 OOM 和 Eviction 的路径。文章中的结论主要针对 kubelet 使用 cgroup v2、containerd 作为容器运行时的集群;cgroup v1、CRI-O 或云厂商定制的监控链路需要重新核对实现。

先记住四个结论

你想回答的问题优先看的值原因
Pod 当前看起来用了多少内存container_memory_working_set_byteskubectl top pod 看到的也是 Working Set
容器是否触发了自己的内存上限memory.current 对比 memory.maxcgroup 的硬限制不直接比较 Working Set
节点是否正在逼近 Evictionkubelet 的 memory.available它基于节点 Working Set,而不是 MemAvailable
Request 是否合理一段时间内的 Working Set 峰值和分位数Request 影响调度、驱逐优先级和节点 OOM 时的保护程度

因此,排查时不要先问「哪个指标才是真的」,而应该先问「这次故障需要回答哪个问题」。

1. cgroup v2 到底记录了什么

Kubernetes 的容器资源限制最终要落到 Linux cgroup。cgroup v2 中,容器所在 cgroup 通常可以看到这些文件:

1
2
3
4
5
memory.current   当前使用量,包含该 cgroup 的后代
memory.max       硬上限,容器的 Memory Limit 通常会写到这里
memory.stat      当前使用量的分类统计
memory.events    low/high/max/oom/oom_kill 等事件计数
memory.pressure  PSI,记录进程因为内存而等待的时间

memory.current 不是应用堆大小,也不是某一个进程的 RSS。Linux 会把匿名页、文件页缓存、tmpfs/共享内存,以及部分内核内存都纳入 cgroup 账本。memory.stat 中最常用的字段是:

字段含义排障时的关注点
anon匿名页,例如堆、栈通常更接近应用真正不能被内核直接回收的内存
file文件页缓存,也包含 shmem文件读写、日志和内存卷可能让它增长
shmemtmpfs、共享内存等不要把它简单当成可回收的磁盘缓存
active_file最近仍活跃的文件页Working Set 会保留它
inactive_file不活跃的文件页Working Set 通常从 memory.current 中减去它
slab、socket 等内核数据结构和网络缓冲解释 anon + filememory.current 的差额

fileactive_file + inactive_file 不一定相等。shmem 会计入 file,但不一定出现在 file LRU 中;emptyDir 使用 medium: Memory 时,本质上也是 tmpfs,应该作为容器内存的一部分管理。

cgroup v2 不只有一个上限

memory.currentmemory.max 解释了「用了多少」和「最多允许多少」,但 cgroup v2 还提供了内存保护和软限制:

文件类型超过或低于边界时的行为
memory.min强保护在有效保护范围内的内存不会被回收;系统无法满足保护时可能触发 OOM
memory.low弱保护尽量避免回收,系统压力极高时仍然可以回收
memory.high软限制触发更强的回收压力和节流,本身不等于 OOM Kill
memory.max硬限制先尝试回收,仍无法满足分配时触发 cgroup OOM

这四个文件对应两类不同模型:memory.min / memory.low 是保护,memory.high / memory.max 是限制。不要把 memory.high 当成一个较早触发的 memory.max;它更接近「让这个 cgroup 变慢并主动回收」,而不是直接杀进程。

Kubernetes 的 Memory QoS 会把部分 Pod 的 Request 和 Limit 映射到这些 cgroup v2 接口。当前官方文档中,Memory QoS 已经是 Beta,但 kubelet 默认配置不会自动启用所有保护和节流行为:memoryThrottlingFactor 决定是否设置 memory.highmemoryReservationPolicy 决定是否设置 memory.min / memory.low。因此,看到 Pod 写了 Request,并不能仅凭 YAML 判断节点上一定已经出现对应的 cgroup 保护;需要直接检查 kubelet 配置和 cgroup 文件。Kubernetes Pod QoS 官方文档对此有完整说明。

memory.events 中的 high 计数增加,通常说明触发过 memory.high 的处理;max 记录触及 memory.max 的次数;oomoom_kill 则用于进一步判断是否发生了 OOM。它们的语义不同,不能只看到 high 就认定容器已经 OOM。

Working Set 为什么和 RSS 不一样

cAdvisor 暴露的 Working Set 可以近似理解为:

1
Working Set ≈ memory.current - inactive_file

在 Prometheus 中,它通常对应 container_memory_working_set_bytes;Metrics Server 使用 kubelet 的资源指标,kubectl top pod 展示的也是这一类值。

Working Set 仍然包括:

  • 匿名页;
  • active 的文件页缓存;
  • tmpfs 和共享内存;
  • cgroup 计费范围内的其他内存。

所以它不是「应用分配的堆」,也不是「马上无法回收的全部内存」。它是 Kubernetes 用来观察资源使用和节点压力的一个工作口径。

container_memory_rss 也不能直接替代进程 RSS。cAdvisor 在 cgroup v2 下通常将它映射到 memory.statanon,而应用通过 /proc/<pid>/status 看到的 VmRSS 还可能包含文件映射页和共享内存,并且统计单位是单个进程。一个是 cgroup 维度,一个是进程维度,二者不一致是正常的。

2. 三条不同的故障路径

2.1 memcg OOM:自己的 cgroup 撞上了硬上限

当容器、Pod 或上层 cgroup 的 memory.max 不能容纳当前分配时,内核会先尝试回收可回收页面;回收后仍然无法满足分配,才会在对应 cgroup 内触发 OOM Kill。

这条路径的关键点是:

  1. 比较的是 memory.currentmemory.max,不是 Working Set 和 container_spec_memory_limit_bytes 的简单视觉比较。
  2. memory.current 到达上限并不等于立刻 Kill,内核还会尝试回收页面。
  3. 节点整体还有很多空闲内存,也不妨碍某个容器触发自己的 Limit。
  4. memory.events 中的 oomoom_kill 可以帮助确认 cgroup 内是否发生过这类事件。

因此,看到 OOMKilled 时,先检查 container_memory_usage_bytes(通常对应 memory.current)和 cgroup 的 memory.max,不要只看 kubectl top 的 Working Set。

2.2 global OOM:节点整体没有可用内存

global OOM 发生在节点层面。即使某个容器没有超过自己的 Limit,只要节点整体无法满足新的内存分配,Linux OOM Killer 仍然可能选择该容器中的进程。

选择谁被 Kill 时,kubelet 设置的 oom_score_adj 会参与计算。QoS、Memory Request、进程实际占用量和系统进程优先级都会影响最终结果。这里没有一个「某个容器的 Working Set 达到 X 就一定 Kill」的单一阈值。

这解释了一个常见现象:Pod 的 Working Set 低于 Limit,却出现 OOMKilled。如果容器自己的 cgroup 没有撞上 memory.max,就应该把调查范围扩大到节点内存、内核日志和其他 Pod。

2.3 Eviction:kubelet 主动退 Pod

Eviction 不是 OOM Kill。它是 kubelet 观察到节点压力达到阈值后,主动终止并标记 Pod 失败,以便回收资源。

Linux 节点上的内存信号可以近似写成:

1
memory.available = node.status.capacity[memory] - node.stats.memory.workingSet

kubelet 会把这个值和 evictionHardevictionSoft 比较。由于节点 Working Set 会保留 active file cache,node_memory_MemAvailable_bytes 看起来还有余量时,kubelet 仍然可能认为节点已接近内存压力。

驱逐哪个 Pod,主要会看:

  1. Pod 的实际使用量是否超过 Request;
  2. Pod Priority;
  3. 使用量相对 Request 的超额程度。

所以 Memory Request 不只是调度字段。它还影响节点能放多少 Pod,以及发生压力后哪些 Pod 更容易被选中。

QoS、Request 和 Limit 不是同一个概念

Pod 的 QoS 类别由资源配置推导出来,不能只看内存这一项:

QoS简化判断内存压力下的含义
BestEffort没有容器设置 CPU/Memory Request 或 Limit没有资源请求保护,通常最容易被驱逐
Burstable至少有一个资源配置,但不满足 Guaranteed 条件Request 以下的部分相对更有保障,超过 Request 的部分更容易成为回收对象
Guaranteed每个容器的 CPU 和 Memory Request、Limit 都设置且相等通常更晚被驱逐,但超过自身 Limit 仍可能 OOM

上表是传统容器级资源配置的简化规则;如果集群启用了 Pod-level resources,还需要按 Pod 级别的资源字段判断。只把 memory.requestsmemory.limits 设置成相等,并不能保证 Pod 一定是 Guaranteed;CPU 的 Request 和 Limit 也必须满足条件。QoS 类别影响调度和压力处理,但它不是一个内存使用量指标,也不能替代对 memory.current、Working Set 和节点状态的检查。

还要区分 CapacityAllocatable

  • Scheduler 使用节点的 Allocatable 判断 Pod 的 Request 是否能放下;
  • kubelet 的 memory.available 以节点 Capacity 和 Working Set 计算压力信号;
  • kube-reservedsystem-reserved 和 Eviction 阈值会减少真正适合工作负载使用的空间。

因此,「Request 总和没有超过 Allocatable」只能说明调度器认为配置可以放置,不代表节点在运行时不会因为 active file、内核内存或系统进程而进入 MemoryPressure。

3. 把常见指标放回同一张表

指标常见来源cgroup v2 或内核中的对应物适合回答的问题
container_memory_working_set_bytescAdvisor、kubelet、Metrics Servermemory.current - inactive_filePod 当前的工作集有多大
container_memory_usage_bytescAdvisormemory.currentcgroup 计费量、Limit 相关问题
container_memory_rsscAdvisormemory.statanon匿名页是否持续增长
container_memory_cachecAdvisormemory.statfile文件缓存、tmpfs 是否增长
pod_memory_working_set_byteskubelet /metrics/resourcePod cgroup 的 Working SetPod 级别的实际工作集
node_memory_MemAvailable_bytesnode-exporter/proc/meminfoMemAvailable内核估计的节点可分配余量
container_memory_working_set_bytes{id="/"}cAdvisor根 cgroup 的 Working Setkubelet 视角的节点内存压力
kube_node_status_capacity{resource="memory"}kube-state-metricsNode 对象的 Capacity把节点使用量换算成比例时的分母

这张表里至少有三个不同层次:

  • 容器 cgroup 的使用量;
  • 节点根 cgroup 的 Working Set;
  • /proc/meminfo 给出的内核估算值。

同一节点上,MemAvailable 使用率和 kubelet Eviction 使用率不一致,并不能单独证明哪一个组件有问题。要先确认它们的定义,再看差额主要来自 active file、tmpfs、共享内存还是其他内核记账。

容器指标之和也不一定等于 Pod cgroup 的使用量。多容器 Pod 可能包含 pause 容器、共享内存和 Pod 级别的 cgroup 开销;排查时可以同时查看容器累加值和 pod_memory_working_set_bytes,不要把两者再相加。

另外,container_* 这个指标前缀不保证数据永远直接来自 cAdvisor。启用 PodAndContainerStatsFromCRI 且容器运行时支持对应统计接口时,kubelet 可能从 CRI 获取 Pod 和容器统计。指标名称可以保持不变,但数据来源和边界应以当前 kubelet、运行时和采集配置为准。可参考 Kubernetes Node Metrics

4. 一条可复用的排查路径

第一步:先确认故障类型

1
2
3
4
kubectl get pod <pod> -n <namespace> \
  -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.exitCode}{"\n"}{end}'

kubectl describe pod <pod> -n <namespace>

重点区分:

  • reason: OOMKilled:某个进程被 Kill,仍需继续判断是 memcg OOM 还是 global OOM;
  • status: Failedreason: Evicted:kubelet 驱逐了 Pod;
  • 只有重启次数增加但没有留下明确状态:可能是短时尖峰,也可能是历史状态已被后续重启覆盖。

OOMKilled 只说明容器最终观察到的退出原因,不携带完整的 OOM 触发路径。不要把它直接等同于「超过了这个容器的 Memory Limit」。

第二步:检查节点事件和内核证据

先找到 Pod 所在节点:

1
2
kubectl get pod <pod> -n <namespace> \
  -o jsonpath='{.spec.nodeName}{"\n"}'

然后检查节点事件:

1
2
3
4
5
kubectl get events --field-selector involvedObject.kind=Node,involvedObject.name=<node> \
  --sort-by=.lastTimestamp

kubectl get events -A --field-selector reason=SystemOOM \
  --sort-by=.lastTimestamp

SystemOOM 可以作为 global OOM 的强证据;没有这个 Event 不能反向证明一定是 memcg OOM,因为事件记录还依赖 kubelet 对内核日志的观察和解析。必要时应该同时查看节点上的内核日志:

1
dmesg -T | grep -E 'oom-kill|Out of memory|Memory cgroup out of memory'

日志中出现 oom_memcg=Memory cgroup out of memory,通常指向 memcg OOM;出现 constraint=CONSTRAINT_NONEglobal_oom,通常指向节点级 global OOM。不同内核版本的日志格式可能略有变化,最终以 memory.events、Pod 状态和节点现场互相印证。

第三步:直接看 cgroup v2 的账本

先在节点上确认 cgroup 版本:

1
2
stat -fc %T /sys/fs/cgroup/
# cgroup2fs 表示 cgroup v2

如果输出是 tmpfs,节点使用的是 cgroup v1,本文关于 memory.currentmemory.max 和部分 memory.stat 字段的映射不能直接套用。cgroup v1 通常要检查 memory.usage_in_bytesmemory.limit_in_bytes 等文件,并重新确认 kubelet 和 cAdvisor 的指标定义。

容器 cgroup 的具体路径取决于 cgroup driver、Pod UID 和容器运行时。找到目标 cgroup 后,优先读取:

1
2
3
4
5
6
cg=/sys/fs/cgroup/<container-cgroup>

cat "$cg/memory.current"
cat "$cg/memory.max"
cat "$cg/memory.events"
grep -E '^(anon|file|shmem|active_file|inactive_file|slab) ' "$cg/memory.stat"

没有设置 Memory Limit 的容器,其 memory.max 通常是 max;对应的 container_spec_memory_limit_bytes 可能以 0 表示无限制。对这类容器直接计算「使用量 / Limit」没有意义,也不能因为它没有触发自己的 memcg OOM,就认为它对节点没有风险。

可以按下面的顺序解释结果:

  1. memory.current 是否接近或超过 memory.max
  2. memory.eventsoom_kill 是否增加;
  3. anon 是否增长,判断是否更像应用匿名内存问题;
  4. fileactive_fileshmem 是否增长,判断是否更像文件缓存、tmpfs 或共享内存问题。

容器退出后,容器 cgroup 可能已经被清理,此时不能依赖现场文件恢复全部信息。应该提前采集 memory.events、节点级 node_vmstat_oom_kill、Pod 的最后退出状态,并保留足够细的 Prometheus 时间序列。

第四步:用 PromQL 看趋势,而不是只看瞬时值

查看 Pod 的 Working Set:

1
2
3
4
5
6
sum by (namespace, pod) (
  container_memory_working_set_bytes{
    container!="",
    image!=""
  }
)

查看匿名页和文件缓存的组成:

1
2
3
4
5
6
7
sum by (namespace, pod) (
  container_memory_rss{container!="", image!=""}
)

sum by (namespace, pod) (
  container_memory_cache{container!="", image!=""}
)

如果已经采集 kube-state-metrics 的资源请求,可以把 Working Set 和 Request 放在一起比较:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
sum by (namespace, pod) (
  container_memory_working_set_bytes{container!="", image!=""}
)
/
sum by (namespace, pod) (
  kube_pod_container_resource_requests{
    resource="memory",
    unit="byte"
  }
)

实际环境中要检查两侧的标签和采集范围,避免把 pause 容器、已结束容器或重复抓取的时间序列算进去。比例超过 1 只能说明当前使用量超过 Request,不能直接推导出即将 OOM。

kubectl top 适合快速定位当前谁的 Working Set 较高,但 Metrics Server 主要保留最近采样值,不能替代长期历史。Request 应该根据一段时间的峰值、分位数、业务启动阶段和异常流量共同确定;也可以让 VPA 根据历史使用量计算建议值,但仍要检查它与业务的发布、扩缩容和 Limit 策略是否兼容。

证据和推论要分开

排障时可以用下面的表避免过早下结论:

看到的现象下一步证据不能直接推出
OOMKilledmemory.events、节点 Event、内核日志一定超过了这个容器的 Memory Limit
Working Set 持续上升拆分 container_memory_rsscontainer_memory_cacheshmem一定是应用内存泄漏
MemAvailable 仍然较高但 Pod 被 Evictkubelet Working Set、Eviction 阈值、Node Conditionkubelet 指标错误
使用量超过 Memory RequestPod QoS、Priority、节点压力和实际 Limit一定马上 OOM
memory.eventshigh 增加memory.high、PSI 和请求延迟已经发生 OOM Kill

5. 对应的处理方式

如果是 memcg OOM

  • 检查 Limit 是否低于正常峰值,必要时提高 Limit,但要同时确认节点可承载容量;
  • 优先看 anon 是否持续增长,排查堆、线程栈、缓存和应用内部队列;
  • 检查 emptyDir: { medium: Memory }、tmpfs 和共享内存,它们也会计入内存;
  • 如果只是短时尖峰,不能只凭一分钟粒度的 Prometheus 曲线判断「没有超过 Limit」;
  • 应用已经释放对象但 RSS 不下降时,继续确认运行时是否真的把页归还给内核。

提高 Request 本身不能解决容器撞上 memory.max 的问题;这条路径要先处理 Limit 和实际分配行为。

如果是 global OOM 或 Eviction

  • 为工作负载设置接近实际使用量的 Memory Request,减少节点过度装箱;
  • 结合 kube-reservedsystem-reserved 和 Eviction 阈值给 kubelet、containerd、内核留出空间;
  • 对文件 I/O、日志或内存卷使用量大的 Pod 单独观察 active_fileshmem
  • 检查 QoS、Priority 和 Request 超额情况,确认为什么某个 Pod 成了优先回收对象;
  • 节点经常出现 PSI 内存等待时,说明系统可能已经出现分配阻塞,即使还没有 OOM,也应该提前处理容量或工作负载。

Request 的作用可以概括为:它影响 Pod 能否被调度、节点压力时是否被认为超额,以及 global OOM 时的 oom_score_adj。它不会替代 Limit,也不会让 memcg OOM 消失。

6. 最容易误判的四种情况

「Working Set 没到 Limit,却 OOMKilled」

可能是 memory.current 到达了 cgroup 上限,也可能是节点发生 global OOM。先区分 cgroup 内的 memory.events、节点 Event 和内核日志。

「MemAvailable 还有很多,却发生 Eviction」

MemAvailable 和 kubelet 的 memory.available 不是同一个公式。后者基于节点 Working Set,active file 可能造成明显差异。

「Working Set 一直涨,是不是内存泄漏」

先拆分 container_memory_rsscontainer_memory_cache。RSS 涨更值得检查应用匿名内存;cache 涨则要继续拆分磁盘页缓存、tmpfs 和共享内存。只看 Working Set 不能完成判断。

「应用 RSS 和 container_memory_rss 对不上」

应用 RSS 是进程维度,container_memory_rss 通常是 cgroup 维度的匿名页统计。它们统计对象不同,不能要求数值相等。

结语

Kubernetes 内存排障最重要的不是背下某个指标名称,而是把「测量对象、统计范围、回收语义和触发动作」对应起来:

  1. container_memory_working_set_bytes 用于观察工作集,适合看 kubectl top 和节点压力;
  2. memory.currentmemory.max 决定 cgroup 限制路径是否可能触发 memcg OOM;
  3. 节点的 Working Set 和 kubelet 的 memory.available 决定 Eviction 视角的压力;
  4. Memory Request 影响调度和节点压力下的选择,但不等于 Memory Limit。

当这些边界明确后,OOMKilledEvicted、Working Set 增长和 RSS 不一致就不再是互相矛盾的现象,而是可以沿着不同证据路径分别验证的问题。

参考

#Kubernetes #Cgroup #Prometheus #OOM