4、CNI(容器网络接口)规范:设计哲学、插件架构与标准操作

聊到容器网络,CNI 是个绕不开的话题。我个人觉得,理解 CNI 的设计哲学,比背几个命令重要得多。说白了,CNI 就是给容器插网线的标准流程。你想想看,Kubernetes 本身不负责网络实现,它只负责调度容器。那容器怎么联网?谁来管?CNI 就是那个「谁」。

4.1 CNI 的设计哲学:简单、可组合、无状态

CNI 的设计哲学,我总结为三个词:简单、可组合、无状态

  • 简单:CNI 规范本身非常轻量。它不定义网络策略、不定义 IPAM 细节,只定义「容器该连哪个网络」这个核心动作。我在项目中见过有人试图在 CNI 里塞进一大堆功能,最后搞得插件又重又难维护。嗯,这其实违背了 CNI 的初衷。
  • 可组合:你可以把多个 CNI 插件串起来用。比如先用一个插件分配 IP,再用另一个插件把容器接入 bridge 网络。这种「搭积木」的方式,让网络方案非常灵活。
  • 无状态:CNI 插件本身不保存状态。每次调用都是独立的。状态由容器运行时(比如 kubelet)或外部系统管理。这样做的好处是,插件崩溃了也不丢数据,重启就能恢复。

核心思想:CNI 只关心「把容器接入网络」和「把容器从网络断开」这两个动作。至于网络怎么规划、IP 怎么分配,那是插件自己的事。

4.2 插件架构:二进制调用与标准输入输出

CNI 的插件架构,说白了就是一个可执行的二进制文件。容器运行时(比如 kubelet)通过环境变量和标准输入,把参数传给插件。插件执行完后,通过标准输出返回结果。

我刚开始接触时觉得这方式有点「原始」。但后来发现,这种设计恰恰是它的优势——跨语言、跨平台。你完全可以用 Go、Rust、甚至 Python 写一个 CNI 插件,只要它能被调用、能输出 JSON 就行。

一个典型的 CNI 插件目录结构是这样的:

/opt/cni/bin/
├── bridge
├── host-local
├── flannel
├── calico
├── macvlan
└── loopback

每个文件都是一个独立的二进制。kubelet 会根据配置,按顺序调用它们。

个人经验:我曾经在生产环境遇到过一个问题——某个 CNI 插件版本不兼容,导致容器启动后网络不通。排查了半天,最后发现是插件二进制文件被覆盖了。从那以后,我习惯在部署前用 cni --version 检查一下插件版本。

4.3 标准操作:ADD / DEL / CHECK

CNI 定义了三个核心操作:ADDDELCHECK。每个操作都有明确的输入和输出。

4.3.1 ADD:把容器接入网络

ADD 操作是 CNI 最核心的调用。当 kubelet 要启动一个 Pod 时,它会调用 CNI 插件的 ADD 方法。插件需要完成以下工作:

  • 创建网络命名空间(如果还没有)
  • 把容器网卡(veth pair 的一端)移入容器的网络命名空间
  • 分配 IP 地址(通过 IPAM 插件)
  • 配置路由和 ARP 表

ADD 操作的输入是通过环境变量传递的:

CNI_COMMAND=ADD
CNI_CONTAINERID=abc123
CNI_NETNS=/var/run/netns/abc123
CNI_IFNAME=eth0
CNI_ARGS=ignore_unknown=1
CNI_PATH=/opt/cni/bin

同时,标准输入会传入一个 JSON 格式的网络配置:

{
  "cniVersion": "0.4.0",
  "name": "mynet",
  "type": "bridge",
  "bridge": "cni0",
  "ipam": {
    "type": "host-local",
    "subnet": "10.88.0.0/16"
  }
}

插件执行成功后,会输出一个 JSON 结果:

{
  "cniVersion": "0.4.0",
  "interfaces": [
    {"name": "eth0", "mac": "0a:58:0a:f4:00:01", "sandbox": "/var/run/netns/abc123"}
  ],
  "ips": [
    {"version": "4", "address": "10.88.0.2/16", "gateway": "10.88.0.1"}
  ],
  "dns": {}
}

注意:ADD 操作必须是幂等的。如果同一个容器被重复调用 ADD,插件应该返回相同的网络配置,而不是报错。我曾经遇到过某个插件在重复 ADD 时直接 panic,导致 Pod 一直处于 ContainerCreating 状态。

4.3.2 DEL:把容器从网络断开

DEL 操作是 ADD 的逆操作。当 Pod 被删除时,kubelet 会调用 DEL 来清理网络资源。DEL 操作需要:

  • 删除容器网卡
  • 释放 IP 地址
  • 清理路由和 ARP 条目
  • 删除网络命名空间(如果不再需要)

DEL 操作的输入和 ADD 类似,只是 CNI_COMMAND=DEL。插件执行成功后,不需要输出任何内容(空 JSON 即可)。

这里有个坑:DEL 操作必须能处理「资源已经被清理过」的情况。比如,容器已经被强制删除了,网络命名空间已经不存在了。这时候插件不能报错,应该静默退出。我见过有些插件在 DEL 时因为找不到网卡而直接返回错误,导致 kubelet 反复重试,最终把节点搞崩了。

4.3.3 CHECK:验证网络连通性

CHECK 操作是 CNI 0.4.0 版本引入的。它的作用是验证容器的网络配置是否仍然有效。kubelet 会定期调用 CHECK 来确保 Pod 的网络是健康的。

CHECK 操作需要检查:

  • 容器网卡是否存在
  • IP 地址是否仍然有效
  • 路由是否配置正确
  • 网关是否可达(可选)

如果 CHECK 失败,kubelet 会认为 Pod 的网络有问题,可能会触发重建。我个人建议,CHECK 操作不要做太重的检查,比如不要发起 ICMP ping。因为网络抖动是正常的,偶尔丢包不代表网络配置有问题。CHECK 应该只检查「配置是否存在」,而不是「网络是否通」。

避坑指南:我曾经在一个大规模集群中遇到过 CHECK 操作导致节点 CPU 飙升的问题。原因是某个 CNI 插件在 CHECK 时遍历了宿主机上所有的网络命名空间,而节点上运行了上千个 Pod。每次 CHECK 都要花好几秒,最终把 kubelet 拖垮了。后来我们给 CHECK 加了一个超时限制,问题才解决。

4.4 总结:CNI 规范的核心要点

操作 触发时机 核心职责 注意事项
ADD Pod 创建时 创建网卡、分配 IP、配置路由 必须幂等,重复调用返回相同结果
DEL Pod 删除时 清理网卡、释放 IP、删除路由 必须能处理资源已被清理的情况
CHECK 周期性健康检查 验证网络配置是否仍然有效 不要做重操作,避免影响性能

嗯,CNI 规范其实就这么点东西。但正是这种「小而美」的设计,让容器网络生态如此繁荣。你想想看,从 Flannel 到 Calico,从 Weave 到 Cilium,底层都遵循着同样的 ADD/DEL/CHECK 流程。理解了这些,你再看任何 CNI 插件的源码,都会觉得「哦,原来如此」。