Kubernetes 基础体系 · 第 10/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes 调度器:Queue、Filter、Score、Bind、插件和扩展

Kubernetes 调度器(kube-scheduler)负责为尚未绑定节点的 Pod 选择一个合适的节点,并把这个选择写回 Kubernetes API。它解决的不是“启动容器”,而是一个带约束的资源分配问题:

在当前集群状态下,为待调度 Pod 找到一个满足硬约束、并且在软约束下尽可能合适的节点,然后完成绑定。

调度器不负责创建 Pod 的容器。节点上的 kubelet 观察到 Pod 已经绑定到自己的节点后,才会负责拉取镜像、创建 Pod Sandbox 和容器。

本文使用的术语主要来自 Kubernetes Scheduling Framework 和稳定的 kube-scheduler 配置 API。具体插件集合、调度配置字段和实验性能力可能随 Kubernetes 版本变化;生产环境应以所部署版本的官方 API 文档和组件配置为准。


一、先建立全局模型:Pod 如何从等待变成已绑定

一个 Pod 的典型调度路径如下:

flowchart LR
    A[Pod 创建<br/>spec.nodeName 为空] --> B[Scheduling Queue]
    B --> C[Scheduling Cycle]
    C --> D[PreFilter]
    D --> E[Filter<br/>逐节点判断]
    E -->|没有可行节点| F[失败记录/可能触发抢占]
    F --> B
    E -->|得到 feasible nodes| G[Score<br/>逐节点打分]
    G --> H[Reserve/Permit]
    H --> I[PreBind]
    I --> J[Bind<br/>写入 Pod 绑定关系]
    J --> K[PostBind]
    K --> L[kubelet 在目标节点运行 Pod]

这张图需要区分两种“状态”:

  1. 调度器内部状态:队列、调度缓存、假设中的节点资源占用。
  2. API Server 中的实际状态:Pod 的 spec.nodeName、Binding 请求、Node 和其他对象的最新数据。

调度器为了性能不会在每次调度前都从 API Server 完整读取全部对象。它通过 Informer 监听对象变化,维护本地缓存;调度过程中又会暂时“假设”某个 Pod 已经占用目标节点资源。真正绑定时,如果 API Server 或其他调度周期已经改变了集群状态,绑定可能失败或后续 kubelet 可能无法正常运行该 Pod。

一个更形式化的表达是:

给定待调度 Pod pp 和候选节点集合 NN,调度器先计算可行集合:

F(p)={nN所有硬约束都满足}F(p) = \{n \in N \mid \text{所有硬约束都满足}\}

如果 F(p)F(p) 为空,当前调度周期不能直接绑定。若 F(p)F(p) 非空,则对每个可行节点计算软约束得分:

S(n,p)=i=1kwisi(n,p)S(n,p) = \sum_{i=1}^{k} w_i \cdot s_i(n,p)

其中:

  • si(n,p)s_i(n,p) 是第 ii 个 Score 插件对节点的归一化得分,通常为 0 到 100;
  • wiw_i 是该插件的权重;
  • kk 是启用的 Score 插件数量。

最终选择得分最高的节点。得分相同并不表示存在逻辑错误,调度器会使用内部的打破平局逻辑选择其中一个节点。


二、Queue:调度器为什么不是看到 Pod 就立即调度

2.1 Scheduling Queue 的职责

调度队列保存“当前需要尝试调度”的 Pod。一个 Pod 通常在以下情况下进入队列:

  • Pod 创建时没有设置 spec.nodeName
  • Pod 因资源不足、亲和性冲突等原因调度失败后被重新排队;
  • Node、Pod、PVC、StorageClass、Taint 等相关对象发生变化,可能使它重新变得可调度;
  • 发生抢占或调度器内部状态变化,需要再次尝试。

队列不是简单的先进先出队列。默认调度器会根据 Pod 优先级等信息决定取出顺序。高优先级 Pod 通常会先于低优先级 Pod 尝试调度,但这不代表高优先级 Pod 一定可以抢占已经运行的 Pod,也不代表它可以绕过所有硬约束。

Pod 的优先级来自 PriorityClass。例如:

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: batch-high
value: 100000
globalDefault: false
description: "示例:较高优先级的批处理 Pod"
---
apiVersion: v1
kind: Pod
metadata:
  name: batch-job
spec:
  priorityClassName: batch-high
  containers:
  - name: worker
    image: busybox:1.36
    command: ["sh", "-c", "sleep 3600"]

创建后可以检查 Pod 的优先级:

kubectl apply -f priority-pod.yaml
kubectl get pod batch-job -o jsonpath='{.spec.priorityClassName}{"\n"}'
kubectl get pod batch-job -o wide

如果没有足够资源,Pod 可能长期处于 Pending。这时 kubectl describe pod batch-job 的 Events 通常会显示类似:

0/3 nodes are available: 3 Insufficient cpu.

具体文字会因插件、版本和集群状态不同而变化,不能把事件文本当作稳定 API。

2.2 三类队列状态

调度器内部通常将 Pod 分为三类队列状态:

  • ActiveQ:当前可以被调度循环取出的 Pod;
  • BackoffQ:刚刚调度失败,经过短暂退避后再尝试;
  • UnschedulableQ:当前没有明显机会成功,需要等待相关集群事件或周期性重新检查。

这样设计的原因是避免失败 Pod 忙等并反复占用调度器 CPU。例如,一个 Pod 因为所有节点都缺少 2 GiB 内存而失败,如果集群没有任何资源变化,让它每毫秒重试一次没有意义。

当 Node 增加资源、节点标签变化、Pod 被删除释放资源,或相关对象发生变化时,调度器会尝试把受影响的 Pod 从不可调度状态重新移回可调度队列。较新的调度器还支持基于事件的 Queueing Hint,以减少无关事件导致的重复重试;这一能力的默认行为和可用扩展点需要结合具体 Kubernetes 版本确认。

2.3 队列顺序不等于最终节点选择

Queue 只决定“先调度哪个 Pod”,不决定“这个 Pod 使用哪个节点”。

例如:

  • Pod A 优先级高,但所有节点都没有足够内存;
  • Pod B 优先级低,但有一个节点满足它的约束。

Pod A 可能先被取出,但仍然会调度失败;随后 Pod B 可能成功。优先级影响调度顺序和抢占候选,不能把不满足条件的节点变成可用节点。


三、Filter:硬约束如何把节点变成可行集合

3.1 Filter 是合取条件

Filter 阶段针对候选节点逐个判断。一个节点只有通过所有启用的 Filter 插件,才会进入可行节点集合。

如果有三个硬约束:

  • 节点必须有 disktype=ssd
  • 节点不能带有未被 Pod 容忍的 dedicated=gpu:NoSchedule 污点;
  • 节点必须有足够的 CPU 和内存;

那么节点可行的条件是:

LabelMatch(n,p)TaintTolerated(n,p)ResourcesFit(n,p)\text{LabelMatch}(n,p) \land \text{TaintTolerated}(n,p) \land \text{ResourcesFit}(n,p)

任何一项为假,节点就会被排除。Filter 不是加权投票:一个节点即使 CPU 资源非常充足,只要不满足必需的节点亲和性,也不能靠“高分”挽救。

3.2 常见内置 Filter 插件与 API 约束的关系

常见内置插件包括:

  • NodeUnschedulable:排除标记为不可调度的节点;
  • NodeName:处理 spec.nodeName 约束;
  • NodeAffinity:处理 nodeSelector 和节点亲和性;
  • TaintToleration:检查污点和容忍;
  • NodePorts:检查主机端口冲突;
  • NodeResourcesFit:检查 CPU、内存、扩展资源等资源请求;
  • VolumeBinding:结合 PVC、PV 和拓扑完成卷绑定约束;
  • InterPodAffinity:处理 Pod 间亲和性和反亲和性;
  • PodTopologySpread:处理拓扑域分布约束。

例如,下面的 Pod 要求节点同时满足标签和容忍条件:

apiVersion: v1
kind: Pod
metadata:
  name: ssd-workload
spec:
  nodeSelector:
    disktype: ssd
  tolerations:
  - key: dedicated
    operator: Equal
    value: gpu
    effect: NoSchedule
  containers:
  - name: app
    image: nginx:1.27
    resources:
      requests:
        cpu: "500m"
        memory: "256Mi"

对应节点至少需要:

kubectl label node worker-1 disktype=ssd
kubectl taint node worker-1 dedicated=gpu:NoSchedule

此时:

  • nodeSelector 由节点标签匹配插件处理;
  • tolerations 只允许该 Pod 忽略指定污点;
  • 容忍污点并不会自动让 Pod 获得 GPU,也不会增加 CPU 或内存;
  • 如果 Pod 没有声明足够的资源请求,NodeResourcesFit 可能依据较小的请求判断它可放置,但运行时资源消耗仍可能超过请求,造成资源争用。

3.3 Filter 的完整算例

假设有三个节点:

节点 标签 污点 可分配 CPU 可分配内存
n1 zone=a, disktype=ssd 2 4 GiB
n2 zone=b, disktype=hdd 8 16 GiB
n3 zone=a, disktype=ssd dedicated=gpu:NoSchedule 8 16 GiB

Pod 的约束:

nodeSelector:
  disktype: ssd
resources:
  requests:
    cpu: "4"
    memory: "8Gi"

且没有容忍 dedicated=gpu

逐步判断:

  1. n1disktype=ssd 满足,但 CPU 2 小于请求 4,Filter 失败;
  2. n2 的 CPU 和内存满足,但标签是 hdd,Filter 失败;
  3. n3 的标签和资源满足,但存在未容忍的 NoSchedule 污点,Filter 失败。

因此:

F(p)=F(p)=\varnothing

Score 阶段根本不会运行,因为没有可评分的节点。

一个常见误解是“给节点加上高分标签可以解决调度失败”。不能。标签通常只是让某些节点进入或离开可行集合;如果所有节点都被硬约束排除,Score 没有输入。

3.4 全量检查与提前结束

调度器可以配置 percentageOfNodesToScore,允许在找到足够多的可行节点后停止继续扫描。它的目的在大规模集群中减少一次调度周期检查的节点数量。

这会带来一个重要边界:

  • Filter 逻辑仍然必须正确;
  • 但一次调度周期可能只检查部分节点;
  • Score 只在本次找到的可行节点上运行;
  • 如果可行节点分布不均,提前结束可能影响最终选择质量。

因此,这个参数是性能与搜索完整性的取舍,不是改变硬约束语义的开关。小型集群通常不必为了追求极限性能而随意降低它;大规模集群需要结合调度延迟、节点分布和工作负载测试。


四、Score:可行节点之间如何比较

4.1 Score 只处理软偏好

Filter 得到可行节点后,Score 插件对这些节点打分。例如:

  • 更倾向于把 Pod 放到剩余资源较多的节点;
  • 更倾向于使用已经存在镜像的节点;
  • 更倾向于满足 preferred node affinity;
  • 更倾向于让副本分散到不同可用区;
  • 更倾向于减少 Pod 之间的冲突。

Score 不应该承担必须满足的条件。若某节点“绝不能使用”,应该使用硬约束,例如 requiredDuringSchedulingIgnoredDuringExecution、污点或资源限制,而不是给该节点一个低分。

4.2 单个插件的归一化与权重

不同插件的原始计算方式不同,因此框架会将插件得分转换到统一范围,通常是 0 到 100。然后乘以权重:

S(n,p)=wresourcesresource(n,p)+wspreadsspread(n,p)+waffinitysaffinity(n,p)S(n,p)=w_{\text{resource}}s_{\text{resource}}(n,p) +w_{\text{spread}}s_{\text{spread}}(n,p) +w_{\text{affinity}}s_{\text{affinity}}(n,p)

例如两个可行节点:

节点 资源插件得分 拓扑插件得分
n1 90 20
n2 60 80

若两个插件权重都是 1:

  • n1 = 90 + 20 = 110
  • n2 = 60 + 80 = 140

选择 n2

若资源插件权重为 3,拓扑插件权重为 1:

  • n1 = 3×90 + 20 = 290
  • n2 = 3×60 + 80 = 260

选择变成 n1。这说明权重不是装饰性配置,它改变了偏好之间的相对优先级。

但这仍然不能违反 Filter 的硬约束。若 n1 不满足必要污点容忍条件,它不会进入上述计算。

4.3 NodeResourcesFit 的打分策略

NodeResourcesFit 不仅可以做资源可行性检查,也可以参与打分。常见资源打分策略包括:

  • LeastAllocated:倾向于选择剩余资源较多的节点;
  • MostAllocated:倾向于把工作负载集中到已有负载较高的节点;
  • RequestedToCapacityRatio:根据请求量与容量的比例使用自定义函数。

一个调度配置示例:

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: resource-aware
  plugins:
    score:
      enabled:
      - name: NodeResourcesFit
        weight: 2
  pluginConfig:
  - name: NodeResourcesFit
    args:
      scoringStrategy:
        type: MostAllocated
        resources:
        - name: cpu
          weight: 1
        - name: memory
          weight: 1

这里的含义是:

  1. NodeResourcesFit 仍然可以参与资源 Filter;
  2. 在 Score 阶段使用 MostAllocated
  3. 该插件最终得分乘以权重 2;
  4. 这只是调度器配置片段,必须由 kube-scheduler 进程加载,不能通过创建一个普通 Pod 动态改变集群调度器。

MostAllocated 可能提高节点利用率,但也可能让少数节点更快耗尽,降低故障域冗余。LeastAllocated 可能改善资源分散,却增加跨节点扩散和镜像拉取成本。没有脱离工作负载和集群拓扑的普遍最优策略。

4.4 Spread 不是绝对平均

Pod Topology Spread 约束通常表达的是“尽量均匀”或“不能超过最大偏差”,而不是保证每个拓扑域恰好拥有相同数量的 Pod。

例如三个可用区当前副本数为:

[3,1,1][3,1,1]

再调度一个副本时,如果 maxSkew=1 且约束为硬约束,目标通常只能选择当前数量为 1 的区域,使结果成为:

[3,2,1][3,1,2][3,2,1]\quad\text{或}\quad[3,1,2]

不能选择数量为 3 的区域,因为偏差会继续增大。若约束是软约束,插件可能只对更均匀的区域给高分,但在资源或其他高权重偏好作用下仍可能选择次优位置。

拓扑分布还依赖节点是否正确标注拓扑标签、哪些节点被纳入统计,以及 nodeAffinityPolicynodeTaintsPolicy 等版本相关配置。云厂商提供的区域和故障域标签也可能存在命名或语义差异,不能假设所有集群完全一致。


五、Bind:选择节点不等于绑定完成

5.1 Bind 的实际动作

当调度器完成节点选择后,默认绑定插件会通过 Kubernetes API 写入 Pod 与节点的绑定关系。概念上等价于设置:

spec:
  nodeName: worker-2

实际实现通常使用 Pod 的 binding 子资源,而不是简单地由客户端直接更新整个 Pod 对象。API Server 接受绑定后,kubelet 通过监听 Pod 变化发现它属于自己的节点。

绑定成功后,可以观察:

kubectl get pod ssd-workload -o wide
kubectl get pod ssd-workload -o jsonpath='{.spec.nodeName}{"\n"}'

spec.nodeName 为空表示尚未绑定;非空表示调度器已经完成节点绑定,但不表示容器一定已经启动。还需要检查:

kubectl get pod ssd-workload
kubectl describe pod ssd-workload
kubectl get events --sort-by=.lastTimestamp

5.2 Reserve、Permit 和 Bind 之间的保护

在真正 Bind 前,调度框架还可能经过以下扩展点:

  • Reserve:在调度器内部预留资源或记录状态;
  • Permit:允许插件暂缓绑定,例如等待一组 Pod 一起决定;
  • PreBind:绑定前执行额外检查或准备;
  • Bind:执行实际绑定;
  • PostBind:绑定成功后执行通知或清理。

“假设”是调度器避免并发冲突的重要机制。假设某个 Pod 要放到节点 n1 后,后续调度周期会把它视为已经占用对应资源,即使 API Server 中的绑定请求尚未完成。

如果后续阶段失败,框架需要调用相应的取消或清理逻辑,例如:

  • Reserve 成功但 PreBind 失败,应执行 Unreserve;
  • Permit 等待超时,应释放相关状态;
  • Bind API 请求失败,应重新入队或按错误类型处理。

一个插件如果只修改了外部系统,却没有在失败路径中撤销修改,就可能产生“调度器认为资源已释放、外部系统认为资源仍被占用”的状态泄漏。

5.3 并发和陈旧信息

调度器本地缓存可能与 API Server 存在短暂延迟,多个调度周期也可能交错运行。调度器通过缓存、假设状态和 API Server 的最终写入来降低冲突,但插件不能把本地观察结果当作强一致锁。

例如:

  1. 调度器根据缓存认为节点 n1 还有 4 CPU;
  2. 调度 Pod A,假设它占用 3 CPU;
  3. 另一个调度路径或控制器同时改变了相关对象;
  4. Pod A 的绑定或后续启动可能失败;
  5. 调度器重新观察状态并重试,或把问题交给 kubelet、控制器和事件系统处理。

这也是为什么自定义插件不能只在内存中做一个永久资源分配表。该表必须能够处理重启、绑定失败、对象删除和事件延迟。


六、插件:调度器框架把流程拆成了哪些扩展点

Scheduling Framework 将调度流程暴露为插件扩展点。常见生命周期如下:

扩展点 作用
QueueSort 决定队列中 Pod 的排序
PreEnqueue Pod 入队前判断是否允许进入调度队列
PreFilter 在遍历节点前计算或校验 Pod 级状态
Filter 判断单个节点是否满足硬约束
PostFilter 没有可行节点时处理,例如抢占
PreScore 评分前准备共享数据
Score 对单个可行节点打分
NormalizeScore 将同一插件的得分归一化
Reserve 在调度器内部预留资源或状态
Permit 暂停、拒绝或允许继续绑定
PreBind 绑定前的检查或外部准备
Bind 执行绑定
PostBind 绑定成功后的处理
Unreserve 后续失败时撤销 Reserve

PreFilterPreScore 的价值在于共享计算结果。假设 Pod 的反亲和性规则很复杂,如果每个节点都重新解析一次规则,成本会很高;插件可以在 PreFilter 中先计算 Pod 级状态,再由 Filter 快速判断每个节点。

6.1 Filter 插件的伪代码模型

一个 Filter 插件的逻辑类似:

func (p *Plugin) Filter(
    ctx context.Context,
    state *framework.CycleState,
    pod *v1.Pod,
    nodeInfo *framework.NodeInfo,
) *framework.Status {
    if !matchesLabels(pod, nodeInfo.Node().Labels) {
        return framework.NewStatus(
            framework.Unschedulable,
            "required node labels are not matched",
        )
    }

    if !fitsResources(pod, nodeInfo) {
        return framework.NewStatus(
            framework.Unschedulable,
            "requested resources do not fit",
        )
    }

    return nil // Success
}

真实插件还要处理:

  • 节点对象为空或缓存信息不完整;
  • Pod 使用扩展资源;
  • PVC 和拓扑绑定;
  • 预留状态;
  • 插件内部错误与普通“不适合”之间的区别。

Unschedulable 表示这个节点不适合,但可能在未来重试;Error 表示插件执行本身发生异常,通常需要更谨慎地处理和观测。不能把所有异常都伪装成普通不适合,否则会掩盖插件故障。

6.2 Score 插件的伪代码模型

func (p *Plugin) Score(
    ctx context.Context,
    state *framework.CycleState,
    pod *v1.Pod,
    nodeName string,
) (int64, *framework.Status) {
    nodeInfo, err := p.handle.SnapshotSharedLister().NodeInfos().Get(nodeName)
    if err != nil {
        return 0, framework.AsStatus(err)
    }

    score := calculatePreference(pod, nodeInfo)
    return score, nil
}

插件应返回框架要求范围内的分数,并正确实现归一化。若插件依赖外部系统,例如 IPAM、机架库存或 GPU 编排服务,还必须定义超时、不可达和重试行为。一个阻塞整个 Score 阶段的外部 HTTP 请求,会把外部系统延迟放大为所有 Pod 的调度延迟。

6.3 插件如何获得集群信息

插件通常通过 Framework Handle 获取:

  • SharedInformer 相关缓存;
  • 调度器快照;
  • Kubernetes Client;
  • EventRecorder;
  • Framework 配置和周期状态。

插件不应在每次 Filter 调用中直接向 API Server 发请求查询节点。因为一次调度周期可能对大量节点调用 Filter,这会形成 API Server 请求风暴,并且使调度结果受网络延迟影响。


七、Profile:一套调度器如何提供多种调度策略

7.1 Profile 与 schedulerName

一个 kube-scheduler 进程可以配置多个 Scheduling Profile。Pod 通过 spec.schedulerName 选择 Profile:

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
- schedulerName: resource-aware
  plugins:
    score:
      enabled:
      - name: NodeResourcesFit
        weight: 2
  pluginConfig:
  - name: NodeResourcesFit
    args:
      scoringStrategy:
        type: MostAllocated
        resources:
        - name: cpu
          weight: 1
        - name: memory
          weight: 1

使用第二个 Profile 的 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: profile-demo
spec:
  schedulerName: resource-aware
  containers:
  - name: app
    image: nginx:1.27
    resources:
      requests:
        cpu: "100m"
        memory: "128Mi"

检查:

kubectl apply -f scheduler-config.yaml
kubectl apply -f profile-demo.yaml
kubectl get pod profile-demo -o jsonpath='{.spec.schedulerName}{"\n"}'
kubectl get pod profile-demo -o wide

scheduler-config.yaml 不会被 kubectl apply 自动应用到正在运行的 kube-scheduler。实际部署时必须让 kube-scheduler 进程以该配置文件启动,或修改静态 Pod、控制平面部署或托管平台提供的调度器配置入口。不同发行版的启动方式差异很大。

7.2 多 Profile 的限制

多 Profile 并不是多个完全隔离的调度器:

  • 它们通常共享同一个调度器进程及其缓存;
  • Pod 仍然写入同一个集群状态;
  • 一个 Profile 的策略不能保证其他 Profile 不把 Pod 放到相同节点;
  • QueueSort 对队列有全局约束,多个 Profile 不能任意定义互相冲突的队列排序逻辑;
  • 一个 Pod 只能通过一个 schedulerName 选择一个 Profile。

如果真正需要独立的调度队列、独立故障域或独立生命周期,可以运行多个 scheduler 实例,但这会增加 leader election、资源竞争、监控和故障排查复杂度。多调度器还必须明确各自处理哪些 schedulerName,避免配置错误导致 Pod 永远无人调度。


八、扩展方式:内置插件、自定义插件和 Extender

8.1 修改配置不等于写代码

内置插件可以通过 KubeSchedulerConfiguration 启用、禁用和配置。它们随 kube-scheduler 二进制发布,通常具有较好的版本兼容性。

如果需要新的调度逻辑,主要有两种路线:

  1. Scheduling Framework 插件
    使用 Go 实现,编译进 kube-scheduler 或基于带插件的调度器二进制。它可以接入 Filter、Score、Reserve、Bind 等生命周期,功能最完整、延迟也通常更可控。

  2. Scheduler Extender
    通过 HTTP 与外部服务交互。它不需要把代码编译进调度器,但边界较老,适合已有 extender 集成或某些外部资源决策场景。新设计通常应优先评估 Framework 插件。

Kubernetes 没有一个可以把任意二进制“热插入”正在运行的官方动态调度插件机制。所谓“安装调度插件”,通常意味着更换或构建调度器镜像、修改启动参数,并进行版本测试。

8.2 Extender 的基本模型

Extender 由 kube-scheduler 调用外部 HTTP 服务,传统上可以参与:

  • 过滤节点;
  • 给节点打分;
  • 提供抢占相关决策;
  • 绑定动作。

一个概念性的配置片段如下:

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
extenders:
- urlPrefix: "http://scheduler-extender.example.svc:8080"
  filterVerb: "filter"
  prioritizeVerb: "prioritize"
  weight: 5
  nodeCacheCapable: true
  ignorable: false

字段是否可用、请求格式和具体版本行为必须以目标 Kubernetes 版本为准。Extender 的关键风险是:

  • 调度器依赖外部网络服务;
  • HTTP 延迟直接进入调度路径;
  • 外部服务不可达时,ignorable 决定是忽略还是影响调度;
  • 失败重试和超时可能造成大量 Pending Pod;
  • 外部服务看到的集群状态也可能陈旧;
  • Extender 与 Framework 插件同时启用时,约束和得分叠加关系需要实际验证。

ignorable: true 并不等于“故障无影响”。它可能让调度器在外部策略失效时继续放置 Pod,从而绕过原本依赖 Extender 的资源安全约束。


九、没有可行节点时:调度失败与抢占

Filter 结束后没有可行节点时,调度器会记录失败原因并让 Pod 保持 Pending。常见原因包括:

  • Insufficient cpuInsufficient memory
  • 节点标签不满足;
  • 未容忍污点;
  • Pod 间反亲和性冲突;
  • PVC 无法绑定到满足拓扑条件的 PV;
  • 拓扑分布硬约束无法满足。

如果 Pod 优先级允许,调度器可能进入 PostFilter 的抢占流程。抢占的思路是:

  1. 在候选节点上寻找可以被低优先级 Pod 让出的受害者;
  2. 模拟删除这些受害者后,判断待调度 Pod 是否满足约束;
  3. 选择代价较小的节点和受害者集合;
  4. 设置相关状态并删除受害者;
  5. 等待资源真正释放后重新调度高优先级 Pod。

抢占不是强制成功机制。以下情况仍可能抢占失败:

  • 受害者被删除后,Pod 亲和性、反亲和性或拓扑约束仍不满足;
  • 资源释放不够;
  • Pod 有 preemptionPolicy: Never
  • 其他调度周期先占用了资源;
  • PDB、控制器重建或拓扑条件使候选方案无效。

抢占还可能导致低优先级工作负载被驱逐、重新创建,并增加业务抖动。因此优先级设计应反映真实业务等级,而不是简单给所有关键服务设置极高数值。


十、一个可验证的调度约束实验

下面的实验不需要修改 scheduler 配置,可以直接观察 Queue、Filter 和 Bind 的结果。

先查看节点标签和资源:

kubectl get nodes --show-labels
kubectl describe node <node-name>

给一个节点添加标签:

kubectl label node <node-name> topology-demo=enabled

创建 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: scheduling-lifecycle-demo
spec:
  nodeSelector:
    topology-demo: enabled
  containers:
  - name: nginx
    image: nginx:1.27
    resources:
      requests:
        cpu: "100m"
        memory: "128Mi"

执行:

kubectl apply -f scheduling-lifecycle-demo.yaml
kubectl get pod scheduling-lifecycle-demo -w
kubectl describe pod scheduling-lifecycle-demo
kubectl get pod scheduling-lifecycle-demo \
  -o jsonpath='{.spec.nodeName}{"\n"}'

预期过程是:

  1. Pod 创建后 spec.nodeName 为空,进入调度队列;
  2. Filter 排除没有 topology-demo=enabled 的节点;
  3. 如果至少有一个节点满足资源等其他条件,Score 在这些节点中进行选择;
  4. Bind 成功后 spec.nodeName 出现目标节点名称;
  5. kubelet 开始创建容器。

删除标签后再创建同样的 Pod:

kubectl label node <node-name> topology-demo-
kubectl delete pod scheduling-lifecycle-demo
kubectl apply -f scheduling-lifecycle-demo.yaml
kubectl describe pod scheduling-lifecycle-demo

此时可能看到:

0/N nodes are available: N node(s) didn't match Pod's node affinity/selector.

该 Pod 不会因为等待时间变长而自动放宽 nodeSelector。只有节点标签变化、约束变化或其他相关事件使条件重新满足,它才可能成功;否则会一直 Pending。


十一、诊断:从 Pending 事件回到调度阶段

11.1 先确认 Pod 是否真的需要调度

kubectl get pod <pod> -o jsonpath='
schedulerName={.spec.schedulerName}
nodeName={.spec.nodeName}
phase={.status.phase}{"\n"}'

以下情况不应归因于调度器:

  • spec.nodeName 已经被手动设置;
  • Pod 已绑定节点但容器处于 ImagePullBackOff
  • Pod 因 CNI、挂载、探针或应用进程失败而未 Ready;
  • Pod 使用了不存在的 schedulerName,导致没有匹配的 Profile 处理它。

11.2 查看调度事件和节点事实

kubectl describe pod <pod>
kubectl get events --field-selector involvedObject.name=<pod> \
  --sort-by=.lastTimestamp
kubectl get nodes
kubectl describe node <node>

重点对照:

  • Pod 的 CPU、内存和扩展资源 requests
  • Node 的 allocatable,而不是只看物理内存;
  • nodeSelector、required/preferred affinity;
  • 污点和容忍;
  • PVC、PV 与 StorageClass 的拓扑;
  • 现有 Pod 的反亲和性和拓扑分布;
  • 节点是否 SchedulingDisabled

11.3 判断是硬约束失败还是软偏好结果

如果事件显示所有节点都因为同一硬约束失败,应修改标签、污点、资源或 Pod 约束,而不是调整 Score 权重。

如果多个节点都可行,但 Pod 总是去某一类节点,才适合检查:

  • Profile 是否正确;
  • Score 插件是否启用;
  • 插件权重;
  • MostAllocatedLeastAllocated 等策略;
  • 拓扑分布约束是否为硬约束或软约束;
  • 是否启用了 percentageOfNodesToScore
  • 多个 scheduler 或 Profile 是否共同处理 Pod。

生产环境还应观察 kube-scheduler 的调度延迟、队列长度、失败原因和插件耗时指标。不能只看 Pod 最终落在哪个节点,因为最终位置无法单独证明每个插件的判断过程。


十二、生产边界与常见误解

12.1 requests 决定调度视图,实际使用可能不同

NodeResourcesFit 主要依据 Pod 的资源请求与节点可分配资源判断。容器实际使用量可能高于请求,也可能长期低于请求。

因此:

  • 请求过低,调度器会高估可用容量;
  • 请求过高,Pod 可能长时间 Pending;
  • Limit 不等于调度器可用容量;
  • 内存超用可能导致 OOM,CPU 超用可能导致 throttling,但这些不是 Score 阶段自动修正的结果。

12.2 Taint 和 Toleration 不是双向绑定

Taint 表示节点排斥某类 Pod,Toleration 表示 Pod 可以接受该排斥。容忍污点并不表示调度器偏好该节点,也不表示该 Pod 拥有节点上的专用硬件。

对于 GPU 等资源,通常还需要:

  • 节点扩展资源;
  • Pod 的资源请求;
  • 设备插件;
  • 可能的节点标签、污点和容忍。

12.3 调度成功不等于业务可用

Bind 成功只说明 Pod 已分配节点。后续仍可能因为以下原因失败:

  • 镜像不存在或无法拉取;
  • CNI 无法配置网络;
  • CSI 无法挂载卷;
  • 容器启动命令错误;
  • 节点 kubelet 异常;
  • 应用探针失败。

调度器负责的是放置决策,不是整个 Pod 生命周期。

12.4 nodeName 会绕过调度器

直接设置:

spec:
  nodeName: worker-1

会让 Pod 被指定到节点,通常不经过正常调度选择。这样可能绕过资源选择、亲和性、污点策略和自定义插件,除非有明确的底层控制器或特殊工作负载需求,否则不应把它当作普通调度约束替代品。

12.5 自定义调度策略必须测试失败路径

调度插件至少要测试:

  • 没有可行节点;
  • 节点对象变化;
  • Pod 删除和重建;
  • Reserve 成功后 Bind 失败;
  • 外部依赖超时;
  • 调度器重启;
  • 多个调度周期并发;
  • 节点数量和 Pod 数量扩大后的耗时;
  • 版本升级后的框架接口和默认插件变化。

尤其是 Bind、Reserve 和外部系统整合。只验证“正常时能把 Pod 放到目标节点”远远不够;生产故障往往发生在取消、超时、重试和状态恢复路径。

Kubernetes 调度器可以概括为一条有明确边界的流水线:

QueueFilterScoreReserve/PermitBind\text{Queue} \rightarrow \text{Filter} \rightarrow \text{Score} \rightarrow \text{Reserve/Permit} \rightarrow \text{Bind}

Queue 决定先处理哪个 Pod,Filter 决定哪些节点根本不合格,Score 在合格节点中表达偏好,Bind 把选择写入集群状态;插件和 Profile 则允许在这些阶段改变或扩展决策。理解这几个阶段之间的因果关系,才能正确判断一个问题究竟是队列等待、硬约束冲突、软偏好差异、绑定失败,还是绑定成功后的节点运行故障。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。