第 9 章 · 插件化:CRI / CNI / CSI 与「接口抽象」的艺术
一句话结论:K8s 把三个「最容易和具体技术绑定、又最该允许自由选择」的环节——容器运行时、网络、存储——用三个插件接口 CRI / CNI / CSI 抽象了出来。这背后是一个深刻的架构思想:用「稳定接口」隔离「快速变化、众口难调的实现」,让核心保持精简,让生态自由生长。
1. 先看一个「反面教材」:dockershim 的移除
K8s 早期,容器运行时只有一个事实标准——Docker。于是 K8s 内核里内嵌了一段叫 dockershim 的代码,直接和 Docker 引擎对接。
问题很快显现:
- 运行时生态变多了:containerd、CRI-O 等更轻量的运行时崛起,大家发现「为什么非得绑死 Docker?」
- dockershim 卡在中间:它既不是 K8s 核心、又不归 Docker 官方维护,成了「没人愿意长期背的中间层」。
最终,K8s 在 1.24 版本(2022-05)移除了 dockershim。这个决定当时引起轩然大波(很多人误以为「K8s 不支持 Docker 了」),但它的真实意图是——「把 K8s 和具体运行时解耦,通过 CRI 接口对接任何运行时」。
这个「移除」教给我们一个关于抽象的教训:「把实现焊死在核心里」短期省事,长期是债。 dockershim 当初就是「图省事」内嵌进去的,结果几年后要花大力气拆出来。抽象要趁早,拆耦合要趁债还没滚大。
2. CRI / CNI / CSI:三个接口,各解耦一个「变化点」
| 接口 | 全称 | 解耦的是 | 为什么这个点「必须可替换」 |
|---|---|---|---|
| CRI | Container Runtime Interface | 容器运行时 | 运行时生态众多(containerd/CRI-O),且快速演进 |
| CNI | Container Network Interface | 网络插件 | 网络方案众口难调(Calico/Flannel/Cilium…),没标准答案 |
| CSI | Container Storage Interface | 存储插件 | 存储厂商五花八门(云盘/NFS/对象存储…),不可能内置 |
它们背后是同一个设计原则:把「会变、且无法统一」的部分,用「稳定的接口」包起来,让「核心」和「实现」可以各自独立演进。
// CRI 的抽象:kubelet 只依赖这个接口,不关心具体是哪个运行时
type RuntimeService interface {
CreateContainer(config *ContainerConfig) (string, error) // 拉镜像起容器
StartContainer(id string) error
StopContainer(id string) error
// ...
}
// kubelet 调用的是接口,运行时(containerd/CRI-O)是接口的实现
func (k *Kubelet) startPod(pod *Pod) {
id, _ := k.runtime.CreateContainer(toConfig(pod)) // 不关心 runtime 是谁
k.runtime.StartContainer(id)
}3. 思考的分叉:接口的「边界」划在哪,是一门艺术
抽象不是「越多越好」,接口划在哪、划多粗,直接影响成败。K8s 的这些接口设计,给了三条可借鉴的判断:
① 接口要划在「真正的变化点」上,而不是「想象的变化点」上。
- CRI/CNI/CSI 之所以值得抽象,是因为运行时、网络、存储真的存在多元生态和强烈替换需求。
- 反之,如果某个环节「根本没有第二个实现」,抽象就是过度设计(呼应第 7 章「等第二个用例出现」)。
② 接口要「够小、够稳定」,实现才有自由。
- CRI 只暴露「创建/启动/停止容器」这类最小必要操作,不规定「你怎么拉镜像、怎么做 cgroup」。实现方在接口之下有充分自由。
- 接口越大,实现越难、越容易「为了迁就接口而牺牲性能」。小接口,大自由。
③ 接口要有「演进机制」,而不是「永远不变」。
- 这些接口都有版本(CRI v1、v1alpha2…),有 deprecation 流程,能平滑演进。「稳定」不等于「冻结」,稳定是指「变化可预期、可迁移」。
4. 为什么这种「接口抽象」是「造系统」的核心能力
回到你「自己造 ES、给行业提供方案」的目标——插件化接口是「平台型系统」的命门:
- 一个系统想「做大、被广泛采用」,就不可能「自己实现所有可能」,必须开放接口、让别人来实现、让生态来生长。
- K8s 能赢过 Mesos、Swarm,一个重要原因就是它的插件化让「选择权」回到了用户和生态手里——你想用哪个网络、哪个存储、哪个运行时,你说了算。
类比:K8s 像「智能手机 + 标准接口」。手机厂不自己造所有 App,而是提供 App Store + 开发接口,让无数开发者来填。平台的价值,不在于「自己有多全能」,而在于「接口设计得有多好、能吸引多少人来共建」。
思考题
Q:接口抽象让「核心」和「实现」解耦了,但也引入一个新问题——「接口」和「实现」之间一旦出现「语义缝隙」(接口表达不了实现的能力,或实现做了接口没规定的事),就会出 bug。 你在设计自己的系统接口时,怎么判断「这个能力该不该放进接口」?
参考答案:
判断标准可以收敛到一句问自己的话:「这个能力,是所有实现都必须有的『共同契约』,还是某个实现独有的『私有特长』?」
- 共同契约(所有实现都能、且都应该提供)→ 放接口。比如 CRI 的「创建容器」——任何运行时都得会。
- 私有特长(只有部分实现有)→ 不放接口,用「能力探测 / 扩展点」来处理。比如「某存储插件支持快照」——不是所有存储都有,就做成可选的扩展能力(CSI 的快照功能就是单独的、可选的能力,不是基础接口的一部分)。
如果硬把「私有特长」塞进接口,会逼着所有实现都去「假装支持」它,结果就是接口虚胖、实现造假、bug 丛生。
这引出一个接口设计的核心心法:接口定义的是「最小公约数」,而不是「并集」。 好的接口,是「所有实现都能诚实满足的最小契约」;扩展能力,应该用「可探测、可选装」的方式挂在接口之外,而不是塞进基础契约里。「最小公约数」的接口,才有资格叫「稳定」。