Kubernetes核心组件学习系列 - 完整指南与学习路线图
Kubernetes核心组件深度学习系列文章导航,提供系统性的学习路径和面试准备指南
Kubernetes核心组件深度学习系列文章导航,提供系统性的学习路径和面试准备指南
The SCST (SCSI Target) framework provides a user-space device handler (scst_user) that allows implementing SCSI target devices in userspace. Recently, we encountered a critical issue where the system would hang during cleanup when the userspace handler crashed or was killed while I/O operations were in progress.
The symptom appeared as an infinite loop in the cleanup code, requiring workarounds like forcing cleanup after 1000 iterations or even triggering a kernel panic. However, these were just treating the symptoms, not the root cause.
During device shutdown, dev_user_process_cleanup() spins at ~2 million
iterations per second, pins one CPU core, and triggers the kernel soft-lockup
detector within seconds. The root cause is a command stuck in ucmd_hash
with ref=1 because sgv_pool_free() caches the scatter-gather buffer on
the pool LRU instead of freeing it — so the allocator’s ucmd_put() callback
never fires and the reference count never reaches zero.
The fix: two sgv_pool_flush() calls added after the unjam loop indev_user_unjam_dev().
SCST is a high-performance storage target subsystem for Linux. The scst_user
module allows user-space applications to implement SCSI target devices via a
character device interface.
Key data structures:
ucmd_hash: hash table tracking all active scst_user_cmd objectsready_cmd_list: queue of commands ready for user-space processingcleanup_cmpl: completion for device cleanup synchronizationucmd_ref: per-command reference count; dev_user_free_ucmd() →cmd_remove_hash() fires only when atomic_dec_and_test() returns trueNormal command lifecycle:
1 | dev_user_alloc_ucmd() ucmd_ref = 1 |
Multiple scst_usr_release threads stuck in D state:
1 | [Thu Jan 23 02:37:11 2025] task:scst_usr_releas state:D stack: 0 pid:334614 |
The threads block on wait_for_completion(&dev->cleanup_cmpl), which is never
signaled because the cleanup thread is spinning in an infinite loop and never
reaches complete_all(&dev->cleanup_cmpl).
When a SCST user device is torn down:
dev_user_exit_dev() — unregisters the device, setsdev->cleanup_done = 1, then blocks onwait_for_completion(&dev->cleanup_cmpl).
dev_user_process_cleanup() — runs in a separate thread, drains
remaining commands, and calls complete_all(&dev->cleanup_cmpl) to
unblock step 1.
The exit condition requires rc1 == 0 (hash empty) and rc == -EAGAIN
(ready list empty) and cleanup_done:
1 | while (1) { |
If any command remains in ucmd_hash but is not in ready_cmd_list,rc1 > 0 and rc == -EAGAIN simultaneously, and the loop has no exit.
The command stuck in ucmd_hash has:
1 | state = UCMD_STATE_ON_FREE_SKIPPED (7) |
State 7 is set in dev_user_on_free_cmd() when on_free_cmd_type isSCST_USER_ON_FREE_CMD_IGNORE:
1 | if (ucmd->dev->on_free_cmd_type == SCST_USER_ON_FREE_CMD_IGNORE) { |
dev_user_process_reply_on_free() frees the SGV buffer and drops a reference:
1 | static int dev_user_process_reply_on_free(struct scst_user_cmd *ucmd) |
This looks correct. The problem is what dev_user_free_sgv() actually does.
sgv_pool_free() is a cache return, not a free1 | static void dev_user_free_sgv(struct scst_user_cmd *ucmd) |
The SGV (scatter-gather vector) pool is a performance cache: it holds
recently freed SG buffers so future commands can reuse them without hitting
the page allocator. When sgv_pool_free() is called:
dev_user_free_sg_entries() — is not called.ucmd_get() reference taken in dev_user_alloc_pages() is not released.dev_user_free_sg_entries() (and its ucmd_put()) only fires when the pool
evicts a cached object — via an explicit sgv_pool_flush().
| Event | Operation | ucmd_ref |
|---|---|---|
dev_user_alloc_ucmd() |
atomic_set(&ucmd_ref, 1) |
1 |
dev_user_alloc_pages() |
ucmd_get() for first SG page |
2 |
dev_user_unjam_dev(): ucmd_get_check() |
bump to verify not zombie | 3 |
dev_user_unjam_cmd() → scst_cmd_done() → dev_user_on_free_cmd() → dev_user_free_sgv() → sgv_pool_free() |
SGV goes to pool LRU; dev_user_free_sg_entries() not called; alloc_pages ref not released |
3 |
dev_user_process_reply_on_free(): ucmd_put() |
3 → 2 | 2 |
dev_user_unjam_dev(): ucmd_put() for ucmd_get_check ref |
2 → 1 | 1 |
cmd_remove_hash() fires only when atomic_dec_and_test() returns true (ref
reaches 0). It never does — the alloc_pages reference is never released becausedev_user_free_sg_entries() never fires. The ucmd stays in ucmd_hash
indefinitely.
After unjamming, the stuck ucmd has sent_to_user = 0 and is not inready_cmd_list. On every subsequent pass:
1 | list_for_each_entry(ucmd, head, hash_list_entry) { |
res is non-zero (rc1 > 0) but no command is unjammed.dev_user_get_next_cmd() returns -EAGAIN (ucmd not in ready_cmd_list).
Both functions acquire and release a spinlock in under a microsecond.
Result: ~2 million iterations per second, 100% CPU on one core, soft-lockup
detector fires within seconds.
dev_user_unjam_dev() already calls sgv_pool_flush() before the unjam
loop:
1 | static int dev_user_unjam_dev(struct scst_user_dev *dev) |
SGV objects are placed into the pool cache during unjamming — whenscst_cmd_done → dev_user_on_free_cmd → dev_user_free_sgv →sgv_pool_free executes inside the unjam loop. A flush that precedes the loop
cannot evict objects that do not yet exist in the cache.
1 | static int dev_user_unjam_dev(struct scst_user_dev *dev) |
sgv_pool_flush() is fully synchronous — it calls sgv_dtor_and_free()
inline in a while loop, so by the time it returns all eviction callbacks have
already fired. The call chain on eviction:
1 | sgv_pool_flush() |
On the next iteration dev_user_unjam_dev() returns res = 0, anddev_user_process_cleanup() breaks normally — within 2–3 iterations.
| Detail | |
|---|---|
| Symptom | dev_user_process_cleanup() loops at ~2M iter/s; soft lockup |
| Stuck ucmd | state=7 (ON_FREE_SKIPPED), ref=1, not in ready list |
| Why ref stays at 1 | sgv_pool_free() caches the SGV on the pool LRU; dev_user_free_sg_entries() never fires; the ucmd_get() from dev_user_alloc_pages() is never balanced |
| Why pre-unjam flush failed | Runs before unjamming; SGV objects are cached during unjamming |
| Fix | sgv_pool_flush() for both pools after the unjam loop |
| Fix size | 2 function calls |
The SGV pool decouples sgv_pool_free() from the actual page release. Code
that relies on “free → callback → ucmd_put” must account for the callback
firing on eviction, not on free. At teardown time, an explicitsgv_pool_flush() is required to force eviction and drain all outstanding
references before checking whether the hash is empty.
Tags: #kernel #scst #storage #debugging #linux #memory-management #sgv-pool #reference-counting
系统梳理业界在 Kubernetes 上运行 vLLM 的常见模式,以及训练和推理为什么通常要解耦部署。
前几篇文章里,我一直在强调一件事:KAI Scheduler 的价值不只是“今天已经实现了哪些特性”,更重要的是 它为什么可以持续装下更多特性。
这一篇就专门看这个问题。
如果只看表层功能,KAI 已经足够复杂:
但真正有意思的地方在于,这些能力并不是一堆互不相干的特判,而是大多能在同一套框架下自然落位:
SchedulingShard 管PodGroup 管换句话说,KAI 的扩展性不是“多写几个 if”,而是 架构层面为扩展预留了空间。
pkg/scheduler/plugins/factory.go 把默认插件注册得非常完整:
predicatesprioritynodeplacementnominatednodenodeavailabilitygpusharingordergpupackgpuspreadresourcetypepodaffinityelastickubeflowraytaskordersubgrouporderdynamicresourcestopologyproportionminruntimesnapshotreflectjoborder这张表最重要的意义不是“插件很多”,而是它说明 KAI 把以下问题都视作 策略问题,而不是写死在主循环里的逻辑:
这就是为什么 KAI 的高级特性能持续叠加,而不会轻易把主调度流程搞乱。
在 pkg/scheduler/framework/session.go / session_plugins.go 里,Session 暴露的函数槽位和注册方法比前几篇里举的更宽,最有代表性的包括:
JobOrderFnsTaskOrderFnsSubGroupOrderFnsQueueOrderFnsNodeOrderFnsGpuOrderFnsPrePredicateFnsPredicateFnsCanReclaimResourcesFnsReclaimVictimFilterFnsPreemptVictimFilterFnsGetQueueAllocatedResourcesFnsGetQueueDeservedResourcesFnsGetQueueFairShareFnsBindRequestMutateFnsAddSubsetNodesFn(...)AddPreJobAllocationFn(...)AddReclaimScenarioValidatorFn(...)AddPreemptScenarioValidatorFn(...)AddHttpHandler(...)AddIsJobOverCapacityFn(...)只要看见这些接口,就能立刻理解 KAI 的插件思想:
它不是只开放一个 score 钩子,而是把“调度过程中可能需要定制的所有局部决策”尽量显式化。
这带来了两个直接好处:
换句话说,KAI 开放的不只是排序/过滤,还包括候选节点裁剪、pre-job allocation、reclaim/preempt 场景校验、执行前 BindRequest mutate,甚至给插件挂自定义 HTTP handler 的能力。
前面几篇更容易让人把注意力放在 scheduler session 上,但 KAI 的扩展面并不只存在于决策侧。
在 pkg/binder/plugins/interface.go 里,binder plugin 暴露的就是一条很清楚的执行期接口:
PreBind(...)PostBind(...)Rollback(...)这意味着执行落地这一侧也不是硬编码死的。凡是和真实 bind、副作用处理、失败回滚相关的逻辑,都可以在 binder 这条插件面上扩展,而不用把它们重新塞回 scheduler 主循环。
SchedulingShard:KAI 真正的平台配置面如果说 plugin 是“代码级扩展点”,那 SchedulingShard 就是“平台级策略面”。
我认为这是 KAI 最值得单独夸的一层设计。
在 pkg/apis/kai/v1/schedulingshard_types.go 里,SchedulingShardSpec 暴露的内容包括:
ArgsPlacementStrategyPartitionLabelValueQueueDepthPerActionMinRuntimeKValueUsageDBConfigPluginsActions这意味着什么?
这意味着 KAI 把很多原本应该藏在源码、flag 或者某个配置文件里的策略选择,提升成了 可声明、可演化、可分片实例化的 Kubernetes API。
还有一个容易被忽略的细节:这些默认 plugin/action 并不是无条件全开。operator 在把 SchedulingShard 物化成 scheduler 配置时,会根据 PlacementStrategy 条件启用或关闭 gpusharingorder、gpupack、gpuspread、consolidation 等默认项。
因为它让“调度器实例”不再是同质的。
你可以想象这样几种 shard:
这就把 KAI 从“一个调度器程序”推进成了“一个可按业务场景切片的调度平台”。
scheduling-shards.md 里说得很清楚:每个 shard 实际上围绕同一个 partition label key/value 工作。也就是说,scheduler 只会考虑带有同一组分片标签的:
这是一种非常典型的 Kubernetes 风格:
这样带来的好处不只是扩容,更重要的是 策略与资源空间同时分片。
这里还有一个容易说错的细节:默认 shard 并不是用“label 等于空字符串”来兜底未分片资源。当前 selector 语义更接近 DoesNotExist——也就是当某个 partition / nodepool value 为空时,它会接住“没有这个标签”的资源,而不是匹配一个空值标签。
也就是说,不同 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 不需要硬插一条特别路径,而是能顺着既有框架表达出来。
从当前插件实现看,topology 主要挂在三类扩展点上:AddSubsetNodesFn(...)、AddNodeOrderFn(...) 和 AddPreJobAllocationFn(...)。也就是说,它更像“先裁候选节点,再做域内节点排序,并在分配前补一次校验/整理”,而不是单独依赖某个 GPU order 钩子。
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先看:
pkg/binder/plugins/interface.goPreBind / PostBind / Rollbackpkg/binder/controllers/bindrequest_controller.go如果只用一句话概括,我会写成:
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/main.gocmd/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/main.go,它只做一件事:调用 app.RunApp()。真正展开运行时准备的地方,才是 cmd/scheduler/app/server.go。
RunApp() 先做的是运行时准备从代码顺序上看,大致是:
Run(...)如果是 operator 管理的 shard 部署,这时传进来的 --scheduler-conf 往往已经是 operator 根据 SchedulingShard 物化出来的配置文件,而不是 scheduler 进程自己去直接读取 CRD 后现场拼装。
也就是说,KAI scheduler 本身就是一个标准 Kubernetes 控制面进程,而不是“读个配置然后进一个大循环”的轻量程序。
Run() 里最重要的两行对理解调度内核最关键的,其实是这两步:
1 | actions.InitDefaultActions() |
这代表 KAI 在启动阶段先把“可用动作”和“可用插件”注册进框架,再从配置解析出本次实际启用的 action/plugin 组合。
这有个很重要的意义:
注册是能力集合,配置才是实际执行计划。
换句话说:
InitDefaultActions() 负责告诉框架有哪些 action 存在InitDefaultPlugins() 负责告诉框架有哪些 plugin 可用ResolveConfigurationFromFile(...) 读取当前进程拿到的配置文件,决定这一轮部署到底启用哪些,以及它们的优先级和参数是什么这比很多项目直接在代码里写死流程要干净得多。
pkg/scheduler/actions/factory.go 很短,但很重要:
1 | func InitDefaultActions() { |
这里要注意一个细节:注册顺序不等于执行顺序。
真正的执行顺序来自配置解析,但这件事有两层来源:
defaultSchedulerConfSchedulingShard.Actions 的 priority 生成最终 config.yamlResolveConfigurationFromFile(...) 读取这个物化后的文件无论哪种路径,代码层面的注册都只是“把工厂挂到框架里”,而不是“马上决定调度流程”。
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)这里要特别注意:真正的 HTTP listener 是在 RunApp() 里启动的;OpenSession() 这一步做的更像是把 framework 的 plugin handler 接到已经存在的 mux 上。
这一步的关键不在于“插件被创建了”,而在于:
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/main.go |
这条链路体现出了 KAI 最核心的分层:
我觉得 KAI 这条源码主链路特别值得看,不是因为它“炫技”,而是因为它把复杂调度系统最容易失控的地方都压住了。
主循环短,意味着:
这是整个系统最重要的长期收益点。
很多 scheduler 一旦开始支持复杂资源、副作用、回滚,就会在主调度线程里越写越重。KAI 用 BindRequest 这层接口把这个问题处理得很优雅。
如果你打算真的跟代码,我建议按下面顺序:
cmd/scheduler/main.gocmd/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]) --> Snapshot[Take Snapshot from Cache]
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 里,action 定义调度流程阶段,而“这一轮按什么顺序跑”由配置决定。runOnce() 每轮都会通过 conf_util.GetActionsFromConfig(s.config) 读取 action 序列;默认配置则是:
allocateconsolidationreclaimpreemptstalegangeviction代码和配置里的实际 token 是上面这组小写名字。
这组默认顺序仍然表达了一种非常清晰的调度哲学:
先尝试无破坏地调度,再做布局整理,再做跨 queue 回收,再做更激进的抢占,最后清理 gang 死锁状态。
换句话说,Action 负责的是“调度流程的阶段控制”,但阶段顺序本身仍然可以被配置覆盖。
KAI 不是随便堆几个插件,而是把“先做什么,后做什么”收敛成一条可配置的 phase pipeline。
allocate优先处理无需干扰即可落地的工作负载。
consolidation在已有布局上做整理,减少碎片。
reclaim当 queue 之间资源分配不公平时,做跨 queue 回收。
preempt当同一 queue 内高优 workload 需要资源时,再做优先级抢占。
stalegangeviction处理 gang 语义下的僵死/失效场景。
这个默认顺序说明 KAI 很重视“尽量把破坏性动作后置”。
如果说 action 决定“什么时候干什么”,那 plugin 决定“具体按什么规则干”。
在 pkg/scheduler/framework/framework.go 里,OpenSession() 会读取配置里的 tiers 和 plugins,然后依次:
plugin.OnSessionOpen(ssn)在 OnSessionOpen 里,plugin 把自己的 callback 注册进 session。
KAI 的 plugin 不是“调用时才临时执行的一段代码”,而是会在 session 打开时把行为注入调度上下文。例如:
不过这些 callback 并不是都以同一种方式组合:有些会累积执行,有些会按顺序挑第一个可用实现;例如部分 reclaim 相关判断在当前源码里就是 first-provider-wins,而不是所有插件结果一起归并。
很多调度框架里,action 和 plugin 的边界不够清楚;而 KAI 这里非常明确:
这让系统具备两个非常好的性质:
流程稳定
策略可演化
这里有两个很重要的概念:
ScenarioStatement我认为这两个概念是 KAI 能处理复杂调度动作的关键。
Scenario 可以理解成 reclaim / preempt / consolidation 这类动作里的“候选情景”或“校验视角”,而不是和 Statement 完全对称的一层通用事务对象。
比如某个 reclaim / preempt 场景,不是直接去驱逐 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 / consolidation / 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 / Metrics Store
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: 回写资源状态与部分运行态
QC->>Q: patch Queue 状态
QC->>PM: 暴露队列级资源指标
PM-->>S: usage DB client 查询历史 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 事件就立刻同步做完全部决策。更准确地说,是启动时先让 cache 跑起来并等待同步;进入稳定运行后,每个 cycle 再做:
这带来两个直接好处:
在同一个 cycle 里,所有 action 和 plugin 都基于同一个 cluster snapshot 工作。
这对复杂特性尤其重要:
如果没有 snapshot 这一层,很多逻辑会变成“边读边改边抢锁”,复杂度会急剧上升。
因为它不是只看一个 Pod,而是看 snapshot 里的:
所以它做出来的是更接近“全局最优”或“局部一致最优”的决策,而不只是一个 request/response 式的即时选择。
BindRequest这是 KAI 整条工作流里最精彩的一步。
在 pkg/scheduler/cache/cache.go 里,SchedulerCache.Bind(...) 的逻辑不是直接调用 Pod binding API,而是创建 BindRequest。
当前 scheduler 创建 BindRequest 时,BindRequestSpec 里主要会写入:
PodNameSelectedNodeReceivedResourceTypeReceivedGPUSelectedGPUGroupsResourceClaimAllocations另外,BindRequest 类型里确实还定义了 BackoffLimit,但当前 scheduler 创建对象时并不会主动填充它。更重要的是,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它主要回写 PodGroup.ResourcesStatus,并补充部分调度/运行态信息。
这让 PodGroup 不只是“调度前的输入”,也变成“调度后的观测对象”。
QueueController它主要 patch 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 client 把这些历史数据带回调度决策里,因此它可以逐步走向时间维度的公平性,而不是只做瞬时队列排序。
如果把 KAI 的控制面 workflow 用一句话概括,我会写成:
先把 Kubernetes workload 翻译成 KAI 的 workload language,再在一致性 snapshot 上做调度决策,最后把决策通过异步 binding pipeline 落地,并把结果反馈回 queue 与 workload 状态。
这条链路的好处是系统边界非常清楚:
理解 workflow 以后,下一步最自然的问题就是:
下一篇就进入调度内核本身,拆解 KAI 最核心的设计:
cycle、snapshot、session、action、plugin、statement。