Kubernetes 源码 / Operator 专题【左扬精讲】——kube-scheduler(调度专题):初识调度模型、内部架构与事件驱动机制
当你 kubectl apply 一个 Pod 之后,Kubernetes 内部发生了什么事?Pod 是怎么"被分配"到某个节点上的?为什么同样一份 Deployment,有时会调度到这台机器,有时又跑到另一台?这背后默默工作的核心组件,就是 kube-scheduler。
对很多初学者来说,kube-scheduler 是个"熟悉的陌生人":知道它是调度器、知道它选节点、但不知道它具体怎么选。本文作为调度专题的开篇,目标不是带你读完所有源码,而是建立一个总览心智模型。读完你应该能回答三个问题:kube-scheduler 的调度模型是什么?它内部架构由哪些模块组成?它如何靠事件驱动持续工作?至于 Scheduling Framework 各扩展点的细节、调度器插件开发、抢占算法、Scheduler Profile 等进阶内容,会在后续文章里展开。
本文源码分析基于 k8s v1.36.1,全部以 cmd/kube-scheduler/ 和 pkg/scheduler/ 目录下的实际代码为依据。
Kubernetes Scheduler Scheduling Framework Informer 事件驱动 Go k8s v1.36.1
🔓 学习重点提示 — 建议先通读全文,再重点回顾标注内容
★ 重点掌握(必须)
• 两阶段调度模型:Filtering(过滤)→ Scoring(打分)→ Binding(绑定),三步走通杀一个 Pod
• 核心 struct 与目录组织:Scheduler(pkg/scheduler/scheduler.go) / SchedulingQueue(backend/queue) / Cache(backend/cache) / Profiles(profile)
• 事件驱动三件套:SharedInformer → ResourceEventHandler → SchedulingQueue,靠 Watch 增量同步 + Queue 异步处理
☆ 次重点(了解即可)
• Scheduling Framework 的扩展点(PreFilter/Filter/Score/Reserve/Bind 等)有哪些,调用顺序如何
• Assume(假定缓存)是什么、为什么需要它、Bind 是怎么把结果写回 apiserver 的
• cmd/kube-scheduler/app/server.go 的 Run 流程(启动入口)
📋 文章目录
- 一、Why:为什么需要 kube-scheduler
- 二、What:kube-scheduler 的调度模型
- 三、How:kube-scheduler 的内部架构
- 四、Detail:事件驱动机制深度解析
- 五、Roadmap:后续专题预告与学习路径
一、Why:为什么需要 kube-scheduler
在 k8s 集群中,kube-scheduler 是控制平面的核心组件之一。它唯一的工作职责,就是为每一个新创建的 Pod 挑选一个最合适的 Node,并把这个决定以 Binding 对象的形式写回 apiserver。一旦 Binding 成功,对应节点上的 kubelet 就会"看到"这个 Pod 并开始创建容器。
它的工作流程可以用一句话概括:监听 → 入队 → 选节点 → 绑定。但这四个动作背后,隐藏着 k8s 集群里最复杂的并发模型之一——成百上千个 Pod 同时涌入、成百上千个 Node 不断变化、还要兼顾资源、亲和性、污点、抢占、本地卷、动态资源分配等几十种约束条件。所以 kube-scheduler 的设计目标就是:在高并发场景下,依然能可扩展地完成 Pod ↔ Node 的最优匹配。
理解 kube-scheduler 不仅是面试常考点,更是深入 k8s 的必经之路:它把 client-go、apimachinery、informer、workqueue、controller pattern 几乎所有核心概念都串了起来——可以说,读懂 kube-scheduler,等于读懂半个 k8s 核心代码。
🚀 小贴士 — 集群里可以同时跑多个 kube-scheduler 实例,通过 Leader Election 选举出一个 Leader 真正工作,其他作为备份(v1.36.1 还新增了 Coordinated LeaderElection,更进一步支持多 scheduler 协调)。
这意味着 kube-scheduler 本身也是一个"高可用"组件,但同一时刻只有一个实例在执行调度决策,避免并发冲突。
二、What:kube-scheduler 的调度模型
2.1 调度问题的本质
从算法角度看,kube-scheduler 解决的是一个典型的多约束配对问题:给定一个待调度的 Pod 和一组 Node,找出"最合适"的那个 Node。这里的"最合适"由一系列过滤(Filter)+打分(Score)规则共同决定。Filter 回答"能不能",Score 回答"好不好"。
社区里很多人把这套模型叫两阶段调度(Two-Stage Scheduling):
┌──────────────────────────────────────────────────────────────┐
│ Stage 1: Filtering(过滤 / 预选) │
│ 输入:Pod 描述 + 全部 Node 列表 │
│ 操作:依次执行若干 Filter 插件,每条规则淘汰不满足的 Node │
│ 输出:Feasible Nodes(可行节点集合) │
├──────────────────────────────────────────────────────────────┤
│ Stage 2: Scoring(打分 / 优选) │
│ 输入:Feasible Nodes │
│ 操作:每个 Score 插件为每个 Node 打一个分数(0~100) │
│ 输出:每个插件的得分加权求和,得到最终排名 │
├──────────────────────────────────────────────────────────────┤
│ Stage 3: Binding(绑定) │
│ 输入:排名第一的 Node │
│ 操作:调用 Bind 插件把 Pod.Spec.NodeName 写入并提交 apiserver │
│ 输出:apiserver 创建 Binding 对象 │
└──────────────────────────────────────────────────────────────┘
这套模型自 kube-scheduler v1.0 起就没有变过——变的只是每阶段的插件实现、扩展点和性能优化。
2.2 一个 Pod 的"一生"
理解了"两阶段"还不够,kube-scheduler 真正的复杂度在于它不止处理新 Pod。下面这些场景全部要触发调度:
- 新建 Pod:spec.nodeName 为空,Pod 处于 Pending 状态,需要被调度到某个 Node
- 已经调度的 Pod:Node 上有变化(资源变化、label 改变、污点改变),需要重新评估是否仍然"合适"
- 被抢占的 Pod:集群资源紧张时,某个高优先级 Pod 抢占低优先级 Pod,被抢占的 Pod 需要重新调度
- unschedulable Pod:之前调度失败(如资源不足)的 Pod,当相关资源释放后需要重新尝试
这就需要 kube-scheduler 维护一个待调度 Pod 队列,并对外监听多种资源变化。这正是后面要讲的"事件驱动"机制的核心价值。
2.3 Scheduling Framework:插件化的两阶段
自 k8s 1.19 起,kube-scheduler 全面迁移到了 Scheduling Framework 架构(v1.36.1 中位于 staging/src/k8s.io/kube-scheduler/framework/)。它的核心思想:把"调度一个 Pod"拆成若干扩展点(Extension Point),每个扩展点是一个 Go interface,开发者可以注册任意多个 plugin 来实现。Framework 负责把这些 plugin 按固定顺序串成一个调度流水线。
下表列出 v1.36.1 中的所有扩展点(按调度流水线顺序排列)。注意:初学者只需要了解前 5 个就够用了,其余会在后续 Scheduling Framework 专题里详解。
| 扩展点 | 对应的 Go Interface | 所属阶段 | 作用 |
|---|---|---|---|
| PreEnqueue | PreEnqueuePlugin | 入队前 | Pod 进队列前做快速检查(如 schedulingGates) |
| PreFilter | PreFilterPlugin | 过滤前预处理 | 预处理(如把资源请求算好、提前算好 inter-pod affinity),写 CycleState |
| Filter | FilterPlugin | 过滤(PreSelection) | 逐个 Node 检查是否满足硬约束(资源、亲和、污点、端口等) |
| PostFilter | PostFilterPlugin | 过滤失败后 | 尝试抢占(默认实现是 defaultpreemption) |
| PreScore | PreScorePlugin | 打分前预处理 | 为 Score 阶段做轻量预处理(如批量算亲和性) |
| Score | ScorePlugin | 打分(Scoring) | 给每个 Feasible Node 打分(资源均衡、亲和权重、镜像本地性等) |
| NormalizeScore | ScoreExtensions | 分数归一化 | 把不同 plugin 的分数归一化到 [0, 100] |
| Reserve | ReservePlugin | 假定(Assume) | 在 cache 中"假定"这个 Pod 已经分配到该 Node(关键设计,下文详述) |
| Permit | PermitPlugin | 同步等待 | 可阻塞等待外部批准(默认 none,所有 Pod 立即放行) |
| PreBind | PreBindPlugin | 绑定前 | 绑定前执行(如 VolumeBinding 预留 PV/PVC) |
| Bind | BindPlugin | 绑定 | 把 Pod 的 nodeName 写回 apiserver(默认实现是 DefaultBinder) |
| PostBind | PostBindPlugin | 绑定后 | 绑定后的清理(默认 none) |
一个 Pod 走完这个流水线,就是一次完整的 scheduling cycle。如果失败(Filter 全部淘汰),会进入 PostFilter / 抢占流程。
2.4 Assume(假定缓存):两阶段解耦的关键设计
初学者最应该理解的设计是 Assume(假定缓存)。它解决了一个核心问题:
在 k8s v1.0 时代,调度器选完节点后,必须等 Binding 写回 apiserver 完成(可能耗时几十毫秒~几秒),才能认为这个 Node 的资源被占用了。这就导致:
- 如果两个 Pod 几乎同时调度,第二个 Pod 可能把第一个 Pod"还没写入"的资源也算进来 → 过载(overbooking)
- 高并发场景下,Binding 网络往返成为瓶颈
Assume 机制巧妙地解决了两者:调度器一旦决定把 Pod 放到 Node X,立刻在本地 cache 里把 Pod 标记为"假定已分配"。之后调度别的 Pod 时,cache 会把这个假定的 Pod 算进 Node X 的资源占用里。
后续如果 Binding 成功,cache 里的假定状态会被"确认"为正式状态;如果 Binding 失败(如 apiserver 拒绝),cache 会调用 ForgetPod 撤销假定,把资源"还回去"。这就是为什么 pkg/scheduler/backend/cache/cache.go 里 AssumePod 和 ForgetPod 这对方法在 v1.36.1 中依然存在(位置:pkg/scheduler/backend/cache/cache.go:397)。
💡 注意
在 v1.36.1 中,Assume 机制对应的源码方法是 Scheduler.Cache.AssumePod(),由 pkg/scheduler/schedule_one.go 在 schedulingCycle 成功结束后立即调用。后续 bind 阶段无论成功失败,对应的 cache 状态都已经准备好了,不会出现"先来后到"问题。
三、How:kube-scheduler 的内部架构
我们把视线从"模型"切到"代码",看 kube-scheduler 的内部模块。k8s 1.36.1 的 kube-scheduler 实现主要在两个目录:
- cmd/kube-scheduler/:二进制入口(
main.go/app/server.go) - pkg/scheduler/:核心实现(含
scheduler.go、schedule_one.go、eventhandlers.go、backend/queue/、backend/cache/、framework/) - staging/src/k8s.io/kube-scheduler/:对外发布的 Scheduling Framework 接口库(独立 module)
3.1 入口:cmd/kube-scheduler/app/server.go
二进制的启动入口遵循 k8s 组件的"标准模板":cobra 命令 → flags 解析 → runCommand → Setup → Run。我们重点看 Run 函数:
// cmd/kube-scheduler/app/server.go (行 173-310, k8s v1.36.1)
// Run executes the scheduler based on the given configuration. It only returns on error or when context is done.
func Run(ctx context.Context, cc *schedulerserverconfig.CompletedConfig, sched *scheduler.Scheduler) error {logger := klog.FromContext(ctx)logger.Info("Starting Kubernetes Scheduler", "version", utilversion.Get())// Configz registration.if cz, err := configz.New("componentconfig"); err != nil { ... }// Start events processing pipeline.cc.EventBroadcaster.StartRecordingToSink(ctx.Done())defer cc.EventBroadcaster.Shutdown()// Setup healthz checks.readyzChecks = append(readyzChecks, healthz.NewShutdownHealthz(ctx.Done()))// 启动健康检查 endpointif cc.SecureServing != nil { ... }// 核心:启动 Informer(Pod / Node / PV / PVC / CSINode ...)并等待同步startInformersAndWaitForSync := func(ctx context.Context) {cc.InformerFactory.Start(ctx.Done())if cc.DynInformerFactory != nil {cc.DynInformerFactory.Start(ctx.Done())}cc.InformerFactory.WaitForCacheSync(ctx.Done())// 关键:等待所有事件 handler 完成首次同步if err := sched.WaitForHandlersSync(ctx); err != nil {logger.Error(err, "handlers are not fully synchronized")}close(handlerSyncReadyCh)}// 启动 Leader Election(如启用)if cc.LeaderElection != nil { ... }// 启动调度器主循环sched.Run(ctx)return nil
}
可以看到,Run 函数的逻辑非常克制:它只负责搭建"骨架"——健康检查、配置注册、Informer 启动、Leader 选举——然后调用 sched.Run(ctx) 把自己交给核心循环阻塞等待。所有真正的调度逻辑都在 pkg/scheduler 里。
3.2 核心 struct:Scheduler
整个 kube-scheduler 的"灵魂"是 pkg/scheduler/scheduler.go 中的 Scheduler struct(v1.36.1 定义于行 68)。我们可以把它拆成 5 个核心字段:
// pkg/scheduler/scheduler.go (行 68-125, k8s v1.36.1)
type Scheduler struct {// 1. 本地缓存:NodeInfo、PVC、PV、ResourceClaim 等// Cache 决定了 Filter / Score 阶段能"看到"什么数据Cache internalcache.Cache// 2. 调度队列:待调度的 Pod 在这里排队SchedulingQueue internalqueue.SchedulingQueue// 3. Scheduling Framework 句柄:含全部 Profile 和 PluginProfiles profile.Map// 4. 客户端:与 apiserver 交互client clientset.Interface// 5. 闭包函数:可被外部替换为测试用的 fakeNextPod func(logger klog.Logger) (*framework.QueuedPodInfo, error)SchedulePod func(ctx context.Context, fwk framework.Framework, ...) (ScheduleResult, error)FailureHandler FailureHandlerFnregisteredHandlers []cache.ResourceEventHandlerRegistration // 已注册的事件 handler
}
一个很巧妙的设计是 NextPod / SchedulePod / FailureHandler 都是函数字段(闭包),默认实现由 applyDefaultHandlers() 注入(行 127),但测试时可以用 fake 实现替换。这是典型的"依赖注入"模式,让 Scheduler 易于单测。
3.3 主循环:Scheduler.Run
看核心的 Run 函数(pkg/scheduler/scheduler.go:546):
// pkg/scheduler/scheduler.go (行 545-573, k8s v1.36.1)
// Run begins watching and scheduling. It starts scheduling and blocked until the context is done.
func (sched *Scheduler) Run(ctx context.Context) {logger := klog.FromContext(ctx)sched.SchedulingQueue.Run(logger) // 1. 启动队列(维护 activeQ/unschedulableQ/backoffQ)if sched.APIDispatcher != nil {sched.APIDispatcher.Run(logger) // 2. 启动异步 API 调用派发器}// 3. 关键:启动 scheduleOne 循环,0 表示不间隔go wait.UntilWithContext(ctx, sched.ScheduleOne, 0)<-ctx.Done() // 4. 阻塞到 ctx 取消if sched.APIDispatcher != nil {sched.APIDispatcher.Close()}sched.SchedulingQueue.Close() // 5. 关闭队列err := sched.Profiles.Close() // 6. 关闭 Profiles(释放 plugin 资源)
}
整个 Scheduler.Run 极其简洁:3 行核心逻辑,1 行阻塞等待。真正的"调度"行为发生在 scheduleOne 这个循环函数里(下一个 goroutine 启动)。
🌟 设计精髓
为什么用 go wait.UntilWithContext(ctx, sched.ScheduleOne, 0) 启动新 goroutine?注释说得很清楚:scheduleOne 会阻塞在 NextPod 上等下一个 Pod。如果在主 goroutine 里跑,关闭队列时就会死锁(没人调 Pop,主 goroutine 永远不退出)。所以单独开一个 goroutine 跑 scheduleOne,主 goroutine 专门负责监听 ctx 取消和清理。
3.4 核心模块全景图
把上面的信息拼起来,kube-scheduler 内部可以拆成 6 大模块,它们各司其职、相互协作:
┌────────────────────────────────────────────────────────────────────┐
│ kube-scheduler (k8s v1.36.1) 内部模块全景 │
└────────────────────────────────────────────────────────────────────┘│┌──────────────────────┐ ┌──────────────────────┐ ││ 1. cmd/kube-scheduler│ │ 2. Scheduler struct │ ││ ────────────────── │ │ ────────────────── │ ││ main.go │ │ pkg/scheduler/ │ ││ app/server.go │ │ scheduler.go │ ││ ─ Run/Setup/flags │ │ ─ Run/ScheduleOne │ ││ ─ healthz/metrics │ │ ─ Cache/Queue/Profile│ ││ ─ Leader Election │ │ ─ NextPod/SchedulePod│ │└──────────┬───────────┘ └──────────┬───────────┘ ││ │ ││ 启动入口 │ 核心调度循环 │└─────────┬─────────────────┘ │▼ │┌─────────────────────────────────────────────────┐ ││ 3. eventhandlers.go │ ││ ───────────────────────────────────────────── │ ││ addAllEventHandlers: │ ││ 注册 Pod/Node/PV/PVC/CSINode/Service 等 │ ││ informer 的 ResourceEventHandler │ ││ ─ 把集群事件"翻译"成 SchedulingQueue 的操作 │ ││ (addPod/updatePod/deletePod/...) │ │└──────────┬──────────────────────────────────────┘ ││ │▼ │┌──────────────────────────────────────────────────┐ ││ 4. backend/queue/scheduling_queue.go │ ││ ─ PriorityQueue 实现 │ ││ ─ 三段队列: activeQ / unschedulableQ / backoffQ │ ││ ─ Pop() / Add() / AddIfNotPresent() / MoveAll... │ ││ ─ 维护 inFlightPods(QHint 特性) │ │└──────────┬───────────────────────────────────────┘ ││ Pop() 返回 QueuedPodInfo │▼ │┌──────────────────────────────────────────────────┐ ││ 5. schedule_one.go (ScheduleOne / schedulePod) │ ││ ────────────────────────────────────────────── │ ││ ① 选 Framework(按 pod.Spec.SchedulerName) │ ││ ② schedulingCycle: │ ││ PreFilter → Filter → PostFilter │ ││ → PreScore → Score → Reserve (AssumePod) │ ││ ③ bindingCycle: │ ││ Permit → PreBind → Bind (写 apiserver) │ ││ ④ 失败 → handleSchedulingFailure │ │└──────────┬───────────────────────────────────────┘ ││ 调用 Filter / Score │▼ │┌──────────────────────────────────────────────────┐ ││ 6. framework/ (Scheduling Framework 核心) │ ││ ────────────────────────────────────────────── │ ││ framework.go ─ Framework interface 实现 │ ││ plugins/ ─ 内置插件(NodeResourcesFit,│ ││ NodeAffinity, TaintToleration,│ ││ VolumeBinding, PodTopologySpread, ...)│ ││ cycle_state.go ─ CycleState(plugin 间传数据)│ ││ registry.go ─ Plugin Registry │ │└──────────┬───────────────────────────────────────┘ ││ │▼ │┌──────────────────────────────────────────────────┐ ││ 7. backend/cache/cache.go (Cache) │ ││ ────────────────────────────────────────────── │ ││ AddPod/UpdatePod/RemovePod/AddNode/... │ ││ AssumePod/ForgetPod(假定缓存,调度即占用资源) │ ││ UpdateSnapshot(生成 snapshot 给 Score 阶段用) │ │└──────────────────────────────────────────────────┘ │
记住这个全景图:事件驱动模块(3)把集群变化翻译为队列操作 → 队列模块(4)管理待调度 Pod → 调度循环(5)从队列取 Pod 跑调度流水线 → Framework(6)执行具体的 Filter / Score 逻辑 → Cache(7)为整个过程提供"实时"集群状态视图。
3.5 scheduleOne 一次循环做了什么
代码位于 pkg/scheduler/schedule_one.go(v1.36.1 行 67)。它的核心骨架如下:
// pkg/scheduler/schedule_one.go(k8s v1.36.1 关键流程简化)
func (sched *Scheduler) ScheduleOne(ctx context.Context) {// 1. 从 SchedulingQueue 阻塞获取下一个待调度 PodpInfo, err := sched.NextPod(logger)if pInfo == nil { return }// 2. 根据 pod.Spec.SchedulerName 选择对应的 Framework(profile)fwk, err := sched.frameworkForPod(pInfo.Pod)// 3. 判断是否跳过(如 pod 已被 nominated 或被替换)if skip, status := sched.skipPodSchedule(ctx, fwk, pInfo.Pod); skip { return }// 4. scheduling cycle:跑 Filter + Scorestate := framework.NewCycleState()scheduleResult, status := sched.schedulingCycle(ctx, fwk, state, pInfo)// 5. 调度成功 → binding cycleif status.IsSuccess() {status = sched.bindingCycle(ctx, fwk, state, scheduleResult, pInfo, start, podsToActivate)}// 6. 失败 → handleSchedulingFailureif !status.IsSuccess() {sched.FailureHandler(ctx, fwk, pInfo, status, clearNominatedNode, start)}
}
注意 schedulingCycle 和 bindingCycle 是分开的。schedulingCycle 只在内存里"算"出最合适的 Node 并假定(Assume),bindingCycle 才真正把 Binding 对象写到 apiserver。中间用 CycleState(pkg/scheduler/framework/cycle_state.go)传数据——这是一个基于 sync.Map 的"写一次读多次"容器,让 PreFilter 算出的中间结果在 Filter 阶段被复用。
3.6 SchedulingQueue:三段优先级队列
实现位于 pkg/scheduler/backend/queue/scheduling_queue.go,核心是 PriorityQueue。它内部维护三个堆/队列:
| 队列 | 存放什么 | 何时出队 |
|---|---|---|
| activeQ | 新 Pod / 重新激活的 Pod | 按 priority + 时间排序,由 scheduleOne Pop() 取出 |
| unschedulableQ | 调度失败的 Pod(含失败原因) | 等 backoff timer 到期 或 相关事件触发 MoveAllToActiveOrBackoffQueue |
| backoffQ | 被 backoff 限流的 Pod | 指数退避计时器到期后移入 unschedulableQ |
| inFlightPods(v1.36.1) | 正在被 scheduleOne 处理的 Pod | 处理完成后调 Done(uid) 移除;用于 QueueingHint 特性 |
为什么要分这么多队列?防止"抖动"和"惊群"。比如一个 Pod 调度失败,盲目立刻重试没有意义(资源还没释放),所以先放 unschedulableQ 等事件触发;又比如大量 Pod 同时涌入,先在 backoffQ 限流避免 apiserver 过载。
🚀 v1.36.1 新增
v1.36.1 引入了 QueueingHint 特性:插件可以告诉队列"某种事件会让某些 Pod 重新可调度",队列据此精准地把 Pod 从 unschedulableQ 移到 activeQ,避免全量重排。代码位置:pkg/scheduler/framework/plugins/schedulinggates/scheduling_gates.go:69(SchedulingGates 插件已率先支持)。
四、Detail:事件驱动机制深度解析
到这里你可能有个疑问:调度器怎么知道有新的 Pod?Node 资源变化时它怎么知道要重排队列?答案就是本节要讲的事件驱动机制。它的核心是 k8s 所有组件都在用的标准模式:List + Watch + Local Cache + EventHandler。
4.1 整体流程图
┌────────────────────┐│ kube-apiserver ││ (etcd 真实存储) │└─────────┬──────────┘│ ▲List 拉全量│ │Watch 推增量(HTTP long-poll)▼ │┌──────────────────────────────┐│ SharedInformerFactory ││ (client-go 提供) ││ ─ PodInformer / NodeInformer ││ ─ PVInformer / PVCInformer ││ ─ CSINodeInformer / ... │└─────────────┬────────────────┘│ Reflector 把事件分发给▼┌──────────────────────────────┐│ ResourceEventHandlerFuncs ││ (注册在 informer 上) ││ ─ AddFunc / UpdateFunc ││ ─ DeleteFunc │└─────────────┬────────────────┘│ 由 Scheduler 在 addAllEventHandlers│ 中注册,桥接到 SchedulingQueue▼┌──────────────────────────────┐│ SchedulingQueue ││ (PriorityQueue 三段队列) ││ ─ Add / AddIfNotPresent ││ ─ MoveAllToActiveOrBackoffQ │└─────────────┬────────────────┘│ Pop▼┌──────────────────────────────┐│ ScheduleOne 循环 ││ 走 Filter / Score / Bind │└──────────────────────────────┘
这是 k8s 所有控制面组件的通用范式:从 apiserver 拉数据到本地缓存 → 通过事件回调通知业务 → 业务把事件转化为工作队列。kube-scheduler 也不例外。
4.2 起点:SharedInformerFactory + 多资源 Informer
kube-scheduler 启动时,通过 staging/src/k8s.io/client-go/informers 包下的 SharedInformerFactory 同时启动多个 Informer:
// cmd/kube-scheduler/app/server.go(v1.36.1)
// 用 client-go 提供的 factory 启动所有内置 Informer
cc.InformerFactory.Start(ctx.Done())
if cc.DynInformerFactory != nil {cc.DynInformerFactory.Start(ctx.Done())
}
// 阻塞等待所有缓存完成首次 List 同步
cc.InformerFactory.WaitForCacheSync(ctx.Done())
// 关键:还要等所有注册的 EventHandler 处理完首次 List 的事件
if err := sched.WaitForHandlersSync(ctx); err != nil {logger.Error(err, "handlers are not fully synchronized")
}
在 v1.36.1 中,kube-scheduler 至少需要监听 以下资源类型:
| 资源 | 来源 | 作用 |
|---|---|---|
| Pod | informerFactory.Core().V1().Pods() | 新 Pod 入队、已调度的 Pod 重排 |
| Node | informerFactory.Core().V1().Nodes() | 资源/标签/污点变化,触发 unschedulable Pod 重排 |
| PersistentVolume | informerFactory.Core().V1().PersistentVolumes() | 新增 PV 后,等 PV 的 Pod 可重新调度 |
| PersistentVolumeClaim | informerFactory.Core().V1().PersistentVolumeClaims() | PVC Bound 后让依赖 Pod 可调度 |
| CSINode / CSIDriver | informerFactory.Storage().V1().CSINodes() | VolumeBinding 插件需要感知存储拓扑 |
| StorageClass | informerFactory.Storage().V1().StorageClasses() | 新建 StorageClass 后影响未绑定 PVC 的 Pod |
| Service | informerFactory.Core().V1().Services() | 用于某些 plugin(如 NodeAffinity)做 Service 拓扑感知 |
| PodGroup (v1.36.1) | informerFactory.Scheduling().V1alpha2().PodGroups() | Gang Scheduling(需开启 GenericWorkload feature gate) |
4.3 核心枢纽:pkg/scheduler/eventhandlers.go
所有"事件 → 队列"的翻译工作,都集中在 pkg/scheduler/eventhandlers.go 中。其中 addAllEventHandlers 函数是入口(v1.36.1 行 481):
// pkg/scheduler/eventhandlers.go (行 481-507, k8s v1.36.1)
func addAllEventHandlers(sched *Scheduler,informerFactory informers.SharedInformerFactory,dynInformerFactory dynamicinformer.DynamicSharedInformerFactory,...
) error {// 1. Pod Informer:调度器最关心的事件源informerFactory.Core().V1().Pods().Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{AddFunc: sched.addPod, // 新 Pod → 入队UpdateFunc: sched.updatePod, // Pod 更新 → 重新入队或忽略DeleteFunc: sched.deletePod, // Pod 删除 → ForgetPod})// 2. Node Informer:节点变化触发 unschedulable Pod 重排informerFactory.Core().V1().Nodes().Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{AddFunc: sched.addNodeToCache,UpdateFunc: sched.updateNodeInCache,DeleteFunc: sched.deleteNodeFromCache,},)// 3. 其他资源:CSINode / PV / PVC / StorageClass / Service / PodGroup ...// 通过 buildEvtResHandler 工厂方法动态注册for gvk, at := range gvkMap {switch gvk {case fwk.CSINode: /* ... */case fwk.PersistentVolume: /* ... */case fwk.PersistentVolumeClaim: /* ... */case fwk.PodGroup: /* ... */default: /* 用 dynInformerFactory 处理 CRD */}}
}
我们以 addPod / addNodeToCache 为例,看具体怎么"翻译"。先看 addNodeToCache 的完整实现(pkg/scheduler/eventhandlers.go:53):
// pkg/scheduler/eventhandlers.go (行 53-66, k8s v1.36.1)
func (sched *Scheduler) addNodeToCache(obj interface{}) {evt := fwk.ClusterEvent{Resource: fwk.Node, ActionType: fwk.Add}defer metrics.EventHandlingLatency.ObserveSince(time.Now(), evt.Label())()logger := sched.loggernode, ok := obj.(*v1.Node)if !ok {utilruntime.HandleErrorWithLogger(logger, nil, "Cannot convert to *v1.Node", "obj", obj)return}logger.V(3).Info("Add event for node", "node", klog.KObj(node))// 步骤 1:把 Node 加入本地 CachenodeInfo := sched.Cache.AddNode(logger, node)// 步骤 2:把所有 unschedulable Pod 重新移到 activeQ 重新评估sched.SchedulingQueue.MoveAllToActiveOrBackoffQueue(logger, evt, nil, node, preCheckForNode(logger, nodeInfo))
}
一个看似简单的"新增节点"事件,触发了两个动作:
- 更新 Cache:
sched.Cache.AddNode()把 Node 加入本地缓存,下一次 Score 就能看到这个 Node - 重排队列:
MoveAllToActiveOrBackoffQueue()把所有当前被"判 unschedulable"的 Pod 重新激活,加入下一轮调度
这就是"事件驱动"的核心价值:不需要轮询,集群一有变化就被推过来,业务侧只需把事件翻译成工作队列操作。
4.4 Pod 事件:addPod / updatePod / deletePod
Pod 是最复杂的事件源,调度器需要做精细判断(不是所有 Pod Update 都要重排)。典型的 addPod 处理逻辑(v1.36.1)会做:
- 判断是否需要调度:
spec.nodeName为空、spec.schedulerName匹配、Pod 未被删除(deletionTimestamp为空) - 调用
runPreEnqueuePlugins:每个 profile 可以注册 PreEnqueue plugin 做"快速门禁"(如 SchedulingGates 检查) - 加入
SchedulingQueue.Add(已存在则用AddIfNotPresent跳过)
updatePod 更复杂:
- 如果 Pod 已经被 nominated(被预选为某个 Node)、或新加入了 SchedulingGates、或关键 label 改变——需要重新调度
- 如果只是 status 字段更新(如容器启动状态变化),调度器不关心,直接 return
这种"事件过滤"非常重要,否则 apiserver 的每一个事件都会冲击调度队列,导致大量无效工作。
4.5 关键:WaitForHandlersSync 是什么?
很多新手会把 WaitForCacheSync 和 WaitForHandlersSync 混为一谈,其实它们是两件事:
| 方法 | 在哪等 | 等什么 |
|---|---|---|
| WaitForCacheSync | Informer 内部 | Reflector 拉完第一次 List,本地 Indexer 填好 |
| WaitForHandlersSync | Scheduler 层 | 所有已注册 EventHandler 把 List 中的每个对象都处理完(都调用了一次 AddFunc/UpdateFunc) |
为什么要等第二层?因为 WaitForCacheSync 完成后,本地缓存虽然填好了,但 List 阶段产生的事件还没被 EventHandler 处理(这些事件是异步分发的)。如果此时 scheduleOne 开始取 Pod,可能错过"先创建后又被删"的 Pod,或漏算某些已被假定但未进 cache 的 Pod。
v1.36.1 的 server.go 专门为它加了 healthz check:
// cmd/kube-scheduler/app/server.go (行 220-227, k8s v1.36.1)
handlerSyncReadyCh := make(chan struct{})
handlerSyncCheck := healthz.NamedCheck("sched-handler-sync", func(_ *http.Request) error {select {case <-handlerSyncReadyCh:return nildefault:}return fmt.Errorf("handlers are not fully synchronized")
})
readyzChecks = append(readyzChecks, handlerSyncCheck)
也就是说,/readyz 返回 200 之前,kube-scheduler 不会真正开始调度。这是生产环境排障的常用入口:如果你看到 kube-scheduler 一直没 Ready,第一反应就是看是不是 handler 还没同步完。
4.6 整体时序图
用户 kubectl kube-apiserver SharedInformer ResourceEventHandler SchedulingQueue ScheduleOne│ │ │ │ │ │ ││ apply Pod ──┼─────────────────►│ │ │ │ ││ │ │ 写入 etcd │ │ │ ││ │ │ 触发 Watch 事件 ───►│ 收到 ADD 事件 │ │ ││ │ │ │ 调用 addPod handler│ │ ││ │ │ │ ──────────────────►│ │ ││ │ │ │ │ Cache.AddPod │ ││ │ │ │ │ Queue.Add(pod) ─────►│ ││ │ │ │ │ │ Pop() 阻塞 ││ │ │ │ │ │ ───────────────────►││ │ │ │ │ │ │ 取到 Pod│ │ │ │ │ │ │ schedulingCycle│ │ │ │ │ │ │ Filter/Score│ │ │ │ │ │ │ AssumePod│ │ │ │ │ │ │ bindingCycle│ │ │◄───────────────────┼────────────────────┼──────────────────────┼────────────────────│ POST /bindings│ │ │ 写入 Pod.Spec.NodeName │ ││ │ │ ────► Node 端 kubelet 监听到 │ ││ │ │ 开始创建容器 │ ││ │ │ │ │ │ │ Done(uid)│ │ │ │ │ │ │ 循环取下一个│ │ │ │ │ │ │
4.7 一个小例子:Node 增加资源后会发生什么?
把上述知识串成一个具体场景:
- 运维给 Node
worker-1扩容 32Gi 内存(kubectl edit node 或 ccm 自动调整) - apiserver 把 Node 对象更新到 etcd
- kube-scheduler 的 NodeInformer 通过 Watch 收到 Update 事件
updateNodeInCache被调用:sched.Cache.UpdateNode()更新本地 cache- 计算
NodeSchedulingPropertiesChange,识别出"capacity 变化"等关键属性 - 调用
MoveAllToActiveOrBackoffQueue(),把所有之前因"内存不足"被标 unschedulable 的 Pod 移到 activeQ - scheduleOne 从 activeQ 取出这些 Pod 重新跑调度,可能命中新 Node
整个过程无需重启 scheduler、无需轮询,纯粹由事件驱动。这就是 k8s 控制面的优雅之处。
💡 注意
有些事件会触发"全量重排"(MoveAllToActiveOrBackoffQueue),这在超大集群(>5000 节点)上可能成为性能瓶颈。v1.36.1 引入的 QueueingHint 特性正是为了解决这个问题:插件能告诉队列"只有 X 类 Pod 受影响",避免无差别重排。
五、Roadmap:后续专题预告与学习路径
本文是调度专题的开篇,只建立总览心智模型。后续会按下面顺序逐篇展开,感兴趣的读者可以先标记:
- Scheduling Framework 深度解析:逐个讲解 PreFilter/Filter/Score/Reserve/Bind 等扩展点的源码、调用顺序、数据如何在 CycleState 中传递
- 内置插件逐个精读:NodeResourcesFit、NodeAffinity、TaintToleration、PodTopologySpread、VolumeBinding、InterPodAffinity 等
- 抢占(Preemption)算法剖析:defaultpreemption 插件如何选择 victim、PodDisruptionBudget 如何约束
- SchedulingQueue 与 QueueingHint:三段队列的细节、v1.36 新引入的 QueueingHint 工作机制
- Scheduler Profile 与多调度器:如何配置多个 profile 实现多租户、Coordinated LeaderElection
- 自定义插件开发实战:手写一个 Score 插件并注册到集群
5.1 给初学者的源码阅读建议
如果你想直接啃源码,推荐按下面的顺序读,每一步都建立在上一部的概念上:
- cmd/kube-scheduler/app/server.go — 看 Run 函数,理解启动流程(约 100 行)
- pkg/scheduler/scheduler.go — 看 Scheduler struct 定义和 Run 方法(约 100 行)
- pkg/scheduler/eventhandlers.go — 看 addAllEventHandlers(约 200 行)
- pkg/scheduler/schedule_one.go — 看 ScheduleOne / schedulingCycle / bindingCycle(约 400 行)
- pkg/scheduler/framework/framework.go — 看 RunPreFilterPlugins / RunFilterPlugins / RunScorePlugins 等 framework 核心方法
- pkg/scheduler/framework/plugins/noderesources/fit.go — 看一个具体 plugin 的完整实现,作为范例
读完这些,你就能把本文的"心智模型"对到具体代码上。
5.2 一句话总结
kube-scheduler 是个"事件驱动的两阶段调度器":通过 Informer 监听 Pod/Node/各种资源变化,把事件翻译成 SchedulingQueue 的入队操作;调度循环从队列里取 Pod,先用一系列 Filter 插件过滤,再用 Score 插件打分排名,最后 Bind 插件把结果写回 apiserver。Assume 缓存机制让调度决策和资源占用"瞬时一致",避免并发过载;Scheduling Framework 把所有这些规则抽象成可插拔的扩展点,让调度行为可以灵活定制。
记住这三个关键词:两阶段(Filter → Score)、插件化(Framework)、事件驱动(Informer → Queue)。下次有人问你 kube-scheduler 怎么工作,就可以用这三点展开。
本文参考与源码链接:
• cmd/kube-scheduler/ 入口
• pkg/scheduler/ 核心实现
• staging/src/k8s.io/kube-scheduler/framework 扩展点定义
• Kubernetes 官方文档:调度与驱逐
• Scheduling Framework 官方文档
Kubernetes 调度专题【左扬精讲】—— 初识 kube-scheduler:调度模型、内部架构与事件驱动机制 · 来源:k8s 源码 v1.36.1 深度分析
