Kubernetes核心组件学习系列 - 完整指南与学习路线图
Kubernetes核心组件深度学习系列文章导航,提供系统性的学习路径和面试准备指南
Kubernetes核心组件深度学习系列文章导航,提供系统性的学习路径和面试准备指南
前几篇文章里,我一直在强调一件事:KAI Scheduler 的价值不只是“今天已经实现了哪些特性”,更重要的是 它为什么可以持续装下更多特性。
这一篇就专门看这个问题。
如果只看表层功能,KAI 已经足够复杂:
但真正有意思的地方在于,这些能力并不是一堆互不相干的特判,而是大多能在同一套框架下自然落位:
SchedulingShard 管PodGroup 管换句话说,KAI 的扩展性不是“多写几个 if”,而是 架构层面为扩展预留了空间。
pkg/scheduler/plugins/factory.go 把默认插件注册得非常完整:
predicatesprioritynodeplacementnominatednodenodeavailabilitygpusharingordergpupackgpuspreadresourcetypepodaffinityelastickubeflowraytaskordersubgrouporderdynamicresourcestopologyproportionminruntimesnapshotreflectjoborder这张表最重要的意义不是“插件很多”,而是它说明 KAI 把以下问题都视作 策略问题,而不是写死在主循环里的逻辑:
这就是为什么 KAI 的高级特性能持续叠加,而不会轻易把主调度流程搞乱。
在 pkg/scheduler/framework/session.go 里,Session 暴露的函数槽位非常多,最有代表性的包括:
JobOrderFnsTaskOrderFnsSubGroupOrderFnsQueueOrderFnsNodeOrderFnsGpuOrderFnsPrePredicateFnsPredicateFnsCanReclaimResourcesFnsReclaimVictimFilterFnsPreemptVictimFilterFnsGetQueueAllocatedResourcesFnsGetQueueDeservedResourcesFnsGetQueueFairShareFnsBindRequestMutateFns只要看见这些接口,就能立刻理解 KAI 的插件思想:
它不是只开放一个 score 钩子,而是把“调度过程中可能需要定制的所有局部决策”尽量显式化。
这带来了两个直接好处:
SchedulingShard:KAI 真正的平台配置面如果说 plugin 是“代码级扩展点”,那 SchedulingShard 就是“平台级策略面”。
我认为这是 KAI 最值得单独夸的一层设计。
在 pkg/apis/kai/v1/schedulingshard_types.go 里,SchedulingShardSpec 暴露的内容包括:
ArgsPlacementStrategyPartitionLabelValueQueueDepthPerActionMinRuntimeKValueUsageDBConfigPluginsActions这意味着什么?
这意味着 KAI 把很多原本应该藏在源码、flag 或者某个配置文件里的策略选择,提升成了 可声明、可演化、可分片实例化的 Kubernetes API。
因为它让“调度器实例”不再是同质的。
你可以想象这样几种 shard:
这就把 KAI 从“一个调度器程序”推进成了“一个可按业务场景切片的调度平台”。
scheduling-shards.md 里说得很清楚:每个 shard 实际上围绕同一个 partition label key/value 工作。也就是说,scheduler 只会考虑带有同一组分片标签的:
这是一种非常典型的 Kubernetes 风格:
这样带来的好处不只是扩容,更重要的是 策略与资源空间同时分片。
也就是说,不同 shard 不只是“不同副本数的 scheduler”,而是:
很多调度器支持 topology,最终只是把它当作额外 affinity。
KAI 的 topology 明显不是这个级别。
docs/topology/README.md 描述得很清楚,它依赖两个东西:
Topology CRD 定义层级拓扑PodGroup / workload 声明 required / preferred placement然后调度时做两层决策:
先选择应该把 workload 放进哪个 topology domain。
这里的策略偏向 bin-packing:优先选择“相对更满但仍能容纳该 workload”的 domain,以减少碎片。
在选中的 domain 内,再按 preferred placement 做节点排序,让 Pod 尽量聚拢到更合适的子域。
这套设计为什么自然?
因为 KAI 的框架本身就已经有:
PodGroup)所以 topology 不需要硬插一条特别路径,而是能顺着既有框架表达出来。
docs/fairness/README.md 里最重要的一句话是:
这意味着 KAI 的公平性不是简单“谁资源少谁先跑”,而是建立在 queue hierarchy 上的:
因为 fairshare 不只是 queue 排序,还会影响:
如果没有 session 里的 queue callback、action 框架和 statement 模型,fairshare 很容易变成一堆局部规则拼接。KAI 的实现方式则更像把它作为一个正式的一等公民。
我觉得 time-based fairshare 是 KAI 很有代表性的一个能力,因为它显示出这套架构并不只看“这一刻的资源状态”,而是开始纳入时间维度。
docs/time-based-fairshare/README.md 给出的主线是:
proportion 等 fairness 相关逻辑把历史 usage 纳入 over-quota 资源分配这个闭环很说明问题:
KAI 不只是个实时调度器,它正在演化成一个“结合当前状态与历史行为”的资源治理系统。
因为 KAI 之前已经有了:
KValue、UsageDBConfig 等策略面配置GetQueueFairShareFns 这类 session 级扩展点所以历史 usage 的接入,不需要推翻已有架构,只需要在已有资源分配框架上把输入变得更丰富。
Config + SchedulingShard 的组合,让 operator 也变成架构的一部分前面聊得多的是 scheduler 内核,但 KAI 的平台味道其实还来自 operator 层。
在 pkg/apis/kai/v1/config_types.go 里,ConfigSpec 直接声明了整套控制面的组件:
PodGrouperBinderAdmissionSchedulerQueueControllerPodGroupControllerNodeScaleAdjusterPrometheus也就是说,KAI 不是把 operator 当“安装器”,而是把它做成了这套架构的一部分:
Config 负责描述整个平台的全局组件组合SchedulingShard 负责描述单个调度分片的策略配置这两个 CRD 组合起来,基本就是 KAI 的控制面 API。
很多系统在功能越来越多以后,会出现一种典型问题:
KAI 目前给我的感受恰好相反。它的高级能力大多还能找到一个比较自然的落点:
PodGroup而不是散落在所有 plugin 里。
Queue而不是偷偷写在 node score 里。
而不是被 plugin 任意控制。
而不是让 action 变成巨无霸。
BindRequest + binder而不是让 scheduler 同步做完所有副作用。
SchedulingShard而不是只能改源码或启动参数。
这就是为什么我觉得 KAI 的架构不仅功能多,而且有继续长下去的空间。
从阅读体验上,我会把 KAI 的扩展入口分成四类。
先看:
PodGroup API先看:
Session 暴露的 callback 槽位plugins/factory.goOnSessionOpen先看:
actions/factory.goSchedulingShard.Actions先看:
SchedulingShard如果只用一句话概括,我会写成:
KAI 把“调度平台”的关键维度分开了:工作负载语义、资源公平性、调度流程、局部策略、执行落地、平台配置,各自都有明确落点,因此高级能力能以组合的方式生长出来。
这跟很多“功能越多越难维护”的 scheduler 最大的不同在于:
如果你是第一次接触 KAI,我会建议按照这条线继续深入:
docs/developer/scheduler-concepts.mddocs/developer/action-framework.mddocs/developer/plugin-framework.mddocs/developer/pod-grouper.md 和 docs/operator/scheduling-shards.mdpkg/scheduler/ 的 session / action / cache 主链路因为 KAI 最值得学习的地方,真的不只是某一个调度算法,而是:
它如何把复杂的 AI workload 调度,拆成一套仍然能持续演化的 Kubernetes 控制面架构。
对我来说,这就是这个仓库最有价值的部分。
前面两篇讲了架构和 workflow,上一篇讲了框架层的抽象。这一篇开始不再停留在概念,而是直接沿着源码主链路走一遍 KAI Scheduler 的一次调度周期。
我的目标不是把每个函数都抄一遍,而是回答五个更实在的问题:
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.gopkg/apis/scheduling/v1alpha2/bindrequest_types.gopkg/binder/controllers/bindrequest_controller.go真正的入口在 cmd/scheduler/app/server.go。
RunApp() 先做的是运行时准备从代码顺序上看,大致是:
Run(...)也就是说,KAI scheduler 本身就是一个标准 Kubernetes 控制面进程,而不是“读个配置然后进一个大循环”的轻量程序。
Run() 里最重要的两行对理解调度内核最关键的,其实是这两步:
1 | actions.InitDefaultActions() |
这代表 KAI 在启动阶段先把“可用动作”和“可用插件”注册进框架,再从配置解析出本次实际启用的 action/plugin 组合。
这有个很重要的意义:
注册是能力集合,配置才是实际执行计划。
换句话说:
InitDefaultActions() 负责告诉框架有哪些 action 存在InitDefaultPlugins() 负责告诉框架有哪些 plugin 可用ResolveConfigurationFromFile(...) 决定这一轮部署到底启用哪些,以及它们的优先级和参数是什么这比很多项目直接在代码里写死流程要干净得多。
pkg/scheduler/actions/factory.go 很短,但很重要:
1 | func InitDefaultActions() { |
这里要注意一个细节:注册顺序不等于执行顺序。
真正的执行顺序来自配置解析,而默认优先级则来自 SchedulingShard 的 action 配置语义:
allocateconsolidationreclaimpreemptstalegangeviction所以代码层面的注册只是“把工厂挂到框架里”,而不是“马上决定调度流程”。
pkg/scheduler/plugins/factory.go 更能体现 KAI 的平台化程度。默认注册的 plugin 包括:
predicatesprioritynodeplacementnominatednodenodeavailabilitygpusharingordergpupackgpuspreadresourcetypepodaffinityelastickubeflowraytaskordersubgrouporderdynamicresourcestopologyproportionminruntimesnapshotreflectjoborder这张列表本身已经非常说明问题:KAI 不是一个只会做 node predicate + score 的 scheduler,它的策略面覆盖了:
也就是说,KAI 的“可扩展性”不是停留在接口层,而是已经沉淀成一个很具体的内建策略生态。
NewScheduler() 组装出来的不是一个算法对象,而是一整套运行环境进入 pkg/scheduler/scheduler.go 后,可以看到 NewScheduler(...) 做了很多准备工作:
SchedulerCacheParamsSchedulerConfiguration 和 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() 非常值得精读。
它的逻辑可以概括为:
openSession(...) 从 cache 里拿 snapshotconfig 塞进 sessionplugin.OnSessionOpen(ssn)这一步的关键不在于“插件被创建了”,而在于:
OnSessionOpen会把 plugin 的 callback 注册进当前 session。
这意味着,每轮调度里真正生效的策略,不是从全局某个 registry 动态查一遍,而是已经在 session 打开时被“装配”进这一轮上下文了。
每个 plugin 都只需要关心:
这让 plugin 的实现边界非常清楚。
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 是如何工作的:
NodePreOrderFnNodeOrderFn score这里能明显看出 KAI 的策略执行与排序逻辑已经抽象得很清楚。
SchedulerCache.Bind()如果你想知道 placement 决策在哪一层真正离开 session,答案就在 pkg/scheduler/cache/cache.go。
SchedulerCache.Bind(...) 做的事情大致是:
createBindRequest(...)最关键的不是状态更新,而是 createBindRequest(...)。
createBindRequest(...) 里真正落地了什么它会构造一个 BindRequest,写入:
selected-node labelPodNameSelectedNodeSelectedGPUGroupsReceivedResourceTypeReceivedGPUResourceClaimAllocations这一步非常有信息量,因为它告诉我们:
也就是说,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:
这是 KAI 解耦最关键的 API 边界之一。
接下来进入 pkg/binder/controllers/bindrequest_controller.go。
Reconcile(...) 的主逻辑其实很好懂:
BindRequestr.binder.Bind(ctx, pod, node, bindRequest)Rollback(...)BindRequest 状态、Pod condition这里有几个很值得注意的点。
这意味着 binder 本身具备:
也就是说,KAI 没有发明一套自定义执行引擎,而是把 binding plane 放回了 Kubernetes 最自然的 controller 范式里。
当 binder.Bind(...) 返回错误时,controller 会显式调用 Rollback(...)。
这点非常重要。
因为在 AI / GPU / DRA 场景里,binding 不只是一次 API call,可能还伴随着:
如果失败后不 rollback,很容易留下脏状态。
从 controller 周围的代码可以看出来,binder 同时在维护:
这意味着 execution plane 不是一层薄封装,而是一个正式子系统。
如果用伪调用栈把它串起来,大致可以写成:
1 | cmd/scheduler/app/server.go |
这条链路体现出了 KAI 最核心的分层:
我觉得 KAI 这条源码主链路特别值得看,不是因为它“炫技”,而是因为它把复杂调度系统最容易失控的地方都压住了。
主循环短,意味着:
这是整个系统最重要的长期收益点。
很多 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 持续演化的,是最后一层:
SchedulingShard 为什么是一个很强的 policy surface?下一篇就专门讲这些“高级能力背后的结构原因”。
如果说上一篇讲的是控制面工作流,那么这一篇要进入 KAI Scheduler 最核心的部分:调度内核是怎么组织的。
我读 KAI 代码时最大的感受是,它并不是把所有逻辑揉进一个“大调度函数”,而是把调度过程拆成了几层非常稳定的抽象:
这些抽象不是为了“显得框架化”,而是为了支撑 KAI 真正需要处理的复杂性:
如果没有这套结构,任何一个高级特性都可能让调度逻辑失控。
在 pkg/scheduler/scheduler.go 里,KAI 的调度主干其实很短:
1 | func (s *Scheduler) Run(stopCh <-chan struct{}) { |
真正的重点在 runOnce():
1 | func (s *Scheduler) runOnce() { |
这段代码非常能代表 KAI 的设计哲学:
scheduler 自己不直接写死“怎么调度每个 Pod”,它做的是:打开一个 session,然后把控制权交给有顺序的 action 和可插拔的 plugin。
也就是说,KAI 的内核不是一个算法,而是一个 按周期运行的调度执行框架。
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])
对于只做简单 node fitting 的调度器,事件驱动模型也许足够。但 KAI 需要解决的问题更复杂:
PodGroup 能不能整体调度成功?这些问题都更像“在一个一致性视图上做组合决策”,而不是“处理一个 Pod Add 事件”。
cycle model 的好处正是:
KAI 的 cache 在架构上是非常重要的一层。文档 docs/developer/scheduler-concepts.md 也把它放在很前面。
它的职责并不是简单“缓存 informer 数据”,而是负责:
也就是说,cache 同时承担了:
在 KAI 里,cache 可以被理解成 scheduler 的“authoritative runtime state”。
因为 scheduler 需要同时看:
如果每次 action 执行都直接查 API server,复杂度和性能都会失控。KAI 的做法是先把这些状态汇总到 cache,再在 snapshot 上运行策略。
在 KAI 的设计里,snapshot 不是一个调试辅助对象,而是调度 cycle 的正式输入。
openSession() 里最关键的一步就是:
1 | snapshot, err := cache.Snapshot() |
也就是说,session 看到的整个世界,都是这一刻从 cache 切出来的静态视图。
snapshot 的核心价值在于三个词:
同一轮调度里,所有 action / plugin 面对的是同一个 cluster image。
你可以在 session 里虚拟 allocate / evict / reorder,而不立刻影响真实集群。
如果你想调试某一轮调度,理论上 snapshot 是最接近“输入快照”的东西。这也是 KAI 为什么会有 snapshot-tool 这类工具。
如果说 snapshot 是输入,那 session 就是这一轮调度的“运行时容器”。
pkg/scheduler/framework/session.go 里的 Session 结构体非常值得看,因为它几乎把 KAI 的扩展点都明牌写出来了:
GpuOrderFnsNodePreOrderFnsNodeOrderFnsJobOrderFnsSubGroupOrderFnsTaskOrderFnsQueueOrderFnsPrePredicateFnsPredicateFnsBindRequestMutateFnsCanReclaimResourcesFnsReclaimVictimFilterFnsPreemptVictimFilterFnsGetQueueFairShareFns这说明 KAI 的 session 不是“存点变量”的上下文对象,而是 整个调度框架的插件总线。
从设计上看,Session 至少承担四件事:
ClusterInfo它把“当前一轮调度会发生什么”从长期状态里剥离出来了。
这能让 plugin 在 OnSessionOpen 时做本轮初始化,在 OnSessionClose 时做清理或指标记录,而不用在长期全局状态里互相污染。
KAI 文档 docs/developer/action-framework.md 给出的 action 顺序是:
AllocateConsolidateReclaimPreemptStaleGangEviction这五个动作本身就已经表达了一种非常清晰的调度哲学:
先尝试无破坏地调度,再尝试优化碎片,再尝试跨 queue 回收资源,再尝试队列内优先级抢占,最后清理 gang 死锁状态。
换句话说,Action 负责的是“调度流程的阶段控制”。
KAI 不是随便堆几个插件,而是明确规定了“先做什么,后做什么”。
优先处理无需干扰即可落地的工作负载。
在已有布局上做整理,减少碎片。
当 queue 之间资源分配不公平时,做跨 queue 回收。
当同一 queue 内高优 workload 需要资源时,再做优先级抢占。
处理 gang 语义下的僵死/失效场景。
这个顺序说明 KAI 很重视“尽量把破坏性动作后置”。
如果说 action 决定“什么时候干什么”,那 plugin 决定“具体按什么规则干”。
在 pkg/scheduler/framework/framework.go 里,OpenSession() 会读取配置里的 tiers 和 plugins,然后依次:
plugin.OnSessionOpen(ssn)在 OnSessionOpen 里,plugin 把自己的 callback 注册进 session。
KAI 的 plugin 不是“调用时才临时执行的一段代码”,而是会在 session 打开时把行为注入调度上下文。例如:
很多调度框架里,action 和 plugin 的边界不够清楚;而 KAI 这里非常明确:
这让系统具备两个非常好的性质:
流程稳定
策略可演化
KAI 文档里有两个很重要但很容易被忽略的概念:
ScenarioStatement我认为这两个概念是 KAI 能处理复杂调度动作的关键。
Scenario 本质上是一个假设中的调度状态。
比如某个 reclaim / preempt / consolidate 场景,不是直接去驱逐 Pod,而是先在内存里构造一个可能的结果:
这意味着 KAI 不是“想到一个动作就立刻提交”,而是先做 what-if analysis。
文档把 Statement 描述成 transaction-like object,我觉得这个词非常准确。
它支持:
Checkpoint()Rollback(checkpoint)Allocate(pod, node)Evict(pod, msg)Pipeline(pod, node)Commit()Discard()也就是说,调度器可以在本轮 session 中:
因为 KAI 做的不只是“找个 node fit 一下”。它经常需要处理下面这种组合问题:
PodGroup 的多个 Pod 必须整体成功没有 statement/scenario,很多逻辑要么只能做局部贪心,要么必须把复杂回滚散落在各处。KAI 用一层统一抽象把这个问题收住了。
我觉得可以把它们理解成三层:
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
这三个层次组合在一起,才形成 KAI 的调度内核。
KAI 后续的很多高级特性,其实都不是“另起炉灶”,而是在这套框架上叠加出来的:
也就是说,KAI 的高阶能力不是靠一堆特判支撑,而是靠一个足够稳的 execution model 支撑。
如果只用一句话概括这套内核,我会写成:
KAI 把调度建模成:在一致性 snapshot 上打开一个 session,由 action 驱动流程、由 plugin 注入策略、由 statement/scenario 提供可回滚模拟,然后把成立的结果提交回集群。
这是一个非常适合复杂 workload 调度的模型。
它的好处不是“优雅”,而是很实用:
框架理解了以后,最自然的下一步就是:
RunApp() 到 runOnce() 之间发生了什么?BindRequest 是在哪一层创建的?下一篇就从源码入口开始,沿着真正的调用链,走一遍 KAI 的一次调度周期。
上一篇我把 KAI Scheduler 当成一个平台来读:它不是单个 scheduler 进程,而是一串围绕 CRD、controller 和 scheduling session 组织起来的控制面。
这一篇开始看它真正的“主干路径”:一个 workload 进入集群后,到底是怎么一步步变成调度决策,再变成真实节点绑定的?
如果要先给结论,我会这样概括:
在 KAI 里,提交 workload 并不会立刻进入“给 Pod 选 node”的同步流程,而是先被重写成更适合调度的 workload model,再进入周期性调度循环,最后通过
BindRequest异步落地。
这条路径一共可以拆成六步:
PodGroupPodGroup 和 Queue 做 placement 决策BindRequestBindRequest 并执行真实绑定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 图里,真正最关键的转折点有两个:
只要把这两个转折点读懂,整个系统就顺了。
KAI 支持的工作负载并不只有单 Pod。它非常强调 AI / batch / distributed workload 的调度语义,所以常见入口包括:
这些对象刚进入集群时,并不天然具备 KAI 需要的完整调度语义。
例如,scheduler 真正关心的问题往往是:
这些信息如果散落在不同 workload 的不同字段里,后续调度逻辑会非常难维护。于是 KAI 先做了一次“语义收敛”。
PodGrouppod-grouper 是整条链路里最容易被低估的组件。
很多人看 scheduler 时会把 pod-grouper 当成辅助服务,但实际上它承担的是“把 Kubernetes 原生 workload 翻译成 KAI workload model”的职责。
pod-grouper 监听 Pod,然后做三件事:
PodGroup文档 docs/developer/pod-grouper.md 给出的核心思路非常清楚:它不是简单按 label 聚合 Pod,而是通过 owner chain 去推断真正的 workload 边界。
因为很多 Kubernetes workload 都有多层 owner reference。
例如:
如果只按 Pod 当前直接 owner 聚合,你得到的是“副本控制器层”的分组,而不是“业务 workload 层”的分组。KAI 的做法是继续往上找,找到最适合代表 workload 语义的对象。
这一步带来的收益很大:
PodGroupPodGroupPodGroup 才是 KAI 真正的 workload 主语在 pkg/apis/scheduling/v2alpha2/podgroup_types.go 里,PodGroupSpec 已经不仅仅是一个 minMember 包装器,它承载的是 workload 的调度语义:
MinMemberMinSubGroupQueuePriorityClassNamePreemptibilityTopologyConstraintSubGroupsMarkUnschedulableSchedulingBackoff这里最重要的不是字段多,而是字段的组合方式。
这意味着 KAI 在表达的是:
也就是说,从这一步开始,系统不再是“对一堆 Pod 做 placement”,而是“对带有完整调度语义的 workload group 做 placement”。
Queue 把 workload 放进资源与公平性的上下文里PodGroup 解决的是 workload 建模问题,Queue 解决的是资源分配问题。
在 KAI 里,queue 不是顺手加的一个标签,而是公平性和资源边界的核心对象。很多关键特性都建立在它上面:
也就是说,scheduler 在做决策时,看到的不是“某个 Pod 能不能放到某个节点”,而是:
某个
PodGroup在它所属Queue的资源语境下,是否应该得到这次调度机会。
这是 KAI 跟只做 node fitting 的调度器非常不同的一点。
等到 PodGroup 和 Queue 都就位以后,scheduler 才开始它真正擅长的事情。
这里的关键点不是某个单独算法,而是 周期性调度模型。
KAI scheduler 不是收到一个 Pod 事件就立刻同步做完全部决策,而是每个 cycle:
这带来两个直接好处:
在同一个 cycle 里,所有 action 和 plugin 都基于同一个 cluster snapshot 工作。
这对复杂特性尤其重要:
如果没有 snapshot 这一层,很多逻辑会变成“边读边改边抢锁”,复杂度会急剧上升。
因为它不是只看一个 Pod,而是看 snapshot 里的:
所以它做出来的是更接近“全局最优”或“局部一致最优”的决策,而不只是一个 request/response 式的即时选择。
BindRequest这是 KAI 整条工作流里最精彩的一步。
在 pkg/scheduler/cache/cache.go 里,SchedulerCache.Bind(...) 的逻辑不是直接调用 Pod binding API,而是创建 BindRequest。
BindRequestSpec 里会写入:
PodNameSelectedNodeReceivedResourceTypeReceivedGPUSelectedGPUGroupsResourceClaimAllocationsBackoffLimit这说明什么?说明 scheduler 输出的不是一个轻量的“node choice”,而是一份 完整的绑定执行意图。
如果 scheduler 直接 bind Pod,那它就必须自己同步承担这些职责:
这会让调度循环变得又慢又重。
KAI 的选择是把它们拆开:
中间用 BindRequest 这个 CRD 做状态中介。
这类设计最大的价值不是“看起来解耦”,而是真正在错误模型和并发模型上得到收益:
BindRequest,把决策变成真实集群状态binder 读取到 BindRequest 后,会进入 controller-runtime 的 reconcile 流程。
它的大致步骤是:
BindRequestBindRequest 状态和 Pod condition这里最值得注意的是两点。
比如:
这些都属于执行面的问题,而不是 placement 逻辑本身的问题。
KAI 通过 binder 把这些风险从主调度循环里隔离了出来。
这很关键。
当 bind 失败时,binder 会尝试 rollback。对于一个支持 DRA、GPU sharing、复杂资源声明的系统来说,这是必须的。
因为 scheduler 的决策不只是“选 node”,它往往还隐含了若干资源分配语义。如果失败后没有清理干净,后面很容易出现资源状态污染。
如果工作流只到 binder 为止,那 KAI 还只是一个“高级 scheduler”。
它之所以更像平台,是因为后面还有完整的反馈闭环。
PodGroupController负责把 workload 运行态、资源状态、调度条件回写到 PodGroup。
这让 PodGroup 不只是“调度前的输入”,也变成“调度后的观测对象”。
QueueController负责聚合 queue 层的资源状态,为公平性和资源分配提供基础。
当 time-based fairshare 打开以后,这条反馈链会继续延伸:
这就让 KAI 不只是“看当前资源”的 scheduler,而是一个能把 历史使用行为 纳入决策的系统。
把整条链路看完以后,会很容易理解它为什么适合 AI 场景。
AI workload 往往不是单 Pod,而是:
KAI 通过 PodGroup 先把这些语义固定下来,再做 scheduling,避免把 workload 复杂性泄漏进每个 action/plugin 的实现里。
AI workload 的 bind-time 复杂度通常更高:
用 BindRequest 把 decision plane 和 execution plane 分开,能让调度器更专注,也让系统更稳定。
公平性不只是“这一刻谁先跑”,而是“长期来看谁占用了多少资源”。
KAI 的 queue controller + Prometheus + usage DB 让它可以逐步走向时间维度的公平性,而不是只做瞬时队列排序。
如果把 KAI 的控制面 workflow 用一句话概括,我会写成:
先把 Kubernetes workload 翻译成 KAI 的 workload language,再在一致性 snapshot 上做调度决策,最后把决策通过异步 binding pipeline 落地,并把结果反馈回 queue 与 workload 状态。
这条链路的好处是系统边界非常清楚:
理解 workflow 以后,下一步最自然的问题就是:
下一篇就进入调度内核本身,拆解 KAI 最核心的设计:
cycle、snapshot、session、action、plugin、statement。
KAI Scheduler 这个项目第一眼很容易被名字误导:看起来像是一个“GPU 版 kube-scheduler”,但真的把仓库翻一遍以后,会发现它更像是一套围绕 AI/ML 工作负载构建的 Kubernetes 原生调度平台。
它当然有 scheduler 这个核心二进制,但仓库真正的架构中心并不是单个调度循环,而是下面这几个层次同时成立:
PodGroup、Queue 这些调度语义更强的 CRD。BindRequest,交给 binder 异步执行。Config 和 SchedulingShard 把整套控制面部署出来,并且把每个 shard 的调度策略参数化。如果只盯着 pkg/scheduler,会看见一个很强的调度内核;但如果把 cmd/、pkg/apis/、pkg/operator/、pkg/binder/、pkg/podgrouper/ 放在一起,才会看见 KAI 真正的系统轮廓。
这个仓库最值得先看的不是某个具体算法,而是目录本身:
1 | cmd/ |
这个结构已经说明了一件事:KAI 的实现方式不是把复杂性都堆进 scheduler 进程,而是把不同职责拆成多个控制器和服务。
cmd/:每个二进制都代表一个架构角色从 cmd/ 可以直接看出 KAI 的主要运行单元:
scheduler:真正做 placement 决策的内核。binder:异步执行 bind、资源准备、回滚。podgrouper:自动把 workload 转换成 PodGroup。podgroupcontroller:回写 PodGroup 的运行态和资源状态。queuecontroller:维护队列层级与资源使用状态。operator:把整套 KAI 控制面部署出来。admission:准入控制和 webhook。nodescaleadjuster:和节点伸缩场景对接。snapshot-tool、fairshare-simulator、time-based-fairshare-simulator:偏调试、验证、离线分析工具。这跟传统印象中的“scheduler 就是一切”非常不同。KAI 的设计明显在强调:
调度不是一个函数调用,而是一条跨多个控制器的控制面流水线。
pkg/apis/:真正的架构边界写在 CRD 里很多 Kubernetes 项目表面上是 controller,实际上系统边界都藏在 CRD 里。KAI 也是这样。
对理解全局最关键的几个 API 是:
ConfigSchedulingShardQueuePodGroupBindRequestTopology这些对象并不是简单的配置载体,它们定义了 KAI 如何把“AI 集群调度”拆成若干可以被控制器协作处理的状态机。
我把它整理成下面这个图:
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
这个图最想表达的是三件事:
Kubernetes 原生对象很多:Pod、Job、Deployment、RayCluster、PyTorchJob、MPIJob、JobSet……
但真正做公平性、gang scheduling、拓扑约束、优先级/可抢占性判断时,直接围绕这些原生对象写逻辑会非常散。
KAI 的做法是通过 pod-grouper 把它们统一抽象成 PodGroup,再配合 Queue 建立调度语义。这一步非常关键:
PodGroup 代表“应该一起考虑的 workload 单元”Queue 代表“资源应该如何分配给哪个组织/租户/项目”也就是说,KAI 不是在调度“孤立的 Pod”,而是在调度 workload group 在 queue 体系里的资源位置。
这是我读完整个仓库以后最喜欢的一点。
KAI 没有把“算出节点”与“执行绑定”写成一个同步链路,而是中间插了一个 BindRequest CRD。
这意味着:
这类设计在控制面系统里非常常见,但在 scheduler 里做得这么明确,其实很少见。
很多项目的“可扩展”只是加几个 flag。KAI 明显不是这个级别。
scheduling shard 这一层直接把下面这些内容暴露成平台配置面:
也就是说,KAI 的目标不是给你一个固定算法,而是给你一个 围绕 AI 工作负载场景可演化的调度框架。
Config:整套控制面的安装与组合Config 不是一个简单的 values 文件替代品,它实际上定义了 KAI 控制面里有哪些服务、这些服务怎么部署、Prometheus 是否启用、全局配置如何下发。
从 pkg/apis/kai/v1/config_types.go 可以看到,ConfigSpec 直接包含:
PodGrouperBinderAdmissionSchedulerQueueControllerPodGroupControllerNodeScaleAdjusterPrometheus这很像在 Kubernetes 里做了一层“平台安装 API”。
SchedulingShard:调度器不再是单实例,而是按分片部署SchedulingShard 是另一个非常有代表性的对象。
它说明 KAI 并不把“集群只有一个 scheduler 实例”当成默认前提,而是支持按 node pool / queue / podgroup label 做逻辑分片。每个 shard 都可以有自己的:
partitionLabelValuekValueusageDBConfigPluginsActions这代表 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:
MinMemberMinSubGroupQueuePriorityClassNamePreemptibilityTopologyConstraintSubGroups而在 Status 侧,PodGroup 还维护了 SchedulingConditions 和 ResourcesStatus,让它同时成为 workload 输入模型和运行态观察点。
看到这里就能理解:KAI 实际上在把“AI workload 的调度语义”沉淀进一个独立 API,而不是永远寄生在原生 Pod 字段上。
如果只能记住 KAI 架构中的一件事,我建议记住这一句:
scheduler 决定“应该放哪”,binder 决定“怎么把它真的放上去”。
这个拆分的收益非常大:
调度循环可以保持短而稳定
复杂资源准备可以异步处理
失败与回滚逻辑可以局部化
状态可观察性更好
BindRequest 本身就成了一个中间态对象,便于排障、审计和重试。这也是为什么我认为 KAI 更像“调度平台”而不是“单个调度器实现”。
读完 repo 以后,我会用下面这句话概括它:
KAI Scheduler 把 AI 集群调度拆成了 工作负载建模、资源分配决策、绑定执行、状态反馈、平台配置 五个层面,并用 CRD + controller + session-based scheduler 的方式把它们拼成一个完整控制面。
这个思路跟很多只在单个 scheduling cycle 上做文章的项目不一样。KAI 真正重视的是:
如果你准备继续往下读源码,我建议按这个顺序:
docs/developer/scheduler-concepts.mddocs/developer/action-framework.mddocs/developer/plugin-framework.mddocs/developer/binder.mddocs/developer/pod-grouper.mddocs/operator/scheduling-shards.mdpkg/scheduler/scheduler.gopkg/scheduler/framework/pkg/scheduler/cache/cache.gopkg/binder/controllers/bindrequest_controller.go理解完组件地图以后,下一步就该看这套系统的“主干工作流”:
PodGroup?Queue、PodGroup、node pool label 是怎么汇合到一起的?BindRequest?下一篇就沿着这条链路,从 Pod 到 BindRequest,把 KAI 的控制面工作流走一遍。
Virtlet supports various volume types:
The volume management is handled through:
VMVolumeSource interface pkg/metadata/types/annotations.go VirtletDiskDriver annotation specifies the disk driver type (virtio, scsi, or nvme) parsePodAnnotations methodpkg/libvirttools/diskdriver.go getDiskDriverFactory selects the appropriate driver factory based on the annotation virtioBlkDriverFactory, scsiDriverFactory, or nvmeDriverFactory diskDriver interfacepkg/libvirttools/volumes.go, pkg/libvirttools/virtualization.go volumeSource function creates VMVolume objects for each required volume pkg/libvirttools/disklist.go 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 pkg/libvirttools/virtualization.go createDomain builds the libvirt domain XML structure diskList.setup are added to the domain devices nvmeXn1 with bus type nvmepkg/libvirttools/virtualization.go DefineDomain diskList.writeImages writes any necessary disk images (e.g., cloud-init) sda, sdb vda, vdb 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.
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.
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.
进程调度是 Linux 内核中最复杂也最关键的子系统之一。在生产环境中,”CPU 使用率 100% 但响应很慢”、”任务唤醒后等了几十毫秒才运行”、”某个进程长期卡在 D 状态”——这些问题的根因往往深藏在调度层。本文是本系列第五篇,聚焦于调度诊断的完整方法论:从 /proc 接口读取原始数据,到 perf sched 分析调度延迟,再到 bpftrace 精确追踪内核路径,最后结合四个典型生产案例给出可落地的排查流程与修复建议。
容器技术的核心在于资源隔离与限制,而这一能力的底层支撑正是 Linux 内核的 cgroup(Control Group)机制。本文基于 Linux 6.4-rc1 源码,深入剖析 cgroup v2 的内核实现,涵盖统一层次框架、Memory/CPU/IO/PID 四大控制器的核心数据结构与关键路径,以及 cgroup namespace 与诊断方法。