26 的 Pod UID解决“到哪台节点、找哪只进程”,06和07解释 cgroup 控制器。现在同一份 Pod 清单写下 `requests.cpu` 和 `limits.memory`,究竟发生了什么?“Pod 因 CPU 不足等不到节点”“进程运行很慢”“容器因内存退出”“节点驱逐 Pod”分别发生在调度、进程 cgroup、内存回收和 kubelet 节点压力处理这几层;把它们统称资源不够,既找不到应读的证据,也容易把修复动作施加在错误对象上。

先分清资源声明的作用时刻

一个容器的 CPU/memory request 表示调度与资源分配要考虑的需求,不是给进程预存一块永远闲置的物理内存。调度器从节点 allocatable 和已承诺的 request 等信息判断新 Pod 是否能安排;计算取决于 Pod 内所有容器、init 容器以及适用的 Pod overhead 等版本/配置细节,不等同于 free 命令看到的实时空闲量。同一节点此刻 CPU 很空闲,也可能因为已承诺的请求量而拒绝新 Pod;相反安排成功以后,实际使用也可能远低于 request。只拿一个瞬时 CPU 百分比去解释 Pending,跳过了调度账本。

limit 的任务不同。CPU 上限通常在节点相应 cgroup 的 CFS 配额路径落实,运行进程用完周期额度会等待下一周期;应用可能变慢但并未退出。内存上限需要观察 memory.max 等本机控制器与进程分配/回收结果,超过上限后可能出现组内 OOM 与进程死亡,并非所有申请都会在越界那一刻得到相同响应。Kubernetes 可以把声明转成运行时资源配置,但某个版本启用了什么控制器、cgroup driver 和资源策略,必须从实际节点上核对,不能因 YAML 有 limits 就写“已在这台机器触发过节流/OOM”。

如果只指定 limit、不指定 request,Kubernetes 的准入或默认化逻辑可能令 request 等于 limit;LimitRange 等准入策略也可改写 Pod 在创建时的最终资源字段。因此实验应保存API 接受后的 Pod JSON,并与提交的原始 YAML 比较,尤其在进入生产集群、命名空间内有默认限制时。否则你以为比较的是“只改 limit”,实际调度器看到的 request 也变了。本文 YAML 三份 Pod 都显式给出 CPU/内存 request 和 limit,仅为降低这个歧义;换集群时仍需核对服务端最终值。

用量单位也不宜混成一列:50m CPU 表示五十分之一核的请求计量单位,100m limit 表示每周期可使用时间的约束,而 64Mi 内存是二进制字节量。即便文字里都写“资源”,CPU 是可在时间上反复分配的处理能力,内存占用则要结合实际页面驻留、缓存和回收路径判断;Pod 的 request 也不是单纯将每个容器 limit 相加。把 kubectl describe node 的 Capacity、Allocatable、Allocated resources 三栏与 Node 上实测进程使用量并排看,可以发现调度承诺与真实消耗不是同一个计数口径;想调整配置先确认是在解决哪一个缺口。

QoS 类别依 requests/limits 的配置关系决定,是一组相对于其它 Pod 的处理线索,不是“百分之百保活”的质量承诺。本篇基线 request 小于 limit,因此不是 Guaranteed;当节点发生压力时,QoS、资源使用量、请求量和 Pod priority 等因素都会进入逐出判断,具体排序应看固定 Kubernetes 版本源码与节点事件。把一个 CPU limit 提大不自动让 Pod 变成 Guaranteed,也不能靠改 QoS 标签绕过内核 OOM。区分“节点因整体压力逐出 Pod”和“容器被自己 cgroup 的内存限制杀掉”,需要观察不同层的指标。

计算一个 Pod 的 QoS 时,还要考虑 Pod 内是不是不止一只容器:一个应用容器 request=limit,并不足以证明带有 sidecar/init 的整个 Pod 都满足 Guaranteed 条件。对于 Kubernetes 支持原生 sidecar 的版本,更不能在没核对其资源规格的前提下按“只有主容器”给 Pod 分类。本篇 YAML 刻意把三轮实验分别放在单容器 Pod 中,让服务端实际写下的 status.qosClass 有可回查的原始定义;以后迁移到真实业务 Pod,再按当时的所有容器和适用特性复核。运行时是否实现对应内核权重仍是另一道问题,API 中的 QoS 字段不等于节点当下的 cgroup 文件。

A:request 太大时,进程根本不存在

有界 Pod 清单 中 `c27-unschedulable` 请求 9999 核 CPU,只在 VM 节点实际 `allocatable` 小于该值时构成明确调度负例。API 对象能创建,scheduler 仍可能拒绝所有节点,Pod 留在 Pending;此时应看 Pod 条件、`FailedScheduling` 事件、所需 request、每台候选节点可分配量和既有已请求资源。该 Pod 通常尚无确定的 `spec.nodeName`、sandbox ID 和 Linux 进程;对它寻找 `cpu.stat` 增量、OOM 退出码没有对象基础。

失败边界也要说清:Pending 并非“CPU request 超了”专属状态。没绑定卷、节点污点、Pod 亲和性不满足或镜像拉取等阶段均可能让用户看到等待;后者甚至可能已经分配到节点。验收要在候选节点层找到具体调度拒绝理由,把 nodeName、Pod UID 和事件时间序列连起来。如果 API 层因 LimitRange 或配额拒绝创建对象,连 Pending Pod 都不会有,此时从 API 返回的 admission 错误分析,不能把它写作 NodeResourcesFit 的调度拒绝。

对这轮故障最有区分力的是一张小的时序表:API 创建是否返回成功、UID 是否已生成、nodeName 是否为空、scheduler 事件的 reason/message、候选节点在当时的 allocatable 与已分配请求。kubectl describe node 在一分钟后读到的值可能已经变了,所以要保留请求前、请求后两个采样点。如果失败事件说的是节点污点或卷拓扑,哪怕请求看起来也偏大,都不能事后把它记作“CPU insufficient 已复现”。只有拒绝原因确实指向 CPU 且调整受控 request 后被接受,才能说这个实验隔离了调度资源条件。

Kubernetes v1.33.4 的 NodeResourcesFit 源码提供这条资源匹配路径的实现阅读定点,滚动的资源管理说明给出 API 语义;二者不是这个云端已运行的 scheduler。将来 kind VM 上应记录节点和控制面实际版本,验证它的过滤插件配置、其他扩展资源与节点可分配量。特别是 9999 核这个示范数值只对资源规模有限的教学 VM 可作负例,不应作为可移植“固定报错常量”:换到另一套环境先重新读 allocatable,再决定安全且明确的测试输入。

还须区分“节点有多少物理资源”与“调度能分配多少”。系统进程与 kubelet 本身要用资源,节点还可能为 kube/system reserved、驱逐预留及 Pod 数量上限留下空间;Capacity 即使看起来足够,Allocatable 也可能较小。一次调度拒绝如果同时报告多种资源不足,先逐个核对请求与已分配资源,不能只因为本篇刻意设置了大 CPU request 就忽略其它条件。对有 Pod overhead 的运行时尤其如此:实际过滤输入可能包含额外开销,实验中需要保存 Pod 最终 JSON 与所选 runtime class,说明为何同一个容器声明在不同节点呈现不同调度可行性。

B:CPU 有额度,变慢也未必退出

c27-baseline 把 CPU request 设为 50m,limit 设为 100m,内存 request/limit 分别为 64Mi/128Mi。程序仍用同一份包中的 probe_app.py 回应 /identity、/ready,但资源测试用同包的 bounded_load.py 在同一容器内运行;它最多计算两秒钟、额外分配最多 32 MiB。这不是另写一套无限循环的压测服务。配套构建文件在同一镜像中复制两个现成脚本,便于 HTTP 正例和受控负载按同一个 containerID/cgroup 取证。

先在对应 Pod 的容器内读 cpu.max 和 cpu.stat,记录 nr_periods、nr_throttled、throttled_usec;等负载结束再读一次,并用运行实际经过的时间作为对照。cgroup v2 cpu.max 写的是可用时间与周期,例如设定 100m 时应核对实际比例,不要把毫秒 CPU request 当成文件中的同一单位。cpu.stat 是该组内累计计数,与节点上其它组的计数不能混算;即使差值显示节流,也只证明这段观测窗口里组消耗额度的事实,不单独解释每一个 HTTP 慢请求。请求时间、是否有并发任务、内核与 kubelet 版本都要记下来。

采样先后若安排不当,同样会误导。先读取计数器、然后等待一分钟才启动两秒负载、负载后再等一分钟读取第二次,所得增量可能混入探针、exec 会话和其它请求;应尽量缩短前后两次读取与负载之间的间隔,记录每一步 UTC 时间。同时在相同 limit 下做一轮不运行负载的空白对照,估计服务自身的背景节流,而不是看到 nr_throttled 不为零就说此次实验导致它发生。CPU 饱和时 kubectl exec 的控制通道也可能被限速,故工具墙钟时间与脚本内 time.monotonic 都要保留,切勿拿两者相减当成精确调度损失。

CPU request 与 CPU limit 不扮演同一个控制量。提高 request 可能影响调度接受和竞争时的权重,而在无竞争的节点上,两次都未用尽 quota 时,不应预设 nr_throttled 必须改变。反之保持 request 不变而缩小 limit,即使周围没有其它 Pod,单个容器也可能在其 quota 周期内被限制。Kubernetes 与 cgroup v2 对 request 到权重的具体映射可能随实现演进;要讨论 cpu.weight,先查该节点真实 cgroup 文件和对应 kubelet/runtime 分支,不能从清单中的 50m 自行算出一个所有版本通用的权重。

取证时别忘了 26 的身份链:同一 Pod 在运行两秒钟的小程序期间可能重启,第一次读取的 /sys/fs/cgroup 来自旧进程组,第二次来自新进程组。保存两次的 Pod UID、containerID、restartCount,记录节点 CRI 容器 ID 与 cgroup 路径,确认确实比较同一容器组。kubectl exec 只证明当前容器内运行了命令,不能省略此检查后宣布宿主进程已经按该资源值节流。如果节点实际使用 cgroup v1,脚本应记录挂载并停止 v2 计数路径;此处不把云端只读 v1 的 cpu.stat 伪装成 kubelet v2 的观测。

在 Pod 之外复核 cgroup 时要注意层级:节点上可能存在 QoS 组、Pod 组、容器组,cpu.max 是你选中哪一层的文件直接决定的。Pod UID 可能经过 systemd 转义进入组名,不能用字符串包含 c27 就确认那是应用进程的叶子组。可靠路径是先从运行时给出的容器 ID 定位进程,再从它的 /proc/<pid>/cgroup 与实际挂载点找到对应组,并在读取前后复查 PID/ID 未变。若查看的是父级 Pod 组的 cpu.stat,它可能聚合不同容器;即便本篇只有一个应用容器,还存在 sandbox 等节点进程,结果也应明确写“父级累计”而非“探针进程自身”。

C:内存 limit 和 OOM 为什么要多对一层证据

c27-memory 是独立 Pod,request 24Mi、limit 32Mi,入口执行同一 bounded_load.py 最多申请额外 32 MiB 两秒钟。解释器自身、共享库和对象管理也占内存,因此这组值可能让进程被组内 OOM 杀死;在另一种镜像或节点配置里,它也可能没触发,甚至在执行本体之前因为镜像、挂载等原因失败。能否发生 OOM 应由运行结果决定,不能从 “32 MiB 分配 + 32 MiB limit” 在纸上直接推断出一个必然的退出码。若未复现,先看真正读到的 memory.current/memory.peak、服务端采用的 limit、事件和程序退出时间,再在安全预算内选择下一轮参数。

这轮内存实验不能与 B 共用一只 Pod:只改资源数值可能迫使重建容器,旧 cgroup 和新 cgroup 的累计计数便没有同一身份基础。两个 Pod 使用同一镜像摘要,才把镜像字节这一变量固定住;但它们的 UID 和 containerID 必须分别记,不能把 A/B/C 三个阶段的 memory.events 接成一条时间线。若限额 32Mi 导致 Python 入口在加载时被杀死,脚本中的“额外申请 32Mi”根本尚未发生,这也是有效的失败观测,却不能称为“受控负载运行两秒后 OOM”。日志缺失、启动时间和节点内核事件应共同帮助区分这两个阶段。

内核侧在 cgroup v2 可读 memory.max 和 memory.events,其中 oom 与 oom_kill 字段需要区别;容器层则看同一容器 ID 的 terminated.reason 与退出码,Kubernetes 层再看 Pod 事件。一次退出 137 也可能来自外部 SIGKILL、节点压力、运行时终止处理,不能仅凭数字归咎于 memory.max。若 Pod 太快结束导致 cgroup 已被移除、事前没有取到内核计数,文档应写“API/容器层提示 OOM,内核证据缺失”,继续在专用 VM 同步采样补齐。节点可能运行 cgroup v1,此时文件名、事件口径都不同,不能硬将 v1 数据贴上 v2 的列标题。

memory.events 的字段各有作用:触碰高水位、真正进入 OOM 处理、最终杀掉进程不是同义事件;在层级组读取还可能包含子组事件。观察时保存文件原始全文、对应组路径、前后采样时间和容器 ID,并标注读取的是 memory.events 还是仅统计本组的 memory.events.local。若只有 oom 上升而 oom_kill 没变,还要看程序是否自行处理分配失败、当前进程是否继续运行;不能把两个数字剪成一个“发生 OOMKill”。若内存限制在节点上根本没应用,先检查 Pod 是否因准入或运行时配置不一致未落到目标路径,别通过让负载申请更大内存来掩盖配置错误。

即使本组触发 OOM,Pod 对外的体验还取决于容器重启策略、终止前有多少请求、业务是否能从持久状态恢复。这里的 memory Pod restartPolicy: Never,方便保留失败状态;不代表日常 Deployment 也会永远停止。c27-baseline 的 /identity 本地返回可以证明低限额下服务暂时存活,却不能外推出在高并发下尾延迟不变。若想进一步测试数据写入与 OOM 的先后关系,需要按 16/30 的存储契约另做对照,而非从一次资源事件推断“数据一定没丢”。

D:节点压力驱逐不是容器自己的配额

节点整体可用内存、nodefs/imagefs 空间或 inode 等信号达到 kubelet 的驱逐条件时,kubelet 可能选择 Pod 进行终止并上报 eviction。它查看的是节点级别的压力阈值、可回收资源和候选 Pod,而不是逐个比较每只容器是否刚好碰到自己的 memory.max。另有 Linux 内核在节点压力下可能先杀进程,时间顺序与状态应以实际事件和日志为准。固定阅读定点 Kubernetes v1.33.4 的 eviction manager 实现也不替代目标 VM 的 kubelet 配置和 MemoryPressure 等 Node condition 原始值。

要证明节点驱逐,至少要记录:专用节点的压力信号及阈值、被选 Pod 的 UID/priority/QoS、kubelet 的 eviction 事件、对象后续阶段、请求失败时间与恢复动作。压测条件还要限定受害 Pod 和节点范围;直接在共享宿主上分配“直到系统卡死”的内存不符合实验边界。本文附件没有提供一个声称能安全触发所有发行版/阈值的注压脚本:实验前需由独占 VM 管理者确认容量、控制台、硬停止条件和恢复预算;无独占资源时这一栏保持 NOT_RUN。把 C 的 OOMKilled 重新标一遍 eviction,只是在表格中造出一个不存在的第四实验。

即使独占节点允许实验,也要在动手前写明“恢复”如何判定:压力解除后 Node condition 恢复、目标 Pod 能在计划的控制器下重新调度、实际业务端点与写入状态达到预设条件,三者不应只靠 kubectl get pods 显示 Running 一行代替。若只用裸 Pod 而没有 Deployment,它被 Evicted 后是否自动再创建是另一道控制器问题,不要把没有重建写成 kubelet 恢复失败。若 pressure 来源是 nodefs 空间,也不能用 memory.events 的 OOM 计数作为压力触发证据;应读取节点文件系统剩余、对应 eviction signal、事件与释放后数值的变化。

CPU 被节流、容器内存 limit 的 OOM 和节点压力逐出会留下不同对象。CPU 节流可能没有容器退出,因而需要两次 cpu.stat 及请求时延;limit OOM 要有相同容器的内存事件与终止状态;节点驱逐需有 Node signal、kubelet 决策和 Pod 状态。调度拒绝更靠前,尚无任何运行中的 cgroup。若四栏发生在同一个实验节点、时间互相重叠,还要记录每轮的基础负载和中间清理,以免上一轮制造的节点压力污染下一轮结果。

四栏的结果最终可以落在同一张报告里,但应保留各自的“未发生”和“不可观测”标记。调度器选不到节点不等于应用进程失败;CPU 负载结束而计数没升不等于 quota 没配置;低内存实验正常退出不等于任何内存上限都不会生效;未获准做节点压力实验时,更不能把它写作一次没有驱逐的负结果。只有输入、取证对象和实际行为齐全,才可提出针对这一环境的结论。

从一个症状选对诊断起点

从 Pending 开始,应先核对是否已分配 nodeName:未分配看 scheduler 事件、最终 API request、候选节点 allocatable;已分配则进入 kubelet/镜像/sandbox 路径,不先改 CPU quota。已运行但请求慢,则按 26 的 containerID 找正确 cgroup,比较 cpu.stat、应用时延和同节点其它负载,不能只看 Pod 的 CPU 图表平均值。容器退出则分别查进程信号、内存事件、CRI 退出信息;若 Pod 被 Evicted,再回到节点压力而不再追单只容器的 memory.max。这套顺序决定先查对象在哪层存在,才知道哪份证据有意义。

如果现有系统把请求路径、日志和资源指标接在一起,仍须验证指标的标签到底引用 Pod 名、Pod UID 还是容器 ID。Pod 名被重用、容器重启以后,仅凭名称汇总的 CPU 节流与本次慢请求并无唯一对应关系。采样分辨率也会改变可见性:两秒钟负载结束后,分钟平均 CPU 使用量可能不显著,但同一窗口的 nr_throttled 增量可以存在;反过来计数上升不代表应用用户可感知的延迟变化。优先保留原始计数、请求时间戳与分位数,再讨论监控系统中的聚合指标,而不是为了迎合图表把几轮测试拼成一个“平均容器”。

另一个迁移边界是管控系统的版本与介入点。Kubernetes v1.33.4 的 scheduler/kubelet 源码只用于说明某种实现的过滤和驱逐路径,运行中的节点可能启用不同的调度配置、资源管理策略,甚至根本不用 kind。cgroup v2 文档不是对该云端只读 cgroup v1 的实测;从 Docker 转到 Kubernetes 也不能把 Docker 的 CLI 参数当成 Pod spec 的完整资源承诺。资源数值、默认化、Pod UID、containerID、内核文件与业务响应串起后,才有条件比较变更前后的效果。

分层补跑与安全清理步骤保存 A/B/C/D 的输入和证据栏,专项附件 SHA-256校验 Dockerfile/三 Pod YAML,共用 probe-app 与有界负载可独立取得。这里通过的仅是 YAML 解析、附件字节校验与正文/页面静态检查;这台云端没有 kind、专用节点或可写 cgroup v2,四种资源案例及数据都 **`NOT_RUN`**。即便将来 A/B/C 在 VM 运行,只要 D 未有合规的专用压力证据,整篇仍不标完整 `LAB_VERIFIED`。
症状 首先核查 缺少什么不能下结论
Pending、没有 nodeName scheduler 事件、request/allocatable 不能说 CPU 已被节流
已运行、nr_throttled 增加 同容器 cgroup 与负载起止时间 不能说全部尾延迟由 quota 导致
容器退出 137 当前容器 ID、memory.events、终止信息 不能仅凭退出码认定 limit OOM
Pod 显示 Evicted 节点压力阈值、kubelet 事件、Pod UID 不能把它等同于 memory.max

练习

  1. 先预测 CPU request 从 50m 提高到 200m、limit 仍为 100m 时,分别在哪些阶段可能看到改变。独占 VM 中先保存 API 默认化、调度事件,再在无竞争与有竞争条件下读取同一个容器 cpu.stat;解释为何“提高 request”不是解除 quota 的通用办法。
  2. 先分别预测 c27-memory 的容器 limit OOM 与专用节点整体压力逐出会在哪些对象上出现事件。限定 VM 容量和观察窗口,记录每一次 Pod UID、containerID、内核计数、Node condition、请求和清理;如果缺少专用节点,保留 D 的 NOT_RUN,不要用 C 的 OOM 记录替代。

上一篇:26:Pod 节点执行;下一篇:28:Running 与 Ready。

系列总目录:从进程隔离到运行时与编排。

参考资料