上一篇我们把 K8s 的网络模型讲清楚了 – 每个 Pod 一个 IP,Pod 之间可以直接通信,不需要 NAT。但模型终归只是模型,它没有告诉你”谁”去创建 veth pair、“谁”去分配 IP、“谁”去配置路由。
答案是 CNI – Container Network Interface。
CNI 不是一个程序,不是一个
daemon,而是一份规范。它定义了一套极简的协议:容器运行时通过环境变量
+ stdin JSON 调用一个可执行文件,这个可执行文件干完活之后在
stdout 输出一段 JSON 结果。就这么简单。没有 gRPC,没有 REST
API,没有 socket – 就是一个 exec 调用。
这篇文章的目标是把 CNI 规范从头到尾拆开看。我们会理解规范的四个操作,跟一遍完整的调用链路,动手写最简 CNI 插件(bash 版和 Go 版),最后深入 IPAM 和 chaining 机制。
本文基于 CNI Spec v1.0.0。 实验环境:Ubuntu 22.04, kind v0.20, containerd 1.7, Go 1.21。
一、CNI 规范核心:四个操作
CNI 规范定义了四个操作(operation),通过环境变量
CNI_COMMAND 传递给插件:
ADD – 给容器接入网络
这是最核心的操作。当一个新的 Pod sandbox 被创建时,CRI 运行时调用 CNI 插件的 ADD 操作。插件需要做的事情包括但不限于:
- 在容器的 network namespace 中创建网络接口(通常是
eth0) - 分配 IP 地址
- 配置路由
- 返回结果 JSON
# ADD 操作的环境变量
CNI_COMMAND=ADD
CNI_CONTAINERID=abc123def456 # 容器 ID
CNI_NETNS=/var/run/netns/cni-xxxx # 容器 network namespace 路径
CNI_IFNAME=eth0 # 期望在容器内创建的接口名
CNI_PATH=/opt/cni/bin # 插件搜索路径插件通过 stdin 接收 JSON 配置,完成后在 stdout 输出结果:
{
"cniVersion": "1.0.0",
"interfaces": [
{
"name": "eth0",
"mac": "0a:58:0a:f4:01:05",
"sandbox": "/var/run/netns/cni-xxxx"
}
],
"ips": [
{
"address": "10.244.1.5/24",
"gateway": "10.244.1.1",
"interface": 0
}
],
"routes": [
{ "dst": "0.0.0.0/0", "gw": "10.244.1.1" }
]
}注意这个结果的结构:interfaces
描述创建了哪些接口,ips
描述分配了哪些地址,routes
描述添加了哪些路由。CRI 运行时拿到这些信息后报告给
kubelet,kubelet 更新 Pod status。
DEL – 从容器断开网络
Pod 被销毁时调用。插件需要删除接口、释放 IP、清理宿主机上的 veth/路由/iptables 规则。关键约定:DEL 必须是幂等的。如果 netns 已不存在(容器进程崩溃后被回收),DEL 不应报错,而应静默成功。
CNI_COMMAND=DEL
CNI_CONTAINERID=abc123def456
CNI_NETNS=/var/run/netns/cni-xxxx # 可能已经不存在
CNI_IFNAME=eth0
CNI_PATH=/opt/cni/binCHECK – 验证网络配置
CHECK 是 CNI 0.4.0 引入的操作,让运行时定期检查容器网络配置是否正常。插件验证接口/IP/路由还在不在,有问题则返回非零退出码,运行时据此决定是否 DEL + ADD 修复。
CNI_COMMAND=CHECK # 环境变量同 ADD/DEL实际使用中,CHECK 的调用频率取决于 CRI 运行时。containerd 从 1.6 开始支持周期性 CHECK,但很多 CNI 插件对它的实现比较敷衍。
VERSION – 查询支持的版本
最简单的操作,不需要 stdin 输入。插件返回自己支持的 CNI 规范版本:
echo '{}' | CNI_COMMAND=VERSION /opt/cni/bin/bridge
# 输出:{"cniVersion":"1.0.0","supportedVersions":["0.1.0","0.2.0","0.3.0","0.3.1","0.4.0","1.0.0"]}四个操作的交互规范小结
| 操作 | 触发时机 | stdin | stdout | 幂等要求 |
|---|---|---|---|---|
| ADD | Pod sandbox 创建 | 网络配置 JSON | result JSON(接口/IP/路由) | 否(重复调用可能报错) |
| DEL | Pod sandbox 销毁 | 网络配置 JSON | 无(或空 JSON) | 是(必须幂等) |
| CHECK | 运行时定期检查 | 网络配置 JSON + prevResult | 无(成功)或 error JSON | – |
| VERSION | 任意时刻 | 无或空 JSON | 支持的版本列表 | – |
二、CNI 配置文件格式
kubelet 在启动时通过 --cni-conf-dir(默认
/etc/cni/net.d/)找到 CNI
配置文件,按文件名字典序取第一个 .conflist 或
.conf 文件。
NetworkList(conflist)格式
现代 CNI 插件都使用 .conflist 格式,支持
chaining。一个典型的 conflist:
{
"cniVersion": "1.0.0",
"name": "k8s-pod-network",
"plugins": [
{
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"isDefaultGateway": true,
"ipMasq": true,
"hairpinMode": true,
"ipam": {
"type": "host-local",
"ranges": [
[{ "subnet": "10.244.1.0/24" }]
],
"routes": [
{ "dst": "0.0.0.0/0" }
]
}
},
{
"type": "portmap",
"capabilities": { "portMappings": true },
"snat": true
},
{
"type": "bandwidth",
"capabilities": { "bandwidth": true }
}
]
}关键字段:cniVersion
声明规范版本,name
是网络名(节点内唯一),plugins
按顺序执行(第一个是 main 插件,后面的是 chained
插件),type 是可执行文件名(运行时在
CNI_PATH 下查找)。
单插件格式(conf)
老式的 .conf 格式只支持一个插件,不支持
chaining,现在基本不建议使用。但你可能在老集群碰到:
{
"cniVersion": "0.3.1",
"name": "my-bridge",
"type": "bridge",
"bridge": "cni0",
"ipam": { "type": "host-local", "subnet": "10.244.0.0/24" }
}IPAM 配置块
ipam 字段定义了 IP 地址管理插件。IPAM 在 CNI
体系中是一个独立的插件 – main 插件(如
bridge)负责创建接口,IPAM 插件负责分配 IP。main 插件在内部
exec IPAM
插件。这种”插件调用插件”的设计让整个体系非常灵活。
三、一个 CNI 调用的完整生命周期
从用户运行 kubectl run 到 Pod 拿到
IP,中间到底经历了什么?让我们逐步拆解。
第一步:kubelet 接收 Pod 调度
kube-scheduler 把 Pod 调度到某个 Node,该 Node 上的 kubelet 通过 watch API Server 发现新 Pod。
第二步:kubelet 通过 CRI 创建 sandbox
kubelet 调用 CRI 的 RunPodSandbox()
方法。sandbox 是 Pod 的基础设施容器,拥有 Pod 的 network
namespace 等。
第三步:CRI 运行时创建 network namespace
containerd 用 unshare(CLONE_NEWNET) 创建新的
netns,bind mount 到
/var/run/netns/cni-<id>。此时 netns
里只有一个 DOWN 状态的 lo 接口。
第四步:CRI 运行时调用 CNI 插件
这是重头戏。containerd 读取 /etc/cni/net.d/
下的配置文件,解析 conflist 中的 plugins
数组,然后逐个调用:
# 简化版调用(实际由 containerd 的 Go 代码完成)
export CNI_COMMAND=ADD
export CNI_CONTAINERID=abc123def456
export CNI_NETNS=/var/run/netns/cni-abcdef123456
export CNI_IFNAME=eth0
export CNI_PATH=/opt/cni/bin
# main 插件
echo '<config JSON>' | /opt/cni/bin/bridge
# chained 插件(config 中包含上一步的 prevResult)
echo '<config JSON + prevResult>' | /opt/cni/bin/portmap第五步:CNI 插件执行网络配置
以 bridge 插件为例,ADD 操作中它会:创建 bridge
cni0(如果不存在),创建 veth
pair,把一端移到容器 netns 重命名为
eth0,另一端 attach 到 bridge,调用 IPAM
插件获取 IP 并在容器中配置,如果配了 ipMasq
则添加 iptables MASQUERADE 规则,最后把结果 JSON 输出到
stdout。
第六步:结果回传
CNI 插件的 stdout 被 containerd 读取并回报给
kubelet,kubelet 更新 Pod 的
status.podIP。整个过程通常在几百毫秒内完成。
四、自己写一个最简 CNI 插件
光看规范不过瘾。我们来动手写 CNI 插件,从 bash 版开始。
bash 版 – 20 行的 CNI 插件
这个插件只做一件事:创建 veth pair,给容器分配一个硬编码的 IP,证明 CNI 规范的交互方式。
#!/bin/bash
# my-cni-plugin.sh -- 一个最简 CNI 插件
# 用法:放到 /opt/cni/bin/my-simple-cni,chmod +x
# 读取 stdin(配置 JSON,这里我们忽略它)
config=$(cat /dev/stdin)
case "$CNI_COMMAND" in
ADD)
# 创建 veth pair
host_ifname="veth$(echo $CNI_CONTAINERID | cut -c1-8)"
ip link add "$host_ifname" type veth peer name "$CNI_IFNAME" netns "$CNI_NETNS"
# 配置容器端
ip netns exec "$CNI_NETNS" ip addr add 10.99.0.2/24 dev "$CNI_IFNAME"
ip netns exec "$CNI_NETNS" ip link set "$CNI_IFNAME" up
ip netns exec "$CNI_NETNS" ip link set lo up
# 配置宿主机端
ip link set "$host_ifname" up
# 输出 CNI result JSON
cat <<EOF
{
"cniVersion": "1.0.0",
"interfaces": [
{"name": "$host_ifname", "mac": ""},
{"name": "$CNI_IFNAME", "mac": "", "sandbox": "$CNI_NETNS"}
],
"ips": [
{"address": "10.99.0.2/24", "gateway": "10.99.0.1", "interface": 1}
]
}
EOF
;;
DEL)
# 清理:删除宿主机端的 veth(容器端会自动消失)
host_ifname="veth$(echo $CNI_CONTAINERID | cut -c1-8)"
ip link del "$host_ifname" 2>/dev/null || true
;;
CHECK)
# 简单检查容器接口是否存在
ip netns exec "$CNI_NETNS" ip link show "$CNI_IFNAME" > /dev/null 2>&1
;;
VERSION)
cat <<EOF
{"cniVersion":"1.0.0","supportedVersions":["1.0.0"]}
EOF
;;
esac这段代码虽然只有 40 来行,但它是一个完全合规的 CNI 插件 – 能被 containerd 调用,能在容器 netns 里创建接口,能返回正确格式的 result JSON。后面第八节我们会把一个更完整的版本部署到 kind 集群中实际运行。
Go 版 – 使用官方 CNI library
真正在生产环境使用的 CNI 插件都是用 Go 写的。官方提供了
github.com/containernetworking/cni 和
github.com/containernetworking/plugins
两个仓库,前者是 CNI 规范的 Go
实现,后者是一批参考插件。
一个最简 Go 版 CNI 插件的骨架:
package main
import (
"encoding/json"
"fmt"
"net"
"runtime"
"github.com/containernetworking/cni/pkg/skel"
"github.com/containernetworking/cni/pkg/types"
current "github.com/containernetworking/cni/pkg/types/100"
"github.com/containernetworking/cni/pkg/version"
"github.com/containernetworking/plugins/pkg/ip"
"github.com/containernetworking/plugins/pkg/ns"
bv "github.com/containernetworking/plugins/pkg/utils/buildversion"
"github.com/vishvananda/netlink"
)
// PluginConf 定义插件的配置结构
type PluginConf struct {
types.NetConf
Subnet string `json:"subnet"`
Gateway string `json:"gateway"`
}
func parseConfig(stdin []byte) (*PluginConf, error) {
conf := &PluginConf{}
if err := json.Unmarshal(stdin, conf); err != nil {
return nil, fmt.Errorf("failed to parse config: %v", err)
}
return conf, nil
}
func cmdAdd(args *skel.CmdArgs) error {
conf, err := parseConfig(args.StdinData)
if err != nil {
return err
}
// 获取容器 netns
netNS, err := ns.GetNS(args.Netns)
if err != nil {
return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
}
defer netNS.Close()
// 创建 veth pair
hostIface, containerIface, err := ip.SetupVeth(args.IfName, 1500, "", netNS)
if err != nil {
return fmt.Errorf("failed to create veth: %v", err)
}
// 在容器 netns 中配置 IP
_, ipNet, _ := net.ParseCIDR(conf.Subnet)
gwIP := net.ParseIP(conf.Gateway)
err = netNS.Do(func(_ ns.NetNS) error {
link, err := netlink.LinkByName(args.IfName)
if err != nil {
return err
}
addr := &netlink.Addr{
IPNet: &net.IPNet{
IP: ipNet.IP,
Mask: ipNet.Mask,
},
}
if err := netlink.AddrAdd(link, addr); err != nil {
return err
}
// 添加默认路由
return ip.AddDefaultRoute(gwIP, link)
})
if err != nil {
return err
}
// 构造 CNI result
result := ¤t.Result{
CNIVersion: conf.CNIVersion,
Interfaces: []*current.Interface{
{Name: hostIface.Name, Mac: hostIface.Mac},
{Name: containerIface.Name, Mac: containerIface.Mac,
Sandbox: args.Netns},
},
IPs: []*current.IPConfig{
{
Address: *ipNet,
Gateway: gwIP,
Interface: current.Int(1),
},
},
}
return types.PrintResult(result, conf.CNIVersion)
}
func cmdDel(args *skel.CmdArgs) error {
// netns 可能已经不存在了,这是正常情况
if args.Netns == "" {
return nil
}
netNS, err := ns.GetNS(args.Netns)
if err != nil {
// netns 不存在,直接返回成功(幂等)
return nil
}
defer netNS.Close()
return netNS.Do(func(_ ns.NetNS) error {
link, err := netlink.LinkByName(args.IfName)
if err != nil {
return nil // 接口不存在也算成功
}
return netlink.LinkDel(link)
})
}
func cmdCheck(args *skel.CmdArgs) error {
netNS, err := ns.GetNS(args.Netns)
if err != nil {
return fmt.Errorf("netns gone: %v", err)
}
defer netNS.Close()
return netNS.Do(func(_ ns.NetNS) error {
_, err := netlink.LinkByName(args.IfName)
return err
})
}
func main() {
runtime.LockOSThread()
skel.PluginMainFuncs(skel.CNIFuncs{
Add: cmdAdd,
Del: cmdDel,
Check: cmdCheck,
}, version.All, bv.BuildString("my-cni"))
}代码关键点:
skel.PluginMainFuncs是官方入口框架,处理环境变量解析、stdin 读取、错误格式化runtime.LockOSThread()必须调用 – Go goroutine 会跨线程调度,而 netns 是 per-thread 的ns.GetNS/ns.Do是进入容器 netns 的标准方式cmdDel对”不存在”的情况都返回 nil,保证幂等
编译和安装:
go build -o my-cni-go .
sudo cp my-cni-go /opt/cni/bin/五、IPAM:IP 地址管理
IPAM 是 CNI 体系的核心子系统。main 插件负责”接线”,IPAM 插件负责”编号”。两者通过同样的 CNI 协议交互。
host-local:最常用的 IPAM
host-local 是最简单也是最常用的 IPAM 插件。它把 IP 分配信息存储在本地文件系统中,每个节点独立管理自己的地址段。
{
"ipam": {
"type": "host-local",
"ranges": [
[
{
"subnet": "10.244.1.0/24",
"rangeStart": "10.244.1.10",
"rangeEnd": "10.244.1.200",
"gateway": "10.244.1.1"
}
]
],
"routes": [
{ "dst": "0.0.0.0/0" }
],
"dataDir": "/var/lib/cni/networks"
}
}host-local 的工作方式很直白 – 每个已分配的 IP 就是一个文件:
ls /var/lib/cni/networks/k8s-pod-network/
# 10.244.1.2 10.244.1.3 10.244.1.4 last_reserved_ip.0 lock
cat /var/lib/cni/networks/k8s-pod-network/10.244.1.2
# abc123def456 (容器 ID)
# eth0数据存在本地文件系统意味着不同节点之间没有协调,所以必须给每个节点规划互不重叠的子网(通过
podSubnet + 每节点 podCIDR)。
dhcp 与 static
dhcp IPAM 让容器从外部 DHCP 服务器获取 IP,需要一个长驻 daemon 维持租约:
/opt/cni/bin/dhcp daemon &
# 监听 /run/cni/dhcp.sock,CNI 插件通过此 socket 与 daemon 通信在 K8s 环境中不太常见,因为 DHCP 租约到期行为和 Pod 生命周期之间可能冲突。
static IPAM 则是直接在配置中写死
IP("type": "static"),生产不用但调试方便。
自定义 IPAM
IPAM 插件就是一个可执行文件,你可以写自己的来对接 NetBox、Infoblox 等。一些第三方 IPAM:
| IPAM | 数据存储 | 适用场景 |
|---|---|---|
| host-local | 本地文件 | 单节点、每节点固定子网 |
| dhcp | 外部 DHCP | 传统 IT、已有 DHCP 基础设施 |
| whereabouts | etcd / CRD | 跨节点 IP 管理,避免子网预分配 |
| calico-ipam | etcd / CRD | Calico 专用,支持 IP pool |
| cilium | etcd / CRD | Cilium 专用,支持 multi-pool |
六、CNI Chaining:多个插件串联执行
在 conflist 的 plugins
数组中可以列多个插件,它们按顺序串联执行。这就是 CNI
chaining。
chaining 的工作机制
ADD 操作时,运行时把原始配置传给第一个插件(main
插件),其 stdout 被当作 prevResult
注入到第二个插件的 stdin 中,依次串联直到所有插件执行完。DEL
操作时顺序反过来 –
从最后一个插件开始逐个调用,确保清理顺序与创建顺序相反。
{
"cniVersion": "1.0.0",
"name": "example-chain",
"plugins": [
{
"type": "bridge",
"bridge": "cni0"
},
{
"type": "portmap",
"capabilities": { "portMappings": true }
},
{
"type": "tuning",
"sysctl": {
"net.core.somaxconn": "1024"
}
}
]
}在这个例子中,bridge 是 main 插件(创建 veth
+ bridge + 分配 IP),portmap 基于
prevResult 创建 iptables DNAT 规则实现
hostPort,tuning 进入容器 netns 调整 sysctl
参数。
chained 插件的 prevResult
chained 插件收到的 stdin JSON 中会多一个
prevResult 字段。比如 portmap
收到的 stdin 大致如下:
{
"cniVersion": "1.0.0",
"name": "example-chain",
"type": "portmap",
"capabilities": { "portMappings": true },
"runtimeConfig": {
"portMappings": [
{ "hostPort": 8080, "containerPort": 80, "protocol": "tcp" }
]
},
"prevResult": {
"interfaces": [{"name": "eth0", "sandbox": "/var/run/netns/cni-xxx"}],
"ips": [{"address": "10.244.1.5/24", "gateway": "10.244.1.1", "interface": 0}]
}
}portmap 从 prevResult.ips
中得知容器 IP 是 10.244.1.5,据此创建 iptables
DNAT 规则。
常见 chained 插件
| 插件 | 功能 | 典型用途 |
|---|---|---|
| portmap | hostPort 端口映射 | Service nodePort 的底层实现 |
| bandwidth | 流量限速 | Pod 的 ingress/egress 限速 |
| firewall | 防火墙规则 | 基于 iptables 的 Pod 网络策略 |
| tuning | sysctl / 接口参数调整 | 调整容器内的内核参数 |
| sbr | 源路由(source-based routing) | 多网卡 Pod 的路由策略 |
七、CNI 调试方法
手工调用 CNI 二进制
最直接的方式 – 模拟 CRI 运行时的行为,手动 exec CNI 插件:
# 先创建一个用于测试的 network namespace
ip netns add cni-test
# 手工调用 bridge 插件的 ADD 操作
export CNI_COMMAND=ADD
export CNI_CONTAINERID=test-$(date +%s)
export CNI_NETNS=/var/run/netns/cni-test
export CNI_IFNAME=eth0
export CNI_PATH=/opt/cni/bin
cat <<EOF | /opt/cni/bin/bridge
{
"cniVersion": "1.0.0",
"name": "test-net",
"type": "bridge",
"bridge": "cni-test0",
"isGateway": true,
"ipam": {
"type": "host-local",
"subnet": "10.88.0.0/16",
"routes": [{ "dst": "0.0.0.0/0" }]
}
}
EOF如果一切正常,你会在 stdout 看到 result JSON。如果出错,stderr 会有错误信息。验证和清理:
# 验证容器 netns 中的网络配置
ip netns exec cni-test ip addr
ip netns exec cni-test ip route
# 清理:调用 DEL(同样的配置 JSON,只需改 CNI_COMMAND=DEL)
export CNI_COMMAND=DEL
cat <<EOF | /opt/cni/bin/bridge
... # 同上面的 JSON 配置
EOF
ip netns del cni-test查看 /var/lib/cni/
host-local IPAM 的数据目录。IP 冲突、IP 耗尽时第一个要看的地方:
ls /var/lib/cni/networks/<network-name>/
cat /var/lib/cni/networks/<network-name>/10.244.1.5 # 查看分配给了哪个容器常见问题:Pod 被
kubectl delete pod --force --grace-period=0
强制删除后,DEL 没被调用导致 IP 文件残留。长期运行的集群需要
IP GC 机制。
kubelet 日志
journalctl -u kubelet | grep -i cni # systemd 环境
docker exec <node> journalctl -u kubelet | grep -i cni # kind 集群常见错误:“plugin type not found”(二进制不在 CNI_PATH)、“no IP addresses available”(host-local 子网耗尽)、“connection refused”(Calico 等插件 datastore 连不上)。
cnitool – 官方调试工具
containernetworking/cni 仓库提供了
cnitool,比手工设环境变量方便:
go install github.com/containernetworking/cni/cnitool@latest
ip netns add cni-debug
CNI_PATH=/opt/cni/bin NETCONFPATH=/etc/cni/net.d \
cnitool add k8s-pod-network /var/run/netns/cni-debug
ip netns exec cni-debug ip addr # 查看结果
CNI_PATH=/opt/cni/bin NETCONFPATH=/etc/cni/net.d \
cnitool del k8s-pod-network /var/run/netns/cni-debug
ip netns del cni-debug用 strace 追踪 CNI 调用
如果想看 containerd 到底怎么调 CNI 的:
strace -f -e trace=execve -p $(pidof containerd) 2>&1 | grep cni每当有 Pod 创建或删除,你就能看到 containerd 具体调了哪个 CNI 二进制。
八、实验:在 kind 中用 bash CNI 插件跑一个 Pod
把前面学到的知识串起来。我们在 kind 集群中实际部署并运行一个 bash 写的 CNI 插件。
准备 kind 集群
# kind 配置:禁用默认 CNI
cat <<'EOF' > kind-cni-lab.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
disableDefaultCNI: true
podSubnet: "10.99.0.0/16"
nodes:
- role: control-plane
- role: worker
EOF
kind create cluster --config kind-cni-lab.yaml --name cni-labkubectl get nodes
# NAME STATUS ROLES AGE VERSION
# cni-lab-control-plane NotReady control-plane 30s v1.29.0
# cni-lab-worker NotReady <none> 10s v1.29.0部署 bash CNI 插件
# 写一个带 IP 分配的 bash CNI 插件
cat <<'SCRIPT' > my-cni.sh
#!/bin/bash
set -e
config=$(cat /dev/stdin)
subnet="10.99.0"
data_dir="/var/lib/cni/my-simple-net"
mkdir -p "$data_dir"
allocate_ip() {
for i in $(seq 2 254); do
ip="${subnet}.${i}"
if [ ! -f "${data_dir}/${ip}" ]; then
echo "$CNI_CONTAINERID" > "${data_dir}/${ip}"
echo "$ip"; return 0
fi
done
echo "no IPs available" >&2; return 1
}
release_ip() {
for f in "${data_dir}"/10.*; do
[ -f "$f" ] || continue
grep -q "$CNI_CONTAINERID" "$f" 2>/dev/null && rm -f "$f"
done
}
case "$CNI_COMMAND" in
ADD)
container_ip=$(allocate_ip)
host_ifname="veth$(echo $CNI_CONTAINERID | cut -c1-8)"
gateway="${subnet}.1"
if ! ip link show my-cni0 > /dev/null 2>&1; then
ip link add my-cni0 type bridge
ip addr add "${gateway}/24" dev my-cni0
ip link set my-cni0 up
fi
ip link add "$host_ifname" type veth peer name "$CNI_IFNAME" netns "$CNI_NETNS"
ip link set "$host_ifname" master my-cni0
ip link set "$host_ifname" up
nsenter --net="$CNI_NETNS" ip addr add "${container_ip}/24" dev "$CNI_IFNAME"
nsenter --net="$CNI_NETNS" ip link set "$CNI_IFNAME" up
nsenter --net="$CNI_NETNS" ip link set lo up
nsenter --net="$CNI_NETNS" ip route add default via "$gateway"
mac=$(nsenter --net="$CNI_NETNS" cat /sys/class/net/"$CNI_IFNAME"/address)
cat <<EOF
{
"cniVersion": "1.0.0",
"interfaces": [
{"name": "$host_ifname"},
{"name": "$CNI_IFNAME", "mac": "$mac", "sandbox": "$CNI_NETNS"}
],
"ips": [{"address": "${container_ip}/24", "gateway": "$gateway", "interface": 1}],
"routes": [{"dst": "0.0.0.0/0", "gw": "$gateway"}]
}
EOF
;;
DEL)
host_ifname="veth$(echo $CNI_CONTAINERID | cut -c1-8)"
ip link del "$host_ifname" 2>/dev/null || true
release_ip
;;
CHECK)
[ -n "$CNI_NETNS" ] && nsenter --net="$CNI_NETNS" ip link show "$CNI_IFNAME" > /dev/null 2>&1
;;
VERSION)
echo '{"cniVersion":"1.0.0","supportedVersions":["0.3.0","0.3.1","0.4.0","1.0.0"]}'
;;
esac
SCRIPT
# 部署到集群节点
for node in cni-lab-control-plane cni-lab-worker; do
docker cp my-cni.sh ${node}:/opt/cni/bin/my-cni
docker exec ${node} chmod +x /opt/cni/bin/my-cni
docker exec ${node} bash -c 'mkdir -p /etc/cni/net.d && cat > /etc/cni/net.d/10-my-cni.conflist << CONF
{
"cniVersion": "1.0.0",
"name": "my-simple-net",
"plugins": [
{ "type": "my-cni" }
]
}
CONF'
done验证
# 等待节点变为 Ready
kubectl get nodes -w
# 创建测试 Pod
kubectl run ping-test --image=busybox --command -- sleep 3600
# 等 Pod Running 后检查
kubectl get pod ping-test -o wide
# NAME READY STATUS IP NODE
# ping-test 1/1 Running 10.99.0.2 cni-lab-worker
# 进入 Pod 验证网络
kubectl exec ping-test -- ip addr show eth0
kubectl exec ping-test -- ip route
kubectl exec ping-test -- ping -c 3 10.99.0.1
# 在 worker 节点上查看 IP 分配记录
docker exec cni-lab-worker ls /var/lib/cni/my-simple-net/删除 Pod 后,DEL 操作会清理 veth 和 IP 文件。再创建新
Pod,应该能复用之前的 IP。如果 Pod 卡在
ContainerCreating,先看 kubelet
日志:docker exec cni-lab-worker journalctl -u kubelet | grep -i cni。
# 清理
kind delete cluster --name cni-lab九、踩坑备忘
1. conflist 文件名排序
kubelet 按文件名字典序取第一个 .conflist。多
CNI 插件共存时注意:
ls /etc/cni/net.d/
# 10-calico.conflist 10-flannel.conflist --> Calico 被使用(字典序靠前)用数字前缀控制优先级,删除不需要的 conflist。
2. DEL 必须幂等
前面反复强调了。DEL 在 netns 不存在时报错的话,kubelet 会反复重试,日志大量错误,影响节点上所有 Pod 的网络操作。
3. CNI 二进制的权限和路径
必须有执行权限(chmod +x),在
CNI_PATH 中,文件名与 type
字段一致。忘了 chmod +x
是最常见的新手错误。
4. host-local IPAM 的 IP 泄漏
容器非正常删除时(节点崩溃、containerd 被 kill -9),DEL
可能没被调用,导致 /var/lib/cni/networks/ 中的
IP 文件残留。长期运行的集群需要 IP GC 机制 – 比如 Calico
有自己的 IP GC 逻辑。
5. stdin 必须被完整读取
CNI 规范要求插件必须读取完整的 stdin
JSON。如果只读了一部分就关闭 stdin,调用方会收到
SIGPIPE。bash 中用 config=$(cat /dev/stdin),Go
中用 io.ReadAll(os.Stdin)。
6. Go 插件中的 LockOSThread
操作 network namespace 时必须调用
runtime.LockOSThread()。Go 的 goroutine
调度器会在 OS 线程间迁移 goroutine,而 netns 是 per-thread
的。不锁定线程会导致”偶尔能用偶尔不能用”的诡异现象。
十、参考与延伸阅读
CNI 规范本身只有一份不长的文档,但生态非常丰富:
- CNI Spec v1.0.0 – 官方规范文档,建议至少通读一遍
- containernetworking/cni
– 规范的 Go 实现,包含
skel框架和cnitool - containernetworking/plugins – 官方参考插件(bridge、host-local、portmap 等)
- CNI 官方网站 – 文档入口
- Kubernetes 网络插件 – K8s 官方文档中关于 CNI 的部分
这篇文章覆盖了 CNI 规范的核心机制。但 CNI 只解决”怎么给单个 Pod 接入网络” – Pod 之间的跨节点通信需要更上层的方案,也就是下一篇要讲的 Flannel、Calico。
系列导航 - 上一篇:K8s 网络模型 - 下一篇:Flannel 实现原理