Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 九):内部架构与核心组件
Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 八):内部架构与核心组件
本篇是 kube-scheduler 调度专题的开篇。kube-scheduler 是 Kubernetes 控制平面的"大脑"——它负责将未调度的 Pod 分配到最合适的节点上。这个看似简单的决策过程,背后是一套精密的工程系统:Informer 负责感知集群状态,Scheduling Queue 管理调度任务队列,Cache 维护节点与 Pod 的内存快照,Scheduling Framework 则将调度流水线插件化。
读完本篇,你应该能回答:kube-scheduler 有哪些核心组件?它们各自的源码路径和核心结构体是什么?调度流程是如何将这四个组件串联起来的?
Kubernetes Scheduler Informer Scheduling Queue Cache Scheduling Framework k8s v1.36.1
学习重点提示 — 建议先通读全文,再重点回顾标注内容
重点掌握(必须)
- Scheduler struct 四大成员(pkg/scheduler/scheduler.go:68-109):Cache、SchedulingQueue、Profiles、nodeInfoSnapshot
- PriorityQueue 三个内部队列(pkg/scheduler/backend/queue/scheduling_queue.go:183-187):activeQ / backoffQ / unschedulablePods
- cacheImpl 双向链表结构(pkg/scheduler/backend/cache/cache.go:53-84):nodeInfoListItem 实现 MRU 顺序
- ScheduleOne 调度主循环(pkg/scheduler/schedule_one.go:67-96):schedulingCycle + bindingCycle 两阶段分离
- frameworkImpl 扩展点插件切片(pkg/scheduler/framework/runtime/framework.go:64-81):11 类插件接口
次重点(了解即可)
- Cache 的 Pod 状态机(pkg/scheduler/backend/cache/cache.go:34-49):Assumed / Added / Forgotten 三态
- Informers 注册的 9 类资源(pkg/scheduler/eventhandlers.go:481-706)
- Opportunistic Batching 与 Pod Signer(pkg/scheduler/framework/runtime/framework.go:872)
文章目录
一、架构总览:四大组件的协同关系
思考记忆提示 — 本节是调度器整体认知的起点,串联后续三章
- 四大组件(Informer / Scheduling Queue / Cache / Scheduling Framework)是后续所有章节的底层基础
- 理解"读本地内存快照、写走 APIServer"这个读写分离设计,是贯穿全篇的主线
- 面试高频提问:kube-scheduler 有哪四大组件?各自职责是什么?
kube-scheduler 的源码组织在 pkg/scheduler/ 下,核心文件如下:
| 文件 | 组件归属 | 职责 |
|---|---|---|
| scheduler.go | 编排层 | Scheduler struct,组装四大组件,主循环入口 |
| schedule_one.go | 编排层 | ScheduleOne 主循环,一次调度一个 Pod |
| eventhandlers.go | Informer | 注册所有 Informer 的事件回调 |
| backend/queue/scheduling_queue.go | Scheduling Queue | PriorityQueue,三个内部队列管理待调度 Pod |
| backend/cache/cache.go | Cache | 内存缓存,维护节点信息和 Pod 状态 |
| framework/runtime/framework.go | Scheduling Framework | frameworkImpl,执行扩展点插件流水线 |
四者的关系用一句话概括:Informer 感知变化,Scheduling Queue 管理任务,Cache 提供快照,Framework 执行调度逻辑。下面这张时序图展示了 kube-scheduler 启动时四组件的组装顺序:
Scheduler.New(ctx)
│
├─ 1. internalcache.New()
│ → cacheImpl{} // 内存缓存,空壳,等待 Informer 填充
│
├─ 2. internalqueue.NewSchedulingQueue()
│ → PriorityQueue{} // activeQ / backoffQ / unschedulablePods
│
├─ 3. profile.NewMap()
│ → frameworkImpl{} // 注册插件,初始化扩展点切片
│
├─ 4. addAllEventHandlers()
│ → 注册 9 类资源的 Informer handlers
│ → handler 回调写入 cache / schedulingQueue
│
└─ Scheduler.Run()
├─ SchedulingQueue.Run() → BackoffQ pump goroutine
├─ APIDispatcher.Run() → 异步 API 调用 goroutine
└─ go wait.Until(ScheduleOne) → 调度主循环(goroutine)
设计精髓
kube-scheduler 采用了"事件驱动 + 内存快照"的设计:所有对 apiserver 的读取都通过 Informer 的本地 Cache(reflector → DeltaFIFO → Indexer),调度时不直接访问 etcd,只在 Bind 阶段才写入 apiserver。这种设计让调度器能高性能地遍历集群所有节点,同时解耦了调度器与 apiserver 的网络依赖。
必记闭环逻辑(核心考点)
kube-scheduler 的四大组件职责:Informer 感知集群变化,Scheduling Queue 缓冲待调度 Pod,Cache 提供调度所需的内存快照,Scheduling Framework 执行插件化流水线。读写分离(读本地内存、写 APIServer)是全篇的设计主线。
二、Component 1:Informer —— 事件驱动的状态感知
2.1 整体架构:为什么需要 Informer?
kube-scheduler 需要感知集群中 Pod、Node、PV、PVC、StorageClass 等资源的变化。如果每次调度都直接请求 apiserver,数千节点的集群会导致 apiserver 过载。Informer 模式(基于 client-go 的 SharedInformer)通过"本地缓存 + 增量推送"解决了这个问题:
- 本地缓存:Informer 在内存中维护一份完整资源副本,调度器读本地缓存而不每次查 etcd
- 增量推送:Informer 监听 apiserver 的 watch 事件,增量更新本地缓存,触发 Handler 回调
- SharedInformerFactory:多个组件共享同一个 Informer 实例,减少 apiserver 连接数
必记核心
Informer 是 kube-scheduler 的感知层:本地缓存让调度器读内存不碰网络,Watch 让 APIServer 主动推变更不轮询,SharedInformerFactory 让同资源全局共用一条连接。理解这三层,是理解整个调度器事件驱动架构的前提。
2.2 源码位置与核心结构
思考记忆提示 — 本节是 Informer 的源码入口,关联 2.1 三大机制
- addAllEventHandlers() = 9 类资源的 Handler 注册总入口,是 Informer 落地的代码证明
- Handler 回调只做两件事:写 Cache 或写 Scheduling Queue,不要在这里写复杂逻辑
- 面试高频提问:Informer 的 Handler 里一般写什么?为什么要最小化?
scheduler 的 Informer 注册逻辑集中在 pkg/scheduler/eventhandlers.go,入口函数 addAllEventHandlers()(行 481-706)。这个函数在 Scheduler.New()(pkg/scheduler/scheduler.go:463)中被调用。
// pkg/scheduler/eventhandlers.go (行 481-520, k8s v1.36.1)
// addAllEventHandlers — 一次性注册所有 Informer 的事件回调
func addAllEventHandlers(
sched *Scheduler,
informerFactory informers.SharedInformerFactory,
dynInformerFactory dynamicinformer.DynamicSharedInformerFactory,
resourceClaimCache *assumecache.AssumeCache,
resourceSliceTracker *resourceslicetracker.Tracker,
draManager fwk.SharedDRAManager,
gvkMap map[fwk.EventResource]fwk.ActionType,
) error {
// ---- Pod Informer ----
podInformer := informerFactory.Core().V1().Pods()
_, err := podInformer.Informer().AddEventHandler(
cache.FilteredResourceEventHandler{
Handler: cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) { sched.addPod(logger, obj) },
UpdateFunc: func(oldObj, newObj interface{}) { sched.updatePod(logger, oldObj, newObj) },
DeleteFunc: func(obj interface{}) { sched.deletePod(logger, obj) },
},
FilterFunc: func(obj interface{}) bool { ... },
},
)
// ---- Node Informer ----
_, err = informerFactory.Core().V1().Nodes().Informer().AddEventHandler(
cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) { sched.addNodeToCache(logger, obj) },
UpdateFunc: func(oldObj, newObj interface{}) { sched.updateNodeInCache(logger, oldObj, newObj) },
DeleteFunc: func(obj interface{}) { sched.deleteNodeFromCache(logger, obj) },
},
)
// ...
}
必记核心
Informer 的 Handler 设计哲学是最小化回调:Add/Update/Delete 只做写 Cache 或写 Queue,复杂判断("哪些 Pod 需要重新调度")交给 MoveAllToActiveOrBackoffQueue()。Pod 的四态流转(未调度 ↔ 已调度)对应 Queue ↔ Cache 的切换。
2.3 Handler 分类与分发逻辑
思考记忆提示 — 本节讲 Handler 如何把事件分流到 Cache 和 Queue
- Handler 只分两类:写 Cache(已调度 Pod)和写 Scheduling Queue(未调度 Pod)
- Pod 的四态流转(未调度 ↔ 已调度)对应 Queue ↔ Cache 的切换
- 面试高频提问:Pod 从创建到调度成功,Handler 经历了哪几步?
所有 Handler 分为两类:写 Cache 和写 Scheduling Queue。addAllEventHandlers() 的设计哲学是——事件回调只做最小化工作,真正复杂的逻辑(如"节点变化后哪些 Pod 需要重新调度")由队列模块的 MoveAllToActiveOrBackoffQueue() 处理。
Pod Handler(行 128-208)
// pkg/scheduler/eventhandlers.go (行 128-141, k8s v1.36.1)
// addPod — Pod 创建时,区分"已调度"和"未调度"走不同分支
func (sched *Scheduler) addPod(logger klog.Logger, podObj interface{}) {
pod, err := util.GetPod(podObj)
if err != nil { return }
if len(pod.Spec.NodeName) == 0 {
// 未调度 Pod → 进入 SchedulingQueue
sched.addPodToSchedulingQueue(logger, pod)
} else {
// 已调度 Pod → 进入 Cache(通知调度器该 Pod 已在某节点运行)
sched.addAssignedPodToCache(logger, pod)
}
}
// updatePod — Pod 更新时,判断是否需要重新调度
func (sched *Scheduler) updatePod(logger klog.Logger, oldObj, newObj interface{}) {
oldPod, _ := util.GetPod(oldObj)
newPod, _ := util.GetPod(newObj)
if oldPod == nil || newPod == nil { return }
if len(newPod.Spec.NodeName) == 0 && len(oldPod.Spec.NodeName) == 0 {
// 未调度 → 未调度:更新 SchedulingQueue
sched.updatePodInSchedulingQueue(logger, oldPod, newPod)
} else if len(newPod.Spec.NodeName) > 0 && len(oldPod.Spec.NodeName) == 0 {
// 未调度 → 已调度:移出 SchedulingQueue,加入 Cache
sched.addAssignedPodToCache(logger, newPod)
sched.deletePodFromSchedulingQueue(logger, oldPod)
} else if len(oldPod.Spec.NodeName) > 0 && len(newPod.Spec.NodeName) == 0 {
// 已调度 → 未调度(解绑):移出 Cache,进入 SchedulingQueue
sched.addPodToSchedulingQueue(logger, newPod)
sched.deleteAssignedPodFromCache(logger, oldPod)
}
// 已调度 → 已调度:仅更新 Cache 中的 Pod 信息
}
Node Handler(行 53-126)
// pkg/scheduler/eventhandlers.go (行 53-66, k8s v1.36.1)
// addNodeToCache — 节点新增时,更新 Cache 并触发待调度 Pod 重试
func (sched *Scheduler) addNodeToCache(logger klog.Logger, obj interface{}) {
node, err := util.GetNode(obj)
if err != nil { return }
nodeInfo := sched.Cache.AddNode(logger, node)
if nodeInfo != nil {
sched.SchedulingQueue.MoveAllToActiveOrBackoffQueue(logger,
fwk.ClusterEvent{Resource: fwk.Node, ActionType: fwk.Added},
nil, nil, nil,
)
}
}
// updateNodeInCache — 节点变化时,判断是否需要重排待调度 Pod
func (sched *Scheduler) updateNodeInCache(logger klog.Logger, oldObj, newObj interface{}) {
node, _ := util.GetNode(newObj)
oldNode, _ := util.GetNode(oldObj)
if node == nil { return }
oldNodeInfo := sched.Cache.UpdateNode(logger, oldNode, node)
if oldNodeInfo != nil && oldNodeInfo.Node().Spec.Unschedulable != node.Spec.Unschedulable {
sched.SchedulingQueue.MoveAllToActiveOrBackoffQueue(logger, ...)
}
}
必记核心
Handler 分类:Cache 写手(Node / PV / PVC / StorageClass / Service / CSINode / CSIDriver / VolumeAttachment,事件触发缓存更新)和Queue 写手(Pod,事件决定 Pod 是否需要重新调度)。核心设计原则:Handler 回调只做最小化工作,复杂逻辑交给 Queue 的 MoveAllToActiveOrBackoffQueue()。
2.4 Informer 三大核心机制
思考记忆提示 — 本节属于调度器底层核心重难点,结合逻辑链理解 + 背诵闭环流程
- 三个机制是流水线闭环,前后强依赖,不要割裂记忆
- 每个机制关联 ZoneSpreadScore 插件场景:Index 本地索引秒查 Zone Pod 数量
- 面试高频提问:不用 Informer 会有什么问题?SharedInformer 解决什么痛点?
Informer 不是简单的"缓存",而是一套精密的本地镜像 + 事件驱动 + 共享复用机制。理解这三个机制,才能真正理解为什么调度器离不开 Informer。
机制一:本地内存缓存(Store / Indexer)—— 调度器读内存不碰网络
Informer 启动时先做一次 List(全量拉取):一次性从 APIServer 获取当前集群所有 Node、Pod、PV 资源,存入本地内存(Store)。之后调度器查节点、统计 Zone 下 Pod 数量、匹配存储资源,全部直接读本地内存,不碰网络、不碰 ETCD,查询延迟微秒级。
Store 只解决"能查到",Indexer 解决"查得快":Indexer 在内存中建立多维索引——按 Node 名称索引、按 Pod 绑定的 NodeName 索引、按 PVC 绑定 PV 索引。ZoneSpreadScore 插件统计某 Zone 下 Pod 数量,靠的就是本地索引秒查。
机制二:Watch 增量推送(DeltaFIFO)—— APIServer 主动推变更,不轮询
List 只做一次初始化。之后全程走 Watch 长连接:Informer 和 APIServer 建立一条 HTTP 持久连接,APIServer 监听 ETCD 变更,只把变化的那一条资源(Add / Update / Delete)推送给本地 Informer,Informer 自动更新本地缓存 + 触发回调。相比轮询:没有重复全量拉取、网络流量极低、状态感知实时。
Watch 推送的资源通过 DeltaFIFO Queue 有序分发:每个资源变更(Delta)按顺序入队,消费端按顺序处理,保证本地缓存与 ETCD 的最终一致性。
机制三:SharedInformerFactory —— 同资源全局共享一条连接
如果不共享:Node / Pod / PV / PVC 各建独立 Informer = 5 条 Watch 长连接,集群中成百上千个控制器同时运行,APIServer 连接数会爆炸。SharedInformerFactory 用工厂模式统一管理:同一种资源全局只创建一个 Informer 实例,所有组件共用这一份本地缓存 + 一条 Watch 连接,内存占用低、APIServer 压力大降。
反向思考辅助记忆:假设 Informer 不共享会怎样?每个插件各自 Watch 自己那份资源 → 5000 节点集群每秒成百次 List 请求 → APIServer → ETCD 被打满 → 集群所有组件(kubectl、controller、其他 scheduler)全部卡死。这正是 SharedInformerFactory 解决的本质问题。
必记闭环逻辑(核心考点)
Informer 的三大机制形成闭环:
List 初始化本地镜像 → Watch 实时增量同步 → Handler 回调触发调度逻辑
这就是 kube-scheduler 实现"高性能 + 低延迟 + 事件驱动"的工程根基。面试被问到 Informer,直接背这 12 个字,比背 200 字定义更有用。
2.5 注册的 9 类资源
思考记忆提示 — 9 类资源分类记忆:4 类写 Cache,1 类写 Queue,4 类做辅助
- 写 Cache(事件驱动缓存更新):Node / PV / PVC / StorageClass / Service / CSINode / CSIDriver / VolumeAttachment
- 写 Queue(Pod 调度决策):Pod(未调度)
- 记忆技巧:调度决策相关 → Queue;资源感知相关 → Cache
addAllEventHandlers() 共注册了 9 类资源的 Informer Handler(行 498-666),以下是完整列表:
| 资源类型 | Informer | Handler 回调 | 写入目标 |
|---|---|---|---|
| Pod | CoreV1().Pods() | addPod / updatePod / deletePod | Queue 或 Cache |
| Node | CoreV1().Nodes() | addNodeToCache / updateNodeInCache / deleteNodeFromCache | Cache + Queue |
| PersistentVolume | CoreV1().PersistentVolumes() | onPvAdd / onPvUpdate / onPvDelete | VolumeBinding Cache |
| PersistentVolumeClaim | CoreV1().PersistentVolumeClaims() | onPvcAdd / onPvcUpdate / onPvcDelete | VolumeBinding Cache |
| StorageClass | StorageV1().StorageClasses() | onStorageClassAdd | VolumeBinding Cache |
| CSINode | StorageV1().CSINodes() | onCSINodeAdd | Cache |
| CSIStorageCapacity | StorageV1().CSIStorageCapacities() | onCSIStorageCapacityAdd | VolumeBinding Cache |
| VolumeAttachment | StorageV1().VolumeAttachments() | onVaAdd | VolumeBinding Cache |
| PodGroup(可选) | schedulingv1alpha1().PodGroups() | onPodGroupAdd | SchedulingQueue |
小贴士 — 关于 SharedInformer 的等待同步
kube-scheduler 启动时必须等待所有 Informer 同步完成(informerFactory.WaitForCacheSync()),否则调度时会看到"过期"的集群状态——Pod 已在某节点运行,但 Cache 里还没有。这个等待发生在 sched.Run()(pkg/scheduler/scheduler.go:545)启动主循环之前,是 bootstrap 阶段最耗时的步骤之一。
必记核心
9 类资源 Handler 的记忆口诀:"Pod 入 Queue,其余写 Cache"。Node 变化时,通过 Queue 的 MoveAllToActiveOrBackoffQueue() 触发相关未调度 Pod 重新评估。
2.6 常见误区:澄清 Informer 的边界
误区一:Informer 是独立缓存服务?
Informer 不是独立运行的缓存中间件(不是 Redis,不是本地代理),而是集成在调度器进程内的内存数据结构。它运行在 kube-scheduler 进程内,直接持有 Node/Pod/PV 的 Go 对象指针。
误区二:Watch 就不用 List 了?
Watch 只推送增量事件,但启动时本地缓存是空的,必须先 List 一次全量数据填充缓存,否则调度器拿到的是"无数据"。List 只在启动时执行一次,之后全程走增量 Watch。
误区三:SharedInformer 每个协程各创建一个?
SharedInformer 是单例——同一种资源(如 Pod)在整个调度器进程中只存在一个实例,所有插件(Filter、Score、Bind)共用同一个 Informer 实例 + 同一份本地缓存 + 同一条 Watch 连接。
误区四:Informer 是强一致性?
Informer 只保证最终一致性。Watch 长连接网络抖动时,增量事件可能短暂丢失,本地缓存会与集群真实状态出现几秒滞后。Resync 机制(默认 30s)会定时重新 List 全量数据、触发 Update 回调兜底修复,保证本地缓存最终与 ETCD 同步。
必记核心
Informer 四大边界:不是独立缓存服务(是进程内内存结构)→ 不是轮询替代品(启动必须 List)→ 不是多实例(全局单例)→ 不保证强一致(只保证最终一致)。记住这四句话,Informer 的核心概念就不再混淆。
三、Component 2:Scheduling Queue —— 调度任务的缓冲区
思考记忆提示 — 本节是 Pod 调度的"缓冲层",关联 Informer 和主循环
- Pod 调度失败为什么要等而不是直接报错?因为资源可能下一秒就释放了(节点污点撤销、Pod 删除腾出资源)
- 三个子队列(activeQ / backoffQ / unschedulablePods)对应三种 Pod 状态:待调度 / 退避中 / 永久失败
- 面试高频提问:调度失败的 Pod 去了哪里?Backoff 退避时间怎么算?
3.1 为什么需要调度队列?
Pod 调度不是"来一个调度一个"那么简单。调度失败时,Pod 需要等待条件变化后重试;节点变化时,未调度的 Pod 需要被触发重新评估。Scheduling Queue 就是管理这些"待调度 Pod"的缓冲区——它不是简单的 FIFO 队列,而是一个包含优先级排序、退避重试、条件触发的智能调度缓冲系统。
3.2 核心结构:PriorityQueue
调度队列在 v1.36.1 中的实现是 PriorityQueue(pkg/scheduler/backend/queue/scheduling_queue.go:172),对外暴露 SchedulingQueue 接口(行 96-153)。它内部维护三个子队列:
// pkg/scheduler/backend/queue/scheduling_queue.go (行 60-84, k8s v1.36.1)
// 常量定义
const (
DefaultPodMaxInUnschedulablePodsDuration time.Duration = 5 * time.Minute // Pod 在不可调度队列中最多待 5 分钟
DefaultPodInitialBackoffDuration time.Duration = 1 * time.Second // 初始退避 1s
DefaultPodMaxBackoffDuration time.Duration = 10 * time.Second // 最大退避 10s
})
// pkg/scheduler/backend/queue/scheduling_queue.go (行 172-210, k8s v1.36.1)
// PriorityQueue — 调度队列的核心实现
type PriorityQueue struct {
*nominator // 记录 Pod 的 nominatedNodeName(等待抢占的节点)
stop chan struct{}
clock clock.WithTicker
lock sync.RWMutex
// 三个内部队列
activeQ activeQueuer // 堆结构,待调度的 Pod 按优先级排序
backoffQ backoffQueuer // 堆结构,调度失败后进入退避等待
unschedulablePods *unschedulablePods // map 结构,调度失败的 Pod
moveRequestCycle int64
// PreEnqueue 插件 map:哪个 Pod 满足哪个插件的前置条件
preEnqueuePluginMap map[string]map[string]fwk.PreEnqueuePlugin
// QueueingHintMap:插件注册的事件 → Pod 重排策略
queueingHintMap QueueingHintMapPerProfile
// Pod 签名器:Opportunistic Batching 优化
podSigners map[string]PodSigner
// ...
}
小贴士 — 关于 QueueingHint
v1.36.1 引入了 QueueingHint 机制(queueingHintMap,scheduling_queue.go:193):每个插件声明"当 X 资源发生变化时,我的调度结果可能需要重新评估"。这让调度器能精确触发受影响的 Pod,而不是每次都把整个 activeQ 清空重排。这是一个 0(n) → 0(1) 的优化,在大集群中效果显著。
3.3 三个子队列的行为
| 队列 | 底层数据结构 | 进入条件 | 离开条件 | 关键方法 |
|---|---|---|---|---|
| activeQ | 堆(heap) | Pod 新建、Backoff 结束、节点变化触发 | Pop() 被调用 | Add() / Pop() |
| backoffQ | 堆(heap)+ 定时器 | 调度失败后进入,指数退避 | 退避计时器到期 → activeQ | moveToBackoffQ() |
| unschedulablePods | map + 堆(超时清理) | 调度失败(不可重试/永久性原因) | 5 分钟后超时 → backoffQ / 节点变化触发 | AddUnschedulableIfNotPresent() |
3.4 Pod 在三个队列之间的流转
// 场景 1: Pod 新建 → activeQ
// pkg/scheduler/backend/queue/scheduling_queue.go (行 739-750)
func (p *PriorityQueue) Add(ctx context.Context, pod *v1.Pod) {
p.lock.Lock()
defer p.lock.Unlock()
if err := p.activeQ.Add(p.podInfo(pod)); err != nil {
logger.Error(err, "Error adding pod to the active queue", "pod", klog.KObj(pod))
}
}
// 场景 2: 调度失败 → backoffQ 或 unschedulablePods
// pkg/scheduler/backend/queue/scheduling_queue.go (行 901-965)
func (p *PriorityQueue) AddUnschedulableIfNotPresent(
logger klog.Logger, podInfo *framework.QueuedPodInfo, podSchedulingCycle int64,
) error {
// 判断是否值得等待(可重试 vs 永久性失败)
if p.isPodWorthRequeuing(podInfo, frwk.Unschedulable) {
// 永久性失败 → unschedulablePods(5 分钟后超时)
p.unschedulablePods.Add(podInfo.Pod, p.moveRequestCycle)
} else {
// 可重试 → backoffQ(指数退避)
p.podInfo(podInfo.Pod).UnschedulableTime = time.Now()
if err := p.backoffQ.Add(p.podInfo(podInfo.Pod)); err != nil {
logger.Error(err, "Error adding pod to the backoff queue", "pod", klog.KObj(podInfo.Pod))
}
}
}
// 场景 3: 节点变化 → 相关 Pod 从 unschedulablePods 移出
// pkg/scheduler/backend/queue/scheduling_queue.go (行 1269-1277)
func (p *PriorityQueue) MoveAllToActiveOrBackoffQueue(...) {
for _, pInfo := range p.unschedulablePods.Pods() {
if p.isPodWorthRequeuing(...) {
p.moveToBackoffQ(pInfo)
}
}
}
设计精髓
PriorityQueue 的核心设计是"最小化重排":不是把所有 Pod 都推倒重来,而是通过 QueueingHint 精确找到"受事件影响"的 Pod。v1.36.1 之前,每次节点变化都会触发 MoveAllToActiveOrBackoffQueue 清空 unschedulablePods;现在,每个插件注册自己的 Hint("我关心 Node.Update" / "我关心 Pod.Delete"),只有相关 Pod 才被重新入队。在 5000 节点、10000 Pod 的集群中,这个优化可以将每次节点更新的处理时间从 O(n) 降到 O(1)。
必记核心
Scheduling Queue 的三队列职责:activeQ(堆结构,Pod 按优先级排队等待调度)、backoffQ(指数退避,失败 Pod 冷却后再重试)、unschedulablePods(永久失败,5 分钟后超时转移)。v1.36.1 的 QueueingHint 机制把节点更新触发范围从 O(n) 降到 O(1)。
四、Component 3:Cache —— 节点与 Pod 的内存快照
思考记忆提示 — Cache 是调度器的"本地数据库",关联 Informer 和主循环
- Cache 和 Informer 的 Indexer 区别:Indexer 存原始对象,Cache 存"调度语义加工"后的数据(如剩余 CPU)
- Assumed 状态解决并发调度"超售"问题:Filter 前先预占资源,Bind 失败则回滚
- 面试高频提问:并发调度时,两个 Pod 同时看到节点 X 有资源会发生什么?Assumed 如何解决?
4.1 Cache 的定位
Cache 是 kube-scheduler 的"数据库"——它存储了调度所需的所有节点和 Pod 的本地内存副本。与 apiserver 中的 etcd 不同,Cache 是纯内存结构,不做持久化;与 Informer 的本地 Indexer 不同,Cache 存储的是经过调度语义加工的数据(如"节点上还剩多少 CPU")。
4.2 核心接口与结构体
// pkg/scheduler/backend/cache/interface.go (行 56-127, k8s v1.36.1)
// Cache 接口 — 调度器只依赖这个抽象,不关心具体实现
type Cache interface {
NodeCount() int
PodCount() (int, error)
GetNode(name string) (*framework.NodeInfo, error)
// Pod 生命周期管理(Assumed = 乐观假设,Added = 确认绑定)
AssumePod(logger klog.Logger, pod *v1.Pod) error // 调度成功后"假设"Pod 已在节点
ForgetPod(logger klog.Logger, pod *v1.Pod) error // 假设失败,取消假设
AddPod(logger klog.Logger, pod *v1.Pod) error // 确认 Pod 已绑定
UpdatePod(logger klog.Logger, oldPod, newPod *v1.Pod) error
RemovePod(logger klog.Logger, pod *v1.Pod) error
IsAssumedPod(pod *v1.Pod) (bool, error) // 检查是否在 Assumed 状态
// Node 管理
AddNode(logger klog.Logger, node *v1.Node) *framework.NodeInfo
UpdateNode(logger klog.Logger, oldNode, newNode *v1.Node) *framework.NodeInfo
RemoveNode(logger klog.Logger, node *v1.Node) error
// 快照:每个调度周期从 Cache 拿一份快照
UpdateSnapshot(logger klog.Logger, nodeSnapshot *Snapshot) error
// 调试与 PodGroup
Dump() *Dump
BindPod(binding *v1.Binding) (
// pkg/scheduler/backend/cache/cache.go (行 59-88, k8s v1.36.1)
// cacheImpl — Cache 的生产实现
type cacheImpl struct {
stop
4.3 Pod 的三态状态机
Cache 中的 Pod 遵循一个三态状态机(pkg/scheduler/backend/cache/cache.go:34-49):
// pkg/scheduler/backend/cache/cache.go (行 34-49, k8s v1.36.1)
// Pod 在 Cache 中的三种状态及其转换
//
// Initial ──────► Assumed ──────► Added
// (未在 Cache) (已假设) (已确认)
// │ │
// ▼ ▼
// Forgotten Deleted
// (假设被取消) (从 Cache 移除)
//
// Assumed 状态是"乐观预占":调度周期开始时,
// 在真正 Bind 之前,把 Pod 的资源算进节点的已分配量。
// AssumePod — 调度成功后,把 Pod "假设"到目标节点
// pkg/scheduler/backend/cache/cache.go (行 397-410)
func (cache *cacheImpl) AssumePod(logger klog.Logger, pod *v1.Pod) error {
key, err := cache.keyFunc(pod)
if err != nil { return err }
cache.mu.Lock()
defer cache.mu.Unlock()
if _, ok := cache.podStates[key]; ok {
return fmt.Errorf("pod %v is not assumed and thus cannot be assumed again", key)
}
cache.assumedPods.Insert(key)
cache.podStates[key] = &podState{pod: pod}
if err := cache.addPod(pod); err != nil {
cache.assumedPods.Delete(key)
delete(cache.podStates, key)
return err
}
return nil
}
// ForgetPod — 假设失败或被取消,把 Pod 从 Cache 移除
// pkg/scheduler/backend/cache/cache.go (行 412-434)
func (cache *cacheImpl) ForgetPod(logger klog.Logger, pod *v1.Pod) error {
key, err := cache.keyFunc(pod)
if err != nil { return err }
cache.mu.Lock()
defer cache.mu.Unlock()
if !cache.assumedPods.Has(key) {
return fmt.Errorf("pod %v is not assumed, cannot be forgotten", key)
}
if err := cache.removePod(pod); err != nil { return err }
cache.assumedPods.Delete(key)
delete(cache.podStates, key)
return nil
}
// AddPod — Bind 确认后,把 Pod 加入 Cache(或从 assumed 升级为 added)
// pkg/scheduler/backend/cache/cache.go (行 515-545)
func (cache *cacheImpl) AddPod(logger klog.Logger, pod *v1.Pod) error {
key, err := cache.keyFunc(pod)
if err != nil { return err }
cache.mu.Lock()
defer cache.mu.Unlock()
if cache.assumedPods.Has(key) {
// 从 Assumed → Added:Pod 已绑定,无需额外操作
cache.assumedPods.Delete(key)
} else {
// 从外部进入(已在某节点运行):需要加入 Cache
if err := cache.addPod(pod); err != nil { return err }
}
cache.podStates[key] = &podState{pod: pod}
return nil
}
小贴士 — 为什么需要 Assumed 状态?
调度是并发的——同一个集群可能有多个调度周期同时运行。如果没有 Assumed 机制:Pod A 和 Pod B 都在 Filter 阶段检查节点 X,X 的剩余资源同时满足两者,都选择了 X。但最终 Bind 只能成功一个,另一个的 Filter 结果就是"过时"的。Assumed 机制让调度器在 Filter 阶段就"预占"资源——Filter 看到的资源量已经扣除了 Assumed Pod,确保并发调度不会"超售"。
记忆思考重点 — Assumed 只存在调度器本地内存 Cache,不会写入 APIServer/ETCD,是临时虚拟占位
- 核心解决两个痛点:多 Pod 并发调度资源争抢、同步 Bind 阻塞调度吞吐量
- 必须联动 Reserve/Unreserve 插件,Bind 失败要回滚释放预占资源
- ZoneSpreadScore 等拓扑均衡打分,统计数量会包含 Assumed Pod,保证分布均匀
无 Assumed 的并发冲突问题
节点 X 剩余 2C2G,Pod A、B 各需 1C1G 同时调度:
- 两者 Filter 都读取 Informer 集群真实状态,都判定节点 X 资源充足
- 两个 Pod 同时选中 X 发起 Bind,APIServer 仅能成功一个,另一个调度失败重排
- 批量扩容场景下会大量重复调度,性能极差
Assumed 核心逻辑(预占"未来资源")
Assumed Pod 是调度器 Cache 内的临时虚拟 Pod,执行时机在 Reserve 插件阶段:
- Pod A 选出节点 X 后,先在本地 Cache 创建 Assumed Pod,节点可用资源扣减 1C1G
- 后台异步发起 Bind 请求,调度器主线程立刻处理 Pod B
- Pod B Filter 读取混合了 Assumed 状态的 Cache,看到节点 X 只剩 1C1G,直接过滤,彻底避免争抢
两大核心价值
- 规避并发资源冲突:Informer 仅同步集群已落地的真实 Pod;Assumed 补充「正在调度、未绑定」的预占视图,插件打分过滤时能看见未来资源占用,防止双绑。
- 异步绑定提升调度吞吐:Bind 是远程网络操作,Assume 先内存预占,不用阻塞调度循环等待 APIServer 响应,调度器可并行处理队列中多个 Pod。
完整生命周期
- Assume(Reserve):本地 Cache 预占资源,生成虚拟 Assumed Pod
- 异步 Bind:后台协程向 APIServer 提交绑定
- 成功:Informer 同步到真实 Pod,Cache 清理 Assumed 临时数据
- 失败(Unreserve):删除 Assumed Pod,节点资源恢复,Pod 重新入队调度
核心总结
Assumed = 调度器本地乐观预占机制,给 Filter/Score 插件提供包含「待绑定 Pod」的未来资源视图,是调度器高并发、无冲突调度的底层保障。
4.4 Snapshot 机制
每个调度周期开始时,调度器从 Cache 提取一份只读快照(nodeInfoSnapshot),Filter 和 Score 都在这个快照上做计算:
// pkg/scheduler/backend/cache/cache.go (行 184-296, k8s v1.36.1)
// UpdateSnapshot — 从 Cache 提取节点列表,构建只读快照
func (cache *cacheImpl) UpdateSnapshot(logger klog.Logger, nodeSnapshot *internalcache.Snapshot) error {
cache.mu.RLock()
defer cache.mu.RUnlock()
// 按双向链表的 MRU 顺序遍历节点
nodes := make([]*framework.NodeInfo, 0, len(cache.nodes))
for item := cache.headNode; item != nil; item = item.next {
nodes = append(nodes, item.info)
}
// 反转(因为链表是从 headNode → tailNode,但调度希望从尾部开始)
for i, j := 0, len(nodes)-1; i < j; i, j = i+1, j-1 {
nodes[i], nodes[j] = nodes[j], nodes[i]
}
nodeSnapshot.NodeInfoMap = make(map[string]*framework.NodeInfo, len(nodes))
for _, nodeInfo := range nodes {
nodeSnapshot.NodeInfoMap[nodeInfo.Node().Name] = nodeInfo
}
nodeSnapshot.Nodes = nodes
return nil
}
4.5 双向链表的 MRU 设计
Cache 用双向链表管理节点(nodeInfoListItem,pkg/scheduler/backend/cache/cache.go:53-60),目的是维护MRU(Most Recently Used)顺序。节点被访问(Filter 阶段遍历)时会被移到链表头部,下次遍历优先使用——这是一种近似轮询的负载均衡策略。
// pkg/scheduler/backend/cache/cache.go (行 114-163, k8s v1.36.1)
// moveNodeInfoToHead — 节点被访问后移到链表头部(MRU)
func (cache *cacheImpl) moveNodeInfoToHead(logger klog.Logger, n *nodeInfoListItem) {
if n == nil { return }
if n == cache.headNode { return } // 已在头部,无需移动
// 从原位置摘下
if n.prev != nil { n.prev.next = n.next }
if n.next != nil { n.next.prev = n.prev }
// 插入头部
n.prev = nil
n.next = cache.headNode
cache.headNode.prev = n
cache.headNode = n
}
// removeNodeInfoFromList — 节点删除时从链表摘下
func (cache *cacheImpl) removeNodeInfoFromList(n *nodeInfoListItem) {
if n == nil { return }
if n.prev != nil { n.prev.next = n.next }
if n.next != nil { n.next.prev = n.prev }
if n == cache.headNode { cache.headNode = n.next }
}
设计精髓
Cache 的双向链表 + Map 混合结构(O(1) 插入、删除、移动;O(n) 遍历)是一种经典的"缓存友好"设计。相比纯 Map,它保留了遍历顺序(MRU);相比纯链表,它提供了 O(1) 随机访问。这个选择在调度场景下是合理的:节点数量(千级)远小于 Pod 数量,但遍历次数极多(每次调度遍历所有节点),MRU 顺序能带来更好的 CPU cache locality。
必记核心
Cache 是调度器的"语义加工层":Assumed 机制解决并发超售(三态:未绑定 → Assumed → 真正 Bind 或回滚),双向链表 + Map 兼顾 O(1) 随机访问和 MRU 遍历顺序,Snapshot 机制保证整个 schedulingCycle 看到一致的资源视图。
五、Component 4:Scheduling Framework —— 插件化的调度流水线
思考记忆提示 — 本节是调度器的"执行层",关联前面三章的感知/缓冲/快照
- Informer 提供集群感知 → Cache 提供调度快照 → Scheduling Queue 缓冲任务 → Scheduling Framework 执行流水线
- 9 个扩展点(PreFilter → Filter → PreScore → Score → Reserve → Permit → PreBind → Bind → PostBind)串成一个 Pod 的完整生命周期
- 面试高频提问:Filter 和 Score 的区别是什么?Reserve 和 PreBind 的区别是什么?
5.1 为什么需要插件化?
Scheduler 面对的调度规则五花八门:节点资源、节点亲和、Pod 间亲和、污点容忍、拓扑打散、卷绑定……每一条都是一个独立的业务规则。如果全量硬编码进调度主循环,schedule_one.go 会膨胀到几万行;新增一条规则就要改核心代码。Scheduling Framework 就是把调度规则全部抽象成扩展点接口,每条规则单独实现成插件。
5.2 扩展点全景
Scheduling Framework 定义了 13 个扩展点(对应 13 个 Go 接口),按执行顺序排列:
| 扩展点 | Go 接口 | 阶段 | 作用 |
|---|---|---|---|
| PreEnqueue | PreEnqueuePlugin | 入队前 | 判断 Pod 是否满足前置条件(如 SchedulingGates 已全部移除) |
| QueueSort | QueueSortPlugin | 队列排序 | 决定 activeQ 中 Pod 的优先级(由 PrioritySort 实现) |
| PreFilter | PreFilterPlugin | 过滤前预处理 | 预处理(如算好资源请求),结果写入 CycleState |
| Filter | FilterPlugin | 过滤 | 逐个 Node 检查硬约束(资源、亲和、污点等) |
| PostFilter | PostFilterPlugin | 过滤后 | 过滤失败时执行(典型:DefaultPreemption 抢占) |
| PreScore | PreScorePlugin | 打分前预处理 | 预计算 Pod 特征向量,供 Score 阶段使用 |
| Score | ScorePlugin | 打分 | 为每个 Feasible Node 打 0~100 分 |
| Reserve | ReservePlugin | 预约 | Bind 前"占座"(典型:VolumeBinding 占用 PV) |
| Permit | PermitPlugin | 批准 | 异步等待外部信号(GangScheduling 等齐兄弟 Pod) |
| PreBind | PreBindPlugin | 绑定前 | 真正创建 PV 绑定等预处理 |
| Bind | BindPlugin | 绑定 | 向 apiserver 提交 Binding 对象(由 DefaultBinder 完成) |
| PostBind | PostBindPlugin | 绑定后 | Bind 成功后清理(释放资源) |
| Sign | SignPlugin | 签名 | Opportunistic Batching:给 Pod 生成签名,复用调度结果 |
5.3 框架运行时:frameworkImpl
// pkg/scheduler/framework/runtime/framework.go (行 58-112, k8s v1.36.1)
// frameworkImpl — 框架运行时的核心结构体
type frameworkImpl struct {
// ---- 13 类插件切片 ----
registry runtime.Registry
snapshotSharedLister fwk.SharedLister // NodeInfo 快照(从 Cache.UpdateSnapshot 传入)
// 队列相关
waitingPods *waitingPodsMap // 等待 Permit 批准的 Pod
podsInPreBind *podsInPreBindMap // 正在 PreBind 的 Pod
queueSortPlugins []fwk.QueueSortPlugin
// 调度流水线
preEnqueuePlugins []fwk.PreEnqueuePlugin
enqueueExtensions []fwk.EnqueueExtensions
preFilterPlugins []fwk.PreFilterPlugin
filterPlugins []fwk.FilterPlugin
postFilterPlugins []fwk.PostFilterPlugin
preScorePlugins []fwk.PreScorePlugin
scorePlugins []fwk.ScorePlugin
reservePlugins []fwk.ReservePlugin
preBindPlugins []fwk.PreBindPlugin
bindPlugins []fwk.BindPlugin
postBindPlugins []fwk.PostBindPlugin
permitPlugins []fwk.PermitPlugin
// 插件权重(Score 时加权)
scorePluginWeight map[string]int
// 签名与批处理
batchablePlugins []fwk.SignPlugin
batch *OpportunisticBatch
// DRA / CSI / PodGroup
sharedDRAManager fwk.SharedDRAManager
sharedCSIManager fwk.CSIManager
podGroupManager fwk.PodGroupManager
// 并行化器
parallelizer fwk.Parallelizer
// 辅助工具
clientSet clientset.Interface
informerFactory informers.SharedInformerFactory
apiDispatcher *apidispatcher.APIDispatcher
logger klog.Logger
metricsRecorder *metrics.MetricAsyncRecorder
profileName string
}
5.4 插件注册中心:NewInTreeRegistry
// pkg/scheduler/framework/plugins/registry.go (行 50-79, k8s v1.36.1)
// NewInTreeRegistry — 23 个内置插件的注册中心
func NewInTreeRegistry() runtime.Registry {
fts := plfeature.NewSchedulerFeaturesFromGates(feature.DefaultFeatureGate)
registry := runtime.Registry{
// ---- Filter/PreFilter 插件 ----
noderesources.Name: runtime.FactoryAdapter(fts, noderesources.NewFit),
tainttoleration.Name: runtime.FactoryAdapter(fts, tainttoleration.New),
nodeaffinity.Name: runtime.FactoryAdapter(fts, nodeaffinity.New),
nodename.Name: runtime.FactoryAdapter(fts, nodename.New),
nodeports.Name: runtime.FactoryAdapter(fts, nodeports.New),
interpodaffinity.Name: runtime.FactoryAdapter(fts, interpodaffinity.New),
podtopologyspread.Name: runtime.FactoryAdapter(fts, podtopologyspread.New),
nodeunschedulable.Name: runtime.FactoryAdapter(fts, nodeunschedulable.New),
nodedeclaredfeatures.Name: runtime.FactoryAdapter(fts, nodedeclaredfeatures.New),
nodevolumelimits.CSIName: runtime.FactoryAdapter(fts, nodevolumelimits.NewCSI),
// ---- 资源打分插件 ----
noderesources.BalancedAllocationName: runtime.FactoryAdapter(fts, noderesources.NewBalancedAllocation),
imagelocality.Name: runtime.FactoryAdapter(fts, imagelocality.New),
// ---- 卷相关插件 ----
volumebinding.Name: runtime.FactoryAdapter(fts, volumebinding.New),
volumerestrictions.Name: runtime.FactoryAdapter(fts, volumerestrictions.New),
volumezone.Name: runtime.FactoryAdapter(fts, volumezone.New),
// ---- 抢占 & 队列 ----
defaultpreemption.Name: runtime.FactoryAdapter(fts, defaultpreemption.New),
queuesort.Name: queuesort.New, // 无需 FeatureGate,直接注册
defaultbinder.Name: defaultbinder.New, // 无需 FeatureGate,直接注册
// ---- 高级特性 ----
dynamicresources.Name: runtime.FactoryAdapter(fts, dynamicresources.New),
gangscheduling.Name: runtime.FactoryAdapter(fts, gangscheduling.New),
schedulinggates.Name: runtime.FactoryAdapter(fts, schedulinggates.New),
topologyaware.Name: runtime.FactoryAdapter(fts, topologyaware.New),
podgrouppodscount.Name: runtime.FactoryAdapter(fts, podgrouppodscount.New),
}
return registry
}
注意
所有通过 runtime.FactoryAdapter 包装的插件都能接收 feature.Features(pkg/scheduler/framework/runtime/registry.go:38),这使得插件在 v1.36.1 中可以感知 DynamicResourceAllocation、NodeInclusionPolicyInPodTopologySpread 等特性门控。queuesort 和 defaultbinder 不需要特性门控,直接以裸 PluginFactory 注册。
必记核心
Scheduling Framework 的核心价值:把调度规则全部抽象成扩展点接口(9 个),插件通过编译期接口断言注册到对应扩展点。Filter(硬约束)→ Score(软权重)→ Reserve(占座防超售)→ Bind(写入 apiserver),四大阶段各司其职。
六、调度主循环:四组件如何串联
思考记忆提示 — 本节是全篇的"总装图",把前五章的组件串成一个完整调度流程
- 两阶段设计:schedulingCycle(同步,Filter → Score → Reserve)和 bindingCycle(异步,Permit → PreBind → Bind → PostBind)
- schedulingCycle 失败 → Pod 回退到 SchedulingQueue;bindingCycle 失败 → Assumed 资源回滚
- 面试高频提问:schedulingCycle 和 bindingCycle 的关系是什么?为什么分开?
6.1 ScheduleOne 的两阶段设计
ScheduleOne()(pkg/scheduler/schedule_one.go:67)是 kube-scheduler 的主循环入口,每次处理一个 Pod。它分为同步的 schedulingCycle 和异步的 bindingCycle 两阶段:
// pkg/scheduler/schedule_one.go (行 67-148, k8s v1.36.1)
// ScheduleOne — 一次调度一个 Pod 的主循环
func (sched *Scheduler) ScheduleOne(ctx context.Context) {
// ① 从 SchedulingQueue 拿出下一个待调度的 Pod(阻塞)
podInfo, err := sched.NextPod(logger)
if err != nil { return }
if podInfo == nil || podInfo.Pod == nil { return }
if sched.genericWorkloadEnabled && podInfo.Pod.Spec.SchedulingGroup != nil {
// PodGroup 调度:批量调度一族相关的 Pod
podGroupInfo, err := sched.podGroupInfoForPod(ctx, podInfo)
sched.scheduleOnePodGroup(ctx, podGroupInfo)
} else {
// 普通 Pod 调度
sched.scheduleOnePod(ctx, podInfo)
}
}
// pkg/scheduler/schedule_one.go (行 99-148, k8s v1.36.1)
// scheduleOnePod — 单个 Pod 的调度流程
func (sched *Scheduler) scheduleOnePod(ctx context.Context, podInfo *fwk.QueuedPodInfo) {
// 获取当前调度周期的 framework(根据 Pod 的 schedulerName 选择 Profile)
fwk, state := sched.getFrameworkAndState(ctx, podInfo)
if fwk == nil { return }
// 同步阶段:schedulingCycle
scheduleResult, facStatus := sched.schedulingCycle(ctx, fwk, state, podInfo, logger)
if facStatus != nil {
sched.handleSchedulingFailure(ctx, fwk, podInfo, facStatus, ...)
return
}
// 异步阶段:bindingCycle(在 goroutine 中执行)
go func() {
defer func() {
if !podInfo.DeferAfter(fwk) {
sched.SchedulingQueue.Done(podInfo)
}
}()
sched.bindingCycle(ctx, fwk, state, podInfo, scheduleResult, logger)
}()
}
6.2 schedulingCycle:同步调度阶段
// pkg/scheduler/schedule_one.go (行 175-198, k8s v1.36.1)
// schedulingCycle — 同步调度阶段
func (sched *Scheduler) schedulingCycle(
ctx context.Context, fwk fwk.Framework, state *fwk.CycleState,
podInfo *fwk.QueuedPodInfo, logger klog.Logger,
) (ScheduleResult, *fwk.Status) {
// ① 从 Cache 提取节点快照(所有 Filter/Score 在这个快照上运行)
if err := sched.Cache.UpdateSnapshot(logger, sched.nodeInfoSnapshot); err != nil {
return ScheduleResult{}, fwk.AsStatus(err)
}
// ② 调用 Framework 执行调度算法
scheduleResult, schedStatus := sched.SchedulePod(ctx, fwk, state, podInfo)
if schedStatus != nil {
return ScheduleResult{}, schedStatus
}
// ③ 假设 Pod 已调度 + 执行 Reserve 插件("占座")
assumeFinish, err := sched.assumeAndReserve(ctx, fwk, state, podInfo, scheduleResult.SuggestedHost)
if err != nil {
return ScheduleResult{}, fwk.AsStatus(err)
}
// ④ 执行 Permit 插件(等待批准,可能返回 Wait)
permitStatus := sched.prepareForBindingCycle(ctx, fwk, state, podInfo)
return scheduleResult, permitStatus
}
// pkg/scheduler/schedule_one.go (行 570-718, k8s v1.36.1)
// SchedulePod — 找节点 + 打分,核心调度算法
func (sched *Scheduler) SchedulePod(
ctx context.Context, fwk fwk.Framework, state fwk.CycleState,
podInfo *fwk.QueuedPodInfo,
) (ScheduleResult, *fwk.Status) {
// ① PreFilter + Filter:找出所有满足硬约束的节点
feasibleNodes, filteredNodesStatuses, fitErr := sched.findNodesThatFitPod(ctx, fwk, state, pod)
if fitErr != nil {
return ScheduleResult{}, fwk.AsStatus(fitErr)
}
// ② 如果没有可行节点 → 运行 PostFilter(DefaultPreemption 抢占)
if len(feasibleNodes) == 0 {
status := fwk.RunPostFilterPlugins(ctx, state, pod, filteredNodesStatuses)
if !status.IsSuccess() && status.Requeue() { /* 抢占成功,等待 nominatedNode */ }
return ScheduleResult{}, status
}
// ③ PreScore + Score:为所有可行节点打分
priorityList, scoreErr := sched.prioritizeNodes(ctx, fwk, state, pod, feasibleNodes)
if scoreErr != nil {
return ScheduleResult{}, fwk.AsStatus(scoreErr)
}
// ④ 选择得分最高的节点
host, err := selectHost(logger, priorityList)
return ScheduleResult{
SuggestedHost: host,
EvaluatedNodes: len(feasibleNodes),
FeasibleNodes: len(feasibleNodes),
}, nil
}
// pkg/scheduler/schedule_one.go (行 628-718)
// findNodesThatFitPod — Filter 阶段的实现
func (sched *Scheduler) findNodesThatFitPod(
ctx context.Context, fwk fwk.Framework, state fwk.CycleState, pod *v1.Pod,
) ([]*v1.Node, *fwk.NodeToStatus, error) {
// ① PreFilter 插件(预处理)
_, s := fwk.RunPreFilterPlugins(ctx, state, pod)
if !s.IsSuccess() { return nil, nil, s.AsError() }
// ② Filter 插件(并行遍历所有节点)
nodes, statuses := fwk.RunFilterPluginsWithNominatedPods(ctx, state, pod, sched.nodeInfoSnapshot)
return nodes, statuses, nil
}
// pkg/scheduler/schedule_one.go (行 938-1054)
// prioritizeNodes — Score 阶段的实现
func (sched *Scheduler) prioritizeNodes(
ctx context.Context, fwk fwk.Framework, state fwk.CycleState,
pod *v1.Pod, feasibleNodes []*v1.Node,
) (framework.NodeScoreList, error) {
fwk.RunPreScorePlugins(ctx, state, pod) // ① PreScore
scores, s := fwk.RunScorePlugins(ctx, state, pod) // ② Score 插件(并行)
if !s.IsSuccess() { return nil, s.AsError() }
// ③ Extender 扩展(如果有外部调度器)
if len(sched.Extenders) > 0 { /* 调用外部 Extender */ }
// ④ 加权求和,返回排序后的节点列表
result := fwk.ScoreNormalizedNodes(ctx, pod, scores)
return result, nil
}
6.3 bindingCycle:异步绑定阶段
// pkg/scheduler/schedule_one.go (行 397-503, k8s v1.36.1)
// bindingCycle — 异步绑定阶段(在 goroutine 中运行)
func (sched *Scheduler) bindingCycle(
ctx context.Context, fwk fwk.Framework, state *fwk.CycleState,
podInfo *fwk.QueuedPodInfo, scheduleResult ScheduleResult, logger klog.Logger,
) {
// ① PreBind PreFlight:检查前提条件(如 Pod 是否还在)
if !fwk.RunPreBindPreFlights(ctx, podInfo.Pod) {
sched.handleBindingCycleError(ctx, fwk, podInfo, scheduleResult, ...)
return
}
// ② 等待 Permit 批准(如果 Permit 返回了 Wait)
waitStatus := fwk.WaitOnPermit(ctx, podInfo.Pod)
if !waitStatus.IsSuccess() {
sched.handleBindingCycleError(ctx, fwk, podInfo, scheduleResult, ...)
return
}
// ③ PreBind 插件(真正创建 PV 绑定等)
if !fwk.RunPreBindPlugins(ctx, state, podInfo.Pod, scheduleResult.SuggestedHost).IsSuccess() {
sched.handleBindingCycleError(ctx, fwk, podInfo, scheduleResult, ...)
return
}
// ④ Bind 插件(向 apiserver 提交 Binding 对象)
bindStatus := fwk.RunBindPlugins(ctx, state, podInfo.Pod, scheduleResult.SuggestedHost)
if !bindStatus.IsSuccess() {
sched.handleBindingCycleError(ctx, fwk, podInfo, scheduleResult, ...)
return
}
// ⑤ PostBind 插件(清理)
fwk.RunPostBindPlugins(ctx, podInfo.Pod, scheduleResult.SuggestedHost, scheduleResult.EvaluatedNodes)
// ⑥ 通知 SchedulingQueue:该 Pod 调度完成
sched.SchedulingQueue.Done(podInfo)
}
6.4 四组件的完整时序图
┌──────────────────────────────────────────────────────────────────────────────────┐
│ ScheduleOne(ctx) │
│ pkg/scheduler/schedule_one.go:67 │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ 阶段一:schedulingCycle(同步) │ │
│ │ │ │
│ │ ① Cache.UpdateSnapshot() │ │
│ │ └─ 从 Cache 的双向链表提取 NodeInfo 快照 │ │
│ │ │ │
│ │ ② Framework.RunPreFilterPlugins() │ │
│ │ └─ PreFilter:预处理(NodeResourcesFit 算资源) │ │
│ │ │ │
│ │ ③ Framework.RunFilterPluginsWithNominatedPods() │ │
│ │ └─ Filter:并行遍历所有节点,Filter 插件链过滤 │ │
│ │ └─ NodeResourcesFit / NodeAffinity / TaintToleration / ... │ │
│ │ │ │
│ │ ④ [无可行节点] → RunPostFilterPlugins() │ │
│ │ └─ DefaultPreemption:尝试抢占低优先级 Pod │ │
│ │ │ │
│ │ ⑤ Framework.RunScorePlugins() │ │
│ │ └─ Score:并行对可行节点打分 │ │
│ │ └─ NodeResourcesFit / InterPodAffinity / PodTopologySpread / ... │ │
│ │ │ │
│ │ ⑥ assumeAndReserve() │ │
│ │ ├─ Cache.AssumePod() → 把 Pod "假设"到目标节点 │ │
│ │ └─ RunReservePluginsReserve() → Reserve:VolumeBinding 占 PV │ │
│ │ │ │
│ │ ⑦ RunPermitPlugins() │ │
│ │ └─ Permit:GangScheduling 等齐(可能返回 Wait) │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ permitStatus(Wait 或 Success) │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ 阶段二:bindingCycle(异步 goroutine) │ │
│ │ │ │
│ │ ① RunPreBindPreFlights() │ │
│ │ └─ 检查 Pod 是否仍然存在、未被删除 │ │
│ │ │ │
│ │ ② WaitOnPermit() │ │
│ │ └─ 等待 Permit 批准(GangScheduling 等齐) │ │
│ │ │ │
│ │ ③ RunPreBindPlugins() │ │
│ │ └─ PreBind:VolumeBinding 真正创建 PV 绑定 │ │
│ │ │ │
│ │ ④ RunBindPlugins() │ │
│ │ └─ Bind:DefaultBinder 提交 Binding 到 apiserver │ │
│ │ │ │
│ │ ⑤ RunPostBindPlugins() │ │
│ │ └─ PostBind:清理 │ │
│ │ │ │
│ │ ⑥ SchedulingQueue.Done() │ │
│ │ └─ 从 activeQ 标记该 Pod 已完成 │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ════════════════════════════════════════════════════════════════════════════════ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ Informer 事件回调(与调度主循环并发运行) │ │
│ │ │ │
│ │ Node Updated → Cache.UpdateNode() + MoveAllToActiveOrBackoffQueue() │ │
│ │ └─ 触发 unschedulableQ 中的相关 Pod 重新入队 │ │
│ │ │ │
│ │ Pod Deleted (scheduled) → Cache.RemovePod() │ │
│ │ └─ 释放该节点资源 │ │
│ │ │ │
│ │ Pod Created → addPodToSchedulingQueue() → activeQ.Add() │ │
│ │ └─ Pod 进入队列,等待 NextPod() 被调用 │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
七、FAQ:常见疑问
思考记忆提示 — FAQ 是全篇的"临考前速背"模块,建议通读全篇后再来看这 20 题
- Q1-Q6 围绕 Informer:Watch 断线 Resync 兜底、List 初始化、Indexer vs Cache 区别
- Q7-Q13 围绕 Scheduling Queue:三队列流转、QueueingHint、Backoff、Pod 三态
- Q14-Q20 围绕 Cache + 主循环:Assumed、Snapshot、schedulingCycle vs bindingCycle
Q1. 为什么不直接调 apiserver 查集群状态,要用 Informer?
直接调 apiserver = 每次调度都访问 etcd,数千节点的集群每秒数万次 List 请求,apiserver 直接被打满。用 Informer 后:启动做一次 List 初始化本地缓存,之后全部读本地内存(微秒级),APIServer 只在 Watch 事件到来时推送增量变更。APIServer 压力从 O(n) 降到 O(1)(只处理变更推送)。
Q2. Informer 的 Indexer 和 Scheduler 的 Cache 有什么区别?
Indexer 是 Informer 的本地缓存,存的是原始 Go 对象(如完整的 Node/Pod 结构体)。Cache 是 Scheduler 在 Indexer 基础上加工的调度语义数据(如"节点上还剩多少 CPU/内存"、"该节点上已有几个 Pod")。Cache 用 Indexer 快照初始化,再由 Informer 事件实时更新。
Q3. Informer 的 Watch 长连接断了会怎样?
Watch 断线后,Informer 会在本地缓存和真实集群状态之间出现几秒不一致(最终一致性)。Resync 机制(默认 30s)会定时重新 List 全量数据,触发 Update 回调修复本地缓存,保证最终同步。生产环境中 Watch 断线一般不超过几秒,对调度影响有限。
Q4. 为什么 scheduler 启动时要等 Informer 同步完成?
如果不等同步就调度,调度器看到的是"不完整"的集群状态——Pod 已在某节点运行,但 Cache 里还没有,Filter 可能选出一台实际已满的节点。informerFactory.WaitForCacheSync()(pkg/scheduler/scheduler.go:545)是启动阶段最耗时的步骤之一,大集群可能需要数十秒。
Q5. Scheduling Queue 的三个子队列分别什么时候用?
activeQ:Pod 新建、Backoff 结束、事件触发后进入,堆结构按优先级排队,等 Pop() 被调度。backoffQ:调度失败(可重试)进入,指数退避等待(初始 10s,最长 5min),到期后转回 activeQ。unschedulablePods:永久失败(不可重试原因)进入,5 分钟后超时才允许重新入队。
Q6. QueueingHint 机制解决了什么问题?
v1.36.1 之前,节点变化会触发 MoveAllToActiveOrBackoffQueue 把所有 unschedulable Pod 全部清空重新入队,在 5000 节点、10000 Pod 集群里这是 O(n) 的操作。QueueingHint 让每个插件声明"我关心 X 资源的变化"(如 VolumeBinding 说"我关心 PVC.Delete"),只有相关 Pod 才被重新入队,变成 O(1)。
Q7. Pod 从创建到调度成功,在三个队列之间是怎么流转的?
Pod 创建 → addPod()(NodeName 为空)→ activeQ.Add()。
调度失败(可重试)→ backoffQ.Add()(指数退避)→ Backoff 到期 → activeQ.Add()。
调度失败(永久)→ unschedulablePods.Add() → 5 分钟超时 → backoffQ → activeQ → 重新调度。
调度成功 → deletePodFromSchedulingQueue()(移出 Queue)→ addAssignedPodToCache()(进入 Cache)。
Q8. Pod 在调度器里有哪三态?
未绑定(Unscheduled):NodeName 为空,Pod 在 SchedulingQueue 的 activeQ 中等待调度。已预占(Assumed):Filter 通过后,Cache 记录 Pod 预占节点资源,但 apiserver 的 Binding 还没提交,防止并发超售。已绑定(Bound):bindingCycle 完成,Pod.Spec.NodeName 被 apiserver 写入 etcd,调度完成。
Q9. 两个 Pod 同时调度到同一节点会怎样?Cache 如何防止并发超售?
如果没有 Assumed 机制,Pod A 和 Pod B 在 Filter 阶段都看到节点 X 有资源,都选择 X,但 Bind 只能成功一个。Assumed 机制在 Filter 前就"预占"资源:Pod A Filter 时把节点 X 的可用资源减去 A 的请求量,Pod B Filter 时只能看到扣减后的资源,从而不会选 X。Assumed 是 Filter 看到"未来"资源状态的关键。
Q10. bindingCycle 失败了会怎样?
bindingCycle 失败时,RunBindingPlugins 返回错误,FinishBinding(pkg/scheduler/schedule_one.go:370)会执行 Assumed 回滚:Cache 中该 Pod 对应的 Assumed 资源被释放,节点 X 的资源恢复到调度前状态,Pod 回到 unschedulableQ 等待重新调度。整个过程对调度器透明,不影响其他 Pod 的调度。
Q11. Cache Snapshot 机制为什么重要?
一个 Pod 的整个 schedulingCycle(Filter → Score → Reserve)期间,所有节点的资源视图必须是一致的。如果 Filter 阶段 Node A 有 4 核可用,Score 阶段变成 2 核(另一个 Pod 在此期间被调度),打分和 Bind 就会出错。Cache.UpdateSnapshot() 在 schedulingCycle 开始前提取一份"冻结"的 NodeInfo 快照,所有插件共享这份快照,保证调度语义的一致性。
Q12. schedulingCycle 和 bindingCycle 为什么要分开?
schedulingCycle 是同步的:Filter/Score/Reserve 必须全部完成才能进入下一阶段,目的是锁定资源、保证一致性。
bindingCycle 是异步的:Bind 调 apiserver 写 etcd,网络延迟不确定,可能需要几秒。如果 bindingCycle 同步阻塞,调度器主循环就会卡住,其他 Pod 全部等待。异步解绑了调度吞吐量和 Bind 网络延迟的依赖关系。
Q13. nominatedNodeName 是干什么用的?
抢占者(Preemptor)Pod 抢占成功后,调度器在 status.nominatedNodeName 字段写入目标节点名。它的作用是告诉调度器"请等这个节点"——即使节点当前还不满足条件(如节点污点还未撤销),调度器也会给这个 Pod 一定的时间窗口等待,而不是立刻把它扔回 unschedulableQ。这样可以避免抢占者和被抢占者都卡住的"双输"局面。
Q14. 节点变成 Unschedulable 时,调度器会发生什么?
节点 Unschedulable 变化触发 updateNodeInCache(pkg/scheduler/eventhandlers.go:53)→ Cache 更新节点信息 → QueueingHint 判断 → MoveAllToActiveOrBackoffQueue 把所有受影响 Pod 移回 activeQ → 这些 Pod 在下一个调度周期重新 Filter。该节点已调度的 Pod 不受影响,已 Bind 的 Pod 不会被驱逐(除非节点变为 NotReady)。
Q15. 节点删除时,调度器会发生什么?
节点 Delete 触发 deleteNodeFromCache(pkg/scheduler/eventhandlers.go:67):Cache 中该节点被移除,所有分配给该节点的 Pod 被标记为"未调度",通过 sched.addPodToSchedulingQueue 进入 activeQ 重新调度。这些 Pod 的 spec.nodeName 被清空,状态变为 Pending,等待重新调度。
Q16. 调度失败后的 Pod 退避(Backoff)时间是怎么算的?
指数退避策略(pkg/scheduler/internal/queue/backoff_utils.go):初始 BackoffDuration = 10ms,每次失败后乘以 2(指数增长),最大不超过 5min。具体时间还受 Pod 优先级影响:高优先级 Pod 的 Backoff 更短(更急着想重试),低优先级 Pod 的 Backoff 更长(让高优先级先跑)。
Q17. Cache 里已完成(Completed/Deleted)的 Pod 什么时候被清理?
Cache 的 RemovePod 由 Pod Informer 的 Delete 事件触发(pkg/scheduler/eventhandlers.go:204):Pod 删除事件到达 → deleteAssignedPodFromCache → 从双向链表中摘除,释放对应节点的资源。没有独立的 GC 线程——删除即清理,因为 Informer 的 Watch 实时推送变更,Cache 始终与 etcd 保持最终一致。
Q18. 多个 Scheduler Profile 之间会冲突吗?
不会冲突。每个 Profile 有独立的 frameworkImpl、独立的插件注册表和独立的 SchedulingQueue。但 Cache 是共享的(pkg/scheduler/scheduler.go:200)——所有 Profile 共用同一个 Cache 实例,因为 Cache 只存节点和已调度 Pod 的资源信息,不区分由哪个 Profile 调度。这保证了同一个节点不会被两个 Profile 同时 Bind 到不同 Pod。
Q19. 为什么 SchedulingQueue 和 Cache 都需要加锁?
因为它们都是读写并发的:
SchedulingQueue 被 Informer 回调(写)和 ScheduleOne.Pop()(读)并发访问。
Cache 被 Informer 回调(写)和 Filter/Score(读)并发访问。
两者都使用 Go 的 sync.RWMutex:写操作用 Lock(互斥),读操作用 RLock(读多写少时并发性能好)。读操作为什么不用原子操作?因为需要批量读取节点快照(一次性拿到所有节点信息),RWMutex 是最佳选择。
Q20. 调度器的"读写分离"设计和 k8s 其他组件有什么关系?
kube-controller-manager、kubelet、operator 都遵循同样的"读 informer cache、写 apiserver"模式。调度器的特殊性在于它的"读"是高频批量读(一次调度要读数千节点),所以在 informer cache 之上又加了一层 Cache(调度语义加工)和 Snapshot(一致性冻结)。理解了这个模式,就理解了 k8s 控制平面的所有读写设计。
全篇必记总纲
kube-scheduler 四大组件的职责闭环:Informer 感知集群变化驱动 Scheduling Queue 缓冲待调度 Pod,ScheduleOne 从 Queue 拿 Pod 走 Cache 快照执行 Scheduling Framework 插件流水线,Bind 结果写回 apiserver 触发 Informer 回调,再驱动新一轮调度。全篇围绕一条主线:读本地内存快照,写单点 apiserver。
七·1、对比与关联:四组件的数据流
思考记忆提示 — 本节是全篇的"全景地图",用数据流把四大组件串起来
- 读路径:Informer → Cache → Snapshot → Filter/Score(全程读内存)
- 写路径:Bind → APIServer(单点写入 etcd)
- 面试高频提问:调度器什么时候访问 apiserver?什么时候只读本地内存?
7.1 数据流总图
┌──────────────┐ 事件回调 ┌──────────────────┐ AssumePod ┌───────────┐
│ Informer │──────────────►│ SchedulingQueue │ │ Cache │
│ (apiserver) │ │ (activeQ 等) │◄─────────────│ (NodeInfo │
│ │◄──────────────│ │ AddPod │ 快照) │
│ Watch 事件 │ NextPod() │ │─────────────►│ │
└──────────────┘ └────────┬─────────┘ UpdateSnapshot│ │
│ │ │
Pop() │ │ │
▼ │ │
┌──────────────┐ ┌──────────────────┐ │ │
│ kube-apiserver │ ScheduleOne() │ │ │
│ │◄──────────────│ │ │ │
│ Binding │ Bind 提交 │ Filter + Score │◄──────────────┘ │
│ 写入 │ │ (Framework) │ NodeInfoSnapshot │
└──────────────┘ └──────────────────┘ │
│ │
│ Pod 解绑 │
▼ │
┌──────────────────┐ │
│ 回到 SchedulingQueue │
│ (backoffQ 或 unschedulablePods) │
└───────────────────────────────────────────────────────────┘
7.2 四组件职责矩阵
| 组件 | 源码路径 | 核心结构体 | 数据类型 | 线程安全 | 与 apiserver 的关系 |
|---|---|---|---|---|---|
| Informer | eventhandlers.go | SharedInformerFactory | Raw API Objects | 线程安全 | Watch + List |
| SchedulingQueue | backend/queue/ | PriorityQueue | QueuedPodInfo | 需要锁(lock) | 无直接交互 |
| Cache | backend/cache/ | cacheImpl | NodeInfo(含聚合资源) | 需要锁(mu) | Bind 时写入 Binding |
| Scheduling Framework | framework/runtime/ | frameworkImpl | CycleState | 无状态(只读快照) | 无直接交互 |
小贴士 — 为什么 SchedulingQueue 和 Cache 都要锁?
两个组件虽然都在内存中,但访问模式不同导致都需要加锁:SchedulingQueue 被 Informer 回调(写)和 ScheduleOne 的 Pop()(读)并发访问;Cache 被 Informer 回调(写)和 Filter/Score(读)并发访问。两者都使用 Go 的 sync.RWMutex:读多写少时用 RLock,并发性能好。
必记核心
四组件数据流的核心是读写分离:读全部走本地内存(Informer Cache → SchedulingQueue 快照 → Filter/Score),写只走 apiserver(Bind 提交 Binding)。这种设计是 kube-scheduler 高性能调度的工程根基,也是理解整个 k8s 控制平面架构的钥匙。
八、Roadmap:调度专题后续预告
本篇覆盖了 kube-scheduler 的内部架构骨架——四大组件的定位、数据结构和协作方式。后续篇章会逐一深入每个组件的细节:
- 专题二(已发布):Scheduling Framework 6 大内置插件精讲——NodeResourcesFit / NodeAffinity / TaintToleration / InterPodAffinity / PodTopologySpread / VolumeBinding 的源码行号 + 接口断言
- 专题三:Scheduling Framework 扩展点源码精讲——framework/runtime/framework.go 的 13 个 RunXxxPlugins 方法,CycleState 如何在扩展点之间传值
- 专题四:DefaultPreemption 抢占机制——当 Filter 阶段没有可行节点时,调度器如何通过抢占低优先级 Pod 来创造空间
- 专题五:SchedulingQueue 深度解析——QueueingHint 机制、PriorityQueue 的三个子队列如何协同、Backoff 算法的指数退避策略
- 专题六:Out-of-Tree 插件实战——用 RegisterPluginBuilder 注册自定义调度插件
调度专题的目标读者是资深运维开发:能读 Go 源码、有集群运维经验、对 k8s 整体架构已有认知。下一篇我会从 Scheduling Framework 的扩展点源码切入,把框架运行时的"骨架"补全。
全篇必记总纲
kube-scheduler 四大组件的职责闭环:Informer 感知集群变化驱动 Scheduling Queue 缓冲待调度 Pod,ScheduleOne 从 Queue 拿 Pod 走 Cache 快照执行 Scheduling Framework 插件流水线,Bind 结果写回 apiserver 触发 Informer 回调,再驱动新一轮调度。全篇围绕一条主线:读本地内存快照,写单点 apiserver。
本文参考与源码链接:
• scheduler.go · Scheduler struct
• eventhandlers.go · Informer 注册
• scheduling_queue.go · PriorityQueue
• cache.go · cacheImpl
• framework.go · frameworkImpl
• registry.go · NewInTreeRegistry
• schedule_one.go · ScheduleOne 入口

浙公网安备 33010602011771号