KAI Scheduler 深入拆解(四):从源码走读一次调度周期
前面两篇讲了架构和 workflow,上一篇讲了框架层的抽象。这一篇开始不再停留在概念,而是直接沿着源码主链路走一遍 KAI Scheduler 的一次调度周期。
我的目标不是把每个函数都抄一遍,而是回答五个更实在的问题:
- scheduler 进程是怎么启动的?
- action / plugin 是怎么注册进去的?
- 一轮 session 是怎么打开和关闭的?
- placement 决策最后是怎么变成
BindRequest的? - binder 是怎么把这个决策真正执行掉的?
如果你只准备精读少数几个文件,我建议把注意力放在下面这几处:
cmd/scheduler/app/server.gopkg/scheduler/scheduler.gopkg/scheduler/framework/framework.gopkg/scheduler/framework/session.gopkg/scheduler/actions/factory.gopkg/scheduler/plugins/factory.gopkg/scheduler/cache/cache.gopkg/apis/scheduling/v1alpha2/bindrequest_types.gopkg/binder/controllers/bindrequest_controller.go
第一站:scheduler 进程入口不是算法,而是平台启动逻辑
真正的入口在 cmd/scheduler/app/server.go。
RunApp() 先做的是运行时准备
从代码顺序上看,大致是:
- 解析和校验命令行参数
- 启动 plugin server 的 HTTP mux
- 初始化 profiling / pyroscope
- 初始化 logging
- 读取 kube config 并设置 QPS / Burst
- 进入
Run(...)
也就是说,KAI scheduler 本身就是一个标准 Kubernetes 控制面进程,而不是“读个配置然后进一个大循环”的轻量程序。
Run() 里最重要的两行
对理解调度内核最关键的,其实是这两步:
1 | actions.InitDefaultActions() |
这代表 KAI 在启动阶段先把“可用动作”和“可用插件”注册进框架,再从配置解析出本次实际启用的 action/plugin 组合。
这有个很重要的意义:
注册是能力集合,配置才是实际执行计划。
换句话说:
InitDefaultActions()负责告诉框架有哪些 action 存在InitDefaultPlugins()负责告诉框架有哪些 plugin 可用ResolveConfigurationFromFile(...)决定这一轮部署到底启用哪些,以及它们的优先级和参数是什么
这比很多项目直接在代码里写死流程要干净得多。
第二站:action / plugin 的“能力注册表”非常直观
Action 工厂:流程阶段被清楚写死
pkg/scheduler/actions/factory.go 很短,但很重要:
1 | func InitDefaultActions() { |
这里要注意一个细节:注册顺序不等于执行顺序。
真正的执行顺序来自配置解析,而默认优先级则来自 SchedulingShard 的 action 配置语义:
allocateconsolidationreclaimpreemptstalegangeviction
所以代码层面的注册只是“把工厂挂到框架里”,而不是“马上决定调度流程”。
Plugin 工厂:策略面非常丰富
pkg/scheduler/plugins/factory.go 更能体现 KAI 的平台化程度。默认注册的 plugin 包括:
predicatesprioritynodeplacementnominatednodenodeavailabilitygpusharingordergpupackgpuspreadresourcetypepodaffinityelastickubeflowraytaskordersubgrouporderdynamicresourcestopologyproportionminruntimesnapshotreflectjoborder
这张列表本身已经非常说明问题: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
- 保存
SchedulerConfiguration和SchedulerParams
这里非常值得注意的是 SchedulerCacheParams 内容,它不是普通缓存初始化参数,而是把 KAI 的运行语义一并带进来了:
SchedulerNameNodePoolParamsRestrictNodeSchedulingDetailedFitErrorsScheduleCSIStorageFullHierarchyFairnessNumOfStatusRecordingWorkersUpdatePodEvictionConditionUsageDBClient
这意味着 cache 不只是个 informer wrapper,而是调度器真正的运行底座。
第四站:调度循环的核心,真的就只有 runOnce() 这么短
从代码阅读体验来看,KAI 最漂亮的地方之一,就是主循环本身极简。
Run():
1 | func (s *Scheduler) Run(stopCh <-chan struct{}) { |
先启动 cache,再等待同步,然后按 schedulePeriod 周期执行 runOnce()。
runOnce() 的主链路
1 | func (s *Scheduler) runOnce() { |
这段代码极度简洁,但它背后隐含的是整个框架层次:
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.go 的 OpenSession() 非常值得精读。
它的逻辑可以概括为:
- 如果 plugin server 还没起来,就初始化它
- 调用底层
openSession(...)从 cache 里拿 snapshot - 把
config塞进 session - 遍历配置中的 tiers/plugins
- 通过 plugin builder 创建 plugin 实例
- 调用
plugin.OnSessionOpen(ssn)
这一步的关键不在于“插件被创建了”,而在于:
OnSessionOpen会把 plugin 的 callback 注册进当前 session。
这意味着,每轮调度里真正生效的策略,不是从全局某个 registry 动态查一遍,而是已经在 session 打开时被“装配”进这一轮上下文了。
这一步带来的一个非常好的效果
每个 plugin 都只需要关心:
- 本轮开始时如何初始化自己的状态
- 往 session 注册哪些 compare/predicate/order/fairness/mutate 函数
- 本轮结束时如何清理或上报
这让 plugin 的实现边界非常清楚。
第六站:Session 里已经把“调度会用到的所有钩子”摆在台面上了
pkg/scheduler/framework/session.go 的 Session 结构体是最值得反复读的文件之一。
因为它几乎就是 KAI 调度能力的“公开清单”。
它里面最有代表性的字段包括:
NodeOrderFnsJobOrderFnsTaskOrderFnsQueueOrderFnsPrePredicateFnsPredicateFnsBindRequestMutateFnsCanReclaimResourcesFnsReclaimVictimFilterFnsPreemptVictimFilterFnsGetQueueFairShareFnseventHandlers
你会发现 KAI 真正的扩展点不是一个单独的 Score() 接口,而是对调度过程的很多局部决策都开放了函数槽位。
这也是为什么它能容纳这么多高级能力。
两个很值得注意的方法
BindPod(...)
1 | func (ssn *Session) BindPod(pod *pod_info.PodInfo) error { |
这个方法本身已经说明了:session 层并不会直接调用原生 Pod binding,而是把绑定动作下放给 cache,再由 cache 生成 BindRequest。
OrderedNodesByTask(...)
这个方法则体现了 KAI 的 node ordering 是如何工作的:
- 先执行
NodePreOrderFn - 再并发计算每个 node 的
NodeOrderFnscore - 按分值排序返回
这里能明显看出 KAI 的策略执行与排序逻辑已经抽象得很清楚。
第七站:真正的“提交点”在 SchedulerCache.Bind()
如果你想知道 placement 决策在哪一层真正离开 session,答案就在 pkg/scheduler/cache/cache.go。
SchedulerCache.Bind(...) 做的事情大致是:
- 记录 PreBind 状态
- 调用
createBindRequest(...) - 如有需要 patch Pod labels
- 更新 Bound 状态
最关键的不是状态更新,而是 createBindRequest(...)。
createBindRequest(...) 里真正落地了什么
它会构造一个 BindRequest,写入:
selected-nodelabel- ownerReference 指向 Pod
- annotations(来自 bind mutate plugins)
PodNameSelectedNodeSelectedGPUGroupsReceivedResourceTypeReceivedGPUResourceClaimAllocations
这一步非常有信息量,因为它告诉我们:
- scheduler 的输出对象是 KAI 自己的 binding contract
- 这个 contract 不只是 node name,还包含 GPU / DRA / annotation 等执行信息
- plugin 甚至能在 bind request 生成前修改 annotations
也就是说,KAI 把“binding 输入模型”也设计成了平台扩展面的一部分。
第八站:BindRequest 这个 CRD 其实就是 decision plane 和 execution plane 的接口
pkg/apis/scheduling/v1alpha2/bindrequest_types.go 里的 BindRequest 很值得单独看一下。
Spec 里最关键的字段是:
PodNameSelectedNodeReceivedResourceTypeReceivedGPUSelectedGPUGroupsResourceClaimAllocations
另外,CRD 里确实还定义了 BackoffLimit 字段,但当前 scheduler 的 createBindRequest() 并不会主动填充它。
Status 则记录:
PhaseReasonFailedAttempts
从设计角度看,它不是一个普通“中间对象”,而是一个非常明确的执行 contract:
- scheduler 负责写入“应该怎样执行绑定”
- binder 负责消费并推进状态
这是 KAI 解耦最关键的 API 边界之一。
第九站:binder controller 把决策真正变成 cluster state
接下来进入 pkg/binder/controllers/bindrequest_controller.go。
Reconcile(...) 的主逻辑其实很好懂:
- 获取
BindRequest - 已删除或已成功则退出
- 取对应 Pod
- 若 Pod 已绑定则退出
- 取目标 Node
- 调用
r.binder.Bind(ctx, pod, node, bindRequest) - 如果失败则
Rollback(...) - 最后更新
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 | cmd/scheduler/app/server.go |
这条链路体现出了 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 这层接口把这个问题处理得很优雅。
一份我自己的“源码精读顺序”
如果你打算真的跟代码,我建议按下面顺序:
cmd/scheduler/app/server.gopkg/scheduler/scheduler.gopkg/scheduler/framework/framework.gopkg/scheduler/framework/session.gopkg/scheduler/actions/factory.gopkg/scheduler/plugins/factory.gopkg/scheduler/cache/cache.go中的 bind / evict / snapshot 相关逻辑pkg/apis/scheduling/v1alpha2/bindrequest_types.gopkg/binder/controllers/bindrequest_controller.go
这样读的好处是:先把主骨架立住,再回头钻进具体 action/plugin 算法时不容易迷路。
下一篇看什么
到这里,KAI 的主调度链路已经比较清楚了。但如果只停在这里,会低估它为什么能持续扩展。
真正支撑 KAI 持续演化的,是最后一层:
- plugin 体系到底开放了哪些扩展点?
SchedulingShard为什么是一个很强的 policy surface?- 拓扑感知、公平性、time-based fairshare 为什么可以自然叠加进这套框架?
下一篇就专门讲这些“高级能力背后的结构原因”。