jimyag's Blog

Kubernetes 内存管理:OOMKilled、QoS 与节点压力驱逐

容器被标记为 OOMKilled 时,节点可能仍有很多可用内存;Pod 显示 Evicted 时,应用也可能从未超过自己的 memory limit。这两种现象来自不同的执行者:Linux 内核负责内存分配与 OOM,kubelet 负责根据节点压力主动驱逐 Pod。

在 zj 的隔离 kind 集群中,一个 memory limit 为 32Mi 的容器尝试分配 64Mi 内存,退出原因是 OOMKilled、退出码为 137,容器打印的 memory.max 为 33554432。这个实验不需要耗尽节点内存,也不需要由 kubelet 先作出驱逐决定。

从三个不同的失败入口判断

现象作出决定的组件主要依据对象层面的结果
容器 cgroup OOMLinux 内核cgroup 的内存限制、使用量与回收结果容器进程被杀,运行时可能报告 OOMKilled
节点全局 OOMLinux 内核节点内存分配失败、回收结果与进程 OOM 分数被选择的进程被杀,可能影响业务或节点组件
节点内存压力驱逐kubeletmemory.available 与配置的驱逐阈值Pod 被终止并标记为 Failed / Evicted

退出码 137 常见于进程因 SIGKILL 结束,不能独立证明 OOM。人工强杀、超时后强制终止也可能得到这个退出码,应用还可以主动返回相同数值。排障时需要同时查看容器的 state.terminated 或 lastState.terminated、节点内核日志和 kubelet 事件。只看到 OOMKilled,也不能据此断定是容器限制还是祖先 cgroup、节点层面的内存耗尽。

Evicted 位于 Pod 的 status.reason;OOMKilled 通常位于某个容器的终止状态。它们不是同一字段的两个可互换取值。一个 Pod 可能经历过容器 OOM,后来又因节点压力被驱逐,需要按 UID、容器 ID 和时间分别理解。

request、limit 与 QoS 各自负责什么

memory request 参与调度容量核算,帮助 scheduler 判断节点还能承诺多少资源。它不会在进程开始运行时预先分配同样大小的物理内存。memory limit 则经 kubelet、CRI 和容器运行时转换为 Linux cgroup 的限制。

在 v1.35.1 的 generateLinuxContainerResources 与 calculateLinuxResources 中,kubelet 读取容器的 memory limit,填写 CRI LinuxContainerResources.MemoryLimitInBytes,并设置 OomScoreAdj。运行时负责把这些参数落实到 cgroup 与进程。使用 cgroup v2 时,memory.max 是观察这条配置链是否生效的直接入口。

正在加载 GitHub 文件…

generateLinuxContainerResources 调用这段资源转换后,另行写入进程 OOM 分数。

正在加载 GitHub 文件…

达到 limit 不意味着内核按一个固定使用率立即杀死进程。内核会尝试回收内存;无法在限制内满足分配时才可能触发 cgroup OOM。计量还包括相关的页缓存等内存,不能只用应用报告的堆大小与 limit 比较。具体内核语义见 cgroup v2 内存控制器文档。

传统的容器级资源配置下,QoS 分为:

  • Guaranteed:所有容器都设置非零 CPU、memory request 与 limit,且两类资源各自的 request 等于 limit。
  • BestEffort:所有容器都没有 CPU、memory request 与 limit。
  • Burstable:其余情况。例如只设置 memory request 与 limit,即使两者相等,也不满足上述 Guaranteed 条件。

分类实现见 ComputePodQOS。本文实验不配置 Pod 级 spec.resources,也不启用 MemoryQoS;不能把这里的容器级判断直接套到所有 feature gate 组合。

正在加载 GitHub 文件…

QoS 会影响 OOM 保护程度,但 Guaranteed 不代表进程永远不会被杀。它仍可能触发自身 cgroup 的内存限制,也无法凭一个分类消除节点故障。

全局 OOM 与 kubelet 驱逐使用不同的排序

GetContainerOOMScoreAdjust 为普通 Guaranteed 容器设置 oom_score_adj=-997,为 BestEffort 设置 1000,对 Burstable 则结合 memory request 与节点内存容量计算并限定范围。内核再结合进程实际占用等因素选择 OOM victim。它不是按 Pod QoS 名称调用 Kubernetes 的排序器。

正在加载 GitHub 文件…

kubelet 的内存压力驱逐是另一条路径。synchronize 从节点统计读取压力信号,判断阈值及 soft threshold 的持续时间,再选择需要回收的资源。通用框架会调用 reclaimNodeLevelResources,但本版本的 buildSignalToNodeReclaimFuncs 只为文件系统信号注册容器或镜像垃圾回收,没有为内存信号注册回收函数。因此内存路径在这个调用中直接返回 false,继续对候选 Pod 排序;不能画成 kubelet 先执行一轮内存回收,再检查是否需要驱逐。内核在内存分配路径中的回收是另一套机制。

正在加载 GitHub 文件…
正在加载 GitHub 文件…
正在加载 GitHub 文件…

rankMemoryPressure 的核心排序是:

正在加载 GitHub 文件…

它依次比较是否超过 memory request、Pod 优先级、相对于 request 的内存使用量。因此,“先 BestEffort,再 Burstable,最后 Guaranteed”只能帮助理解某些常见结果,不能替代这段实际判断。一个超过 request 的高优先级 Pod,与一个未超过 request 的低优先级 Pod,不能仅按优先级比较。

  flowchart TB
    A[内存分配或节点统计变化] --> B{谁发现问题}
    B -->|Linux 内核| C{限制或全局内存分配失败}
    C --> D[尝试回收内存]
    D --> E{回收后能否满足分配}
    E -->|不能| F[OOM 选择并终止进程]
    E -->|能| G[继续运行]
    B -->|kubelet| H[检查 memory.available 与驱逐阈值]
    H --> I{达到阈值及必要的持续时间}
    I -->|是| M[内存信号没有节点级回收函数 直接排序候选 Pod]
    M --> K[终止选中的 Pod 并记录 Evicted]
    I -->|否| G
    style C fill:#fff3df,color:#402800,stroke:#a96b00
    style I fill:#e3f0ff,color:#102d50,stroke:#386da6

图中的两条路径可以竞争发生。kubelet 需要采样和执行时间,突增的内存分配可能让内核先触发 OOM;不能把驱逐阈值当作“保证节点永远不发生 OOM”的屏障。hard eviction 使用零秒宽限;soft eviction 使用 evictionMaxPodGracePeriod 约束宽限,不能一律理解为应用都有完整的 terminationGracePeriodSeconds。节点压力驱逐由 kubelet 本地发起,不经过 Eviction API 的 PDB 预算检查;PDB 不能阻止这种驱逐。具体宽限值的选择见 synchronize 中终止 Pod 的分支。

正在加载 GitHub 文件…

一个不耗尽节点内存的实验

实验使用 kind v0.33.0、Kubernetes v1.35.1(提交 8fea90b45245ef5c8ba54e7ae044d3e777c22500)、containerd v2.2.1,运行在 Linux 主机 zj。分配程序最多尝试申请 64Mi,容器限制为 32Mi,使用 restartPolicy: Never 保留一次退出状态,不形成持续分配循环。

配置、校验脚本、证据和复现说明见 k8sdev/article-resources-memory。先按该目录 README 的固定提交准备步骤创建独立工作目录,再运行实验。

实验脚本同时检查 cgroup 配置与容器终止状态,关键断言如下。

正在加载 GitHub 文件…

实际记录为:

1
2
3
4
5
memory.max=33554432
oom_score_adj=999
qosClass: Burstable
terminated.reason: OOMKilled
terminated.exitCode: 137

这里没有给容器配置 CPU request 与 limit,所以 QoS 是 Burstable。节点容量远大于 16Mi memory request,计算后的 oom_score_adj 接近上界。本次进程因自身 cgroup 限制退出,不能用这个实验声称已验证全局 OOM 的 victim 排序。

脚本同时检查终止原因与退出码,不把“Pod 不再 Running”当作成功条件。它也不能证明 kubelet 的压力驱逐顺序:本轮没有制造节点内存压力,相关结论来自上述固定版本源码。

退出之后,容器重启与 Pod 重建仍要分开

容器 OOM 后,kubelet 根据适用的重启策略决定是否重新启动容器。默认 Always 的普通工作负载通常会重启该容器,Pod UID 可以保持不变;反复失败可能进入退避。本文使用 Never,所以保留终止结果。启用容器级重启规则时,还应检查容器自身的配置。

Pod 被驱逐后,原 Pod 已失败,restartPolicy 不会让它在另一台节点上原地恢复。Deployment、StatefulSet 或 Job 等控制器可能创建替代 Pod,替代对象有新的 UID;直接创建的独立 Pod 没有这层副本恢复保证。

判断一次内存故障时,应把容器限制、节点压力、QoS、优先级和控制器恢复分别对齐到证据:先确认被终止的对象,再判断谁作出了终止决定,最后解释谁负责恢复。提高 memory limit 只能改变其中一层约束,不能替代对内存泄漏、节点容量或不合理 request 的检查。

#Kubernetes #源码分析 #Kind