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

Kubernetes 调度性能与扩展:Profile、Plugin、Queue、规模和测试

Kubernetes 调度器(kube-scheduler)的职责,是为尚未绑定节点的 Pod 选择一个满足约束、资源和策略要求的节点,并将调度结果写回 Pod 的 spec.nodeName。它不是一个单纯的“遍历节点并打分”的函数,而是一个由缓存、队列、调度框架、插件、绑定流程和 API Server 交互组成的并发系统。

要讨论调度性能,必须同时回答五个问题:

  1. 一个 Pod 如何进入、停留和离开调度队列?
  2. 一个调度周期如何从候选节点中筛选并选择节点?
  3. Profile 和 Plugin 如何改变调度器行为?
  4. 节点、Pod、约束和并发规模如何影响复杂度?
  5. 如何证明扩展没有破坏正确性,并测量优化是否真的有效?

下文基于当前稳定的 Kubernetes 调度框架 API 和配置模型说明机制。具体插件参数、指标名称和默认值可能随 Kubernetes 版本变化;生产环境应以所运行版本的官方 API 文档和 /metrics 输出为准。


一、从 Pod 到 Node:调度器的完整数据流

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

flowchart TD
    A[API Server 中的 Pending Pod] --> B[Shared Informer / Scheduler Cache]
    B --> C[Scheduling Queue]
    C --> D[PreEnqueue]
    D --> E[Scheduling Cycle]
    E --> F[PreFilter]
    F --> G[Filter: 过滤节点]
    G --> H{存在可行节点?}
    H -- 否 --> I[PostFilter / Unschedulable]
    I --> J[UnschedulableQ 或 BackoffQ]
    J --> C
    H -- 是 --> K[PreScore]
    K --> L[Score]
    L --> M[NormalizeScore]
    M --> N[选择最高分节点]
    N --> O[Reserve]
    O --> P[Permit]
    P --> Q[PreBind]
    Q --> R[Bind]
    R --> S[PostBind]
    S --> T[Pod 获得 spec.nodeName]

这个流程包含两类容易混淆的状态:

  • 调度器缓存中的状态:来自 Node、Pod、PV、Service、存储拓扑等 Informer 事件。
  • API Server 中的真实对象状态:调度器最终必须通过 API 请求完成绑定或更新。

缓存用于提高读取性能,但不是绝对实时的。因此调度器必须接受“缓存观察到的状态”和“API Server 当前状态”之间存在短暂差异。绑定阶段还可能因为资源版本冲突、节点消失或其他控制器修改对象而失败。

1. Scheduling Cycle 和 Binding Cycle

调度器通常一次为一个 Pod 执行调度周期(Scheduling Cycle),包括:

  1. 从队列取出 Pod;
  2. 运行过滤插件;
  3. 运行打分插件;
  4. 选择节点;
  5. 执行保留和许可逻辑。

绑定周期(Binding Cycle)负责:

  1. 执行 PreBind
  2. 执行 Bind
  3. 执行 PostBind
  4. 最终将 Pod 绑定到节点。

调度周期在逻辑上是串行的:同一时间只有一个 Pod 处于选择节点的主调度过程。这种串行化有利于一致性,尤其是插件需要依据调度器缓存执行资源假设时。绑定阶段可以异步执行,因此绑定延迟不一定阻塞下一个 Pod 的完整调度周期。

这也解释了一个常见现象:CPU 很空闲,但调度吞吐仍然不高。瓶颈可能在:

  • 某个串行调度插件;
  • 队列锁竞争;
  • API Server 绑定请求;
  • 缓存事件处理;
  • 大量候选节点的过滤和打分;
  • 插件等待 Permit
  • 失败 Pod 的退避和重入队列。

二、Queue:调度器如何管理 Pending Pod

1. 三种核心队列状态

调度队列通常由三部分组成:

  • ActiveQ:当前可被调度器取出的 Pod。
  • BackoffQ:最近调度失败、处于退避窗口内的 Pod。
  • UnschedulableQ:当前没有可行节点,且暂时没有明确事件触发重试的 Pod。

可以将一个 Pod 的典型状态抽象为:

New
  └─> ActiveQ
        ├─> Scheduling Cycle 成功 ──> Bind
        ├─> 调度失败且需要退避 ─────> BackoffQ
        └─> 无可行节点 ────────────> UnschedulableQ

ActiveQ 通常使用堆结构,堆的优先级由 QueueSort 插件决定。队列元素不是“越早创建越优先”这一种固定规则,而是可以由优先级、创建时间、PodGroup 信息或其他策略计算。

BackoffQ 的目的是防止一个必然失败的 Pod 在短时间内反复占用调度器。退避时间通常会随连续失败次数增加,但有上限。具体退避参数和默认值属于版本实现细节,不应在业务逻辑中假设固定数字。

UnschedulableQ 中的 Pod 并不一定要等到固定定时器才重试。节点资源、污点、PV、Pod 删除等事件可能通过插件注册的事件关系,将相关 Pod 移回 ActiveQ

2. 为什么事件注册会影响性能和正确性

假设一个 Pod 因为没有满足 Node Affinity 的节点而不可调度:

  • 如果后来创建了一个满足 Affinity 的 Node;
  • 事件处理逻辑能够识别该 Node 变化与此类 Pod 相关;
  • Pod 就可以被重新加入 ActiveQ

如果插件错误地没有注册相关事件,Pod 可能长时间停留在 UnschedulableQ,直到周期性刷新或其他事件偶然触发。反过来,如果插件对所有 Node 事件都唤醒所有 Pending Pod,会造成大量无效重试。

因此,队列性能不仅取决于队列数据结构,还取决于插件对“什么变化会使一个失败结论失效”的建模质量。

3. QueueSort 的全局约束

QueueSort 决定 ActiveQ 中 Pod 的排序规则。一个重要限制是:同一个调度器实例中的不同 Profile 不能随意使用互不兼容的 QueueSort 逻辑。调度队列是共享的,调度器必须有一个一致的全局顺序。

因此,Profile 可以改变某个 Pod 的过滤、打分或绑定插件,但不能把同一共享队列变成多个互相矛盾的排序系统。需要多套完全不同队列语义时,通常应运行多个调度器实例,并让 Pod 使用不同的 schedulerName


三、Profile:同一个调度器中的多套调度策略

1. Profile 的含义

一个调度器 Profile 是一组插件配置和插件参数的组合。Pod 通过:

spec:
  schedulerName: batch-scheduler

选择某个 Profile。调度器通过 schedulerName 找到对应 Profile,然后使用该 Profile 的插件集合执行调度。

默认 Profile 的名称通常是 default-scheduler。如果 Pod 不设置 schedulerName,它会使用默认调度器名称;自定义 Profile 不会自动接管这些 Pod。

Profile 适合表达以下差异:

  • 在线服务偏好均衡;
  • 批处理任务偏好高装箱率;
  • 特定租户启用额外的拓扑约束;
  • 某类 Pod 需要许可插件;
  • 不同工作负载使用不同的打分权重。

它不等于启动多个调度器进程。多个 Profile 通常共享:

  • 调度器进程;
  • Informer;
  • Scheduler Cache;
  • 调度队列;
  • API 客户端;
  • 进程资源和故障域。

2. 一个 Profile 配置示例

下面配置使用内置插件构造两个 Profile。字段和插件参数应以目标 Kubernetes 版本的 kubescheduler.config.k8s.io/v1 API 为准。

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration

profiles:
  - schedulerName: default-scheduler
    plugins:
      score:
        disabled:
          - name: PodTopologySpread
        enabled:
          - name: NodeResourcesFit
            weight: 1

  - schedulerName: batch-scheduler
    plugins:
      score:
        enabled:
          - name: NodeResourcesFit
            weight: 5
          - name: PodTopologySpread
            weight: 1

这里的含义不是“第二个 Profile 只运行列出的插件”。插件有默认启用集合,enableddisabled 用于在默认集合基础上调整。具体启用关系由调度框架和版本默认配置决定;生产中应使用目标版本的默认配置生成结果核对,而不是只凭配置片段推断最终插件列表。

创建一个使用 batch-scheduler 的 Pod:

apiVersion: v1
kind: Pod
metadata:
  name: batch-example
spec:
  schedulerName: batch-scheduler
  restartPolicy: Never
  containers:
    - name: workload
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]
      resources:
        requests:
          cpu: "500m"
          memory: "256Mi"

前置条件是:

  1. 调度器进程加载了上面的配置;
  2. batch-scheduler Profile 存在;
  3. 集群中有满足资源和其他默认约束的节点。

如果 Profile 不存在,或者调度器实例没有运行名为 batch-scheduler 的配置,Pod 会保持 Pending,并在事件或调度器日志中表现为找不到对应调度器处理路径。schedulerName 只是路由字段,不会自动创建调度器。

3. Profile 的隔离边界

Profile 并不提供进程级隔离。一个 Profile 中的插件发生 panic、死循环或严重内存泄漏,可能影响整个调度器进程。需要不同升级节奏、不同 RBAC、不同故障域或不同资源上限时,应考虑运行独立调度器实例。

多个调度器实例还会带来新的协调问题:

  • 它们可能竞争处理同一个未绑定 Pod;
  • Pod 必须明确设置 schedulerName
  • 自定义调度器需要正确观察和更新对象;
  • 绑定冲突和重复决策需要由 API Server 及调度器逻辑处理;
  • 监控和排障复杂度增加。

四、Plugin:调度框架扩展点及其生命周期

1. Plugin 不是一个统一阶段

调度插件(Plugin)是实现调度框架接口的 Go 组件。不同扩展点表示不同生命周期阶段:

扩展点 作用
QueueSort 决定队列中的 Pod 顺序
PreEnqueue Pod 进入 ActiveQ 前进行预检查
PreFilter 调度节点前做一次性预处理
Filter 判断单个节点是否可行
PostFilter 没有可行节点时执行,例如抢占
PreScore 打分前做一次性预处理
Score 为每个可行节点计算分数
NormalizeScore 将某插件的分数归一化
Reserve 在调度器内部预留资源或状态
Permit 允许、拒绝或等待调度继续
PreBind 写入绑定前执行准备动作
Bind 执行实际绑定
PostBind 绑定完成后的处理

插件可以实现多个扩展点,但每个扩展点都应保持清晰的职责边界。

2. Filter 与 Score 的形式化模型

设节点集合为:

N={n1,n2,,nm}N = \{n_1,n_2,\ldots,n_m\}

对一个待调度 Pod pp,每个过滤插件 fjf_j 返回节点是否满足约束。定义:

F(p,ni)={1,所有 Filter 插件都允许 ni0,至少一个 Filter 插件拒绝 niF(p,n_i)= \begin{cases} 1, & \text{所有 Filter 插件都允许 } n_i \\ 0, & \text{至少一个 Filter 插件拒绝 } n_i \end{cases}

可行节点集合为:

Np={niNF(p,ni)=1}N_p = \{n_i \in N \mid F(p,n_i)=1\}

只有 NpN_p 中的节点会进入打分阶段。对某个 Score 插件 sks_k,归一化后的得分记为 Sk(p,ni)S_k(p,n_i),插件权重为 wkw_k,最终总分为:

Score(p,ni)=k=1qwkSk(p,ni)Score(p,n_i)=\sum_{k=1}^{q} w_k S_k(p,n_i)

调度器选择总分最高的节点。如果最高分相同,则使用确定性的 tie-break 规则或随机选择机制,具体行为属于实现细节,插件不能依赖某一个未公开保证的节点顺序。

3. 完整算例

假设有三个节点:

节点 剩余 CPU Zone 是否有污点
n1 2 CPU zone-a
n2 8 CPU zone-b dedicated=batch:NoSchedule
n3 4 CPU zone-a

Pod 请求 3 CPU,且没有容忍 dedicated=batch 污点。

先执行 Filter:

  • n1 剩余 2 CPU,不满足 3 CPU,拒绝;
  • n2 资源满足,但污点没有对应容忍,拒绝;
  • n3 资源满足且污点满足,保留。

因此:

Np={n3}N_p=\{n3\}

此时即使 n1n2 的 Score 更高也没有意义,因为它们已经被过滤掉。Score 只在可行节点上排序,不负责挽救一个已经违反硬约束的节点。

如果 Pod 增加:

tolerations:
  - key: dedicated
    operator: Equal
    value: batch
    effect: NoSchedule

n2 也可能进入 NpN_p。随后资源打分插件可能给 n2n3 不同分数,拓扑插件再叠加另一组分数,最终由加权和决定节点。

4. Filter 与 Score 的常见误用

错误做法是把硬约束放入 Score:

不喜欢带污点的节点:打 0 分

这不能保证节点不会被使用。只要所有节点都被打分,某个带污点节点仍可能得到最高分。硬约束必须由 Filter、Admission 校验或其他具有拒绝语义的机制实现。

另一个错误是把高代价查询放在每个节点的 Filter 中。例如每次过滤都访问 API Server、读取远程数据库或执行磁盘扫描。若有 mm 个候选节点,这类操作可能被执行 mm 次,并且处于调度关键路径中。可行的插件通常会:

  1. PreFilter 中为当前 Pod 计算一次不随节点变化的数据;
  2. Filter 中只进行轻量节点判断;
  3. 使用 Informer 或调度器缓存读取共享状态;
  4. 对需要更新的内部状态通过 ReserveUnreserve 管理生命周期。

5. 插件状态和错误回滚

插件可以通过框架提供的 CycleState 在同一个调度周期内共享预计算结果。状态不能假设跨 Pod 持久有效,因为不同 Pod 的约束和缓存视图可能不同。

如果插件在 Reserve 阶段记录了资源预留,而后续 PermitPreBind 或绑定失败,就必须实现对应的回滚逻辑,例如 Unreserve。否则插件内部可能认为资源已经被占用,实际却没有绑定成功,最终导致“虚假的资源不足”。

Permit 插件尤其容易造成隐性故障:

  • 返回等待后,必须有明确的批准路径;
  • 等待应有超时;
  • 相关 Pod 删除、调度器重启和绑定失败都要清理等待状态;
  • 不能把外部系统的不可用无限期转换成调度器线程阻塞。

五、如何编写一个插件

一个简单的 Filter 插件通常实现以下接口:

type FilterPlugin interface {
    Plugin
    Filter(
        context.Context,
        *CycleState,
        *v1.Pod,
        *NodeInfo,
    *Status
}

以下示例表达“节点必须带有 workload=trusted 标签”的逻辑:

package trustednode

import (
	"context"

	v1 "k8s.io/api/core/v1"
	"k8s.io/kubernetes/pkg/scheduler/framework"
)

const Name = "TrustedNode"

type Plugin struct{}

var _ framework.FilterPlugin = Plugin{}

func (Plugin) Name() string {
	return Name
}

func (Plugin) Filter(
	_ context.Context,
	_ *framework.CycleState,
	_ *v1.Pod,
	nodeInfo *framework.NodeInfo,
) *framework.Status {
	if nodeInfo == nil || nodeInfo.Node() == nil {
		return framework.NewStatus(
			framework.Error,
			"node information is unavailable",
		)
	}

	if nodeInfo.Node().Labels["workload"] != "trusted" {
		return framework.NewStatus(
			framework.Unschedulable,
			"node does not have workload=trusted",
		)
	}

	return nil
}

这个代码片段可以说明接口和错误语义,但它不是一个可通过 YAML 动态加载的“插件文件”。Kubernetes 调度插件是编译进调度器二进制的 Go 代码,通常需要:

  1. 使用与目标 Kubernetes 版本匹配的源码和依赖;
  2. 在调度器初始化时注册插件工厂;
  3. 将插件名称加入 Profile;
  4. 构建自定义 kube-scheduler
  5. 以正确的配置启动它;
  6. 通过集群测试验证缓存、并发和失败回滚。

调度器内部包依赖较多,直接从独立 Go 模块导入 k8s.io/kubernetes/pkg/scheduler/framework 时,模块版本、replace 规则和构建方式必须与 Kubernetes 源码树匹配。不能把上面的代码复制到普通业务服务后,期待它自动改变集群调度行为。

如果不能维护自定义调度器二进制,可评估调度器 Extender 或外部控制器。但它们的能力不同:

  • Extender 通常通过 HTTP 与调度器通信,存在序列化和网络延迟;
  • 它不等价于原生 Framework Plugin;
  • 外部控制器可以修改标签、污点或 Pod,但会引入异步一致性;
  • 通过修改 Pod 来“间接调度”时,必须避免与调度器互相触发更新循环。

六、规模如何影响调度性能

1. 基本复杂度

如果一个调度周期需要检查 mm 个节点,并运行 pp 个 Filter 插件、qq 个 Score 插件,粗略工作量可以写为:

T(pod)O(mpCf)+O(NpqCs)+Cqueue+Ccache+CbindT(pod) \approx O(m \cdot p \cdot C_f) + O(|N_p| \cdot q \cdot C_s) + C_{queue} + C_{cache} + C_{bind}

其中:

  • CfC_f:一次 Filter 的平均成本;
  • CsC_s:一次 Score 的平均成本;
  • NpN_p:过滤后的可行节点集合;
  • CqueueC_{queue}:队列操作、锁和事件处理成本;
  • CcacheC_{cache}:读取调度缓存和维护索引的成本;
  • CbindC_{bind}:绑定请求、重试和 API Server 交互成本。

这不是 Kubernetes 对所有实现的严格复杂度保证,而是分析瓶颈的模型。实际运行中还会受到 Filter 短路、节点并行度、缓存局部性、插件内部索引和绑定并发影响。

2. percentageOfNodesToScore

调度器可以配置每个 Pod 至少考虑多少比例的节点。示例:

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
percentageOfNodesToScore: 20

在节点数为 10,000 时,直觉上 20% 对应约 2,000 个节点,但实际筛选过程还会结合调度器的逐节点扫描和提前停止逻辑,不能简单理解为每次恰好只检查 2,000 个节点。

该参数的基本取舍是:

  • 比例较高:更可能找到全局更优节点,但每个 Pod 的筛选和打分成本更高;
  • 比例较低:调度吞吐可能提高,但可能错过后续节点中的更优选择;
  • 节点异构程度越高,过度降低比例越容易造成分配质量下降;
  • 节点数量较少时,该参数的收益通常有限。

例子:假设前 2,000 个节点中只有一个可行节点,但第 2,001 个节点拥有更合适的拓扑位置。如果扫描在达到目标可行节点数量后停止,调度器可能不会看到后者。这不是“错误调度”,因为它仍满足硬约束;但它可能改变成本、均衡性或碎片率。

因此,不能只根据调度延迟调低该值,还要观察:

  • 调度失败率;
  • 抢占次数;
  • 资源碎片;
  • 跨 Zone 分布;
  • Pod 的最终资源利用率;
  • 大规模扩缩容期间的行为。

3. parallelism 的边界

调度框架可以并行执行部分节点级插件工作。并行度提高后,不一定线性提升吞吐,因为还会受到:

  • 调度周期的串行边界;
  • 插件共享状态的锁;
  • CPU 缓存和内存带宽;
  • GC;
  • API Server 绑定吞吐;
  • Cache 更新竞争。

插件必须假设节点级调用可能并发发生,不应写入未加锁的全局 Map,也不应复用可变的共享切片。一个插件在单元测试中串行通过,并不能证明它在实际调度器中线程安全。


七、调度性能的真正瓶颈通常在哪里

1. 节点数不是唯一变量

调度成本不仅由节点数决定,还与下列因素有关:

  • Pending Pod 数量;
  • 每个 Pod 的 Node Affinity、反亲和、拓扑分布约束;
  • Pod 间亲和与反亲和;
  • PV/PVC 和存储拓扑;
  • 节点污点和容忍;
  • 资源请求维度;
  • 自定义插件的数据索引;
  • 失败后重新入队频率;
  • API Server 的写入延迟;
  • 集群事件风暴。

例如,1,000 个简单 Pod 在 1,000 个同构节点上,可能比 100 个具有复杂 Pod Anti-Affinity 的 Pod 更容易调度。后者的每个节点判断可能需要读取和统计大量相关 Pod。

2. 缓存不是免费数据库

调度器使用 Informer 和本地 Cache,避免每次调度都从 API Server 查询节点和 Pod。这样降低了网络延迟和 API Server 压力,但代价是:

  • Cache 需要处理初始 List;
  • 需要持续处理 Watch 事件;
  • 需要维护节点、Pod、PV 等索引;
  • 大量对象会占用内存;
  • 事件突发会造成缓存更新积压。

如果插件绕过 Cache,频繁调用 API Server,可能出现“调度器看起来 CPU 不高,但 API Server 被打满”的反模式。插件访问外部系统也会让调度延迟从本地内存操作变成网络尾延迟。

3. 调度器缓存与资源假设

在调度周期中,调度器会根据缓存中的已绑定 Pod 和当前周期内的假设,判断节点剩余资源。多个调度周期之间,API Server 和其他控制器可能改变对象,因此调度结果不是事务性地锁住整个集群。

一个正确的插件不能把“本次看到节点可用”解释为“绑定一定成功”。绑定失败必须被视为正常错误路径,而不是罕见异常。


八、失败路径:为什么 Pod 会一直 Pending

诊断 Pending Pod 时,应区分不同失败原因。

1. Filter 拒绝所有节点

典型事件可能包含:

  • Insufficient CPU 或内存;
  • 节点污点没有容忍;
  • Node Affinity 不匹配;
  • Pod Anti-Affinity 不满足;
  • Volume 拓扑不满足;
  • 端口已被占用。

此时 Pod 通常进入 UnschedulableQ 或退避队列。只有相关集群状态变化后,才有必要重新调度。

2. 没有匹配的 Profile

如果 Pod 指定了错误的 schedulerName,调度器可能根本不处理它。应检查:

kubectl get pod batch-example -o jsonpath='{.spec.schedulerName}{"\n"}'
kubectl describe pod batch-example
kubectl get pods -n kube-system -l component=kube-scheduler

还要确认调度器启动参数实际加载了哪一个配置文件。不同部署方式可能通过静态 Pod、Deployment、托管控制面或云厂商封装参数启动调度器,不能假设本地文件就是生效配置。

3. 插件等待或绑定失败

如果 Permit 等待没有批准,Pod 可能长期停留在等待状态。此时不能只查看 Filter 失败信息,还应查看:

  • 调度器日志中的插件阶段;
  • 插件等待对象是否有超时;
  • 外部批准者是否运行;
  • 调度器是否发生重启;
  • 绑定请求是否返回冲突或权限错误。

4. API Server 或 RBAC 问题

调度器需要访问 Node、Pod、相关资源并执行绑定。若 ServiceAccount 缺少权限,可能出现:

  • Cache 无法同步;
  • 绑定请求被拒绝;
  • 调度器反复重试;
  • Pod 被选中但没有最终 spec.nodeName

应查看调度器日志、事件和审计日志,而不能把所有 Pending 都归因于“节点不够”。


九、测试:从插件单测到集群级压测

性能优化必须建立在正确性测试之上。调度器测试至少分为四层。

1. 插件单元测试

单元测试验证插件本身的规则,例如:

  • workload=trusted 的节点通过;
  • 没有该标签的节点返回 Unschedulable
  • 缺失 NodeInfo 返回错误;
  • Pod 删除或绑定失败后状态可以回滚;
  • 并发调用不会产生数据竞争。

示意测试结构:

func TestFilter(t *testing.T) {
    tests := []struct {
        name       string
        labels     map[string]string
        wantStatus framework.Code
    }{
        {
            name:       "trusted node",
            labels:     map[string]string{"workload": "trusted"},
            wantStatus: framework.Success,
        },
        {
            name:       "untrusted node",
            labels:     map[string]string{"workload": "general"},
            wantStatus: framework.Unschedulable,
        },
    }

    // 使用目标 Kubernetes 版本提供的 NodeInfo 和 Status 构造方式,
    // 为每个 case 创建 NodeInfo,调用 Plugin.Filter 并断言状态。
}

实际代码应使用与目标版本一致的 framework.NodeInfo 构造 API。调度框架内部类型在版本间可能有小幅变化,因此测试代码应和自定义调度器源码一起锁定版本。

运行 Go 测试时,至少使用:

go test -race ./...

-race 可以发现插件全局状态的并发读写问题,但它不能证明插件逻辑正确,也不能模拟真实 API Server 延迟。

2. Framework 集成测试

集成测试应验证插件与其他扩展点的组合:

  1. 创建多个 Node;
  2. 创建具有不同标签、污点和资源的 Pod;
  3. 启动加载该 Profile 的调度器;
  4. 等待 Pod 被绑定;
  5. 检查 spec.nodeName
  6. 删除节点或修改标签;
  7. 检查 Pending Pod 是否重新入队;
  8. 注入绑定失败,检查 Unreserve 是否执行。

尤其要测试“成功前的每一个失败点”:

Filter 失败
  -> UnschedulableQ
  -> Node 事件
  -> ActiveQ
  -> Reserve 成功
  -> Permit 超时
  -> Unreserve
  -> BackoffQ

如果只测试最终成功路径,最容易漏掉资源预留泄漏和等待状态泄漏。

3. 端到端测试

端到端测试在真实 API Server、Informer、Scheduler Cache 和控制器环境中验证行为。一个基本场景可以是:

kubectl apply -f trusted-node.yaml
kubectl apply -f batch-example.yaml
kubectl get pod batch-example -o wide
kubectl describe pod batch-example

预期结果是:

  • Pod 的 schedulerName 与 Profile 名称一致;
  • 节点具有 workload=trusted 标签;
  • Pod 最终进入 Running 或完成绑定;
  • spec.nodeName 被写入;
  • 事件没有持续的调度失败信息。

然后移除标签:

kubectl label node <node-name> workload-
kubectl delete pod batch-example
kubectl apply -f batch-example.yaml
kubectl describe pod batch-example

预期是 Pod 不应被绑定到不满足条件的节点。这个测试同时验证了缓存事件、过滤逻辑和最终绑定结果。

4. 性能和容量测试

性能测试必须固定变量,否则结果无法解释。至少记录:

  • 节点数量和节点类型;
  • Pending Pod 数量;
  • Pod 资源请求;
  • Affinity、Anti-Affinity、拓扑约束;
  • Profile 和插件集合;
  • 调度器 CPU、内存和 GC;
  • API Server 请求延迟和限流;
  • 从 Pod 创建到绑定的端到端延迟;
  • 单次调度周期耗时;
  • 调度失败和重试次数;
  • ActiveQ、BackoffQ、UnschedulableQ 的变化。

可将测试目标分为:

Throughput=成功绑定的 Pod 数测试时间Throughput = \frac{\text{成功绑定的 Pod 数}}{\text{测试时间}}

同时观察延迟分位数,例如 P50、P95 和 P99。只看平均值会掩盖少量 Pod 因复杂约束或 API Server 尾延迟而长期 Pending 的问题。

测试应至少包含三类负载:

  1. 容易成功:大多数节点满足约束,观察峰值吞吐;
  2. 大量失败:没有可行节点,观察队列退避和事件风暴;
  3. 动态变化:节点标签、污点、资源和 Pod 持续变化,观察重新入队正确性。

规模测试不能简单地用“节点数乘以 Pod 数”外推生产结果,因为不同插件的代价函数不同。应使用与生产约束分布相近的工作负载。


十、监控和诊断方法

调度器通常暴露 Prometheus 指标,包括调度延迟、调度尝试、Pending Pod、插件执行耗时和队列状态等类别。指标名称和标签会随版本变化,建议先查看实际端点:

kubectl -n kube-system port-forward pod/<scheduler-pod> 10259:10259
curl -k https://127.0.0.1:10259/metrics

前提是调度器安全端口可访问,并且本地证书和认证方式允许该请求。托管 Kubernetes 可能不允许直接访问控制面组件,此时应使用云厂商提供的监控入口。

诊断时应按阶段拆分:

Pod 创建到进入队列
  -> 队列等待时间
  -> Filter/Score 执行时间
  -> Permit 等待时间
  -> Bind 请求时间
  -> API Server 观察到绑定的时间

如果总延迟高但插件执行时间低,问题可能在队列或 API Server;如果某个插件 P99 明显高于其他插件,应检查它是否:

  • 在每个节点重复执行昂贵操作;
  • 访问远程服务;
  • 持有全局锁;
  • 产生大量临时对象;
  • 在失败 Pod 上反复计算相同结果。

日志级别调高有助于定位具体失败节点和插件,但高并发集群中详细日志可能造成自身 I/O 瓶颈。生产环境应在短时间诊断窗口内启用,并监控日志量。


十一、常见误解和生产取舍

1. “分数最高的节点就是最优节点”

不是。首先必须通过所有硬约束;Score 只是在可行节点中表达软偏好。一个拥有更高分数的节点,如果 Filter 已拒绝,就不会被选择。

2. “Profile 等于独立调度器”

不是。Profile 共享进程和队列。要获得独立故障域和资源边界,应使用独立调度器实例,但这会增加路由、权限、监控和运维成本。

3. “降低扫描节点比例一定能提升性能”

不一定。它可能减少单 Pod 的计算量,但也可能降低调度质量,造成更多资源碎片、抢占或后续迁移。必须同时观察吞吐、延迟和放置质量。

4. “自定义插件只要返回正确结果即可”

不完整。插件还必须处理:

  • 并发调用;
  • 缓存陈旧;
  • 失败回滚;
  • 事件驱动重试;
  • API Server 错误;
  • 调度器重启;
  • 内存和 CPU 上界。

调度器插件是控制面关键路径代码,不应把不可控的外部 RPC 直接放入每个节点的 Filter 或 Score。

5. “Extender 和 Framework Plugin 可以互换”

不能简单互换。Framework Plugin 运行在调度器内部,能参与多个生命周期阶段并使用框架状态;Extender 通常需要网络通信,接口和可表达的生命周期不同。选择哪一种取决于扩展需求、延迟预算、升级方式和故障隔离要求。


十二、一个可执行的验证顺序

在生产启用自定义调度策略前,可以按以下顺序验证:

  1. 使用内置插件和固定 Profile 建立基线;
  2. 在小规模集群中验证 Pod 能否按 schedulerName 路由;
  3. 对每个 Filter 失败原因编写单元测试;
  4. ReservePermitPreBind 失败编写回滚测试;
  5. 使用 go test -race 检查并发访问;
  6. 在集成环境验证节点事件能否唤醒不可调度 Pod;
  7. 执行成功、失败和动态变化三类容量测试;
  8. 比较调度延迟、队列长度、绑定延迟和资源放置质量;
  9. 逐步扩大节点和 Pod 数量;
  10. 保留回滚到内置 Profile 或旧调度器二进制的路径。

调度性能优化的核心不是让某一个函数更快,而是减少关键路径上的无效工作:让队列只唤醒真正可能成功的 Pod,让 Filter 使用本地索引而不是远程查询,让 Score 只处理可行节点,让插件状态在失败时完整回滚,并用端到端指标证明吞吐提升没有换来错误放置和长期 Pending。

Kubernetes 官方的扩展模型、调度框架接口和 client-go 组件文档分别说明了扩展点、控制器客户端和 Informer/缓存等基础能力。实现自定义调度器或调度插件时,应以运行版本对应的官方 API 定义为准,尤其要核对配置版本、默认插件集合、指标名称和实验性字段。


系列导航与关联阅读

官方资料

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