4. CNI规范深度解读

CNI(Container Network Interface)规范的核心价值在于把“容器网络怎么接”这件事从运行时中解耦出来。Kubernetes 的 kubelet 并不直接创建 veth、分配 IP 或写路由,而是通过 CRI 调用容器运行时(containerd、CRI-O),运行时再以“执行外部可执行文件 + 传递 JSON 配置 + 读取标准输出”的方式调用 CNI 插件。理解这条链路,是排查 Pod 网络问题的起点。

一、CNI 的三方角色与调用契约

CNI 规范定义了三个角色:运行时(Runtime)、插件(Plugin)和配置(Configuration)。运行时负责在容器网络命名空间创建后、容器启动前调用插件;插件是一个可执行文件,从标准输入读取 JSON 配置,从环境变量读取命令参数,向标准输出写回结果 JSON,并用退出码表达成功或失败。

关键约定有三条:其一,插件必须是无状态的一次性进程,每次调用独立完成;其二,所有输入通过环境变量(如 CNI_COMMAND、CNI_CONTAINERID、CNI_NETNS、CNI_IFNAME、CNI_ARGS、CNI_PATH)和标准输入 JSON 传入;其三,成功时退出码为 0,失败时非 0,且错误信息写入标准错误。运行时根据退出码决定是否继续创建 Pod。

二、配置格式:从 conflist 到插件链

CNI 配置有两种形态:单插件配置(type 直接指定插件名)和插件列表配置(plugins 数组,即 conflist)。Kubernetes 场景下通常使用 conflist,因为需要把“主插件 + IPAM 插件 + 调优插件”串成一条链。下面是一个典型的 conflist 示例,用于说明字段含义:

{
  "cniVersion": "1.0.0",
  "name": "mynet",
  "plugins": [
    {
      "type": "bridge",
      "bridge": "cni0",
      "isGateway": true,
      "ipMasq": true,
      "ipam": {
        "type": "host-local",
        "ranges": [[{ "subnet": "10.244.1.0/24" }]],
        "routes": [{ "dst": "0.0.0.0/0" }]
      }
    },
    {
      "type": "portmap",
      "capabilities": { "portMappings": true }
    }
  ]
}

其中 cniVersion 决定运行时与插件之间的结果格式协商;name 是网络名,同一 Pod 的 ADD 与 DEL 必须使用相同 name 才能正确清理;plugins 数组按顺序执行,前一个插件的输出(尤其是 ips 字段)会作为 prevResult 传给下一个插件。这就是“插件链”的数据流基础。

三、ADD / DEL / CHECK 命令流程

CNI 规范定义了三个核心命令,运行时按生命周期调用:

  • ADD:容器网络命名空间创建后调用。插件需完成接口创建、IP 分配、路由配置,并返回包含 cniVersion、interfaces、ips、routes、dns 的结果 JSON。
  • DEL:容器销毁时调用。插件需释放 IP、删除 veth、清理路由和 iptables 规则。DEL 必须幂等,即使资源已不存在也应返回成功。
  • CHECK:用于校验网络状态是否符合预期,Kubernetes 中由 kubelet 在特定场景触发。插件应检查接口、IP、路由是否与 prevResult 一致,不一致则返回错误。

以 bridge 插件为例,ADD 的典型内部动作是:在容器 netns 内创建 eth0,在宿主机创建 veth 对端并接入 cni0 网桥,调用 IPAM 插件获取 IP,配置容器内路由和 ARP,最后返回结果。DEL 则反向执行:删除 veth、释放 IP、清理网桥转发表。CHECK 会验证 eth0 是否存在、IP 是否与记录一致。

四、运行时交互细节与结果传递

运行时调用插件时,会把前一个插件的输出作为下一个插件的 prevResult 字段注入标准输入。这意味着插件链中的每个插件都必须能解析并透传 prevResult,否则后续插件无法获知已分配的 IP 和接口信息。这也是为什么 conflist 中插件顺序不能随意调整:IPAM 必须在主插件之后或作为其子字段执行,调优类插件(如 bandwidth、portmap)通常放在最后。

另一个关键细节是 CNI_PATH。运行时通过该环境变量告诉插件去哪些目录查找其他插件可执行文件,默认包括 /opt/cni/bin。如果 IPAM 插件不在该路径下,ADD 会直接失败并报“plugin not found”。

CNI 插件调用链与 ADD/DEL/CHECK 数据流 容器运行时 containerd / CRI-O kubelet → CRI CNI 配置 /etc/cni/net.d/ 10-mynet.conflist cniVersion / name / plugins 主插件 bridge 创建 veth / 接入 cni0 配置容器内路由 IPAM host-local 分配 10.244.1.x 记录 IP 分配状态 调优插件 portmap iptables DNAT 规则 hostPort 映射 结果 JSON(stdout) cniVersion / interfaces ips / routes / dns prevResult 透传 退出码 0 = 成功 命令生命周期 ADD → 创建网络 CHECK → 校验状态 DEL → 清理资源 DEL 必须幂等 失败写 stderr + 非 0 退出码 调用 读取配置 输出 prevResult prevResult 状态反馈 环境变量输入 CNI_COMMAND CNI_CONTAINERID CNI_NETNS CNI_IFNAME / CNI_ARGS CNI_PATH

五、一次 ADD 的完整交互时序

把上述细节串起来,一次 Pod 创建的 ADD 时序大致如下:kubelet 通过 CRI 请求创建 Pod 沙箱;containerd 创建网络命名空间后,读取 /etc/cni/net.d/ 下按字典序排列的第一个 conflist;containerd 依次执行 plugins 数组中的每个插件,每次调用都设置 CNI_COMMAND=ADD、CNI_CONTAINERID、CNI_NETNS、CNI_IFNAME=eth0、CNI_PATH,并把配置 JSON 和 prevResult 写入标准输入;bridge 插件创建 veth 并调用 host-local 分配 IP;portmap 插件根据 runtimeConfig.portMappings 写入 iptables 规则;所有插件返回 0 后,containerd 把最终结果返回给 kubelet,Pod 网络就绪。

如果某个插件返回非 0,运行时通常会反向调用已成功插件的 DEL 做回滚,然后向 kubelet 报错,Pod 停留在 ContainerCreating 或进入 Failed。因此排查时第一步应看 kubectl describe pod 的事件,第二步看运行时日志(如 journalctl -u containerd),第三步才是进入节点检查 cni0、veth 和 IP 分配记录。

CNI 插件是“无状态可执行文件”,运行时通过环境变量 + stdin JSON + stdout JSON + 退出码与之交互。理解 prevResult 透传和 CNI_PATH 查找机制,能解释绝大多数“插件找不到”和“IP 未分配”类故障。

六、CHECK 命令与版本协商

CHECK 是 CNI 1.0.0 引入的命令,用于验证网络是否仍符合预期。它接收与 ADD 相同的配置和 prevResult,插件需逐项比对:接口是否存在、IP 是否仍在、路由是否被篡改。若不一致,返回错误,kubelet 可据此触发重建。需要注意的是,并非所有插件都实现了 CHECK,运行时在调用前会检查插件是否声明支持该命令;未实现时通常跳过而非报错。

版本协商同样重要。cniVersion 决定结果 JSON 的字段结构,例如 0.3.0 之前使用 ip4/ip6 字段,0.3.0 之后统一为 ips 数组。如果 conflist 声明的版本高于插件支持的版本,ADD 会失败并提示版本不兼容。工程实践中应保持 conflist 的 cniVersion 与插件二进制版本匹配,升级 CNI 插件时同步更新配置版本。

最后,DEL 的幂等性值得单独强调:当 Pod 创建失败触发回滚时,运行时可能对尚未成功 ADD 的插件也调用 DEL。插件必须能处理“资源不存在”的情况并返回成功,否则会阻塞 Pod 清理,导致节点上残留大量 Terminating 状态的 Pod。这是 CNI 实现中最容易被忽视、也最容易在生产环境暴露的细节。