18 问题链:一条因果线看 K8s 的诞生与演化

前面 19 章讲「这个组件长什么样」(实现),1217 章讲「这个组件怎么被推翻重做」(演进)。这一章换一个视角——把 K8s 十年的历史拉成一条因果线:每一步都是「遇到什么问题 → 才逼出什么功能或改法 → 这个解决又埋下什么新问题」。这条线,才是「从 0 到 1 再到现在」的完整叙事。


一、先立一个模型:问题链

老哥的洞察一句话就能说清:

K8s 十年不是「0.1 加了啥、0.2 加了啥」的流水账,而是一条问题驱动的因果链——每一环都是「上一环的解决,种下了下一环的问题」。

这条链上的每一环,只有三种形态:

形态是什么典型例子
新功能撞上从没遇过的问题 → 发明一个新组件Service、StatefulSet、CRD
改进老组件撞上新问题 → 在它上面打补丁原地扩容、Pod 级资源管理
推翻重做底层假设被彻底打破 → 推倒重建调度框架、CSI 迁移、Gateway API

而驱动这些问题一个接一个冒出来的,只有两个源头:

  1. 规模在涨:单机 → 百机 → 万机 → 十万机。一个在 10 台机器上「够用」的设计,到 1 万台必然崩,于是逼出改进或推翻。
  2. 场景在变:从「跑无状态 Web」→「跑有状态数据库」→「跑 AI 训练」。每个新场景都会打破一批旧假设。

抓住这两个源头,你就握住了 K8s 演化的总开关。 下面按「圈」把这条线完整走一遍。


二、第一圈:从真空到最小可用(2014)

问题 0:Docker 会打包单个容器,但「一群容器跨多台机器怎么跑」没人管。

2013 年 Docker 把「打包容器」变得极简单,但 Docker 只解决了一台机器上的事。你有一台机器能跑 5 个容器,可你有 50 台机器要跑 500 个容器——谁来复制、谁来路由流量、谁来发现服务、谁把机器当资源池派活? 这个空白,就是 K8s 诞生的理由。

→ 方案:最小可用编排器(minimally viable orchestrator)四件套。

Google 团队 2014 年给自己定的小目标,就四件事,而且顺序本身就是「问题逼出来的顺序」:

顺序问题逼出的功能
1一个应用要跑多份副本,怎么声明、怎么保证份数Replication(复制)
2副本多了,流量怎么路由、服务怎么被找到Service(负载均衡 + 服务发现)
3容器挂了,谁来发现、谁来拉起健康检查 + 自愈
4一堆机器,怎么当资源池统一派活Scheduler(调度)

思考分叉:为什么不先把「调度」做完美,反而先去搞「复制」和「服务发现」?

因为顺序错了会死。调度的前提是「有东西可调度、有流量可路由」——如果连「跑多份副本」「找到服务」都还没解决,调度得再漂亮也只是空转。先解决「能不能跑起来」,再解决「跑得好不好」,这是从 0 到 1 的铁律。

这四件套,就是 0.x(2014.6~2015.7)到 v1.0 的全部骨架。顺带一提两个花絮:Pod 当时还叫 Task(名字在 2014-06-08 那次 commit 前两天才改),项目内部代号是 Seven of Nine(《星际迷航》角色),所以 logo 是七边形。


三、第二圈:企业级落地(1.0 ~ 1.6)

第一圈解决了「能跑无状态 Web」。但真正拿去跑企业业务,立刻撞墙。

问题:有状态应用怎么办?

无状态 Web 副本可以随便杀、随便换。但数据库呢?——副本身份不固定(重启换个名字)、数据没地方持久存、网络地址会变。这三个坑,四件套一个都兜不住。

→ 新功能:StatefulSet + PV/PVC。

  • StatefulSet(1.3 引入时叫 PetSet,1.5 更名 StatefulSet 进 beta):给副本「稳定的身份」——固定名字(db-0、db-1)、固定网络标识、按序启动。
  • PV/PVC:把「存储」从「Pod」里拆出来,变成独立声明。Pod 死了,卷还在。

问题:多租户,谁能干什么?

v1.0 居然没有权限系统——这在今天简直不可想象。任何能进集群的人都能删任何东西。

→ 新功能:RBAC(1.6 GA,2017)。角色、绑定、命名空间隔离,整套权限模型才补齐。

问题:手动滚动升级太累。

你有一批副本要发新版本,得手动 kubectl delete 旧 Pod、再 kubectl run 新 Pod,还要自己盯着「旧的没全停、新的没全上」。

→ 新功能:Deployment(1.2 引入,早期为 beta,1.9 才 GA)。声明「我要 3 个副本跑 v2」,控制器自动滚动——先起新的、再停旧的,全程零中断。

这一圈的本质:四件套是「能跑」,第二圈是「能放心地跑生产」。每一步都是企业把 K8s 往真实业务上压时,压出来的缺口。


四、第三圈:生态扩张(1.7 ~ 1.16)

第二圈补完了核心功能,但新问题来自另一个方向——「扩展」。

问题:第三方想扩展 API,但 core 不给加,也加不过来。

全世界的人都想让 K8s 认识自己的资源(证书、数据库实例、监控规则……),不可能都塞进 core。core 团队也加不动——需求是无限的。

→ 新功能:CRD(自定义资源,1.7 alpha → 1.16 GA)。

让任何人在不改 K8s 源码的前提下,定义自己的资源类型。这是 K8s 从「产品」变成「平台」的一跃。

→ 紧接着的问题:CRD 写起来太啰嗦。 光定义资源不够,还得写控制器去对账。每个人从零写一遍「监听 → 调谐 → 重试」,重复且易错。

→ 改进:Operator 模式(2016 CoreOS 提出)。把「资源 + 控制器 + 运维知识」打包成一体,让「会用 K8s 的人」能把「不会用的人」的运维经验固化下来。注意这一环:新功能(CRD)种下了新问题(写控制器啰嗦),逼出了改进(Operator)。问题链第一次显形。

问题:存储、网络、运行时被厂商绑死。

  • 存储:每加一个云厂商,就要往 core 里塞一卷代码(in-tree)。
  • 网络:各家 CNI 实现(Flannel、Calico、Weave……)各玩各的。
  • 运行时:Docker 一家独大,core 里写死了对 Docker 的假设。

→ 新功能:CSI / CNI / CRI 三个「I」(Interface)插件化。

把「做什么」和「怎么做」分开:core 只定义接口,厂商自己实现插件。K8s 从此不必为每个厂商改代码。


五、第四圈:规模成熟后的推翻(1.14 ~ 现在)

先说明一点:这四圈是按「问题类型」分的,不是严格的时间轴——第三圈「加功能」和第四圈「推翻」在 1.14~1.16 这段是并行发生的(比如 CRD 到 1.16 才 GA,而 CSI 迁移 1.14 就已经起步)。时间有重叠是刻意的,别把它当成分秒不差的编年史。

这一圈的共同点,是**「推翻」**。而且推翻的理由出奇地一致:

当初为了快、为了兼容而「内嵌、写死、在外围打补丁」的东西,规模一大、场景一变,全变成了债。

问题:调度器扩展点架在流程外围,改不动。

旧调度器的扩展方式叫「extender」——一个 webhook,在调度流程结束后才被调用。结果它只能「事后打补丁」,影响不了核心的过滤/打分流程。谁想深度定制调度,谁就卡死。

→ 推翻:调度框架(Scheduling Framework,1.15~1.19)。

把扩展点内嵌到调度的每一个阶段(过滤前、过滤后、打分、绑定……),第三方直接往每个阶段插代码。核心思想:「扩展点必须长在流程里,而不是挂在流程外」。详见第 13 章。

问题:in-tree 卷绑死云厂商,改一行就要动 core。

第三圈虽然引进了 CSI,但历史遗留的 in-tree 卷还在 core 里,每个云厂商的存储代码都堆在 K8s 主仓库。要改、要修 bug,都得跟着 K8s 的发布节奏走。

→ 推翻:CSI 迁移(1.14 ~ 1.25,十一个版本)。

K8s 史上最谨慎的搬家:旧 API 不变,底层实现悄悄换成 CSI,中间加一层「翻译」。用户完全无感知,但引擎偷偷换了。之所以谨慎到横跨 11 个版本,因为动的是数据——不可逆,所以宁可双轨维护好几年。详见第 14 章。

问题:Docker 不是唯一运行时,dockershim 成了拦路石。

core 里有一段叫 dockershim 的胶水,专门伺候 Docker。当 containerd、CRI-O 这些纯 CRI 运行时成熟后,这段胶水从「便利」变成了「包袱」——它让「Docker」和「CRI」这两个概念被错误地绑死。

→ 推翻:弃用并移除 dockershim(1.20 宣布 → 1.24 移除)。 详见第 17 章。

问题:Ingress 各家实现不兼容,一个注解都不能通用。

Ingress 虽然解决了「七层路由」,但它是个「最小公约数」——各家(nginx、Traefik、Istio……)的注解五花八门,你写的一堆 annotations 换个实现就失效。

→ 推翻:Gateway API(2020 提出,持续演进)。

用「角色分离」重构——基础设施团队定义 Gateway,应用团队只声明 Route。核心思想:把「能力」抽象成带角色、可扩展的对象,而不是靠注解堆料。 详见第 16 章。

问题:AI 负载要 gang scheduling,打破「Pod 独立调度」的旧假设。

AI 训练是「N 个 Pod 要么一起跑、要么都别跑」(All-or-Nothing)。旧调度器默认 Pod 各自独立、互不相关,这个假设在 AI 时代崩了。

→ 新功能 + 改进:gang scheduling、in-place 原地扩容(1.27 ~ 1.37)。 详见第 10 章。


六、串起来:每个方案都是下一个问题的种子

现在把这条链完整提起来看,会发现一个残酷而深刻的规律:

Service(四层路由)
   └─ 问题:七层不够 → Ingress
        └─ 问题:Ingress 不兼容 → Gateway API

CRD(能自定义资源)
   └─ 问题:写控制器啰嗦 → Operator

CSI(存储插件化)
   └─ 问题:in-tree 迁移要平滑 → 翻译层双轨

CRI(运行时插件化)
   └─ 问题:dockershim 变包袱 → 弃用

这条链的精髓:解决一个问题,从来不是终点,而是下一个问题的起点。 因为每一个「方案」,都基于当时对「规模」和「场景」的假设;一旦规模涨一个量级、场景扩一类,这个假设就崩,方案就变债。

这就是为什么 K8s 永远在改、永远有下个版本——不是设计者不行,是规模在涨、场景在变,问题链是活的。


七、回到你的目标:造工具时怎么用这条链

老哥你想「自己造工具、给行业提供方案」,这条链给你三条可以直接用的认知:

  1. 别幻想一次设计到位。 任何方案都只是「当前规模 + 当前场景」下的局部最优。你真正要问的不是「这设计优雅吗」,而是「将来规模涨十倍、场景扩一类时,它会在哪里崩」。

  2. 判断方案好坏,看它「将来被推翻时,翻起来疼不疼」。 好的方案,是把「今天的内嵌、写死、外围补丁」这些债,提前设计成接口——因为接口翻起来不疼(换实现就行),写死的东西翻起来要命(得动 core)。K8s 的三个「I」(CRI/CNI/CSI)就是这句话的注脚。

  3. 两个驱动源,就是你的技术债预警雷达。 每次你要造一个新系统,先问自己两件事:

    • 规模会不会涨一个量级?(10 台 → 1 万台)
    • 场景会不会扩一类?(无状态 → 有状态 → AI) 一旦答案是「会」,就先检查哪些设计假设会崩——这比事后推翻,便宜一百倍。

思考题 + 参考答案

思考题 1:四件套(复制/服务发现/健康检查/调度)里,为什么「调度」被排在最后,而不是最先?如果让你从零设计,你会先做哪个?

参考答案:调度的价值前提是「有东西值得调度、有流量值得路由」。如果复制、服务发现、自愈都还没有,调度器调度谁?调度出来的东西又能服务谁?所以「先跑起来、再跑得好」是顺序的底层逻辑。但注意,这有个隐含假设——「规模从一开始就大到需要调度」吗? 如果只有 3 台机器,手动指定跑哪台反而更简单。四件套的排序,本质是「默认你要面对多机集群」这个场景假设。一旦你造的系统默认单机,那你的「问题链」起点就和 K8s 完全不同。

思考题 2:CSI 迁移横跨 11 个版本才完成,为什么「慢」在这里反而是优点,而不是缺点?

参考答案:因为迁移动的是数据——数据不可逆,坏了就是事故。所以「慢」的本质是「用时间换安全」:旧 API 不动、新实现悄悄上、中间加翻译层,用户可以随时退回。这里有个更深的点——「平滑迁移」的代价是「长期双轨维护」,双轨本身又是新的技术债。所以任何「平滑」都不是免费的,它只是把「一次性的剧烈疼痛」换成了「长期的隐形成本」。你造工具时,要先判断「这个迁移值不值得付出双轨的代价」——K8s 的答案是「存储值得,因为不可逆」,但不是所有东西都值得。

思考题 3:为什么「扩展点内嵌在流程里」(调度框架)能成功,而「扩展点挂在流程外」(extender)会失败?这两种设计,在你自己写 Go 的代码里,对应什么?

参考答案:挂在流程外,意味着扩展点只能看到结果、改不了过程,它是在「事后补丁」。内嵌在流程里,扩展点成为流程本身的一环,可以影响每一步的决策。对应到 Go 代码,这其实就是「回调函数 vs 接口注入」的区别:extender 像「跑完主流程再调个 hook」,调度框架像「把策略抽象成 interface,主流程每一步都调用你注入的实现」。可扩展性从来不是「留个口子」,而是「把决策点抽成接口,让主流程依赖抽象而非具体」——这也是 SOLID 里「依赖倒置」在系统架构层的放大版。

思考题 4:如果你现在要造一个「任务调度系统」,用这条链的视角,你会提前把哪些「写死」的东西设计成「接口」,来避免将来被推翻?

参考答案(一种站得住的推导,不是标准答案):先找「最可能变」的两样——存储后端和调度策略。存储后端一定会从「本地文件」变到「对象存储/数据库」,所以第一版就该定义 Store 接口而不是写死文件路径;调度策略一定会从「先来先服务」变到「优先级/亲和/抢占」,所以第一版就该定义 Scheduler 接口而不是写死一个排序函数。判断标准就一条:凡是「今天够用、但规模涨十倍或场景扩一类就必然不够用」的地方,第一版就抽接口。 反过来,真正「十年不变」的东西(比如任务的状态机、幂等语义),才值得写死、写清楚——因为写死的东西一旦正确,就是最可靠的。


到这里,这套 K8s 教程的三层视角就齐了:是什么(19 章实现)→ 怎么变(1217 章演进)→ 为什么变(本章问题链)。下一章若继续深挖,可回到第 13 章调度框架 KEP-624 的源码走读,或第 11 章的 Go 落点,把这套思想真正写进你自己的代码里。