Kubernetes 原生调度器:Scheduling Cycle 和 Binding Cycle 详解
最后更新:
节点的 CPU 使用率很低,Pod 为什么仍然 Pending?如果节点标签不匹配,增加节点空闲内存又有没有帮助?理解调度器之前,可以先用两个实验区分“资源请求不满足”和“节点约束不满足”。
调度过程分为 Scheduling Cycle(调度周期)和 Binding Cycle(绑定周期)。下文先看故障表现,再沿 pkg/scheduler/ 的调用路径解释两个阶段,以及插件在哪些扩展点介入。
实验:节点空闲不等于 Pod 可以调度
本次通过 SSH 在 zj 上运行 kind v0.33.0、Kubernetes v1.35.1。第一组 Pod 只执行休眠,但设置 requests.cpu: "1000"。调度事件明确报告 Insufficient cpu,Pod 保持 Pending。
这里的 1000 是 1000 CPU,不是 1000m。调度器先判断请求是否能被接纳,并不会先启动进程、看到它几乎不消耗 CPU,再决定忽略请求。请求量应与实际应用需求相符,而不是为了让 Pod 能进节点随意删掉。
第二组 Pod 使用 nodeSelector: {lab.example.com/zone: missing}。集群没有节点携带这个标签,Pod 的 PodScheduled 为 false。脚本只给 worker2 加上匹配标签,没有增加 CPU 或内存,原来的 Pod 就被调度到 worker2 并变为 Ready。
| 变化 | 调度结果 | 验证的条件 |
|---|---|---|
| 休眠进程请求 1000 CPU | FailedScheduling,Insufficient cpu | 实际 CPU 空闲不能替代 request 准入 |
| 请求一个不存在的节点标签 | PodScheduled=False | 节点资源足够也不能绕过硬约束 |
| 给 worker2 增加所需标签 | 原 Pod 在 worker2 Ready | 满足缺少的标签后,调度可以继续 |
亲和性、污点容忍、拓扑分布和存储拓扑也可能排除节点。它们应该分别核对,不能用一张节点 CPU 图表概括所有过滤条件。本文这组实验直接验证 request 和 nodeSelector;PVC 生命周期实验另外验证了延迟绑定与单 Pod 访问限制。
一次调度尝试从哪里开始
本文固定阅读 Kubernetes v1.35.1 的 schedule_one.go,与上面的实验版本一致。入口是 ScheduleOne:从队列取出 Pod,按 schedulerName 选择 Framework,创建本次尝试使用的 CycleState,再调用 schedulingCycle。
Framework 负责按配置调用插件;插件实现资源、标签、卷和拓扑等具体约束。一个插件可以参与多个扩展点,扩展点名称也不等于独立的控制器。比如 VolumeBinding 同时参与节点选择和绑定前准备,因为卷能在哪里使用会影响节点选择,而真正的供应与绑定又需要等待其他组件。
入口与两个周期的关系可以直接从函数调用看出:
| |
这里省略了功能门控分支、指标与错误处理,完整实现见 ScheduleOne。同一个调度器主循环逐个进行 Scheduling Cycle;Binding Cycle 可以与后续 Pod 的调度重叠。节点过滤本身也可以并行,不能把“周期串行”理解成每个节点都串行计算。
Pending 的 Pod 怎样重新获得调度机会
Pod 进入活跃队列前,PreEnqueue 插件可以阻止一次没有意义的尝试。例如 SchedulingGates 检查 spec.schedulingGates;门还未解除时,Pod 不应不断重复节点过滤。QueueSort 则决定已经可以尝试的 Pod 谁先出队,默认 PrioritySort 优先比较优先级,相同优先级再比较队列时间。
一次失败也不意味着 Pod 从此失去调度资格。调度队列会记录哪些插件拒绝了 Pod,并根据节点、PVC 或其他资源的变化决定是否重新激活;退避队列限制连续重试的频率。具体实现位于 scheduling_queue.go。
上面的标签实验正好经过这条路径:NodeAffinity 拒绝没有匹配节点的 Pod;给 worker2 添加标签后,节点更新使原 Pod 获得新的调度机会,随后完成绑定。既不需要删除 Pod,也不需要重启 scheduler。
UnschedulableAndUnresolvable 表示当前约束不能靠在该节点移走其他 Pod 来解决,例如节点标签不匹配。它不表示“永久过滤、不再尝试”。节点标签改变、PVC 完成绑定或其他相关条件变化后,Pod 仍可能重新入队。VolumeBinding 的 PreFilter 注释明确说明了未绑定 Immediate PVC 后续绑定时的重新入队行为。
Filter 先决定能否放下,Score 再决定放哪里
PreFilter 为一次尝试准备可复用的数据,例如 Pod 的资源请求、节点选择条件、PVC 列表。数据放进本次 CycleState,后续逐节点检查不必反复解析相同的 Pod。插件返回 Skip 时,可以跳过它对应的后续工作,不代表整个调度成功。
Filter 对候选节点执行硬约束检查。只有通过所需检查的节点才进入后续打分。几个容易混淆的检查分别由不同插件承担:
| 约束 | 插件 | 判断依据 |
|---|---|---|
| CPU、内存等请求是否容得下 | NodeResourcesFit | 节点 allocatable、已计入缓存的请求量和新 Pod 请求 |
| 节点标签、required node affinity | NodeAffinity | Pod 的硬选择条件与节点标签 |
| 污点是否被容忍 | TaintToleration | 节点污点与 Pod tolerations |
| 节点是否被 cordon | NodeUnschedulable | spec.unschedulable 与对应容忍 |
| hostPort 是否冲突 | NodePorts | 已使用的主机地址、协议、端口;不是 Service NodePort |
| Pod 间硬亲和性、拓扑分布 | InterPodAffinity、PodTopologySpread | 已有 Pod 分布、标签与拓扑域 |
| 卷是否能在节点使用 | VolumeBinding、VolumeZone、NodeVolumeLimits、VolumeRestrictions | PV 拓扑、供应条件、CSI 卷数量与使用限制 |
因此,节点 CPU 监控很低并不会抵消 requests.cpu: "1000"。NodeResourcesFit 检查的是是否能接纳声明的请求;NodeAffinity 则完全可能在资源充足时拒绝节点。实验分别改变这两个条件,避免把所有 Pending 都归因于“资源不够”。
所有节点都被过滤后,PostFilter 才有机会尝试补救,例如 DefaultPreemption 评估移除低优先级 Pod 能否创造调度空间。抢占无法改变节点标签或凭空补出存储拓扑;即使找到候选节点,也还需要等待被抢占 Pod 退出和后续调度。它不承诺当前这次调用立即绑定成功。
存在可行节点时,PreScore 准备打分数据,Score 计算每个插件的偏好,NormalizeScore 在插件需要时调整分数,Framework 再结合插件权重汇总。NodeResourcesBalancedAllocation 比较 CPU、内存等资源的分配比例,ImageLocality 考虑镜像本地性,InterPodAffinity 和 PodTopologySpread 还可以表达软偏好。打分只能在可行节点之间选择,不能用高分抵消 Filter 的拒绝。
实现入口见 findNodesThatFitPod 和 prioritizeNodes。调度器还可能只寻找足够数量的可行节点再打分,因此“选中节点”也不等于遍历全体节点后求得某个全局最优解。
还没写入 API,为什么就要 Assume 和 Reserve
绑定可能等待存储供应或 API 请求。如果调度器在等待期间仍把目标节点的资源看作空闲,下一个 Pod 就可能重复占用同一份容量。
assume 会先在调度器自己的缓存中把 Pod 计入选中节点,让后续调度看到这部分请求已经占用。接着 Reserve 插件预留各自维护的状态,例如 VolumeBinding 假设某个 PV 将分配给当前 PVC。它们都是调度器侧的暂定状态,不代表 kubelet 已启动容器,也不是 API Server 对全部资源加了一把事务锁。
Permit 可以立即允许、拒绝,或返回等待。需要等待时,Scheduling Cycle 可以结束,Binding Cycle 在 WaitOnPermit 处等许可,主循环继续调度其他 Pod。这种机制可供成组调度使用;某插件是否启用仍取决于版本、功能门控和 scheduler profile,不能由注册表里存在插件推断所有集群都使用它。
这也说明 Scheduling Cycle 不能简单叫作“无副作用的纯计算”:它会修改缓存、执行 Reserve 与 Permit;失败处理还可能更新 Pod 的调度状态。应该区分本地暂定状态、API 中的绑定状态和节点实际运行状态。
两个周期为什么采用不同的并发方式
Scheduling Cycle 的节点选择会读共享调度状态,选择后又通过 assume 修改状态。主循环逐个完成这一段,可以让后一个 Pod 看到前一个 Pod 已暂定占用的容量;Binding Cycle 中等待存储、Permit 或 API 的部分则交给 goroutine,避免一个慢请求挡住所有后续节点选择。这是上述 ScheduleOne安排带来的直接效果。
CycleState 与共享缓存承担不同职责。前者保存当前 Pod 这次调度尝试的插件中间结果,例如 PreFilter 算出的信息,让后续扩展点复用;后者需要让不同 Pod 的调度尝试看见已占用资源。把暂定资源只记在 CycleState,其他 Pod 就看不到;把所有临时计算都塞进共享状态,又会扩大并发与清理负担。
代价是必须处理“本地已假设成功,最终绑定失败”。因此 assume 与 ForgetPod、Reserve 与 Unreserve 需要配合。这里的回退不是把时间倒回去:绑定等待期间其他 Pod 可能已经做出选择,外部存储也可能已经创建。框架撤销自己维护的暂定状态,插件负责自身的清理,后续尝试再依据当前状态计算。只有这样理解,才能解释为什么一个插件的失败路径与正常路径同样重要。
PreBind 等什么,Bind 又提交了什么
PreBind 执行绑定前必须完成的工作。以动态卷为例,VolumeBinding 需要让卷供应与绑定达到可以继续的状态;这个等待放在 Binding Cycle,避免阻塞其他 Pod 的节点选择。
Bind 插件负责提交绑定。默认 DefaultBinder 使用 Pod 的 Binding 子资源,把节点作为 target 提交给 API Server;绑定成功后,API 中的 spec.nodeName 确定目标节点。该实现见 default_binder.go。
PostBind 是绑定成功后的扩展点。到这里,scheduler 的职责已经完成;镜像、沙箱网络、节点侧卷挂载、进程启动和探针由 kubelet 及其协作组件继续处理。调度成功不能直接写成 Pod Ready。
PreBind 或 Bind 失败时,错误处理调用 Unreserve,并尝试从缓存中 ForgetPod,撤销本次暂定占用,再进入失败重试处理。Unreserve 必须允许重复调用,也要能处理 Reserve 只完成一部分的情况。外部已经产生的副作用仍由插件负责正确处理,框架不会自动回滚任意外部系统。
用 VolumeBinding 串起两个周期
卷绑定最能说明为什么调度不能只做一次“节点打分”。该插件在 v1.35.1 实现中跨越多个扩展点:
| 扩展点 | 对卷做什么 | 为下一步留下什么 |
|---|---|---|
| PreFilter | 区分已绑定、Immediate 未绑定、延迟绑定的 PVC | 本次要检查的卷集合 |
| Filter | 对每个节点检查已有 PV 的亲和性,以及未绑定卷的匹配或供应条件 | 每个可行节点的卷方案 |
| Reserve | 调用 AssumePodVolumes,暂存选中节点对应的方案 | 避免后续调度重复分配同一候选卷 |
| PreBind | 调用 BindPodVolumes,提交所需更新并等待绑定完成 | 满足继续绑定 Pod 的存储条件 |
| Unreserve | 撤销本次假设的 PV/PVC 缓存状态 | 允许失败后的后续尝试重新选择 |
Immediate PVC 尚未绑定时,PreFilter 可以直接拒绝;WaitForFirstConsumer PVC 则需要保留到节点选择中一起考虑。若把所有未绑定 PVC 都提前拒绝,延迟绑定就永远没有机会找到消费者节点。
对动态延迟供应,调度器通过选中节点信息把拓扑条件交给供应链路;external-provisioner 再调用 CSI 驱动创建合适的卷。scheduler 不直接代替 CSI 驱动创建磁盘,卷完成绑定后也还需要节点上的挂载流程。
PVC 生命周期实验实测了“没有消费者时 Pending,出现消费者后 Bound”,以及 Bound 后被另一个缺失卷阻塞的情况。这两组结果分别对应调度与存储协调、节点启动条件,不能只凭 PVC 的一个 phase 概括整条链路。
查 Pending 时怎样对应到源码
先看 spec.nodeName。它仍为空时,检查 Pod 的 PodScheduled condition 和 FailedScheduling 事件,按拒绝原因找到对应插件;已有 nodeName 时,转向 kubelet 的镜像、网络与卷事件。
| |
如果多个节点报告不同原因,要分别核对;不存在“只要修好其中任意一个原因,所有节点就都可用”的保证。条件修复后观察原 Pod 是否重新调度,可以验证变更是否真正解除了约束。
本文实验直接验证 request 和 nodeSelector;Assume、Reserve、Permit、绑定失败清理与插件职责来自固定版本源码。DRA(Dynamic Resource Allocation,动态资源分配)和成组调度的具体策略应另结合功能门控与工作负载验证,不把插件注册等同于已验证行为。可注册插件见 v1.35.1 registry,扩展点约定见 Scheduling Framework。
实验代码与使用说明见 k8sdev/deep-dive-scheduling。