土法炼钢兴趣小组的算法知识备份

CNI 规范拆解:一个 JSON 配置怎么变成网络

目录

上一篇我们把 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 调用流程:从 kubelet 到 netns

一、CNI 规范核心:四个操作

CNI 规范定义了四个操作(operation),通过环境变量 CNI_COMMAND 传递给插件:

ADD – 给容器接入网络

这是最核心的操作。当一个新的 Pod sandbox 被创建时,CRI 运行时调用 CNI 插件的 ADD 操作。插件需要做的事情包括但不限于:

# 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/bin

CHECK – 验证网络配置

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/cnigithub.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 := &current.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"))
}

代码关键点:

编译和安装:

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}]
  }
}

portmapprevResult.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-lab
kubectl 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 规范的核心机制。但 CNI 只解决”怎么给单个 Pod 接入网络” – Pod 之间的跨节点通信需要更上层的方案,也就是下一篇要讲的 Flannel、Calico。


系列导航 - 上一篇:K8s 网络模型 - 下一篇:Flannel 实现原理


By .