编写一个简易 CNI 插件给容器分配静态 IP
解读
在国内容器云与私有云交付现场,面试官抛出“写个 CNI 插件给容器分配静态 IP”并不是让你背理论,而是现场验证你对 CNI 规范、veth 设备、Linux 路由表、ARP 邻居表以及 Docker 网络命名空间的理解深度。
题目强调“简易”,意味着:
- 不依赖 etcd、IPAM 微服务,用本地文件或内存表完成 IP 记录;
- 只支持 bridge 模式,veth pair 一端放容器内,一端放主机网桥;
- 支持
ADD/DEL/VERSION三个 CNI 命令即可,无需 CHECK 与 STATUS; - 静态 IP 由用户通过
CNI_ARGS显式传入,插件只做冲突检测与回收; - 代码能现场手撕,15 分钟内可写完主体逻辑,但边界异常必须考虑(IP 冲突、网桥不存在、命名空间句柄泄漏)。
面试官会追问:
- “如果容器重启,IP 如何保持?”
- “多节点怎么办?”
- “如何防止 ARP 欺骗?”
答得好直接锁定网络方向高级工程师,答得差会被判定为只会调参数。”
知识点
- CNI 规范 0.4.0+ 调用流程:可执行文件接收 stdin JSON(包含 prevResult、args、cniVersion),通过 stdout 返回 result,stderr 只打日志。
- 四个必写字段:
cniVersion、name、type、ipam。 - veth pair 创建流程:
ip link add veth0 type veth peer name veth1→ 一端移入容器 netns → 重命名 eth0 → 设置 IP/路由。 - Linux 网桥自动学习 MAC,但静态 IP 必须手动维护 ARP 表,否则跨主机通信会丢包。
- CNI_ARGS 环境变量格式:
"IP=10.244.1.88/24;GATEWAY=10.244.1.1",需自己解析分号分隔的 K=V。 - IP 冲突检测:先读本地
/var/lib/cni/static-ip.json的 JSON 数组,若 IP 已存在且容器 ID 不同,则返回 code=100 兼容错误码,让上层调度重试。 - DEL 命令必须幂等:容器可能已经消失,不能因找不到 veth 就 panic。
- Docker 调用 CNI 的路径:
/opt/cni/bin必须在 Docker daemon 的$PATH,否则 docker run --network=none + cnitool 会报 exec 找不到。 - 性能陷阱:每创建一个容器都写一次文件,高并发场景下要 flock 文件锁,否则会出现竞态双配 IP。
- 安全红线:插件以 root 运行,禁止用 system 直接拼命令,全部用
netlink库或ip子进程,防止命令注入。
答案
以下代码用 Go 1.20 实现,单文件即可编译,依赖 github.com/containernetworking/cni/pkg/skel、github.com/vishvananda/netlink。
保存为 static-ip,go 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 := ¤t.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")
}
编译与使用步骤:
-
安装依赖
go mod init static-ip
go get github.com/containernetworking/cni/pkg/skel@latest
go get github.com/vishvananda/netlink@latest -
构建
CGO_ENABLED=0 go build -o static-ip -
写入 CNI 配置
/etc/cni/net.d/10-static.conf{ "cniVersion": "1.0.0", "name": "static", "type": "static-ip", "bridge": "br0", "gateway": "10.244.1.1" } -
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/CONTAINER_ID) -
验证
容器内eth0即10.244.1.88/24,主机侧br0学习到 MAC,跨容器 ping 通即算成功。
拓展思考
-
多节点静态 IP 如何保持唯一?
本地文件显然不够,需引入分布式 IPAM,如基于 etcd 的whereabouts,或 Calico IPAM 的IPPool做 nodeSelector 隔离,但静态语义与动态池天然冲突,国内金融客户往往要求 “IP 随业务工单终身绑定”,此时最佳实践是 CRD + webhook 预占模式,插件只读 CRD,不再本地写文件。 -
容器重启后 IP 漂移?
上述方案中 CNI DEL 会删记录,重启后重新 ADD 会拿到新 IP。
解决:- 把记录持久化到 Kubernetes 的 status.annotation,CNI DEL 不删记录,只有工单关闭才释放;
- 或者 CNI CHECK 阶段对比 sandbox 是否一致,一致则跳过分配,实现 “热升级不丢 IP”。
-
ARP 欺骗与 IP 冲突检测
静态 IP 绕过了 DHCP,必须开启arp_notify与arp_announce,并在 br0 上开启 hairpin 与 port isolation,防止容器伪装网关。
国内银行容器云要求 “东西向流量强制经过策略路由器”,此时要把 br0 改成 ovs 桥,流表下发静态 IP-MAC 绑定,违规包直接 drop。 -
性能调优
高并发创建场景下,本地 JSON 文件锁成为瓶颈,可换成 SQLite + WAL 模式,单节点 500 Pod/s 创建无锁等待;
或 把分配逻辑拆成 gRPC 服务,CNI 插件只做轻量客户端,与 K8s 调度器 Extender 联动,提前预占 IP,实现 2 万 Pod 级集群 30 秒全量扩容。 -
与 Docker 原生网络共存
国内不少央企存量系统仍用docker run --net=bridge,如何让静态 IP 插件与 docker0 共存?
答:- 禁止 docker0 分配与静态段重叠的 IP,修改
/etc/docker/daemon.json固定 bip; - 用 iptables mark 给静态 IP 流量打标签,走独立策略路由表,避免反向路径过滤 (rp_filter) 丢包;
- 交付时提供 ansible 剧本一键初始化宿主机,面试现场能讲出 rp_filter 与 mark 的联动细节,直接加分。
- 禁止 docker0 分配与静态段重叠的 IP,修改
掌握以上拓展,你不仅写出一个“简易”插件,更能在面试中把国内落地痛点、性能、安全、运维全链路讲透,稳稳拿到 Docker 网络方向高分。