第 13 章 · 调度器的演进:从「写死的两阶段」到「插件框架」
一句话结论:K8s 调度器的演进,是「一个核心组件如何在不推翻架构的前提下,从『写死』走向『可扩展』」的教科书案例——它用了 5 年(v1.0~v1.19)才完成从「谓词/优先级(predicate/priority)」到「调度框架(Scheduling Framework)」的重构,而重构的根本动因是:调度功能越加越多,代码越来越复杂,谁都没法再往里加东西了。
1. 当年是什么样:predicate/priority 两阶段(v1.0 ~ v1.14)
第 6 章讲过调度器的核心思路——「过滤 + 打分」。在早期,它的实现是写死的两阶段:
- Predicates(谓词/预选):硬性过滤,剔除「放不下」的节点。
- Priorities(优先级/优选):软性打分,选「最好」的节点。
这套架构从 v1.0 到 v1.14 用了 15 个版本,基本没怎么变。问题在于:它太「硬」了——
- 所有调度逻辑(资源检查、亲和性、污点容忍、端口冲突…)都硬编码在核心代码里。
- 你想加一个「自己的调度规则」?要么改 K8s 核心代码(等 release、风险大),要么用「调度扩展器(extender)」——一个 HTTP webhook 机制,但限制重重。
2. 踩了什么坑:extender(webhook 扩展)为什么不够用
早期 K8s 确实提供了「extender」来扩展调度器——通过 webhook 调外部服务。但它有一堆致命局限(这些局限,就是后来「为什么必须重构」的直接理由,来自 KEP-624 的原文动机):
- 扩展点太少:extender 只能在「默认谓词跑完之后」插入过滤、「默认优先级跑完之后」插入打分。你不能在「跑谓词之前」做点事,也不能在「绑定之后」做清理。
- 性能差:每次调 extender 都要「JSON 序列化 + HTTP 请求」,比调本地函数慢得多。
- 无法用调度器的缓存:extender 是独立进程,访问不到调度器的节点缓存,只能自己再建一份缓存,或者靠 API server 现查。
- 错误难回滚:如果 extender 帮你「预占了一个资源」,结果调度中途失败要中止,很难通知 extender 去撤销那个预占。
一句话总结坑的本质:「webhook 扩展」是把扩展点架在了「流程的外围」,而不是「流程的内部」——它只能在你跑完主要逻辑之后打补丁,却无法真正参与调度的核心决策。
3. 为什么必须改:功能增长 vs 代码可维护性的死结
更根本的矛盾是:调度器的功能需求在爆炸式增长(GPU 调度、亲和性、拓扑分布、抢占…),而每加一个功能,都是在「已经臃肿的核心代码」里再塞一段。
官方 KEP 把动机写得很直白:
「越来越多的功能被加进调度器,它们让代码越来越大、逻辑越来越复杂。一个更复杂的调度器更难维护,它的 bug 更难找、更难修,那些跑自定义调度器的用户,也跟不上、接不住这些新变化。」
这是一个「不重构就死」的临界点——要么继续往一个「谁也不敢动」的核心里堆代码,要么重新设计一套「能安全地往里加东西」的架构。
4. 新方案:调度框架(Scheduling Framework,v1.15 引入,v1.19 GA)
新方案的核心思想,和所有「插件化」一样——把调度流程拆成一系列「扩展点」,每个扩展点都允许插入「插件」,而核心只负责「按顺序调用插件」。
// 调度框架:核心只做「按顺序调用插件」,具体逻辑全在插件里
type Framework interface {
RunPreFilterPlugins(ctx, state, pod) // 扩展点1:预过滤
RunFilterPlugins(ctx, state, pod, node) // 扩展点2:过滤(=原 predicate)
RunPostFilterPlugins(ctx, state, pod, nodes) // 扩展点3:过滤后(如抢占)
RunPreScorePlugins(ctx, state, pod, nodes) // 扩展点4:打分前
RunScorePlugins(ctx, state, pod, nodes) // 扩展点5:打分(=原 priority)
RunReservePlugins(ctx, state, pod, node) // 扩展点6:预留资源
RunPermitPlugins(ctx, state, pod, node) // 扩展点7:许可(如 gang scheduling)
RunPreBindPlugins(ctx, state, pod, node) // 扩展点8:绑定前
RunBindPlugins(ctx, state, pod, node) // 扩展点9:绑定
RunPostBindPlugins(ctx, state, pod, node) // 扩展点10:绑定后
}对比一下,它一次解决了 extender 的所有局限:
| extender 的老问题 | 调度框架怎么解决 |
|---|---|
| 扩展点太少 | 一口气提供 10+ 个扩展点,覆盖调度的每个阶段 |
| 性能差(HTTP + JSON) | 插件编译进调度器二进制,直接函数调用 |
| 无法用缓存 | 插件在进程内,直接访问调度器的节点缓存 |
| 错误难回滚 | 有 Unreserve 机制,预留失败可撤销 |
而更关键的是「为什么这个改动是安全的」:
- 原来的 predicate/priority 被重新实现成了 Filter/Score 插件,行为完全等价。
- 用户可以通过配置文件(ComponentConfig)来开关、排序插件,不改代码。
- 自定义调度器可以在进程外编译自己的插件,和官方调度器共用同一套框架代码。
类比:extender 像「你只能在别人做完整件事之后,隔着电话提点建议」;调度框架像「把你请进厨房,给你一堆标准工位(切菜、炒菜、装盘),你可以在任意工位上接手」。前者是「外围打补丁」,后者是「内部开放扩展点」——这才是真正可扩展的架构。
5. 这个演进教给我们什么
-
「可扩展」不是「留一个 webhook 口子」,而是「把流程本身设计成一串可插拔的扩展点」。 extender 之所以失败,是因为它把扩展点架在「流程外围」;调度框架之所以成功,是因为它把扩展点内置到了流程的每个阶段。
-
重构要「等价迁移 + 平滑过渡」。调度框架不是「推倒重写」,而是「把旧的 predicate/priority 重写成新框架下的插件,行为等价」,再让用户逐步迁移。这是「不打断用户」的重构范式——新旧并存、等价、可逐步切换(这和第 14 章 CSI 迁移是同一套方法论)。
-
「功能增长」和「代码可维护性」的冲突,是核心组件迟早要面对的。解决它的标准答案,几乎总是「把单体逻辑,拆成可独立开发、独立演进的插件」。你造的系统,一旦某个核心模块「谁也不敢再往里加功能了」,就是该做这种「插件化重构」的时候了。
6. 思维流重放:从「写死够用」到「必须内嵌扩展点」
上面把 extender 的局限和调度框架的方案都讲清了,这一节重走「从『写死两阶段够用』这个初始直觉,到『扩展点必须内嵌』这个结论,中间是怎么拐过来的」。
第 1 步 · 直觉:调度器就干「过滤 + 打分」两件事,功能少,写在核心里最简单、最快、最可靠。没人会觉得这有问题。
第 2 步 · 裂缝一(功能爆炸):GPU 调度、亲和性、拓扑分布、抢占……需求一个接一个,每加一个就往核心塞一段代码。核心越来越臃肿,谁都不敢再动它。但「不敢动」不等于「没问题」——它是「债」在累积。
第 3 步 · 试修补(extender):为了「不改核心也能扩展」,早期给了 extender(webhook)。但这个修补是治标的——扩展点架在「流程外围」,只能在主流程跑完后打补丁,参与不了核心决策。四个致命局限(扩展点少、性能差、无缓存、难回滚)全来自「架在外围」这个根。
第 4 步 · 反转:问题不在「有没有扩展口子」,在「口子开在哪」。extender 是把口子开在流程外,所以只能打补丁;正确做法是把流程本身拆成一串扩展点,让每个扩展点成为流程的一部分——你插进去的插件,就是流程的一环,而不是事后的旁观者。
第 5 步 · 权衡收敛:把插件「编译进二进制」换高性能和正确性,代价是牺牲「热插拔」。但这个代价值得——调度是「高频、低延迟」的关键路径,性能压倒一切。选「内嵌扩展点 + 编译型插件」。
一句话复盘:从「写死」到「插件化」,拐点不是「代码复杂了」,而是意识到「扩展点开在流程外」这个根因。可扩展性从来不是「留个口子」,而是「把决策点抽成接口、让扩展成为流程的一环」。
思考题
Q:调度框架把「插件」编译进调度器二进制,换来了高性能。但这也意味着「加一个插件 = 重新编译整个调度器」,相比 extender 的「webhook 热插拔」,这算不算一种倒退?为什么 K8s 宁可牺牲「热插拔」,也要「编译进二进制」?
参考答案:
这不是倒退,而是一个清醒的权衡——它用「热插拔的便利」换来了「性能和正确性」,而这两个才是调度器真正要命的:
-
性能:调度器是「高频、低延迟」的关键路径,每个 Pod 的调度都要跑一遍所有插件。如果每个插件都是一次 HTTP 调用,调度延迟会不可接受。编译进二进制,插件调用就是一次普通函数调用,几乎零开销。
-
正确性/可调试性:编译进二进制,意味着插件和核心在同一进程、同一类型系统里,可以共享缓存、可以做静态检查、崩溃时能一起被诊断。而 webhook 是「黑盒」——它挂了、慢了、返回错了,调度器很难判断发生了什么。
更深一层:「可扩展性」不是只有「热插拔」一种形态。 编译型插件(plugin compiled in)和运行时插件(webhook/runtime plugin)是两种不同的扩展模型,各有适用场景:
- 编译型:适合「高频、要性能、要强一致」的核心路径扩展(调度器)。
- 运行时型:适合「低频、可容忍延迟、需要动态装卸」的扩展(比如一些 admission webhook)。
好的系统设计,是「在不同路径上用不同的扩展模型」,而不是「一种扩展方式打天下」。 调度器选编译型,恰恰说明它分清了「什么该快、什么该灵活」。