

























当你 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 流程(启动入口)
📋 文章目录
在 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 本身也是一个"高可用"组件,但同一时刻只有一个实例在执行调度决策,避免并发冲突。
从算法角度看,kube-scheduler 解决的是一个典型的多约束配对问题:给定一个待调度的 Pod 和一组 Node,找出"最合适"的那个 Node。这里的"最合适"由一系列过滤(Filter)+打分(Score)规则共同决定。Filter 回答"能不能",Score 回答"好不好"。
社区里很多人把这套模型叫两阶段调度(Two-Stage Scheduling):
Text
┌──────────────────────────────────────────────────────────────┐
│ 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 起就没有变过——变的只是每阶段的插件实现、扩展点和性能优化。
理解了"两阶段"还不够,kube-scheduler 真正的复杂度在于它不止处理新 Pod。下面这些场景全部要触发调度:
这就需要 kube-scheduler 维护一个待调度 Pod 队列,并对外监听多种资源变化。这正是后面要讲的"事件驱动"机制的核心价值。
自 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 / 抢占流程。
初学者最应该理解的设计是 Assume(假定缓存)。它解决了一个核心问题:
在 k8s v1.0 时代,调度器选完节点后,必须等 Binding 写回 apiserver 完成(可能耗时几十毫秒~几秒),才能认为这个 Node 的资源被占用了。这就导致:
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 状态都已经准备好了,不会出现"先来后到"问题。
我们把视线从"模型"切到"代码",看 kube-scheduler 的内部模块。k8s 1.36.1 的 kube-scheduler 实现主要在两个目录:
main.go / app/server.go)scheduler.go、schedule_one.go、eventhandlers.go、backend/queue/、backend/cache/、framework/)二进制的启动入口遵循 k8s 组件的"标准模板":cobra 命令 → flags 解析 → runCommand → Setup → Run。我们重点看 Run 函数:
// cmd/kube-scheduler/app/server.go (行 173-310, k8s v1.36.1)
Go
// 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()))
// 启动健康检查 endpoint
if 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 里。
整个 kube-scheduler 的"灵魂"是 pkg/scheduler/scheduler.go 中的 Scheduler struct(v1.36.1 定义于行 68)。我们可以把它拆成 5 个核心字段:
// pkg/scheduler/scheduler.go (行 68-125, k8s v1.36.1)
Go
type Scheduler struct {
// 1. 本地缓存:NodeInfo、PVC、PV、ResourceClaim 等
// Cache 决定了 Filter / Score 阶段能"看到"什么数据
Cache internalcache.Cache
// 2. 调度队列:待调度的 Pod 在这里排队
SchedulingQueue internalqueue.SchedulingQueue
// 3. Scheduling Framework 句柄:含全部 Profile 和 Plugin
Profiles profile.Map
// 4. 客户端:与 apiserver 交互
client clientset.Interface
// 5. 闭包函数:可被外部替换为测试用的 fake
NextPod func(logger klog.Logger) (*framework.QueuedPodInfo, error)
SchedulePod func(ctx context.Context, fwk framework.Framework, ...) (ScheduleResult, error)
FailureHandler FailureHandlerFn
registeredHandlers []cache.ResourceEventHandlerRegistration // 已注册的事件 handler
}
一个很巧妙的设计是 NextPod / SchedulePod / FailureHandler 都是函数字段(闭包),默认实现由 applyDefaultHandlers() 注入(行 127),但测试时可以用 fake 实现替换。这是典型的"依赖注入"模式,让 Scheduler 易于单测。
看核心的 Run 函数(pkg/scheduler/scheduler.go:546):
// pkg/scheduler/scheduler.go (行 545-573, k8s v1.36.1)
Go
// 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 取消和清理。
把上面的信息拼起来,kube-scheduler 内部可以拆成 6 大模块,它们各司其职、相互协作:
Text
┌────────────────────────────────────────────────────────────────────┐
│ 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)为整个过程提供"实时"集群状态视图。
代码位于 pkg/scheduler/schedule_one.go(v1.36.1 行 67)。它的核心骨架如下:
// pkg/scheduler/schedule_one.go(k8s v1.36.1 关键流程简化)
Go
func (sched *Scheduler) ScheduleOne(ctx context.Context) {
// 1. 从 SchedulingQueue 阻塞获取下一个待调度 Pod
pInfo, 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 + Score
state := framework.NewCycleState()
scheduleResult, status := sched.schedulingCycle(ctx, fwk, state, pInfo)
// 5. 调度成功 → binding cycle
if status.IsSuccess() {
status = sched.bindingCycle(ctx, fwk, state, scheduleResult, pInfo, start, podsToActivate)
}
// 6. 失败 → handleSchedulingFailure
if !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 阶段被复用。
实现位于 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 插件已率先支持)。
到这里你可能有个疑问:调度器怎么知道有新的 Pod?Node 资源变化时它怎么知道要重排队列?答案就是本节要讲的事件驱动机制。它的核心是 k8s 所有组件都在用的标准模式:List + Watch + Local Cache + EventHandler。
Text
┌────────────────────┐
│ 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 也不例外。
kube-scheduler 启动时,通过 staging/src/k8s.io/client-go/informers 包下的 SharedInformerFactory 同时启动多个 Informer:
// cmd/kube-scheduler/app/server.go(v1.36.1)
Go
// 用 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) |
所有"事件 → 队列"的翻译工作,都集中在 pkg/scheduler/eventhandlers.go 中。其中 addAllEventHandlers 函数是入口(v1.36.1 行 481):
// pkg/scheduler/eventhandlers.go (行 481-507, k8s v1.36.1)
Go
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)
Go
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.logger
node, 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 加入本地 Cache
nodeInfo := sched.Cache.AddNode(logger, node)
// 步骤 2:把所有 unschedulable Pod 重新移到 activeQ 重新评估
sched.SchedulingQueue.MoveAllToActiveOrBackoffQueue(logger, evt, nil, node, preCheckForNode(logger, nodeInfo))
}
一个看似简单的"新增节点"事件,触发了两个动作:
sched.Cache.AddNode() 把 Node 加入本地缓存,下一次 Score 就能看到这个 NodeMoveAllToActiveOrBackoffQueue() 把所有当前被"判 unschedulable"的 Pod 重新激活,加入下一轮调度这就是"事件驱动"的核心价值:不需要轮询,集群一有变化就被推过来,业务侧只需把事件翻译成工作队列操作。
Pod 是最复杂的事件源,调度器需要做精细判断(不是所有 Pod Update 都要重排)。典型的 addPod 处理逻辑(v1.36.1)会做:
spec.nodeName 为空、spec.schedulerName 匹配、Pod 未被删除(deletionTimestamp 为空)runPreEnqueuePlugins:每个 profile 可以注册 PreEnqueue plugin 做"快速门禁"(如 SchedulingGates 检查)SchedulingQueue.Add(已存在则用 AddIfNotPresent 跳过)updatePod 更复杂:
这种"事件过滤"非常重要,否则 apiserver 的每一个事件都会冲击调度队列,导致大量无效工作。
很多新手会把 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)
Go
handlerSyncReadyCh := make(chan struct{})
handlerSyncCheck := healthz.NamedCheck("sched-handler-sync", func(_ *http.Request) error {
select {
case <-handlerSyncReadyCh:
return nil
default:
}
return fmt.Errorf("handlers are not fully synchronized")
})
readyzChecks = append(readyzChecks, handlerSyncCheck)
也就是说,/readyz 返回 200 之前,kube-scheduler 不会真正开始调度。这是生产环境排障的常用入口:如果你看到 kube-scheduler 一直没 Ready,第一反应就是看是不是 handler 还没同步完。
Text
用户 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)
│ │ │ │ │ │ │ 循环取下一个
│ │ │ │ │ │ │
把上述知识串成一个具体场景:
worker-1 扩容 32Gi 内存(kubectl edit node 或 ccm 自动调整)updateNodeInCache 被调用:sched.Cache.UpdateNode() 更新本地 cacheNodeSchedulingPropertiesChange,识别出"capacity 变化"等关键属性MoveAllToActiveOrBackoffQueue(),把所有之前因"内存不足"被标 unschedulable 的 Pod 移到 activeQ整个过程无需重启 scheduler、无需轮询,纯粹由事件驱动。这就是 k8s 控制面的优雅之处。
💡 注意
有些事件会触发"全量重排"(MoveAllToActiveOrBackoffQueue),这在超大集群(>5000 节点)上可能成为性能瓶颈。v1.36.1 引入的 QueueingHint 特性正是为了解决这个问题:插件能告诉队列"只有 X 类 Pod 受影响",避免无差别重排。
本文是调度专题的开篇,只建立总览心智模型。后续会按下面顺序逐篇展开,感兴趣的读者可以先标记:
如果你想直接啃源码,推荐按下面的顺序读,每一步都建立在上一部的概念上:
读完这些,你就能把本文的"心智模型"对到具体代码上。
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 深度分析
D:\worker-go\kubernetes-1.36.1\blog-output\kubernetes-1.36.1-kube-scheduler-intro-cnblogs.html
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。