前几篇文章里,我一直在强调一件事:KAI Scheduler 的价值不只是“今天已经实现了哪些特性”,更重要的是 它为什么可以持续装下更多特性

这一篇就专门看这个问题。

如果只看表层功能,KAI 已经足够复杂:

  • hierarchical queues
  • reclaim / preempt
  • topology-aware scheduling
  • hierarchical podgroups
  • DRA
  • GPU sharing
  • time-based fairshare
  • scheduling shards

但真正有意思的地方在于,这些能力并不是一堆互不相干的特判,而是大多能在同一套框架下自然落位:

  • 流程由 action 管
  • 策略由 plugin 管
  • 运行边界由 session / statement 管
  • 平台级定制由 SchedulingShard
  • 工作负载语义由 PodGroup
  • 历史反馈由 queue controller + Prometheus + usage DB 管

换句话说,KAI 的扩展性不是“多写几个 if”,而是 架构层面为扩展预留了空间

先看最关键的一点:plugin 不是装饰品,而是调度策略的主入口

pkg/scheduler/plugins/factory.go 把默认插件注册得非常完整:

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

这张表最重要的意义不是“插件很多”,而是它说明 KAI 把以下问题都视作 策略问题,而不是写死在主循环里的逻辑:

  • 节点排序
  • GPU 排序
  • queue fairness
  • reclaim / preempt 判断
  • workload integration
  • subgroup 顺序
  • topology 约束
  • dynamic resources
  • bind request mutation

这就是为什么 KAI 的高级特性能持续叠加,而不会轻易把主调度流程搞乱。

Session 暴露的扩展点,基本等于 KAI 的能力边界

pkg/scheduler/framework/session.go 里,Session 暴露的函数槽位非常多,最有代表性的包括:

  • JobOrderFns
  • TaskOrderFns
  • SubGroupOrderFns
  • QueueOrderFns
  • NodeOrderFns
  • GpuOrderFns
  • PrePredicateFns
  • PredicateFns
  • CanReclaimResourcesFns
  • ReclaimVictimFilterFns
  • PreemptVictimFilterFns
  • GetQueueAllocatedResourcesFns
  • GetQueueDeservedResourcesFns
  • GetQueueFairShareFns
  • BindRequestMutateFns
  • event handlers

只要看见这些接口,就能立刻理解 KAI 的插件思想:

它不是只开放一个 score 钩子,而是把“调度过程中可能需要定制的所有局部决策”尽量显式化。

这带来了两个直接好处:

  1. 新能力可以落在明确的扩展点上,而不是侵入主流程。
  2. 现有能力可以通过配置和优先级组合,而不是硬编码互斥。

SchedulingShard:KAI 真正的平台配置面

如果说 plugin 是“代码级扩展点”,那 SchedulingShard 就是“平台级策略面”。

我认为这是 KAI 最值得单独夸的一层设计。

pkg/apis/kai/v1/schedulingshard_types.go 里,SchedulingShardSpec 暴露的内容包括:

  • Args
  • PlacementStrategy
  • PartitionLabelValue
  • QueueDepthPerAction
  • MinRuntime
  • KValue
  • UsageDBConfig
  • Plugins
  • Actions

这意味着什么?

这意味着 KAI 把很多原本应该藏在源码、flag 或者某个配置文件里的策略选择,提升成了 可声明、可演化、可分片实例化的 Kubernetes API

为什么这层设计非常强

因为它让“调度器实例”不再是同质的。

你可以想象这样几种 shard:

  • 一个 shard 专门服务 GPU 训练任务,偏向 binpack
  • 一个 shard 服务在线推理,偏向 spread / 更保守 preempt
  • 一个 shard 打开更积极的 time-based fairshare
  • 一个 shard 限制特定 action 的 queue depth
  • 一个 shard 针对某类 workload 打开或关闭特定 plugin

这就把 KAI 从“一个调度器程序”推进成了“一个可按业务场景切片的调度平台”。

分片不是部署技巧,而是语义隔离能力

scheduling-shards.md 里说得很清楚:每个 shard 实际上围绕同一个 partition label key/value 工作。也就是说,scheduler 只会考虑带有同一组分片标签的:

  • Nodes
  • Queues
  • PodGroups

这是一种非常典型的 Kubernetes 风格:

  • 用 label 做资源归属
  • 用独立 scheduler deployment 做计算隔离
  • 用 CRD 做配置入口

这样带来的好处不只是扩容,更重要的是 策略与资源空间同时分片

也就是说,不同 shard 不只是“不同副本数的 scheduler”,而是:

  • 看到的资源集合不同
  • 使用的策略可以不同
  • action/plugin 组合可以不同
  • fairness / minRuntime / placement 参数可以不同

拓扑感知:KAI 的 topology 不是附属 feature,而是主框架里的自然延伸

很多调度器支持 topology,最终只是把它当作额外 affinity。

KAI 的 topology 明显不是这个级别。

docs/topology/README.md 描述得很清楚,它依赖两个东西:

  1. Topology CRD 定义层级拓扑
  2. PodGroup / workload 声明 required / preferred placement

然后调度时做两层决策:

第一层:domain selection

先选择应该把 workload 放进哪个 topology domain。

这里的策略偏向 bin-packing:优先选择“相对更满但仍能容纳该 workload”的 domain,以减少碎片。

第二层:node ordering within domain

在选中的 domain 内,再按 preferred placement 做节点排序,让 Pod 尽量聚拢到更合适的子域。

这套设计为什么自然?

因为 KAI 的框架本身就已经有:

  • workload-level abstraction(PodGroup
  • node / GPU ordering 扩展点
  • scenario / statement 模拟能力
  • 多级 subgroup 结构

所以 topology 不需要硬插一条特别路径,而是能顺着既有框架表达出来。

公平性:KAI 的 fairshare 不是单一排序函数,而是 queue 系统的一部分

docs/fairness/README.md 里最重要的一句话是:

  • deserved quota 一定优先满足
  • 剩余资源再按 priority / weight 分 over-quota share

这意味着 KAI 的公平性不是简单“谁资源少谁先跑”,而是建立在 queue hierarchy 上的:

  1. 先分配 deserved quota
  2. 再按 over-quota weight/priority 分多出来的资源
  3. 如果当前实际占用和 fair share 偏离太多,就通过 reclaim 收敛

这件事为什么离不开框架抽象

因为 fairshare 不只是 queue 排序,还会影响:

  • queue ordering
  • reclaim eligibility
  • victim filtering
  • 资源记账
  • action 执行顺序

如果没有 session 里的 queue callback、action 框架和 statement 模型,fairshare 很容易变成一堆局部规则拼接。KAI 的实现方式则更像把它作为一个正式的一等公民。

Time-based fairshare:KAI 开始把“历史行为”放进调度决策

我觉得 time-based fairshare 是 KAI 很有代表性的一个能力,因为它显示出这套架构并不只看“这一刻的资源状态”,而是开始纳入时间维度。

docs/time-based-fairshare/README.md 给出的主线是:

  1. PodGroup Controller 发布 podgroup 资源状态
  2. Queue Controller 聚合 queue usage
  3. Prometheus 存储历史数据
  4. scheduler 通过 usage DB client 拉取 usage
  5. proportion 等 fairness 相关逻辑把历史 usage 纳入 over-quota 资源分配

这个闭环很说明问题:

KAI 不只是个实时调度器,它正在演化成一个“结合当前状态与历史行为”的资源治理系统。

为什么这件事能自然接进去

因为 KAI 之前已经有了:

  • Queue 作为公平性主语
  • Queue Controller 作为聚合器
  • SchedulerCache / usage DB 作为数据接入点
  • KValueUsageDBConfig 等策略面配置
  • GetQueueFairShareFns 这类 session 级扩展点

所以历史 usage 的接入,不需要推翻已有架构,只需要在已有资源分配框架上把输入变得更丰富。

Config + SchedulingShard 的组合,让 operator 也变成架构的一部分

前面聊得多的是 scheduler 内核,但 KAI 的平台味道其实还来自 operator 层。

pkg/apis/kai/v1/config_types.go 里,ConfigSpec 直接声明了整套控制面的组件:

  • PodGrouper
  • Binder
  • Admission
  • Scheduler
  • QueueController
  • PodGroupController
  • NodeScaleAdjuster
  • Prometheus

也就是说,KAI 不是把 operator 当“安装器”,而是把它做成了这套架构的一部分:

  • Config 负责描述整个平台的全局组件组合
  • SchedulingShard 负责描述单个调度分片的策略配置

这两个 CRD 组合起来,基本就是 KAI 的控制面 API。

为什么我认为 KAI 的扩展路径是健康的

很多系统在功能越来越多以后,会出现一种典型问题:

  • 新特性只能往旧代码里塞特判
  • 不同能力互相穿透
  • 配置开始变得不可推理
  • 主循环越来越重
  • 某个高级功能一加,全局行为就变得不可预测

KAI 目前给我的感受恰好相反。它的高级能力大多还能找到一个比较自然的落点:

1. workload 语义落在 PodGroup

而不是散落在所有 plugin 里。

2. 公平性与资源治理落在 Queue

而不是偷偷写在 node score 里。

3. 执行流程落在 action

而不是被 plugin 任意控制。

4. 局部策略落在 plugin

而不是让 action 变成巨无霸。

5. 中间态执行落在 BindRequest + binder

而不是让 scheduler 同步做完所有副作用。

6. 平台调优落在 SchedulingShard

而不是只能改源码或启动参数。

这就是为什么我觉得 KAI 的架构不仅功能多,而且有继续长下去的空间。

如果想扩展 KAI,最值得看哪几类点

从阅读体验上,我会把 KAI 的扩展入口分成四类。

1. 想加 workload 语义

先看:

  • PodGroup API
  • pod-grouper 的 supported types / grouping plugins
  • subgroup / topology 相关设计文档

2. 想改调度策略

先看:

  • Session 暴露的 callback 槽位
  • plugins/factory.go
  • 现有 plugin 的 OnSessionOpen

3. 想改调度流程

先看:

  • action framework
  • actions/factory.go
  • action 优先级和 SchedulingShard.Actions

4. 想做平台级多租户 / 多场景隔离

先看:

  • SchedulingShard
  • partition label 机制
  • operator 相关 reconciliation

我对 KAI 扩展能力的一个总结

如果只用一句话概括,我会写成:

KAI 把“调度平台”的关键维度分开了:工作负载语义、资源公平性、调度流程、局部策略、执行落地、平台配置,各自都有明确落点,因此高级能力能以组合的方式生长出来。

这跟很多“功能越多越难维护”的 scheduler 最大的不同在于:

  • 它不是靠主循环越来越聪明来进化
  • 而是靠边界越来越清楚来进化

系列收束:怎么继续读这个仓库

如果你是第一次接触 KAI,我会建议按照这条线继续深入:

  1. 先读 docs/developer/scheduler-concepts.md
  2. 再读 docs/developer/action-framework.md
  3. docs/developer/plugin-framework.md
  4. docs/developer/pod-grouper.mddocs/operator/scheduling-shards.md
  5. 进入 pkg/scheduler/ 的 session / action / cache 主链路
  6. 最后再去看 topology、fairness、time-based fairshare 等高级能力

因为 KAI 最值得学习的地方,真的不只是某一个调度算法,而是:

它如何把复杂的 AI workload 调度,拆成一套仍然能持续演化的 Kubernetes 控制面架构。

对我来说,这就是这个仓库最有价值的部分。

前面两篇讲了架构和 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 为什么可以自然叠加进这套框架?

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

如果说上一篇讲的是控制面工作流,那么这一篇要进入 KAI Scheduler 最核心的部分:调度内核是怎么组织的。

我读 KAI 代码时最大的感受是,它并不是把所有逻辑揉进一个“大调度函数”,而是把调度过程拆成了几层非常稳定的抽象:

  • cycle
  • cache
  • snapshot
  • session
  • actions
  • plugins
  • statement / scenario

这些抽象不是为了“显得框架化”,而是为了支撑 KAI 真正需要处理的复杂性:

  • gang scheduling
  • queue fairness
  • reclaim / preempt
  • GPU sharing
  • DRA
  • topology-aware scheduling
  • hierarchical podgroups
  • time-based fairshare

如果没有这套结构,任何一个高级特性都可能让调度逻辑失控。

先看最短的调度主循环

pkg/scheduler/scheduler.go 里,KAI 的调度主干其实很短:

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)
}()
}

真正的重点在 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)
}
}

这段代码非常能代表 KAI 的设计哲学:

scheduler 自己不直接写死“怎么调度每个 Pod”,它做的是:打开一个 session,然后把控制权交给有顺序的 action 和可插拔的 plugin。

也就是说,KAI 的内核不是一个算法,而是一个 按周期运行的调度执行框架

第一层:Cycle —— 调度不是事件回调,而是周期性计算

KAI 文档里一直强调“scheduling cycle”,这不是表述习惯,而是真正的架构选择。

每个 cycle 的流程可以简化成:

flowchart LR
    Start([Cycle Start]) --> Cache[Cache Sync]
    Cache --> Snapshot[Take Snapshot]
    Snapshot --> Session[Open Session]
    Session --> Actions[Execute Actions]
    Actions --> Close[Close Session]
    Close --> End([Cycle End])

为什么 KAI 选择 cycle model

对于只做简单 node fitting 的调度器,事件驱动模型也许足够。但 KAI 需要解决的问题更复杂:

  • 一个 PodGroup 能不能整体调度成功?
  • 某个 queue 是不是超出 fair share?
  • 为了让高优 workload 运行,当前要不要 reclaim / preempt?
  • 某个 topology domain 是否更适合这个 workload?
  • 某些资源是否能在一个模拟场景里整体成立?

这些问题都更像“在一个一致性视图上做组合决策”,而不是“处理一个 Pod Add 事件”。

cycle model 的好处正是:

  1. 所有决策基于同一份快照
  2. 复杂动作可以按顺序执行
  3. 调度器可以先模拟,再提交
  4. 高成本策略可以集中在每轮计算里完成

第二层:Cache —— 调度器真正依赖的是聚合态,而不是零散 API 调用

KAI 的 cache 在架构上是非常重要的一层。文档 docs/developer/scheduler-concepts.md 也把它放在很前面。

它的职责并不是简单“缓存 informer 数据”,而是负责:

  • 聚合多个 Kubernetes / KAI 资源对象
  • 维护调度需要的派生状态
  • 生成 snapshot
  • 把调度提交结果写回集群

也就是说,cache 同时承担了:

  • 输入聚合层
  • 状态维护层
  • 提交出口层

在 KAI 里,cache 可以被理解成 scheduler 的“authoritative runtime state”。

为什么这层很关键

因为 scheduler 需要同时看:

  • nodes
  • pods
  • podgroups
  • queues
  • bindrequests
  • storage / DRA / resource claims
  • 历史 usage

如果每次 action 执行都直接查 API server,复杂度和性能都会失控。KAI 的做法是先把这些状态汇总到 cache,再在 snapshot 上运行策略。

第三层:Snapshot —— 每一轮调度都从一致性视图出发

在 KAI 的设计里,snapshot 不是一个调试辅助对象,而是调度 cycle 的正式输入。

openSession() 里最关键的一步就是:

1
2
snapshot, err := cache.Snapshot()
ssn.ClusterInfo = snapshot

也就是说,session 看到的整个世界,都是这一刻从 cache 切出来的静态视图。

snapshot 的意义

snapshot 的核心价值在于三个词:

  • 一致性
  • 可模拟
  • 可复现

一致性

同一轮调度里,所有 action / plugin 面对的是同一个 cluster image。

可模拟

你可以在 session 里虚拟 allocate / evict / reorder,而不立刻影响真实集群。

可复现

如果你想调试某一轮调度,理论上 snapshot 是最接近“输入快照”的东西。这也是 KAI 为什么会有 snapshot-tool 这类工具。

第四层:Session —— 一轮调度真正的执行上下文

如果说 snapshot 是输入,那 session 就是这一轮调度的“运行时容器”。

pkg/scheduler/framework/session.go 里的 Session 结构体非常值得看,因为它几乎把 KAI 的扩展点都明牌写出来了:

  • GpuOrderFns
  • NodePreOrderFns
  • NodeOrderFns
  • JobOrderFns
  • SubGroupOrderFns
  • TaskOrderFns
  • QueueOrderFns
  • PrePredicateFns
  • PredicateFns
  • BindRequestMutateFns
  • CanReclaimResourcesFns
  • ReclaimVictimFilterFns
  • PreemptVictimFilterFns
  • GetQueueFairShareFns
  • event handlers

这说明 KAI 的 session 不是“存点变量”的上下文对象,而是 整个调度框架的插件总线

Session 负责什么

从设计上看,Session 至少承担四件事:

  1. 保存 snapshot 得到的 ClusterInfo
  2. 承载本轮调度的 callback registry
  3. 提供绑定、驱逐、节点排序、GPU 排序等调度操作
  4. 为 statement/scenario 提供统一语义边界

这层设计的妙处

它把“当前一轮调度会发生什么”从长期状态里剥离出来了。

  • cache 是长期维护的系统状态
  • session 是某一轮的计算上下文

这能让 plugin 在 OnSessionOpen 时做本轮初始化,在 OnSessionClose 时做清理或指标记录,而不用在长期全局状态里互相污染。

第五层:Action —— 流程由 action 决定,不由 plugin 决定

KAI 文档 docs/developer/action-framework.md 给出的 action 顺序是:

  1. Allocate
  2. Consolidate
  3. Reclaim
  4. Preempt
  5. StaleGangEviction

这五个动作本身就已经表达了一种非常清晰的调度哲学:

先尝试无破坏地调度,再尝试优化碎片,再尝试跨 queue 回收资源,再尝试队列内优先级抢占,最后清理 gang 死锁状态。

换句话说,Action 负责的是“调度流程的阶段控制”

为什么 action 顺序很重要

KAI 不是随便堆几个插件,而是明确规定了“先做什么,后做什么”。

Allocate

优先处理无需干扰即可落地的工作负载。

Consolidate

在已有布局上做整理,减少碎片。

Reclaim

当 queue 之间资源分配不公平时,做跨 queue 回收。

Preempt

当同一 queue 内高优 workload 需要资源时,再做优先级抢占。

StaleGangEviction

处理 gang 语义下的僵死/失效场景。

这个顺序说明 KAI 很重视“尽量把破坏性动作后置”。

第六层:Plugin —— 策略由 plugin 注入,不由 action 写死

如果说 action 决定“什么时候干什么”,那 plugin 决定“具体按什么规则干”。

pkg/scheduler/framework/framework.go 里,OpenSession() 会读取配置里的 tiers 和 plugins,然后依次:

  1. 取到 plugin builder
  2. 实例化 plugin
  3. 调用 plugin.OnSessionOpen(ssn)

OnSessionOpen 里,plugin 把自己的 callback 注册进 session。

这意味着什么

KAI 的 plugin 不是“调用时才临时执行的一段代码”,而是会在 session 打开时把行为注入调度上下文。例如:

  • queue ordering
  • task ordering
  • node scoring
  • GPU scoring
  • predicates
  • fairness calculation
  • reclaim / preempt validation
  • bind request mutation
  • allocate / deallocate event handlers

一个非常关键的区分

很多调度框架里,action 和 plugin 的边界不够清楚;而 KAI 这里非常明确:

  • Action = 调度流程阶段
  • Plugin = 每个阶段使用的策略函数集合

这让系统具备两个非常好的性质:

  1. 流程稳定

    • cycle 还是那个 cycle,action 还是那些 phase。
  2. 策略可演化

    • node placement、fairness、topology、min runtime、dynamicresources 都可以通过 plugin 扩展。

第七层:Statement / Scenario —— KAI 最有意思的设计之一

KAI 文档里有两个很重要但很容易被忽略的概念:

  • Scenario
  • Statement

我认为这两个概念是 KAI 能处理复杂调度动作的关键。

Scenario:先构造“如果这样调度,会发生什么”

Scenario 本质上是一个假设中的调度状态。

比如某个 reclaim / preempt / consolidate 场景,不是直接去驱逐 Pod,而是先在内存里构造一个可能的结果:

  • 如果把这几个 Pod 挪走会怎样?
  • 如果这个 PodGroup 迁移到另一个 domain 会怎样?
  • 如果先释放某些资源再放入新 workload,会不会整体成立?

这意味着 KAI 不是“想到一个动作就立刻提交”,而是先做 what-if analysis

Statement:把一组调度操作组织成事务式单元

文档把 Statement 描述成 transaction-like object,我觉得这个词非常准确。

它支持:

  • Checkpoint()
  • Rollback(checkpoint)
  • Allocate(pod, node)
  • Evict(pod, msg)
  • Pipeline(pod, node)
  • Commit()
  • Discard()

也就是说,调度器可以在本轮 session 中:

  1. 先尝试若干虚拟操作
  2. 观察这些操作在 plugin callback、资源状态、fairness 上的影响
  3. 如果不成立就 rollback
  4. 如果成立再 commit

为什么这非常适合 KAI

因为 KAI 做的不只是“找个 node fit 一下”。它经常需要处理下面这种组合问题:

  • 一个 PodGroup 的多个 Pod 必须整体成功
  • 某个 queue reclaim 以后,另一个 queue 是否真的能受益
  • topology domain 切换后,整体 locality 是否更优
  • consolidation 是否会造成新的碎片
  • DRA / resource claim 场景是不是整体可行

没有 statement/scenario,很多逻辑要么只能做局部贪心,要么必须把复杂回滚散落在各处。KAI 用一层统一抽象把这个问题收住了。

Action + Plugin + Statement 这三个东西怎么配合

我觉得可以把它们理解成三层:

flowchart TD
    A[Action] --> B[Statement / Scenario]
    C[Plugin callbacks] --> B
    B --> D[Commit to cache / cluster]

    A1[Allocate / Consolidate / Reclaim / Preempt] -.控制流程.-> A
    C1[Predicate / Order / FairShare / Bind Mutate] -.提供策略.-> C
    B1[Checkpoint / Rollback / Commit] -.提供事务化模拟.-> B
  • action 决定“当前做哪类动作”
  • plugin 决定“按什么规则评估和排序”
  • statement/scenario 决定“怎么安全地模拟和提交”

这三个层次组合在一起,才形成 KAI 的调度内核。

为什么这套框架足以承载高级能力

KAI 后续的很多高级特性,其实都不是“另起炉灶”,而是在这套框架上叠加出来的:

  • 拓扑感知:本质上是 plugin + node/domain ordering
  • fairshare:本质上是 queue ordering / resource accounting / reclaim policy
  • time-based fairshare:本质上是 fairness 数据输入变得更丰富
  • hierarchical podgroups:本质上是 workload model + subgroup ordering + action evaluation 更复杂
  • DRA:本质上是 predicates / bind execution / resource claims 集成

也就是说,KAI 的高阶能力不是靠一堆特判支撑,而是靠一个足够稳的 execution model 支撑。

我对 KAI 调度框架的一个总结

如果只用一句话概括这套内核,我会写成:

KAI 把调度建模成:在一致性 snapshot 上打开一个 session,由 action 驱动流程、由 plugin 注入策略、由 statement/scenario 提供可回滚模拟,然后把成立的结果提交回集群。

这是一个非常适合复杂 workload 调度的模型。

它的好处不是“优雅”,而是很实用:

  • 可以承载越来越复杂的 AI workload 语义
  • 可以在保证流程稳定的同时持续增加新策略
  • 可以把破坏性动作控制在明确的阶段和边界里
  • 可以让调试和离线复现更有抓手

下一篇看什么

框架理解了以后,最自然的下一步就是:

  • 代码里到底是怎么启动的?
  • RunApp()runOnce() 之间发生了什么?
  • action / plugin 注册和配置覆盖怎么配合?
  • BindRequest 是在哪一层创建的?
  • binder 又是怎么接住这个对象的?

下一篇就从源码入口开始,沿着真正的调用链,走一遍 KAI 的一次调度周期。

上一篇我把 KAI Scheduler 当成一个平台来读:它不是单个 scheduler 进程,而是一串围绕 CRD、controller 和 scheduling session 组织起来的控制面。

这一篇开始看它真正的“主干路径”:一个 workload 进入集群后,到底是怎么一步步变成调度决策,再变成真实节点绑定的?

如果要先给结论,我会这样概括:

在 KAI 里,提交 workload 并不会立刻进入“给 Pod 选 node”的同步流程,而是先被重写成更适合调度的 workload model,再进入周期性调度循环,最后通过 BindRequest 异步落地。

这条路径一共可以拆成六步:

  1. workload 进入 Kubernetes
  2. pod-grouper 抽取 workload 语义,创建 PodGroup
  3. scheduler 在一个 cycle 里对 PodGroupQueue 做 placement 决策
  4. scheduler 创建 BindRequest
  5. binder reconcile BindRequest 并执行真实绑定
  6. PodGroup/Queue controller 回写状态,形成反馈闭环

先看整条链路

sequenceDiagram
    participant U as User / Workload Controller
    participant A as Admission
    participant PG as Pod Grouper
    participant PGCRD as PodGroup CRD
    participant Q as Queue CRD
    participant S as Scheduler
    participant BR as BindRequest CRD
    participant B as Binder
    participant PGC as PodGroup Controller
    participant QC as Queue Controller
    participant PM as Prometheus / Usage DB

    U->>A: 创建 Pod / Job / Ray / Kubeflow workload
    A-->>U: 准入校验 / 变更
    U->>PG: Pod 被 watch 到
    PG->>PGCRD: 基于 top owner 创建或更新 PodGroup
    U->>Q: workload 通过 queue 标签归属某个 Queue
    S->>PGCRD: 在 snapshot 中读取 PodGroup
    S->>Q: 在 snapshot 中读取 Queue
    S->>BR: 为选中的 Pod 创建 BindRequest
    B->>BR: reconcile BindRequest
    B->>B: 执行 bind / 资源准备 / 回滚
    B-->>U: Pod 绑定到目标节点
    PGC->>PGCRD: 回写 workload 运行态和资源状态
    QC->>Q: 聚合队列资源状态
    QC->>PM: 暴露历史资源使用数据
    PM-->>S: 为 time-based fairshare 提供历史 usage

这个 sequence 图里,真正最关键的转折点有两个:

  • Pod -> PodGroup:KAI 把原始 workload 转成更适合调度的抽象。
  • Decision -> BindRequest:KAI 把调度决策和执行绑定解耦。

只要把这两个转折点读懂,整个系统就顺了。

第一步:workload 进入集群,但 scheduler 还不会立刻直接处理它

KAI 支持的工作负载并不只有单 Pod。它非常强调 AI / batch / distributed workload 的调度语义,所以常见入口包括:

  • 原生 Pod
  • Job / CronJob(以及其他 batch 类上层对象)
  • Deployment
  • MPIJob
  • Ray
  • Kubeflow 相关 workload
  • JobSet
  • 以及其他带 owner reference 的上层对象

这些对象刚进入集群时,并不天然具备 KAI 需要的完整调度语义。

例如,scheduler 真正关心的问题往往是:

  • 这些 Pod 是否必须一起启动?
  • 它们属于哪个 queue?
  • 它们默认优先级和 preemptibility 是什么?
  • 它们有没有 topology constraint?
  • 它们是不是多级 subgroup 结构?

这些信息如果散落在不同 workload 的不同字段里,后续调度逻辑会非常难维护。于是 KAI 先做了一次“语义收敛”。

第二步:pod-grouper 把 workload 重写成 PodGroup

pod-grouper 是整条链路里最容易被低估的组件。

很多人看 scheduler 时会把 pod-grouper 当成辅助服务,但实际上它承担的是“把 Kubernetes 原生 workload 翻译成 KAI workload model”的职责。

它在做什么

pod-grouper 监听 Pod,然后做三件事:

  1. 找到 Pod 的 top owner
  2. 根据 owner 类型选择合适的 grouping plugin
  3. 生成或更新 PodGroup

文档 docs/developer/pod-grouper.md 给出的核心思路非常清楚:它不是简单按 label 聚合 Pod,而是通过 owner chain 去推断真正的 workload 边界。

为什么一定要找 top owner

因为很多 Kubernetes workload 都有多层 owner reference。

例如:

  • Pod 的 owner 是 ReplicaSet
  • ReplicaSet 的 owner 又是 Deployment

如果只按 Pod 当前直接 owner 聚合,你得到的是“副本控制器层”的分组,而不是“业务 workload 层”的分组。KAI 的做法是继续往上找,找到最适合代表 workload 语义的对象。

这一步带来的收益很大:

  • 同一个 workload 的 Pod 可以稳定归到同一个 PodGroup
  • 不同 workload 类型可以保留各自的分组逻辑
  • 调度器不需要理解所有 workload CRD 的细节,只需要理解 PodGroup

PodGroup 才是 KAI 真正的 workload 主语

pkg/apis/scheduling/v2alpha2/podgroup_types.go 里,PodGroupSpec 已经不仅仅是一个 minMember 包装器,它承载的是 workload 的调度语义:

  • MinMember
  • MinSubGroup
  • Queue
  • PriorityClassName
  • Preemptibility
  • TopologyConstraint
  • SubGroups
  • MarkUnschedulable
  • SchedulingBackoff

这里最重要的不是字段多,而是字段的组合方式。

这意味着 KAI 在表达的是:

  • 这是一个 gang 还是普通任务?
  • 这是平面 workload 还是层级 workload?
  • 它属于哪个资源队列?
  • 它可以被抢占吗?
  • 它有机架、zone、拓扑域要求吗?

也就是说,从这一步开始,系统不再是“对一堆 Pod 做 placement”,而是“对带有完整调度语义的 workload group 做 placement”。

第三步:Queue 把 workload 放进资源与公平性的上下文里

PodGroup 解决的是 workload 建模问题,Queue 解决的是资源分配问题。

在 KAI 里,queue 不是顺手加的一个标签,而是公平性和资源边界的核心对象。很多关键特性都建立在它上面:

  • hierarchical queue
  • quota / deserved quota
  • over-quota share
  • reclaim
  • fair-share
  • time-based fairshare

也就是说,scheduler 在做决策时,看到的不是“某个 Pod 能不能放到某个节点”,而是:

某个 PodGroup 在它所属 Queue 的资源语境下,是否应该得到这次调度机会。

这是 KAI 跟只做 node fitting 的调度器非常不同的一点。

第四步:scheduler 在一个 cycle 里对 snapshot 做决策

等到 PodGroupQueue 都就位以后,scheduler 才开始它真正擅长的事情。

这里的关键点不是某个单独算法,而是 周期性调度模型

KAI scheduler 不是收到一个 Pod 事件就立刻同步做完全部决策,而是每个 cycle:

  1. 同步 cache
  2. 获取 snapshot
  3. 打开 session
  4. 执行 actions
  5. 关闭 session

这带来两个直接好处:

1. 所有决策基于同一份一致视图

在同一个 cycle 里,所有 action 和 plugin 都基于同一个 cluster snapshot 工作。

这对复杂特性尤其重要:

  • gang scheduling
  • reclaim
  • preempt
  • topology-aware placement
  • dynamic resources
  • queue fairshare

如果没有 snapshot 这一层,很多逻辑会变成“边读边改边抢锁”,复杂度会急剧上升。

2. scheduler 可以处理的是 workload 级别的组合决策

因为它不是只看一个 Pod,而是看 snapshot 里的:

  • nodes
  • queues
  • podgroups
  • bindrequests
  • 资源状态
  • 历史 usage

所以它做出来的是更接近“全局最优”或“局部一致最优”的决策,而不只是一个 request/response 式的即时选择。

第五步:scheduler 不直接 bind,而是创建 BindRequest

这是 KAI 整条工作流里最精彩的一步。

pkg/scheduler/cache/cache.go 里,SchedulerCache.Bind(...) 的逻辑不是直接调用 Pod binding API,而是创建 BindRequest

BindRequestSpec 里会写入:

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

这说明什么?说明 scheduler 输出的不是一个轻量的“node choice”,而是一份 完整的绑定执行意图

为什么这一步非常重要

如果 scheduler 直接 bind Pod,那它就必须自己同步承担这些职责:

  • 资源声明与 DRA 处理
  • 共享 GPU 相关准备
  • 失败重试
  • 失败回滚
  • 状态更新
  • 绑定副作用控制

这会让调度循环变得又慢又重。

KAI 的选择是把它们拆开:

  • scheduler 专注于 决策
  • binder 专注于 执行

中间用 BindRequest 这个 CRD 做状态中介。

这类设计最大的价值不是“看起来解耦”,而是真正在错误模型和并发模型上得到收益:

  • scheduler cycle 可以更短
  • binder 可以独立重试
  • 执行错误不会把调度内核拖进复杂副作用里
  • 中间态对象便于排障和观测

第六步:binder reconcile BindRequest,把决策变成真实集群状态

binder 读取到 BindRequest 后,会进入 controller-runtime 的 reconcile 流程。

它的大致步骤是:

  1. 取回 BindRequest
  2. 找到对应 Pod
  3. 找到目标 Node
  4. 如果 Pod 已经绑定则退出
  5. 调用 binder 实现执行真实 bind
  6. 如果失败则 rollback
  7. 更新 BindRequest 状态和 Pod condition

这里最值得注意的是两点。

1. binder 处理的是“执行型失败”

比如:

  • 节点在短时间内变化了
  • 资源声明失败了
  • 共享 GPU 相关准备失败了
  • 某些 bind-time side effect 没完成

这些都属于执行面的问题,而不是 placement 逻辑本身的问题。

KAI 通过 binder 把这些风险从主调度循环里隔离了出来。

2. binder 能做 rollback

这很关键。

当 bind 失败时,binder 会尝试 rollback。对于一个支持 DRA、GPU sharing、复杂资源声明的系统来说,这是必须的。

因为 scheduler 的决策不只是“选 node”,它往往还隐含了若干资源分配语义。如果失败后没有清理干净,后面很容易出现资源状态污染。

最后一环:状态回写与历史反馈

如果工作流只到 binder 为止,那 KAI 还只是一个“高级 scheduler”。

它之所以更像平台,是因为后面还有完整的反馈闭环。

PodGroupController

负责把 workload 运行态、资源状态、调度条件回写到 PodGroup

这让 PodGroup 不只是“调度前的输入”,也变成“调度后的观测对象”。

QueueController

负责聚合 queue 层的资源状态,为公平性和资源分配提供基础。

Prometheus / usage DB

当 time-based fairshare 打开以后,这条反馈链会继续延伸:

  • pod-group-controller 更新 podgroup 资源状态
  • queue-controller 聚合 queue 级 usage
  • Prometheus 存储历史 usage
  • scheduler 下一轮从 usage DB 读取历史数据

这就让 KAI 不只是“看当前资源”的 scheduler,而是一个能把 历史使用行为 纳入决策的系统。

KAI 的 workflow 为什么适合 AI / batch workload

把整条链路看完以后,会很容易理解它为什么适合 AI 场景。

1. 它先建模,再调度

AI workload 往往不是单 Pod,而是:

  • 多副本训练
  • 分布式推理
  • 分层通信结构
  • gang / subgroup / topology 约束

KAI 通过 PodGroup 先把这些语义固定下来,再做 scheduling,避免把 workload 复杂性泄漏进每个 action/plugin 的实现里。

2. 它先决策,再执行

AI workload 的 bind-time 复杂度通常更高:

  • GPU sharing
  • DRA / ResourceClaim
  • volume / storage 约束
  • 失败回滚

BindRequest 把 decision plane 和 execution plane 分开,能让调度器更专注,也让系统更稳定。

3. 它有反馈闭环

公平性不只是“这一刻谁先跑”,而是“长期来看谁占用了多少资源”。

KAI 的 queue controller + Prometheus + usage DB 让它可以逐步走向时间维度的公平性,而不是只做瞬时队列排序。

我对这条工作流的一个总结

如果把 KAI 的控制面 workflow 用一句话概括,我会写成:

先把 Kubernetes workload 翻译成 KAI 的 workload language,再在一致性 snapshot 上做调度决策,最后把决策通过异步 binding pipeline 落地,并把结果反馈回 queue 与 workload 状态。

这条链路的好处是系统边界非常清楚:

  • pod-grouper 负责“翻译 workload”
  • scheduler 负责“决定谁该上、上到哪”
  • binder 负责“把决定真的执行掉”
  • controller 们负责“把执行结果重新喂回系统”

下一篇看什么

理解 workflow 以后,下一步最自然的问题就是:

  • snapshot 到底是什么?
  • session 里都存了什么?
  • action 和 plugin 究竟谁负责流程,谁负责策略?
  • KAI 为什么要引入 statement / scenario 这种“事务化模拟”模型?

下一篇就进入调度内核本身,拆解 KAI 最核心的设计:

cycle、snapshot、session、action、plugin、statement。

KAI Scheduler 这个项目第一眼很容易被名字误导:看起来像是一个“GPU 版 kube-scheduler”,但真的把仓库翻一遍以后,会发现它更像是一套围绕 AI/ML 工作负载构建的 Kubernetes 原生调度平台

它当然有 scheduler 这个核心二进制,但仓库真正的架构中心并不是单个调度循环,而是下面这几个层次同时成立:

  1. 工作负载建模:把 Pod、Job、Ray、Kubeflow、JobSet 之类的对象,统一收敛成 PodGroupQueue 这些调度语义更强的 CRD。
  2. 调度决策:scheduler 基于 snapshot/session/actions/plugins 做周期性调度。
  3. 执行落地:scheduler 不直接 bind Pod,而是创建 BindRequest,交给 binder 异步执行。
  4. 状态与反馈:PodGroup Controller、Queue Controller、Prometheus、usage DB 一起把“当前状态”和“历史使用量”喂回调度器。
  5. 平台配置:operator 通过 ConfigSchedulingShard 把整套控制面部署出来,并且把每个 shard 的调度策略参数化。

如果只盯着 pkg/scheduler,会看见一个很强的调度内核;但如果把 cmd/pkg/apis/pkg/operator/pkg/binder/pkg/podgrouper/ 放在一起,才会看见 KAI 真正的系统轮廓。

先从 repo 结构建立“地图感”

这个仓库最值得先看的不是某个具体算法,而是目录本身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
cmd/
admission/
binder/
operator/
podgrouper/
podgroupcontroller/
queuecontroller/
scheduler/
nodescaleadjuster/
resourcereservation/
snapshot-tool/
fairshare-simulator/
time-based-fairshare-simulator/

pkg/
apis/
binder/
operator/
podgrouper/
podgroupcontroller/
queuecontroller/
scheduler/
common/

docs/
developer/
operator/
fairness/
queues/
topology/
time-based-fairshare/

这个结构已经说明了一件事:KAI 的实现方式不是把复杂性都堆进 scheduler 进程,而是把不同职责拆成多个控制器和服务。

cmd/:每个二进制都代表一个架构角色

cmd/ 可以直接看出 KAI 的主要运行单元:

  • scheduler:真正做 placement 决策的内核。
  • binder:异步执行 bind、资源准备、回滚。
  • podgrouper:自动把 workload 转换成 PodGroup
  • podgroupcontroller:回写 PodGroup 的运行态和资源状态。
  • queuecontroller:维护队列层级与资源使用状态。
  • operator:把整套 KAI 控制面部署出来。
  • admission:准入控制和 webhook。
  • nodescaleadjuster:和节点伸缩场景对接。
  • snapshot-toolfairshare-simulatortime-based-fairshare-simulator:偏调试、验证、离线分析工具。

这跟传统印象中的“scheduler 就是一切”非常不同。KAI 的设计明显在强调:

调度不是一个函数调用,而是一条跨多个控制器的控制面流水线。

pkg/apis/:真正的架构边界写在 CRD 里

很多 Kubernetes 项目表面上是 controller,实际上系统边界都藏在 CRD 里。KAI 也是这样。

对理解全局最关键的几个 API 是:

  • Config
  • SchedulingShard
  • Queue
  • PodGroup
  • BindRequest
  • Topology

这些对象并不是简单的配置载体,它们定义了 KAI 如何把“AI 集群调度”拆成若干可以被控制器协作处理的状态机。

KAI 的整体架构可以拆成五层

我把它整理成下面这个图:

flowchart LR
    User[用户 / 工作负载控制器] --> Admission[Admission]
    Admission --> PodGrouper[Pod Grouper]
    PodGrouper --> PodGroup[PodGroup CRD]
    User --> Queue[Queue CRD]

    Config[Config CRD] --> Operator[Operator]
    Shard[SchedulingShard CRD] --> Operator
    Operator --> Scheduler
    Operator --> Binder
    Operator --> QueueController
    Operator --> PodGroupController
    Operator --> PodGrouper

    Queue --> Scheduler[Scheduler]
    PodGroup --> Scheduler
    Nodes[Node / PVC / DRA / CSI / etc] --> Scheduler

    Scheduler --> BindRequest[BindRequest CRD]
    BindRequest --> Binder[Binder]
    Binder --> Pods[Pods bound to nodes]

    Pods --> PodGroupController[PodGroup Controller]
    Pods --> QueueController[Queue Controller]
    PodGroupController --> PodGroup
    QueueController --> Queue
    QueueController --> Prometheus[Prometheus / Usage DB]
    Prometheus --> Scheduler

这个图最想表达的是三件事:

1. KAI 先把 workload 变成“可调度对象”,再做调度

Kubernetes 原生对象很多:Pod、Job、Deployment、RayCluster、PyTorchJob、MPIJob、JobSet……

但真正做公平性、gang scheduling、拓扑约束、优先级/可抢占性判断时,直接围绕这些原生对象写逻辑会非常散。

KAI 的做法是通过 pod-grouper 把它们统一抽象成 PodGroup,再配合 Queue 建立调度语义。这一步非常关键:

  • PodGroup 代表“应该一起考虑的 workload 单元”
  • Queue 代表“资源应该如何分配给哪个组织/租户/项目”

也就是说,KAI 不是在调度“孤立的 Pod”,而是在调度 workload group 在 queue 体系里的资源位置

2. scheduler 只负责决策,binder 负责执行

这是我读完整个仓库以后最喜欢的一点。

KAI 没有把“算出节点”与“执行绑定”写成一个同步链路,而是中间插了一个 BindRequest CRD。

这意味着:

  • scheduler 可以更专注在 决策速度和一致性 上;
  • binder 可以独立处理 绑定、副作用、资源声明、GPU sharing、失败回滚
  • 两个子系统的错误模型、重试模型、并发模型可以分开设计。

这类设计在控制面系统里非常常见,但在 scheduler 里做得这么明确,其实很少见。

3. KAI 不是静态策略,而是可配置的调度平台

很多项目的“可扩展”只是加几个 flag。KAI 明显不是这个级别。

scheduling shard 这一层直接把下面这些内容暴露成平台配置面:

  • partition / node pool
  • placement strategy(binpack / spread)
  • queue depth
  • min runtime
  • plugin/action override
  • historical usage 配置
  • time-based fairshare 参数

也就是说,KAI 的目标不是给你一个固定算法,而是给你一个 围绕 AI 工作负载场景可演化的调度框架

四个最关键的 CRD:它们决定了系统的“语义骨架”

Config:整套控制面的安装与组合

Config 不是一个简单的 values 文件替代品,它实际上定义了 KAI 控制面里有哪些服务、这些服务怎么部署、Prometheus 是否启用、全局配置如何下发。

pkg/apis/kai/v1/config_types.go 可以看到,ConfigSpec 直接包含:

  • PodGrouper
  • Binder
  • Admission
  • Scheduler
  • QueueController
  • PodGroupController
  • NodeScaleAdjuster
  • Prometheus

这很像在 Kubernetes 里做了一层“平台安装 API”。

SchedulingShard:调度器不再是单实例,而是按分片部署

SchedulingShard 是另一个非常有代表性的对象。

它说明 KAI 并不把“集群只有一个 scheduler 实例”当成默认前提,而是支持按 node pool / queue / podgroup label 做逻辑分片。每个 shard 都可以有自己的:

  • partitionLabelValue
  • placement strategy
  • queueDepthPerAction
  • kValue
  • usageDBConfig
  • Plugins
  • Actions

这代表 KAI 的“平台视角”很强:

不是所有 GPU 集群都该共享同一种调度策略。

Queue:资源公平性的组织边界

如果只把 KAI 看成 GPU 调度器,容易低估 Queue 的重要性。实际上 queue 才是公平性、quota、over-quota、reclaim 的基础对象。

KAI 的很多高级能力——尤其是 fairshare、hierarchical queue、reclaim、time-based fairshare——都建立在 queue 体系之上。

PodGroup:KAI 的工作负载主语

我觉得 PodGroup 是这个项目最值得花时间理解的对象。

pkg/apis/scheduling/v2alpha2/podgroup_types.go 里,它已经不只是“gang scheduling 的 minMember 容器”,而是逐渐长成了 KAI 的 workload API:

  • MinMember
  • MinSubGroup
  • Queue
  • PriorityClassName
  • Preemptibility
  • TopologyConstraint
  • SubGroups

而在 Status 侧,PodGroup 还维护了 SchedulingConditionsResourcesStatus,让它同时成为 workload 输入模型和运行态观察点。

看到这里就能理解:KAI 实际上在把“AI workload 的调度语义”沉淀进一个独立 API,而不是永远寄生在原生 Pod 字段上。

为什么 scheduler / binder 拆分是全局设计的支点

如果只能记住 KAI 架构中的一件事,我建议记住这一句:

scheduler 决定“应该放哪”,binder 决定“怎么把它真的放上去”。

这个拆分的收益非常大:

  1. 调度循环可以保持短而稳定

    • 不需要在一个 scheduling cycle 里等待所有 bind 副作用完成。
  2. 复杂资源准备可以异步处理

    • 比如 DRA、PVC/CSI、GPU sharing、resource reservation。
  3. 失败与回滚逻辑可以局部化

    • binder 失败不等于 scheduler 内核设计失败;它只是执行面的一次 reconcile 失败。
  4. 状态可观察性更好

    • BindRequest 本身就成了一个中间态对象,便于排障、审计和重试。

这也是为什么我认为 KAI 更像“调度平台”而不是“单个调度器实现”。

我对 KAI 架构的一个总结

读完 repo 以后,我会用下面这句话概括它:

KAI Scheduler 把 AI 集群调度拆成了 工作负载建模、资源分配决策、绑定执行、状态反馈、平台配置 五个层面,并用 CRD + controller + session-based scheduler 的方式把它们拼成一个完整控制面。

这个思路跟很多只在单个 scheduling cycle 上做文章的项目不一样。KAI 真正重视的是:

  • gang scheduling 不是插件细节,而是 workload model;
  • fairshare 不是一个排序函数,而是 queue 系统;
  • topology 不是一个 affinity 小技巧,而是跨 workload 结构的策略面;
  • scheduler 的职责不是把所有事情都做完,而是生成高质量、可执行、可回滚的调度决策。

推荐的阅读顺序

如果你准备继续往下读源码,我建议按这个顺序:

  1. docs/developer/scheduler-concepts.md
  2. docs/developer/action-framework.md
  3. docs/developer/plugin-framework.md
  4. docs/developer/binder.md
  5. docs/developer/pod-grouper.md
  6. docs/operator/scheduling-shards.md
  7. pkg/scheduler/scheduler.go
  8. pkg/scheduler/framework/
  9. pkg/scheduler/cache/cache.go
  10. pkg/binder/controllers/bindrequest_controller.go

下一篇看什么

理解完组件地图以后,下一步就该看这套系统的“主干工作流”:

  • 一个 Pod 进入集群之后,什么时候变成 PodGroup
  • QueuePodGroup、node pool label 是怎么汇合到一起的?
  • scheduler 为什么不直接 bind Pod,而是创建 BindRequest
  • PodGroup Controller 和 Queue Controller 又把什么信息回写给系统?

下一篇就沿着这条链路,从 Pod 到 BindRequest,把 KAI 的控制面工作流走一遍。

Virtlet learning

Key Components

  1. Virtlet Manager:
    • Implements the CRI interface for virtualization and image handling
    • Processes requests from kubelet
    • Sets up libvirt VM environment (virtual drives, network interfaces, resources)
    • Manages VM lifecycle through libvirt
  2. Tapmanager:
    • Controls VM networking using CNI
    • Takes setup requests from Virtlet manager
    • Runs DHCP server for each active VM
    • Serves requests from vmwrapper
    • Tears down VM networks upon Virtlet manager requests
  3. VMWrapper:
    • Run by libvirt, wraps the emulator (QEMU/KVM)
    • Requests tap file descriptor from tapmanager
    • Adds command line arguments for the emulator
    • Execs the emulator
  4. Libvirt:
    • Manages VM lifecycle
    • Provides API for VM operations
  5. QEMU/KVM:
    • The actual emulator that runs VMs
  6. CRI Proxy:
    • Allows running multiple CRI implementations on the same node
    • Routes requests to appropriate runtime (Virtlet or dockershim)

Volume Management

Virtlet supports various volume types:

  1. Root Volumes: The main VM disk
  2. Cloud-Init Volumes: For VM configuration
  3. Raw Devices: Direct access to host devices
  4. Kubernetes Volumes: Integration with Kubernetes volume system

The volume management is handled through:

  • VMVolumeSource interface
  • Various volume implementations (rootfs, cloudinit, raw, etc.)
  • Libvirt storage pools

Networking Architecture

VM Lifecycle Management

Code Flow

Detailed Code Flow Explanation

1. Pod Creation and Annotation Parsing

  • Starting Point: Kubernetes creates a pod with Virtlet-specific annotations
  • Key Files: pkg/metadata/types/annotations.go
  • Process:
    • The VirtletDiskDriver annotation specifies the disk driver type (virtio, scsi, or nvme)
    • Annotations are parsed in parsePodAnnotations method

2. Disk Driver Selection

  • Key Files: pkg/libvirttools/diskdriver.go
  • Process:
    • getDiskDriverFactory selects the appropriate driver factory based on the annotation
    • Driver factories: virtioBlkDriverFactory, scsiDriverFactory, or nvmeDriverFactory
    • Each factory creates a driver implementing the diskDriver interface

3. Volume Source and Volume Creation

  • Key Files: pkg/libvirttools/volumes.go, pkg/libvirttools/virtualization.go
  • Process:
    • volumeSource function creates VMVolume objects for each required volume
    • Volumes include root disk, cloud-init config, and additional volumes from flexvolume

4. Disk List Setup

  • Key Files: pkg/libvirttools/disklist.go
  • Process:
    • newDiskList creates a list of disk items, each with a driver and volume
    • diskList.setup calls volume.Setup() for each volume to get disk definitions
    • Each disk definition gets its target from the corresponding driver

5. Domain Creation

  • Key Files: pkg/libvirttools/virtualization.go
  • Process:
    • createDomain builds the libvirt domain XML structure
    • Disk definitions from diskList.setup are added to the domain devices
    • For NVMe disks, the target is set to nvmeXn1 with bus type nvme

6. Domain Definition and Start

  • Key Files: pkg/libvirttools/virtualization.go
  • Process:
    • Domain is defined in libvirt using DefineDomain
    • diskList.writeImages writes any necessary disk images (e.g., cloud-init)
    • Domain is started, launching QEMU with the configured devices

Device Mapping Details

For SCSI Disks (default):

  1. The domain includes a SCSI controller
  2. Disks are attached to this controller with names like sda, sdb
  3. SCSI addressing is used to connect disks to the controller

For virtio-blk Disks:

  1. Disks are attached directly to the PCI bus
  2. Disk names follow the pattern vda, vdb
  3. No controller is needed, simplifying the setup

Key Insights

  1. Modular Design: Virtlet uses a modular architecture with clear separation of concerns between components.
  2. Integration with Kubernetes: Fully implements the CRI interface, allowing seamless integration with Kubernetes.
  3. Networking: Uses CNI for network setup and runs a DHCP server for each VM.
  4. Volume Management: Flexible volume system supporting various volume types and Kubernetes volume integration.
  5. Resource Management: Supports CPU and memory limits, CPU pinning, and NUMA topology.

This architecture allows Virtlet to run VMs as if they were containers from Kubernetes’ perspective, providing a way to run legacy applications or workloads that require full virtualization in a Kubernetes environment.

Questions:

  1. Key Functions and Interfaces Between vmwrapper and tapmanager

Core Interface: FDClient/FDServer Protocol
The communication between vmwrapper and tapmanager is primarily facilitated through the FDClient/FDServer protocol, which allows passing file descriptors across process boundaries.

Key Functions in vmwrapper
In cmd/vmwrapper/vmwrapper.go:

// Main function that retrieves network FDs from tapmanager
if netFdKey != “” {
c := tapmanager.NewFDClient(fdSocketPath)
fds, marshaledData, err := c.GetFDs(netFdKey)
if err != nil {
glog.Errorf(“Failed to obtain tap fds for key %q: %v”, netFdKey, err)
os.Exit(1)
}

var descriptions \[\]tapmanager.InterfaceDescription  
if err := json.Unmarshal(marshaledData, \&descriptions); err \!= nil {  
    glog.Errorf("Failed to unmarshal network interface info: %v", err)  
    os.Exit(1)  
}  
// ...  

}
This code in vmwrapper:

Creates a new FDClient connected to tapmanager’s socket
Calls GetFDs() with the network key to retrieve file descriptors
Unmarshals the interface descriptions
Uses these FDs to configure QEMU network devices
Key Functions in tapmanager
In pkg/tapmanager/fdserver.go:

// FDServer.serveGet - handles GetFDs requests from clients
func (s *FDServer) serveGet(c *net.UnixConn, hdr *fdHeader) (*fdHeader, []byte, []byte, error) {
key := hdr.getKey()
fds, err := s.getFDs(key)
if err != nil {
return nil, nil, nil, err
}
info, err := s.source.GetInfo(key)
if err != nil {
return nil, nil, nil, fmt.Errorf(“can’t get key info: %v”, err)
}

// ... prepare and return file descriptors ...  
rights := syscall.UnixRights(fds...)  
return \&fdHeader{/\*...\*/}, info, rights, nil  

}
In pkg/tapmanager/tapfdsource.go:

// GetFDs implements GetFDs method of FDSource interface
func (s *TapFDSource) GetFDs(key string, data []byte) ([]int, []byte, error) {
var payload GetFDPayload
if err := json.Unmarshal(data, &payload); err != nil {
return nil, nil, fmt.Errorf(“error unmarshalling GetFD payload: %v”, err)
}

// ... network namespace and CNI setup ...  
  
// Setup container side network  
if csn, err \= nettools.SetupContainerSideNetwork(netConfig, netNSPath, allLinks, s.enableSriov, hostNS); err \!= nil {  
    return nil, err  
}

// Marshal network configuration to return to client  
if respData, err \= json.Marshal(csn); err \!= nil {  
    return nil, fmt.Errorf("error marshalling net config: %v", err)  
}

// Collect file descriptors for tap devices  
for \_, i := range csn.Interfaces {  
    fds \= append(fds, int(i.Fo.Fd()))  
}  
  
return fds, respData, nil  

}
Key Interfaces
FDSource Interface
// FDSource denotes an ‘executive’ part for FDServer which
// creates and destroys (closes) the file descriptors and
// associated resources
type FDSource interface {
// GetFDs sets up file descriptors based on key and extra data
GetFDs(key string, data []byte) ([]int, []byte, error)

// Release destroys the file descriptor and associated resources  
Release(key string) error  
  
// GetInfo returns information to propagate back to FDClient  
GetInfo(key string) (\[\]byte, error)  
  
// Recover recovers FDSource's state after restart  
Recover(key string, data \[\]byte) error  
  
// RetrieveFDs retrieves file descriptors  
RetrieveFDs(key string) (\[\]int, error)  
  
// Stop stops goroutines associated with FDSource  
Stop() error  

}
FDManager Interface
// FDManager denotes an object that provides ‘master’-side
// functionality of FDClient
type FDManager interface {
// AddFDs adds new file descriptor to the FDManager
AddFDs(key string, data interface{}) ([]byte, error)

// ReleaseFDs makes FDManager close the file descriptor  
ReleaseFDs(key string) error  
  
// Recover recovers the state regarding the specified key  
Recover(key string, data interface{}) error  

}
InterfaceDescription Struct
// InterfaceDescription contains interface type with additional data
// needed to identify it
type InterfaceDescription struct {
Type network.InterfaceType `json:”type”`
HardwareAddr net.HardwareAddr `json:”mac”`
FdIndex int `json:”fdIndex”`
PCIAddress string `json:”pciAddress”`
}
Network Setup Functions
The actual network setup is handled by nettools.SetupContainerSideNetwork(), which:

Creates tap devices
Sets up bridges
Configures networking
Returns a ContainerSideNetwork structure with interface descriptions
Data Flow Between Components
Virtlet creates a VM and generates a network key
Virtlet passes this key to vmwrapper via environment variables
vmwrapper connects to tapmanager’s socket and calls GetFDs(key)
tapmanager calls TapFDSource.GetFDs() to set up networking
TapFDSource uses nettools to create and configure tap devices
TapFDSource returns file descriptors and interface descriptions
vmwrapper uses these to configure QEMU network devices
QEMU uses the file descriptors to communicate with the tap devices
This architecture allows for clean separation between the VM process and the network setup, with file descriptors being the primary interface between the components.

  1. Key Functions and Interfaces Between virtlet and vmwrapper

Key Functions and Interfaces Between Virtlet and vmwrapper
Overview
The interaction between Virtlet and vmwrapper is primarily through environment variables and the libvirt domain definition. Virtlet configures the VM domain and sets vmwrapper as the emulator, passing necessary configuration through environment variables.

Key Functions in Virtlet (VirtualizationTool)
Domain Creation
In pkg/libvirttools/virtualization.go, the CreateContainer method is responsible for defining the libvirt domain with vmwrapper as the emulator:

func (v *VirtualizationTool) CreateContainer(config *types.VMConfig, netFdKey string) (string, error) {
// …
settings := domainSettings{
domainUUID: domainUUID,
domainName: “virtlet-“ + domainUUID[:13] + “-“ + config.Name,
netFdKey: netFdKey,
// … other settings
}

domainDef := settings.createDomain(config)  
// ...  

}
Domain Definition
The createDomain method in domainSettings sets up the domain definition with vmwrapper as the emulator and passes configuration through environment variables:

func (ds *domainSettings) createDomain(config *types.VMConfig) *libvirtxml.Domain {
// …
domain := &libvirtxml.Domain{
Devices: &libvirtxml.DomainDeviceList{
Emulator: “/vmwrapper”,
// … other devices
},
// … other domain settings

    QEMUCommandline: \&libvirtxml.DomainQEMUCommandline{  
        Envs: \[\]libvirtxml.DomainQEMUCommandlineEnv{  
            {Name: vconfig.EmulatorEnvVarName, Value: emulator},  
            {Name: vconfig.NetKeyEnvVarName, Value: ds.netFdKey},  
            {Name: vconfig.ContainerIDEnvVarName, Value: config.DomainUUID},  
            {Name: vconfig.LogPathEnvVarName, Value: filepath.Join(config.LogDirectory, config.LogPath)},  
            {Name: vconfig.NetworkDeviceEnvVarName, Value: config.ParsedAnnotations.NetworkDevice},  
        },  
    },  
}  
// ...  
return domain  

}
Environment Variables (Communication Interface)
The key environment variables used for communication between Virtlet and vmwrapper are defined in pkg/config/constants.go:

const (
// ContainerIDEnvVarName contains name of env variable passed from virtlet to vmwrapper
ContainerIDEnvVarName = “VIRTLET_CONTAINER_ID”
// CpusetsEnvVarName contains name of env variable passed from virtlet to vmwrapper
CpusetsEnvVarName = “VIRTLET_CPUSETS”
// EmulatorEnvVarName contains name of env variable passed from virtlet to vmwrapper
EmulatorEnvVarName = “VIRTLET_EMULATOR”
// LogPathEnvVarName contains name of env variable passed from virtlet to vmwrapper
LogPathEnvVarName = “VIRTLET_CONTAINER_LOG_PATH”
// NetKeyEnvVarName contains name of env variable passed from virtlet to vmwrapper
NetKeyEnvVarName = “VIRTLET_NET_KEY”
// Network device
NetworkDeviceEnvVarName = “VIRTLET_NETWORK_DEVICE”
)
Key Functions in vmwrapper
In cmd/vmwrapper/vmwrapper.go, the main function processes these environment variables:

func main() {
// …
emulator := os.Getenv(config.EmulatorEnvVarName)
emulatorArgs := os.Args[1:]
var netArgs []string
if emulator == “” {
// this happens during ‘qemu -help’ invocation by libvirt
// (capability check)
emulator = defaultEmulator
} else {
netFdKey := os.Getenv(config.NetKeyEnvVarName)
// …
if netFdKey != “” {
c := tapmanager.NewFDClient(fdSocketPath)
fds, marshaledData, err := c.GetFDs(netFdKey)
// …
// Process network interfaces
// …
}
}

args := append(\[\]string{emulator}, emulatorArgs...)  
args \= append(args, netArgs...)  
env := os.Environ()  
if err := syscall.Exec(args\[0\], args, env); err \!= nil {  
    glog.Errorf("Can't exec emulator: %v", err)  
    os.Exit(1)  
}  

}
Data Flow Between Components
Virtlet creates a VM configuration with a unique domain UUID
Virtlet generates a network key for the VM
Virtlet defines a libvirt domain with:
/vmwrapper as the emulator
Environment variables containing configuration:
VIRTLET_EMULATOR: The actual QEMU emulator path
VIRTLET_NET_KEY: Key to retrieve network interfaces
VIRTLET_CONTAINER_ID: The domain UUID
VIRTLET_CONTAINER_LOG_PATH: Path for VM logs
VIRTLET_NETWORK_DEVICE: Network device type (e.g., “virtio”)
VIRTLET_CPUSETS: CPU sets for the VM (optional)
libvirt starts the domain, executing /vmwrapper with the environment variables
vmwrapper:
Reads the environment variables
Connects to tapmanager using the socket path
Retrieves network interfaces using the network key
Constructs QEMU command line arguments
Executes the actual QEMU emulator with the arguments
Key Interfaces
The primary interface between Virtlet and vmwrapper is the set of environment variables passed through the libvirt domain definition. These variables provide vmwrapper with all the information it needs to:

Identify the VM (container ID)
Locate the actual emulator to use
Set up networking by retrieving the appropriate file descriptors
Configure logging
Set CPU affinity (if specified)
This design allows Virtlet to remain in control of the VM configuration while delegating the actual execution and network setup to vmwrapper, which runs in a separate process.

Function Relationship

进程调度是 Linux 内核中最复杂也最关键的子系统之一。在生产环境中,”CPU 使用率 100% 但响应很慢”、”任务唤醒后等了几十毫秒才运行”、”某个进程长期卡在 D 状态”——这些问题的根因往往深藏在调度层。本文是本系列第五篇,聚焦于调度诊断的完整方法论:从 /proc 接口读取原始数据,到 perf sched 分析调度延迟,再到 bpftrace 精确追踪内核路径,最后结合四个典型生产案例给出可落地的排查流程与修复建议。

Read more »

容器技术的核心在于资源隔离与限制,而这一能力的底层支撑正是 Linux 内核的 cgroup(Control Group)机制。本文基于 Linux 6.4-rc1 源码,深入剖析 cgroup v2 的内核实现,涵盖统一层次框架、Memory/CPU/IO/PID 四大控制器的核心数据结构与关键路径,以及 cgroup namespace 与诊断方法。

Read more »
0%