KAI Scheduler 深入拆解(四):从源码走读一次调度周期

前面两篇讲了架构和 workflow,上一篇讲了框架层的抽象。这一篇开始不再停留在概念,而是直接沿着源码主链路走一遍 KAI Scheduler 的一次调度周期。

我的目标不是把每个函数都抄一遍,而是回答五个更实在的问题:

  1. scheduler 进程是怎么启动的?
  2. action / plugin 是怎么注册进去的?
  3. 一轮 session 是怎么打开和关闭的?
  4. placement 决策最后是怎么变成 BindRequest 的?
  5. binder 是怎么把这个决策真正执行掉的?

如果你只准备精读少数几个文件,我建议把注意力放在下面这几处:

  • cmd/scheduler/app/server.go
  • pkg/scheduler/scheduler.go
  • pkg/scheduler/framework/framework.go
  • pkg/scheduler/framework/session.go
  • pkg/scheduler/actions/factory.go
  • pkg/scheduler/plugins/factory.go
  • pkg/scheduler/cache/cache.go
  • pkg/apis/scheduling/v1alpha2/bindrequest_types.go
  • pkg/binder/controllers/bindrequest_controller.go

第一站:scheduler 进程入口不是算法,而是平台启动逻辑

真正的入口在 cmd/scheduler/app/server.go

RunApp() 先做的是运行时准备

从代码顺序上看,大致是:

  1. 解析和校验命令行参数
  2. 启动 plugin server 的 HTTP mux
  3. 初始化 profiling / pyroscope
  4. 初始化 logging
  5. 读取 kube config 并设置 QPS / Burst
  6. 进入 Run(...)

也就是说,KAI scheduler 本身就是一个标准 Kubernetes 控制面进程,而不是“读个配置然后进一个大循环”的轻量程序。

Run() 里最重要的两行

对理解调度内核最关键的,其实是这两步:

1
2
actions.InitDefaultActions()
plugins.InitDefaultPlugins()

这代表 KAI 在启动阶段先把“可用动作”和“可用插件”注册进框架,再从配置解析出本次实际启用的 action/plugin 组合。

这有个很重要的意义:

注册是能力集合,配置才是实际执行计划。

换句话说:

  • InitDefaultActions() 负责告诉框架有哪些 action 存在
  • InitDefaultPlugins() 负责告诉框架有哪些 plugin 可用
  • ResolveConfigurationFromFile(...) 决定这一轮部署到底启用哪些,以及它们的优先级和参数是什么

这比很多项目直接在代码里写死流程要干净得多。

第二站:action / plugin 的“能力注册表”非常直观

Action 工厂:流程阶段被清楚写死

pkg/scheduler/actions/factory.go 很短,但很重要:

1
2
3
4
5
6
7
func InitDefaultActions() {
framework.RegisterAction(reclaim.New())
framework.RegisterAction(allocate.New())
framework.RegisterAction(preempt.New())
framework.RegisterAction(consolidation.New())
framework.RegisterAction(stalegangeviction.New())
}

这里要注意一个细节:注册顺序不等于执行顺序

真正的执行顺序来自配置解析,而默认优先级则来自 SchedulingShard 的 action 配置语义:

  • allocate
  • consolidation
  • reclaim
  • preempt
  • stalegangeviction

所以代码层面的注册只是“把工厂挂到框架里”,而不是“马上决定调度流程”。

Plugin 工厂:策略面非常丰富

pkg/scheduler/plugins/factory.go 更能体现 KAI 的平台化程度。默认注册的 plugin 包括:

  • predicates
  • priority
  • nodeplacement
  • nominatednode
  • nodeavailability
  • gpusharingorder
  • gpupack
  • gpuspread
  • resourcetype
  • podaffinity
  • elastic
  • kubeflow
  • ray
  • taskorder
  • subgrouporder
  • dynamicresources
  • topology
  • proportion
  • minruntime
  • snapshot
  • reflectjoborder

这张列表本身已经非常说明问题:KAI 不是一个只会做 node predicate + score 的 scheduler,它的策略面覆盖了:

  • queue fairness
  • AI workload integration
  • GPU placement
  • topology
  • subgroup ordering
  • dynamic resources
  • minimum runtime
  • snapshot analysis

也就是说,KAI 的“可扩展性”不是停留在接口层,而是已经沉淀成一个很具体的内建策略生态。

第三站:NewScheduler() 组装出来的不是一个算法对象,而是一整套运行环境

进入 pkg/scheduler/scheduler.go 后,可以看到 NewScheduler(...) 做了很多准备工作:

  • 创建 Kubernetes client 和 KAI 自定义 client
  • 创建 discovery client
  • 初始化 usage DB client
  • 组装 SchedulerCacheParams
  • 创建 scheduler cache
  • 保存 SchedulerConfigurationSchedulerParams

这里非常值得注意的是 SchedulerCacheParams 内容,它不是普通缓存初始化参数,而是把 KAI 的运行语义一并带进来了:

  • SchedulerName
  • NodePoolParams
  • RestrictNodeScheduling
  • DetailedFitErrors
  • ScheduleCSIStorage
  • FullHierarchyFairness
  • NumOfStatusRecordingWorkers
  • UpdatePodEvictionCondition
  • UsageDBClient

这意味着 cache 不只是个 informer wrapper,而是调度器真正的运行底座。

第四站:调度循环的核心,真的就只有 runOnce() 这么短

从代码阅读体验来看,KAI 最漂亮的地方之一,就是主循环本身极简。

Run()

1
2
3
4
5
6
7
8
func (s *Scheduler) Run(stopCh <-chan struct{}) {
s.cache.Run(stopCh)
s.cache.WaitForCacheSync(stopCh)

go func() {
wait.Until(s.runOnce, s.schedulePeriod, stopCh)
}()
}

先启动 cache,再等待同步,然后按 schedulePeriod 周期执行 runOnce()

runOnce() 的主链路

1
2
3
4
5
6
7
8
9
10
func (s *Scheduler) runOnce() {
ssn, err := framework.OpenSession(...)
if err != nil { return }
defer framework.CloseSession(ssn)

actions, _ := conf_util.GetActionsFromConfig(s.config)
for _, action := range actions {
action.Execute(ssn)
}
}

这段代码极度简洁,但它背后隐含的是整个框架层次:

  • OpenSession() 负责把 cluster snapshot 和 plugin callbacks 组织好
  • GetActionsFromConfig() 负责把本轮要执行的流程读出来
  • action.Execute(ssn) 负责真正的调度阶段逻辑
  • CloseSession() 负责收尾、状态事件和清理

这就形成了一条非常稳定的主干:

flowchart LR
    A[Run] --> B[cache.Run]
    B --> C[WaitForCacheSync]
    C --> D[runOnce]
    D --> E[OpenSession]
    E --> F[Resolve Actions]
    F --> G[Execute Actions]
    G --> H[CloseSession]

第五站:OpenSession() 真正把 plugin 注入到本轮调度里

pkg/scheduler/framework/framework.goOpenSession() 非常值得精读。

它的逻辑可以概括为:

  1. 如果 plugin server 还没起来,就初始化它
  2. 调用底层 openSession(...) 从 cache 里拿 snapshot
  3. config 塞进 session
  4. 遍历配置中的 tiers/plugins
  5. 通过 plugin builder 创建 plugin 实例
  6. 调用 plugin.OnSessionOpen(ssn)

这一步的关键不在于“插件被创建了”,而在于:

OnSessionOpen 会把 plugin 的 callback 注册进当前 session。

这意味着,每轮调度里真正生效的策略,不是从全局某个 registry 动态查一遍,而是已经在 session 打开时被“装配”进这一轮上下文了。

这一步带来的一个非常好的效果

每个 plugin 都只需要关心:

  • 本轮开始时如何初始化自己的状态
  • 往 session 注册哪些 compare/predicate/order/fairness/mutate 函数
  • 本轮结束时如何清理或上报

这让 plugin 的实现边界非常清楚。

第六站:Session 里已经把“调度会用到的所有钩子”摆在台面上了

pkg/scheduler/framework/session.goSession 结构体是最值得反复读的文件之一。

因为它几乎就是 KAI 调度能力的“公开清单”。

它里面最有代表性的字段包括:

  • NodeOrderFns
  • JobOrderFns
  • TaskOrderFns
  • QueueOrderFns
  • PrePredicateFns
  • PredicateFns
  • BindRequestMutateFns
  • CanReclaimResourcesFns
  • ReclaimVictimFilterFns
  • PreemptVictimFilterFns
  • GetQueueFairShareFns
  • eventHandlers

你会发现 KAI 真正的扩展点不是一个单独的 Score() 接口,而是对调度过程的很多局部决策都开放了函数槽位。

这也是为什么它能容纳这么多高级能力。

两个很值得注意的方法

BindPod(...)

1
2
3
4
5
6
7
func (ssn *Session) BindPod(pod *pod_info.PodInfo) error {
bindRequestAnnotations := ssn.MutateBindRequestAnnotations(pod, pod.NodeName)
if err := ssn.Cache.Bind(pod, pod.NodeName, bindRequestAnnotations); err != nil {
return err
}
...
}

这个方法本身已经说明了:session 层并不会直接调用原生 Pod binding,而是把绑定动作下放给 cache,再由 cache 生成 BindRequest

OrderedNodesByTask(...)

这个方法则体现了 KAI 的 node ordering 是如何工作的:

  1. 先执行 NodePreOrderFn
  2. 再并发计算每个 node 的 NodeOrderFn score
  3. 按分值排序返回

这里能明显看出 KAI 的策略执行与排序逻辑已经抽象得很清楚。

第七站:真正的“提交点”在 SchedulerCache.Bind()

如果你想知道 placement 决策在哪一层真正离开 session,答案就在 pkg/scheduler/cache/cache.go

SchedulerCache.Bind(...) 做的事情大致是:

  1. 记录 PreBind 状态
  2. 调用 createBindRequest(...)
  3. 如有需要 patch Pod labels
  4. 更新 Bound 状态

最关键的不是状态更新,而是 createBindRequest(...)

createBindRequest(...) 里真正落地了什么

它会构造一个 BindRequest,写入:

  • selected-node label
  • ownerReference 指向 Pod
  • annotations(来自 bind mutate plugins)
  • PodName
  • SelectedNode
  • SelectedGPUGroups
  • ReceivedResourceType
  • ReceivedGPU
  • ResourceClaimAllocations

这一步非常有信息量,因为它告诉我们:

  1. scheduler 的输出对象是 KAI 自己的 binding contract
  2. 这个 contract 不只是 node name,还包含 GPU / DRA / annotation 等执行信息
  3. plugin 甚至能在 bind request 生成前修改 annotations

也就是说,KAI 把“binding 输入模型”也设计成了平台扩展面的一部分。

第八站:BindRequest 这个 CRD 其实就是 decision plane 和 execution plane 的接口

pkg/apis/scheduling/v1alpha2/bindrequest_types.go 里的 BindRequest 很值得单独看一下。

Spec 里最关键的字段是:

  • PodName
  • SelectedNode
  • ReceivedResourceType
  • ReceivedGPU
  • SelectedGPUGroups
  • ResourceClaimAllocations

另外,CRD 里确实还定义了 BackoffLimit 字段,但当前 scheduler 的 createBindRequest() 并不会主动填充它。

Status 则记录:

  • Phase
  • Reason
  • FailedAttempts

从设计角度看,它不是一个普通“中间对象”,而是一个非常明确的执行 contract:

  • scheduler 负责写入“应该怎样执行绑定”
  • binder 负责消费并推进状态

这是 KAI 解耦最关键的 API 边界之一。

第九站:binder controller 把决策真正变成 cluster state

接下来进入 pkg/binder/controllers/bindrequest_controller.go

Reconcile(...) 的主逻辑其实很好懂:

  1. 获取 BindRequest
  2. 已删除或已成功则退出
  3. 取对应 Pod
  4. 若 Pod 已绑定则退出
  5. 取目标 Node
  6. 调用 r.binder.Bind(ctx, pod, node, bindRequest)
  7. 如果失败则 Rollback(...)
  8. 最后更新 BindRequest 状态、Pod condition

这里有几个很值得注意的点。

1. binder 是标准的 controller-runtime controller

这意味着 binder 本身具备:

  • reconcile 模型
  • watch / queue / rate limiter
  • status update
  • delete event handler
  • MaxConcurrentReconciles 控制

也就是说,KAI 没有发明一套自定义执行引擎,而是把 binding plane 放回了 Kubernetes 最自然的 controller 范式里。

2. 失败路径被认真对待了

binder.Bind(...) 返回错误时,controller 会显式调用 Rollback(...)

这点非常重要。

因为在 AI / GPU / DRA 场景里,binding 不只是一次 API call,可能还伴随着:

  • ResourceClaim 更新
  • 共享 GPU 相关预留
  • 状态条件修改
  • 其他资源副作用

如果失败后不 rollback,很容易留下脏状态。

3. binder 不只是 bind Pod,它还维护执行语义

从 controller 周围的代码可以看出来,binder 同时在维护:

  • BindRequest phase/status
  • PodBound condition
  • 与 resource reservation 的协同
  • 删除事件后的清理动作

这意味着 execution plane 不是一层薄封装,而是一个正式子系统。

第十站:把调用链串起来看,KAI 的一次调度就非常清楚了

如果用伪调用栈把它串起来,大致可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
cmd/scheduler/app/server.go
RunApp()
-> Run(...)
-> actions.InitDefaultActions()
-> plugins.InitDefaultPlugins()
-> scheduler.NewScheduler(...)
-> scheduler.Run(...)
-> wait.Until(runOnce)
-> framework.OpenSession(...)
-> cache.Snapshot()
-> plugin.OnSessionOpen(ssn)
-> action.Execute(ssn)
-> ssn.BindPod(...)
-> ssn.Cache.Bind(...)
-> createBindRequest(...)
-> create BindRequest CR
-> framework.CloseSession(ssn)

pkg/binder/controllers/bindrequest_controller.go
Reconcile(...)
-> fetch BindRequest / Pod / Node
-> binder.Bind(...)
-> rollback on error
-> update status / pod condition

这条链路体现出了 KAI 最核心的分层:

  • 启动层:server / options / leader election / metrics
  • 执行框架层:scheduler / framework / session
  • 策略层:actions + plugins
  • 提交层:cache -> BindRequest
  • 执行层:binder controller

为什么这条实现链路值得学习

我觉得 KAI 这条源码主链路特别值得看,不是因为它“炫技”,而是因为它把复杂调度系统最容易失控的地方都压住了。

1. 主循环足够短

主循环短,意味着:

  • 更容易审计
  • 更容易稳定
  • 高级能力不会把入口拖成泥球

2. 策略和流程拆得很干净

  • action 决定流程阶段
  • plugin 决定策略
  • cache/session 决定执行上下文
  • binder 决定落地执行

3. 决策与执行明确解耦

这是整个系统最重要的长期收益点。

很多 scheduler 一旦开始支持复杂资源、副作用、回滚,就会在主调度线程里越写越重。KAI 用 BindRequest 这层接口把这个问题处理得很优雅。

一份我自己的“源码精读顺序”

如果你打算真的跟代码,我建议按下面顺序:

  1. cmd/scheduler/app/server.go
  2. pkg/scheduler/scheduler.go
  3. pkg/scheduler/framework/framework.go
  4. pkg/scheduler/framework/session.go
  5. pkg/scheduler/actions/factory.go
  6. pkg/scheduler/plugins/factory.go
  7. pkg/scheduler/cache/cache.go 中的 bind / evict / snapshot 相关逻辑
  8. pkg/apis/scheduling/v1alpha2/bindrequest_types.go
  9. pkg/binder/controllers/bindrequest_controller.go

这样读的好处是:先把主骨架立住,再回头钻进具体 action/plugin 算法时不容易迷路。

下一篇看什么

到这里,KAI 的主调度链路已经比较清楚了。但如果只停在这里,会低估它为什么能持续扩展。

真正支撑 KAI 持续演化的,是最后一层:

  • plugin 体系到底开放了哪些扩展点?
  • SchedulingShard 为什么是一个很强的 policy surface?
  • 拓扑感知、公平性、time-based fairshare 为什么可以自然叠加进这套框架?

下一篇就专门讲这些“高级能力背后的结构原因”。