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

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

<!--more-->

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

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

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

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

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

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

[`NodeResourcesFit`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/noderesources/fit.go#L660-L698) 在 `PreFilter` 阶段计算待调度 Pod 的请求量，并在 `Filter` 中比较节点的 allocatable、已占用请求量和新增请求量。CPU 分支的核心关系是：

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/noderesources/fit.go#L660-L698" >}}

```text
新 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 限流文章](/posts/kubernetes-cpu-throttling/)区分“调度放不进去”和“已经运行但执行受限”。

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

[`NodeAffinity.Filter`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go#L218-L241) 在节点标签不满足 required 条件时返回 `UnschedulableAndUnresolvable`。这个状态表示当前失败不能通过从该节点移走其他 Pod 解决，不表示该 Pod 永久无法调度：给节点添加满足条件的标签、修改工作负载或加入合适的新节点，都可能改变结果。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go#L218-L241" >}}

[`TaintToleration.Filter`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/tainttoleration/taint_toleration.go#L119-L132) 对未容忍的硬污点也作类似判断。删除低优先级 Pod 不会修改节点污点，也不会给新 Pod 增加 toleration。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/tainttoleration/taint_toleration.go#L119-L132" >}}

拓扑分布约束则要看具体计数。对于 `DoNotSchedule`，[`PodTopologySpread.Filter`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/podtopologyspread/filtering.go#L315-L359) 按拓扑域计数，并检查加入新 Pod 后的 skew：

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/podtopologyspread/filtering.go#L315-L359" >}}

```text
当前候选拓扑域匹配的 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-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/defaultpreemption/default_preemption.go#L207-L258" >}}

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

默认抢占由 PostFilter 插件 [`DefaultPreemption`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/defaultpreemption/default_preemption.go#L317-L350) 实现。`PodEligibleToPreemptOthers` 先判断新 Pod 是否具有抢占资格；`preemptionPolicy: Never` 的 Pod 即使优先级很高，也不会主动驱逐其他 Pod。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/defaultpreemption/default_preemption.go#L317-L350" >}}

对可能通过抢占改善的节点，`SelectVictimsOnNode` 的关键步骤是：

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

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

```mermaid
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`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/preemption/preemption.go#L464-L502) 通过 API 请求推进；kubelet 再负责节点上进程的终止。同步或异步抢占路径受相关 feature gate 影响，但都不能把 API 删除请求受理当作 CPU、内存已经可用。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/preemption/preemption.go#L464-L502" >}}

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

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

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

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

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/defaultpreemption/default_preemption.go#L265-L297" >}}

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

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

配置、动态请求量计算、断言与清理说明见 [k8sdev/article-resources-scheduling](https://github.com/jimyag/k8sdev/tree/3cfa654905a750b1716de8c32aa42d7573220954/article-resources-scheduling)。先按该目录 README 的固定提交准备步骤创建独立工作目录，再运行实验。

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

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-resources-scheduling/run.py#L47-L59" >}}

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

```text
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。记录的事件包含：

```text
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、扩容节点、修正标签与容忍，还是为关键业务设置优先级。

