jimyag's Blog

Kubernetes 调度约束与抢占:Pending 背后的资源和硬约束

节点 CPU 使用率不高,Pod 却提示 Insufficient cpu;把 Pod 优先级调高后,它仍然 Pending,并出现 Preemption is not helpful for scheduling。这两种结果都可能符合 scheduler 的设计:它依据资源请求量和约束选择节点,抢占也只能解决删除低优先级 Pod 后能够消除的障碍。

本文把调度失败分成两类:容量被已有请求占用,以及节点本身不符合要求。两类问题可以同时存在,修掉其中一个,不保证另一个也随之消失。

调度先筛选可行节点,再比较偏好

一次调度中,硬约束决定节点能否进入候选集合,软约束影响可行节点的评分。没有节点通过 Filter 时,再高的偏好分也没有作用。

约束主要实现不满足时的含义
CPU、memory、临时存储 requestNodeResourcesFit可分配容量无法容纳新增请求
nodeSelector、required node affinityNodeAffinity节点标签或字段不满足硬条件
未容忍的 NoSchedule / NoExecute 污点TaintToleration新 Pod 不能通过相应调度过滤
required Pod affinity / anti-affinityInterPodAffinity与其他 Pod 的位置关系不满足要求
DoNotSchedule 拓扑分布约束PodTopologySpread加入新 Pod 后偏斜超过允许值

preferredDuringSchedulingIgnoredDuringExecution、PreferNoSchedule 与 ScheduleAnyway 属于偏好,不能和对应的硬约束混为一谈。容忍污点只移除一个阻碍,不会强制把 Pod 放到这类节点,也不会绕开资源不足或亲和性限制。

本文源码与实测均使用 Kubernetes v1.35.1(8fea90b45245ef5c8ba54e7ae044d3e777c22500),采用 kind 默认 scheduler profile,没有自定义插件或调度器。其他 profile 可以增删插件,因此排查前还应确认 schedulerName 与实际调度配置。

使用率低与 request 不足可以同时成立

NodeResourcesFit 在 PreFilter 阶段计算待调度 Pod 的请求量,并在 Filter 中比较节点的 allocatable、已占用请求量和新增请求量。CPU 分支的核心关系是:

正在加载 GitHub 文件…
1
2
新 Pod 的 CPU request
    <= 节点 allocatable CPU - 已有 Pod 的 CPU requests

这里没有把 kubectl top 的瞬时 CPU 使用量代入公式。一个 request 为 9 CPU、实际只在 sleep 的 Pod,仍然占用 9 CPU 的调度预算。scheduler 不会因为它此刻空闲,就把同一份已承诺容量完整地再次承诺给另一个 Pod。

对多容器 Pod 也不能简单地把所有字段无条件相加。普通容器、传统 init container、可重启 init sidecar、Pod overhead 以及 Pod 级资源设置都有相应的汇总规则,插件通过资源 helper 计算有效请求。排障时应检查准入默认之后的实际 Pod,而不是只看提交前的模板。

CPU limit 的执行限制由节点上的 cgroup 实现,和此处 request 核算的职责不同。可以结合CPU 限流文章区分“调度放不进去”和“已经运行但执行受限”。

有些失败不可能通过删除其他 Pod 消除

NodeAffinity.Filter 在节点标签不满足 required 条件时返回 UnschedulableAndUnresolvable。这个状态表示当前失败不能通过从该节点移走其他 Pod 解决,不表示该 Pod 永久无法调度:给节点添加满足条件的标签、修改工作负载或加入合适的新节点,都可能改变结果。

正在加载 GitHub 文件…

TaintToleration.Filter 对未容忍的硬污点也作类似判断。删除低优先级 Pod 不会修改节点污点,也不会给新 Pod 增加 toleration。

正在加载 GitHub 文件…

拓扑分布约束则要看具体计数。对于 DoNotSchedule,PodTopologySpread.Filter 按拓扑域计数,并检查加入新 Pod 后的 skew:

正在加载 GitHub 文件…
1
2
3
4
当前候选拓扑域匹配的 Pod 数
    + 新 Pod 自身是否匹配 selector
    - 全局最小匹配数
    <= maxSkew

“全局最小”依赖 eligible domains;当符合条件的域数量少于 minDomains 时,实现会把最小值视为 0。只看每台节点有几个 Pod,或者忽略 labelSelector 与节点拓扑标签,容易算错约束。本文没有运行多节点拓扑实验,这里的行为依据固定版本源码。

Pod anti-affinity 和拓扑计数涉及其他 Pod,删除它们有时能够改变可行性;因此也不能把所有“硬约束”一概理解为“抢占无效”。应问具体失败条件是否会随 victim 移除而改变。

默认抢占还有一个范围限制:SelectVictimsOnNode 只在当前候选节点的快照中移除 victim,不会为了让 Pod 落到节点 A,顺便删除节点 B 上的 Pod。若跨节点或跨可用区的 required anti-affinity 必须移除另一节点上的 Pod 才能满足,默认抢占不会解决这个组合。这里限制的是默认插件的单节点模拟范围,不是说所有自定义调度器都必须如此。

正在加载 GitHub 文件…

抢占先模拟移除,再决定是否真的删除

默认抢占由 PostFilter 插件 DefaultPreemption 实现。PodEligibleToPreemptOthers 先判断新 Pod 是否具有抢占资格;preemptionPolicy: Never 的 Pod 即使优先级很高,也不会主动驱逐其他 Pod。

正在加载 GitHub 文件…

对可能通过抢占改善的节点,SelectVictimsOnNode 的关键步骤是:

  • 在调度快照的副本中移除允许抢占的低优先级 Pod,并同步 PreFilter 的统计状态。
  • 对移除后的节点重新运行 Filter。如果仍不符合条件,放弃这个候选节点,不实际删除这些 Pod。
  • 如果能够放下新 Pod,按重要性逐个尝试把 Pod 加回去;只有加回后又无法调度的 Pod,才保留为 victim。

这解释了两个容易被省略的条件:victim 的优先级必须低于新 Pod,而且移除 victim 后必须确实能通过过滤。同优先级 Pod 不会因为“更早占用了位置”就被默认抢占。

  flowchart TB
    A[待调度 Pod] --> B[PreFilter 与 Filter]
    B --> C{有可行节点}
    C -->|有| D[评分并推进绑定]
    C -->|没有| E{允许主动抢占}
    E -->|否| F[记录失败并等待相关变化]
    E -->|是| G[筛选可由移除 Pod 改善的节点]
    G --> H[快照中移除合格低优先级 Pod]
    H --> I{重新 Filter 能否通过}
    I -->|不能| F
    I -->|能| J[尝试加回 Pod 以减少 victim]
    J --> K[选择候选并请求终止 victim]
    K --> L[等待状态变化后再次调度]
    L --> B
    style E fill:#fff0db,color:#402500,stroke:#9b6200
    style I fill:#e2efff,color:#173456,stroke:#376ea6

图中的模拟发生于调度器内存,不是先删 Pod 再试试看。真正的抢占准备由 preemption.Evaluator 通过 API 请求推进;kubelet 再负责节点上进程的终止。同步或异步抢占路径受相关 feature gate 影响,但都不能把 API 删除请求受理当作 CPU、内存已经可用。

正在加载 GitHub 文件…

nominatedNodeName 表达调度器的提名,不等于 spec.nodeName 已绑定,也不保证新 Pod 最终一定运行在该节点。victim 退出需要时间,期间节点条件、其他高优先级 Pod 和新 Pod 本身都可能变化,因此还需要后续调度周期重新判断。

PDB 限制不能代替抢占容量设计

抢占选择会考虑 PodDisruptionBudget(PDB),优先尝试保留会导致 PDB 违约的 Pod。上述 SelectVictimsOnNode 把潜在 victim 分为违反和不违反 PDB 的两组,再分别尝试加回。

但如果没有不违反 PDB 的可用方案,默认抢占仍可能选择会违反 PDB 的 victim。PDB 对抢占的保护属于尽力而为,不能据此承诺“设置 minAvailable 后,高优先级 Pod 永远抢不走这些副本”。本轮没有触发 PDB 违约实验,这段结论对应源码中的分组与 victim 保留逻辑。

正在加载 GitHub 文件…

同一个集群对比容量抢占与硬条件失败

实验使用 zj 上的专用单节点 kind 集群 article-resources,kind v0.33.0、Kubernetes v1.35.1。节点 allocatable CPU 为 12,脚本按它的四分之三生成每个 Pod 的 request,即 9000m。容器实际执行 sleep,因此实验改变的是调度承诺量,不制造 CPU 压力。

配置、动态请求量计算、断言与清理说明见 k8sdev/article-resources-scheduling。先按该目录 README 的固定提交准备步骤创建独立工作目录,再运行实验。

实验先确认标签不匹配没有删除 low,再检查实际抢占事件及 high 就绪,关键检查如下。

正在加载 GitHub 文件…

先运行优先级 100 的 low Pod,再创建优先级 1000、要求不存在节点标签的 impossible Pod。后者保持 Pending,PodScheduled=False,条件消息包含:

1
2
node(s) didn't match Pod's node affinity/selector
Preemption is not helpful for scheduling

脚本在此时检查 low Pod 的 UID 和 deletionTimestamp,确认失败没有导致它被删除。新 Pod 所需标签不会通过删除 low Pod 获得。

再创建没有该 nodeSelector 的 high Pod,优先级仍为 1000、CPU request 同为 9000m。low 与 high 的请求量总和是 18 CPU,超过节点 12 CPU 的 allocatable;默认抢占选择 low。记录的事件包含:

1
2
Preempted by pod 59d6690a-570f-4df8-b086-adbfbb01041c
on node article-resources-control-plane

脚本按 low 的 UID 核对 Preempted 事件,并等待 high Ready,同时确认 impossible 仍 Pending。这组对照分别验证了“删除低优先级 Pod 能释放请求预算”和“删除它不能改变节点标签”。它不证明多节点候选选择公平性,也没有验证复杂 affinity、拓扑分布或 PDB 的所有组合。

遇到 Pending 时,最有用的入口是实际 Pod 的 requests、PodScheduled 条件、事件和节点 allocatable / 已分配请求量。只有把失败归到可被改变的具体约束后,才有依据判断应该调整 request、扩容节点、修正标签与容忍,还是为关键业务设置优先级。

#Kubernetes #源码分析 #Kind