编写一个简易 CNI 插件给容器分配静态 IP

解读

在国内容器云与私有云交付现场,面试官抛出“写个 CNI 插件给容器分配静态 IP”并不是让你背理论,而是现场验证你对 CNI 规范、veth 设备、Linux 路由表、ARP 邻居表以及 Docker 网络命名空间的理解深度
题目强调“简易”,意味着:

  1. 不依赖 etcd、IPAM 微服务,用本地文件或内存表完成 IP 记录
  2. 只支持 bridge 模式,veth pair 一端放容器内,一端放主机网桥
  3. 支持 ADD/DEL/VERSION 三个 CNI 命令即可,无需 CHECK 与 STATUS
  4. 静态 IP 由用户通过 CNI_ARGS 显式传入,插件只做冲突检测与回收
  5. 代码能现场手撕,15 分钟内可写完主体逻辑,但边界异常必须考虑(IP 冲突、网桥不存在、命名空间句柄泄漏)。

面试官会追问:

  • “如果容器重启,IP 如何保持?”
  • “多节点怎么办?”
  • “如何防止 ARP 欺骗?”
    答得好直接锁定网络方向高级工程师,答得差会被判定为只会调参数。”

知识点

  1. CNI 规范 0.4.0+ 调用流程:可执行文件接收 stdin JSON(包含 prevResult、args、cniVersion),通过 stdout 返回 result,stderr 只打日志。
  2. 四个必写字段cniVersionnametypeipam
  3. veth pair 创建流程ip link add veth0 type veth peer name veth1 → 一端移入容器 netns → 重命名 eth0 → 设置 IP/路由。
  4. Linux 网桥自动学习 MAC,但静态 IP 必须手动维护 ARP 表,否则跨主机通信会丢包。
  5. CNI_ARGS 环境变量格式"IP=10.244.1.88/24;GATEWAY=10.244.1.1"需自己解析分号分隔的 K=V
  6. IP 冲突检测:先读本地 /var/lib/cni/static-ip.json 的 JSON 数组,若 IP 已存在且容器 ID 不同,则返回 code=100 兼容错误码,让上层调度重试。
  7. DEL 命令必须幂等:容器可能已经消失,不能因找不到 veth 就 panic
  8. Docker 调用 CNI 的路径/opt/cni/bin 必须在 Docker daemon 的 $PATH否则 docker run --network=none + cnitool 会报 exec 找不到
  9. 性能陷阱:每创建一个容器都写一次文件,高并发场景下要 flock 文件锁,否则会出现竞态双配 IP
  10. 安全红线:插件以 root 运行,禁止用 system 直接拼命令,全部用 netlink 库或 ip 子进程,防止命令注入

答案

以下代码用 Go 1.20 实现,单文件即可编译,依赖 github.com/containernetworking/cni/pkg/skelgithub.com/vishvananda/netlink
保存为 static-ipgo build -o static-ip 后放 /opt/cni/bin给 0755 权限

package main

import (
	"encoding/json"
	"fmt"
	"net"
	"os"
	"path/filepath"
	"syscall"

	"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/vishvananda/netlink"
)

const dataFile = "/var/lib/cni/static-ip.json"

type IPRecord struct {
	ContainerID string `json:"containerID"`
	IP          string `json:"ip"`
}

type NetConf struct {
	types.NetConf
	Bridge string `json:"bridge"` // 主机网桥名
}

func loadIndex() ([]IPRecord, error) {
	if _, err := os.Stat(dataFile); os.IsNotExist(err) {
		return []IPRecord{}, nil
	}
	d, err := os.ReadFile(dataFile)
	if err != nil {
		return nil, err
	}
	var rec []IPRecord
	if err := json.Unmarshal(d, &rec); err != nil {
		return nil, err
	}
	return rec, nil
}

func saveIndex(rec []IPRecord) error {
	if err := os.MkdirAll(filepath.Dir(dataFile), 0755); err != nil {
		return err
	}
	d, _ := json.Marshal(rec)
	return os.WriteFile(dataFile, d, 0644)
}

func allocateIP(containerID, wantedIP string) error {
	recs, err := loadIndex()
	if err != nil {
		return err
	}
	for _, r := range recs {
		if r.IP == wantedIP && r.ContainerID != containerID {
			return fmt.Errorf("IP %s already allocated to %s", wantedIP, r.ContainerID)
		}
	}
	// 幂等:若已记录同一容器同一 IP,直接返回
	for _, r := range recs {
		if r.ContainerID == containerID && r.IP == wantedIP {
			return nil
		}
	}
	recs = append(recs, IPRecord{ContainerID: containerID, IP: wantedIP})
	return saveIndex(recs)
}

func releaseIP(containerID string) error {
	recs, err := loadIndex()
	if err != nil {
		return err
	}
	newRecs := make([]IPRecord, 0, len(recs))
	for _, r := range recs {
		if r.ContainerID != containerID {
			newRecs = append(newRecs, r)
		}
	}
	return saveIndex(newRecs)
}

func ensureBridge(brName string) (*netlink.Bridge, error) {
	l, err := netlink.LinkByName(brName)
	if err == nil {
		if br, ok := l.(*netlink.Bridge); ok {
			return br, nil
		}
		return nil, fmt.Errorf("%s already exists but not a bridge", brName)
	}
	br := &netlink.Bridge{LinkAttrs: netlink.LinkAttrs{Name: brName}}
	if err := netlink.LinkAdd(br); err != nil {
		return nil, err
	}
	if err := netlink.LinkSetUp(br); err != nil {
		return nil, err
	}
	return br, nil
}

func cmdAdd(args *skel.CmdArgs) error {
	n := &NetConf{}
	if err := json.Unmarshal(args.StdinData, n); err != nil {
		return err
	}
	// 解析 CNI_ARGS
	ipStr := ""
	for _, kv := range strings.Split(os.Getenv("CNI_ARGS"), ";") {
		if strings.HasPrefix(kv, "IP=") {
			ipStr = strings.TrimPrefix(kv, "IP=")
		}
	}
	if ipStr == "" {
		return fmt.Errorf("missing IP= in CNI_ARGS")
	}
	ip, ipNet, err := net.ParseCIDR(ipStr)
	if err != nil {
		return fmt.Errorf("invalid CIDR %s: %v", ipStr, err)
	}
	ipNet.IP = ip

	if err := allocateIP(args.ContainerID, ipStr); err != nil {
		return err
	}

	br, err := ensureBridge(n.Bridge)
	if err != nil {
		return err
	}

	// 创建 veth pair
	hostVethName := "veth" + args.ContainerID[:8]
	peer := &netlink.Veth{
		LinkAttrs: netlink.LinkAttrs{
			Name: hostVethName,
		},
		PeerName: "eth0",
	}
	if err := netlink.LinkAdd(peer); err != nil {
		return err
	}
	hostVeth, err := netlink.LinkByName(hostVethName)
	if err != nil {
		return err
	}
	contVeth, err := netlink.LinkByName("eth0")
	if err != nil {
		return err
	}

	// 将容器端移入 netns
	fd, err := syscall.Open(fmt.Sprintf("/proc/%s/ns/net", args.Netns), syscall.O_RDONLY, 0)
	if err != nil {
		return err
	}
	defer syscall.Close(fd)
	if err := netlink.LinkSetNsFd(contVeth, int(fd)); err != nil {
		return err
	}

	// 主机端插到网桥
	if err := netlink.LinkSetMaster(hostVeth, br); err != nil {
		return err
	}
	if err := netlink.LinkSetUp(hostVeth); err != nil {
		return err
	}

	// 进入容器 netns 配置 IP
	err = ns.WithNetNSPath(args.Netns, func(_ ns.NetNS) error {
		if err := netlink.LinkSetName(contVeth, "eth0"); err != nil {
			return err
		}
		if err := netlink.LinkSetUp(contVeth); err != nil {
			return err
		}
		addr := &netlink.Addr{IPNet: ipNet, Label: ""}
		if err := netlink.AddrAdd(contVeth, addr); err != nil {
			return err
		}
		// 默认路由
		gw := n.Gateway
		if gw == "" {
			gw = ipNet.IP.Mask(ipNet.Mask).String() + ".1"
		}
		gwIP := net.ParseIP(gw)
		if gwIP == nil {
			return fmt.Errorf("invalid gateway %s", gw)
		}
		return netlink.RouteAdd(&netlink.Route{
			Scope: netlink.SCOPE_UNIVERSE,
			Dst:   nil,
			Gw:    gwIP,
		})
	})
	if err != nil {
		return err
	}

	result := &current.Result{
		CNIVersion: current.ImplementedSpecVersion,
		Interfaces: []*current.Interface{
			{Name: hostVethName, Mac: hostVeth.Attrs().HardwareAddr.String()},
			{Name: "eth0", Mac: contVeth.Attrs().HardwareAddr.String(), Sandbox: args.Netns},
		},
		IPs: []*current.IPConfig{
			{
				Version:   "4",
				Interface: current.Int(1),
				Address:   *ipNet,
				Gateway:   net.ParseIP(n.Gateway),
			},
		},
	}
	return types.PrintResult(result, n.CNIVersion)
}

func cmdDel(args *skel.CmdArgs) error {
	n := &NetConf{}
	_ = json.Unmarshal(args.StdinData, n)
	// 释放 IP 记录
	_ = releaseIP(args.ContainerID)
	// 删除主机端 veth
	hostVethName := "veth" + args.ContainerID[:8]
	if link, err := netlink.LinkByName(hostVethName); err == nil {
		_ = netlink.LinkDel(link)
	}
	return nil
}

func main() {
	skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, "static-ip CNI plugin")
}

编译与使用步骤

  1. 安装依赖
    go mod init static-ip
    go get github.com/containernetworking/cni/pkg/skel@latest
    go get github.com/vishvananda/netlink@latest

  2. 构建
    CGO_ENABLED=0 go build -o static-ip

  3. 写入 CNI 配置 /etc/cni/net.d/10-static.conf

    {
      "cniVersion": "1.0.0",
      "name": "static",
      "type": "static-ip",
      "bridge": "br0",
      "gateway": "10.244.1.1"
    }
    
  4. Docker 侧调用
    docker run --rm --network=none alpine sh -c 'ip a'
    export CNI_ARGS="IP=10.244.1.88/24"
    cnitool add static /var/run/netns/(dockerinspectf.State.Pid(docker inspect -f '{{.State.Pid}}' CONTAINER_ID)

  5. 验证
    容器内 eth010.244.1.88/24,主机侧 br0 学习到 MAC,跨容器 ping 通即算成功

拓展思考

  1. 多节点静态 IP 如何保持唯一?
    本地文件显然不够,需引入分布式 IPAM,如基于 etcd 的 whereabouts,或 Calico IPAM 的 IPPoolnodeSelector 隔离但静态语义与动态池天然冲突,国内金融客户往往要求 “IP 随业务工单终身绑定”,此时最佳实践是 CRD + webhook 预占模式,插件只读 CRD,不再本地写文件

  2. 容器重启后 IP 漂移?
    上述方案中 CNI DEL 会删记录,重启后重新 ADD 会拿到新 IP。
    解决:

    • 把记录持久化到 Kubernetes 的 status.annotationCNI DEL 不删记录,只有工单关闭才释放;
    • 或者 CNI CHECK 阶段对比 sandbox 是否一致一致则跳过分配,实现 “热升级不丢 IP”
  3. ARP 欺骗与 IP 冲突检测
    静态 IP 绕过了 DHCP,必须开启 arp_notifyarp_announce,并在 br0 上开启 hairpin 与 port isolation,防止容器伪装网关。
    国内银行容器云要求 “东西向流量强制经过策略路由器”,此时要把 br0 改成 ovs 桥流表下发静态 IP-MAC 绑定违规包直接 drop

  4. 性能调优
    高并发创建场景下,本地 JSON 文件锁成为瓶颈,可换成 SQLite + WAL 模式单节点 500 Pod/s 创建无锁等待
    把分配逻辑拆成 gRPC 服务CNI 插件只做轻量客户端与 K8s 调度器 Extender 联动提前预占 IP实现 2 万 Pod 级集群 30 秒全量扩容

  5. 与 Docker 原生网络共存
    国内不少央企存量系统仍用 docker run --net=bridge如何让静态 IP 插件与 docker0 共存?
    答:

    • 禁止 docker0 分配与静态段重叠的 IP修改 /etc/docker/daemon.json 固定 bip
    • 用 iptables mark 给静态 IP 流量打标签走独立策略路由表避免反向路径过滤 (rp_filter) 丢包
    • 交付时提供 ansible 剧本一键初始化宿主机面试现场能讲出 rp_filter 与 mark 的联动细节直接加分

掌握以上拓展,你不仅写出一个“简易”插件,更能在面试中把国内落地痛点、性能、安全、运维全链路讲透稳稳拿到 Docker 网络方向高分