各位同学,大家好!
今天,我们将共同踏上一段硬核之旅,深入探索容器网络的奥秘,并通过 Go 语言亲手编写一个自定义的 CNI (Container Network Interface) 插件。这不是简单的API调用,而是从零开始构建容器网络协议栈,理解其底层机制,最终掌握容器网络的核心控制权。
1. CNI:容器网络的通用语
在深入实践之前,我们首先要理解 CNI 是什么,以及它为何成为容器生态系统中不可或缺的一部分。
什么是 CNI?
CNI,全称 Container Network Interface,是由 CNCF (Cloud Native Computing Foundation) 定义的一套规范和库,用于配置 Linux 容器的网络接口。它提供了一种标准的、与容器运行时无关的方式来连接容器和主机网络。简单来说,CNI 规范定义了容器运行时(如 containerd、CRI-O,进而由 Kubernetes 的 Kubelet 调用)如何与网络插件进行交互,以实现容器网络的配置和管理。
为什么需要 CNI?
在没有 CNI 之前,每个容器运行时都有自己管理网络的方式。这导致了碎片化,使得网络解决方案与特定的容器运行时紧密耦合。当 Kubernetes 这样的容器编排系统出现时,它需要一种统一的方式来管理集群中所有容器的网络,无论这些容器是由哪个运行时创建的。CNI 的出现解决了这个问题,它将网络配置的责任从容器运行时中解耦出来,将其委托给专门的网络插件。
CNI 的核心价值:
- 解耦 (Decoupling): 将容器网络配置与容器运行时解耦,使得容器运行时可以专注于容器生命周期管理,而网络插件则专注于网络拓扑和策略。
- 标准化 (Standardization): 提供了一个通用的接口,允许不同的网络解决方案(如 Calico, Flannel, Weave Net 等)与任何支持 CNI 的容器运行时协同工作。
- 可插拔性 (Pluggability): 允许用户根据自己的需求选择或开发不同的网络插件,满足各种复杂的网络场景。
- 简单性 (Simplicity): CNI 规范本身相对简洁,易于理解和实现。
为什么我们要自定义 CNI 插件?
尽管市面上已经有许多成熟的 CNI 插件,但自定义 CNI 插件仍然具有重要的意义:
- 特定需求: 现有插件可能无法满足某些特殊的网络策略、性能要求或与传统网络的集成需求。
- 深度学习: 通过亲手实现,我们可以深入理解容器网络的底层机制,包括 Linux 网络命名空间、虚拟网卡 (veth pair)、网桥 (bridge)、路由等。
- 创新: 为新的网络技术(如 eBPF、SR-IOV 等)提供支持,或者探索更高效、更安全的容器网络方案。
- 故障排查: 了解 CNI 原理有助于更有效地诊断和解决容器网络问题。
本次讲座的目标是,带领大家从零开始,使用 Go 语言实现一个简单的 CNI 插件,它能为容器创建一个网络接口,分配 IP 地址,并将其连接到主机上的一个网桥。我们将涵盖 CNI 规范的核心概念、Go 语言实现细节以及 Linux 网络基础知识。
2. CNI 核心概念与工作原理
CNI 规范定义了容器运行时与网络插件之间交互的协议。理解这些核心概念是编写插件的基础。
2.1 CNI 规范中的操作
CNI 插件本质上是一个可执行文件,它根据命令行参数和标准输入 (stdin) 接收的 JSON 配置来执行不同的操作。规范定义了以下主要操作:
| 操作名称 | 参数 | 描述 |
|---|---|---|
ADD |
NETNS, IFNAME, CONTAINERID, ARGS |
将容器添加到网络。这是最核心的操作,插件负责在容器网络命名空间中创建网络接口,分配IP地址,并设置路由。 |
DEL |
NETNS, IFNAME, CONTAINERID, ARGS |
从网络中删除容器。插件负责清理在 ADD 操作中创建的资源,如删除网络接口、释放IP地址。 |
CHECK |
NETNS, IFNAME, CONTAINERID, ARGS |
检查容器网络接口的健康状态。插件应验证其在 ADD 操作中配置的网络状态是否仍处于期望状态。 |
VERSION |
无 | 返回插件支持的 CNI 规范版本。 |
GC |
无 | 执行垃圾回收,清理可能残留的资源。这个操作通常由编排器调用,用于清理长期未被删除的资源。 |
Kubelet 在创建 Pod 时,会为 Pod 中的每个容器(或更准确地说,是 Pod 的 Infra 容器)调用 CNI 插件的 ADD 操作。当 Pod 终止时,Kubelet 会调用 DEL 操作。
2.2 网络配置 (netconf)
CNI 插件通过标准输入接收一个 JSON 格式的网络配置 (network configuration)。这个配置告诉插件如何设置网络。一个典型的 CNI 配置可能如下所示:
{
"cniVersion": "1.0.0",
"name": "my-bridge-net",
"type": "my-custom-cni",
"bridge": "cni0", // 我们的自定义插件可能需要的参数
"ipam": {
"type": "host-local",
"subnet": "10.244.0.0/24",
"routes": [
{ "dst": "0.0.0.0/0" }
]
},
"dns": {
"nameservers": ["8.8.8.8"]
}
}
关键字段解释:
cniVersion: 插件支持的 CNI 规范版本。name: 网络名称,通常与配置文件名相关。type: 插件的可执行文件名。当kubelet或其他容器运行时看到这个字段时,它会在CNI_PATH环境变量指定的目录中查找名为my-custom-cni的可执行文件。args: 可选字段,用于传递额外的键值对参数。ipam: IP 地址管理 (IPAM) 配置。这是一个嵌套的 JSON 对象,告诉插件如何获取 IP 地址。通常,IPAM 会委托给另一个专门的 IPAM 插件(如host-local)。- 其他字段:如
dns等,用于配置 DNS 解析。
2.3 IP 地址管理 (IPAM)
IPAM 是 CNI 的一个重要组成部分。它负责为容器分配和回收 IP 地址。CNI 规范允许 IPAM 作为独立的插件存在,这样网络插件就可以专注于数据平面,而将 IP 地址管理委托给 IPAM 插件。
当我们的自定义 CNI 插件接收到配置时,如果其中包含 ipam 字段,它就需要调用指定的 IPAM 插件来获取 IP 地址。IPAM 插件会返回一个包含 IP 地址、网关和路由信息的 JSON 结果。
2.4 CNI 插件的执行流程
- Kubelet 启动 Pod: Kubelet 收到创建 Pod 的指令。
- Kubelet 调用 CNI: Kubelet 会通过
containerd或CRI-O等容器运行时,为 Pod 的网络命名空间调用 CNI 插件。它会设置一系列环境变量(如CNI_COMMAND,CNI_CONTAINERID,CNI_NETNS,CNI_IFNAME,CNI_PATH等),并通过标准输入将网络配置 JSON 传递给插件。 - 插件执行: 我们的自定义 CNI 插件(例如
my-custom-cni)被执行。 - 解析配置: 插件解析标准输入中的 JSON 配置,以及环境变量。
- IPAM 分配: 如果配置中包含 IPAM 部分,插件会执行 IPAM 插件(例如
host-local)的ADD操作来获取 IP 地址。 - 网络接口配置: 插件利用 Linux 系统调用或 Go 语言的网络库,在宿主机和容器的网络命名空间中创建和配置网络接口(如 veth pair、网桥),将容器连接到网络。
- 返回结果: 插件将操作结果(包括分配的 IP 地址、路由等)以 JSON 格式通过标准输出返回给容器运行时。
- Kubelet 完成 Pod 启动: Kubelet 根据 CNI 插件的返回结果,完成 Pod 的网络配置,并继续启动 Pod 中的业务容器。
3. 构建基础环境与工具
在开始编码之前,我们需要准备好开发环境和必要的工具。
3.1 Go 语言环境
请确保你的系统已经安装了 Go 语言的最新稳定版本。你可以通过 go version 命令进行验证。
3.2 Linux 网络基础知识
我们的 CNI 插件将直接操作 Linux 内核的网络功能。熟悉以下概念至关重要:
- 网络命名空间 (Network Namespaces): Linux 内核提供的一种隔离机制,每个命名空间拥有自己独立的网络设备、IP 地址、路由表、ARP 表等。容器就是运行在独立的网络命名空间中。
- 虚拟以太网设备对 (Virtual Ethernet Pair / veth pair): 一种特殊的网络设备,总是成对出现。它们就像一根虚拟的网线,一端的数据会从另一端流出。veth pair 经常用于连接不同的网络命名空间,或将一个命名空间连接到主机网络设备(如网桥)。
- 网桥 (Bridge): 作用类似于物理交换机,它连接多个网络设备(物理网卡、veth pair 等),使得连接到网桥的设备可以在二层进行通信。
- IP 地址、子网、路由: 网络通信的基础概念。为接口分配 IP 地址,通过路由表决定数据包的转发路径。
ip命令: Linux 系统上强大的网络配置工具,例如ip link,ip addr,ip route,ip netns。
3.3 CNI Go 语言库
CNI 项目本身提供了一些 Go 语言库,它们极大地简化了 CNI 插件的开发:
github.com/containernetworking/cni/pkg/skel: 提供了 CNI 插件的主函数骨架,负责解析命令行参数、环境变量,并根据CNI_COMMAND调用对应的ADD,DEL,CHECK函数。github.com/containernetworking/cni/pkg/types: 定义了 CNI 规范中使用的各种数据结构,例如网络配置、结果对象等。github.com/containernetworking/cni/pkg/ipam: 提供了执行 IPAM 插件的函数,方便我们的插件委托 IP 地址分配。github.com/containernetworking/plugins/pkg/ns: 提供了操作网络命名空间的辅助函数。github.com/containernetworking/plugins/pkg/ip: 提供了 IP 地址和路由相关的实用函数。github.com/containernetworking/plugins/pkg/utils/sysctl: 操作 sysctl 参数。github.com/vishvananda/netlink: (非 CNI 官方库,但被 CNI 插件广泛使用) 一个强大的 Go 语言库,用于与 Linux 内核的netlink接口交互,进行网络设备的创建、配置、删除等操作。这是我们进行底层网络操作的核心工具。
我们将使用这些库来构建我们的插件。
4. 设计我们的自定义 CNI 插件
为了演示目的,我们将实现一个名为 my-custom-cni 的插件。它将执行以下操作:
- 创建网桥: 在主机上创建一个名为
mycni0的 Linux 网桥(如果它不存在)。 - 连接容器: 为每个容器创建一个
veth pair。veth pair的一端 (veth_host) 连接到mycni0网桥,另一端 (eth0) 移入容器的网络命名空间。 - IP 地址分配: 委托给标准的
host-localIPAM 插件来为容器分配 IP 地址。 - 路由配置: 在容器内部配置默认路由,使其可以通过网桥访问外部网络。
网络拓扑示意图:
+----------------------------------------------------------------------------------+
| Host (Root Network Namespace) |
| |
| +---------------------------------+ +------------------------------+ |
| | mycni0 (Bridge) | | Physical Interface | |
| | IP: 10.244.0.1/24 (Gateway) | | e.g., eth0 | |
| +---------------------------------+ +------------------------------+ |
| | | | |
| | | | |
| +-------+---------+-------+-------+ |
| | veth_host1 veth_host2 ... |
| | |
| +--------------------------------------------------------------------------+
| | |
| | |
| +-------+-------+ +-------+-------+
| | Container 1 | | Container 2 |
| | (Net Namespace 1) | | (Net Namespace 2) |
| | | | |
| | eth0: 10.244.0.2/24 | | eth0: 10.244.0.3/24 |
| | (veth_container1) | | (veth_container2) |
| +---------------------+ +---------------------+
5. Go 语言实现:CNI 插件骨架
首先,我们来搭建插件的基本框架。
创建一个新的 Go 模块:
mkdir my-custom-cni
cd my-custom-cni
go mod init my-custom-cni
在 main.go 文件中,我们将使用 skel.PluginMain 作为入口点。
package main
import (
"encoding/json"
"fmt"
"runtime"
"github.com/containernetworking/cni/pkg/skel"
"github.com/containernetworking/cni/pkg/types"
current "github.com/containernetworking/cni/pkg/types/100" // CNI 1.0.0 版本的类型
"github.com/containernetworking/cni/pkg/version"
"github.com/containernetworking/plugins/pkg/ip"
"github.com/containernetworking/plugins/pkg/ipam"
"github.com/containernetworking/plugins/pkg/ns"
"github.com/vishvananda/netlink"
// 导入日志库,方便调试
"github.com/sirupsen/logrus"
)
// PluginConf 是我们自定义 CNI 插件的配置结构体
// 它嵌入了 types.NetConf,并增加了我们自己的字段
type PluginConf struct {
types.NetConf
Bridge string `json:"bridge"` // 定义网桥名称
IsGW bool `json:"isGateway"` // 是否是网关 (可选,简化为 true)
IPMasq bool `json:"ipMasq"` // 是否开启 IP masquerade (可选)
// IPAM 字段从 types.NetConf 继承,不需要单独定义
}
func main() {
// 确保 Go 运行时只使用一个 OS 线程。
// 这是 CNI 插件的最佳实践,避免在网络命名空间切换时出现意外情况。
runtime.LockOSThread()
// 配置 Logrus
logrus.SetLevel(logrus.DebugLevel)
logrus.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
})
// skel.PluginMain 是 CNI 插件的入口点,它会根据 CNI_COMMAND 环境变量
// 调用相应的 cmdAdd, cmdCheck, cmdDel 函数。
skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.PluginSupports("1.0.0"))
}
// parseConfig 解析 CNI 插件的配置
func parseConfig(stdinData []byte, args string) (*PluginConf, string, error) {
conf := &PluginConf{}
if err := json.Unmarshal(stdinData, conf); err != nil {
return nil, "", fmt.Errorf("failed to parse network configuration: %v", err)
}
// 检查 CNI 规范版本
if conf.CNIVersion == "" {
conf.CNIVersion = "0.3.0" // 默认版本
}
// 确保网桥名称已定义
if conf.Bridge == "" {
conf.Bridge = "cni0" // 默认网桥名称
}
return conf, args, nil
}
// cmdAdd 是 CNI 插件的 ADD 操作实现
func cmdAdd(args *skel.CmdArgs) error {
logrus.Debugf("cmdAdd called with args: %+v", *args)
conf, _, err := parseConfig(args.StdinData, args.Args)
if err != nil {
return err
}
// 委托 IPAM 插件获取 IP 地址
// 注意:这里我们使用 cni/pkg/ipam.ExecAdd 调用 IPAM 插件。
// IPAM 插件的配置在 PluginConf.IPAM 字段中。
ipamResult, err := ipam.ExecAdd(conf.IPAM.Type, args.StdinData)
if err != nil {
return fmt.Errorf("failed to exec IPAM plugin %q: %v", conf.IPAM.Type, err)
}
// 将 IPAM 结果转换为 CNI 1.0.0 版本的类型
ipamResultV1, err := current.NewResultFromResult(ipamResult)
if err != nil {
return fmt.Errorf("failed to convert IPAM result to CNI 1.0.0: %v", err)
}
// 检查是否有 IP 地址分配
if len(ipamResultV1.IPs) == 0 {
return fmt.Errorf("IPAM plugin %q returned no IP addresses", conf.IPAM.Type)
}
// 获取容器的网络命名空间
netns, err := ns.GetNS(args.Netns)
if err != nil {
return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
}
defer netns.Close()
// 核心逻辑:在主机和容器中配置网络接口
// (这部分将在后续详细实现)
result := ¤t.Result{
CNIVersion: current.ImplementedSpecVersion,
IPs: ipamResultV1.IPs,
Routes: ipamResultV1.Routes,
DNS: ipamResultV1.DNS,
}
// 将结果通过标准输出返回给容器运行时
return types.PrintResult(result, conf.CNIVersion)
}
// cmdDel 是 CNI 插件的 DEL 操作实现
func cmdDel(args *skel.CmdArgs) error {
logrus.Debugf("cmdDel called with args: %+v", *args)
conf, _, err := parseConfig(args.StdinData, args.Args)
if err != nil {
return err
}
// 委托 IPAM 插件释放 IP 地址
err = ipam.ExecDel(conf.IPAM.Type, args.StdinData)
if err != nil {
logrus.Errorf("failed to exec IPAM plugin %q DEL: %v", conf.IPAM.Type, err)
// 即使 IPAM DEL 失败,我们仍尝试清理数据平面资源
}
// 获取容器的网络命名空间 (可能已经不存在了)
if args.Netns == "" {
return nil // 如果 netns 不存在,则无需清理
}
netns, err := ns.GetNS(args.Netns)
if err != nil {
// 如果 netns 已经不存在,可能已经被删除了,直接返回 nil
// 否则,如果是其他错误,就返回错误
if _, ok := err.(ns.NSPathNotExistErr); ok {
return nil
}
return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
}
defer netns.Close()
// 清理容器网络接口 (如果容器网络命名空间仍然存在)
// (这部分将在后续详细实现)
return nil
}
// cmdCheck 是 CNI 插件的 CHECK 操作实现
func cmdCheck(args *skel.CmdArgs) error {
logrus.Debugf("cmdCheck called with args: %+v", *args)
conf, _, err := parseConfig(args.StdinData, args.Args)
if err != nil {
return err
}
// 委托 IPAM 插件检查 IP 地址状态
err = ipam.ExecCheck(conf.IPAM.Type, args.StdinData)
if err != nil {
return fmt.Errorf("failed to exec IPAM plugin %q CHECK: %v", conf.IPAM.Type, err)
}
// 获取容器的网络命名空间
netns, err := ns.GetNS(args.Netns)
if err != nil {
return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
}
defer netns.Close()
// 检查容器网络接口和主机网桥的状态
// (这部分将在后续详细实现)
return nil
}
代码解释:
runtime.LockOSThread(): 这是 CNI 插件开发的最佳实践。Go 运行时默认会在多个 OS 线程之间调度 Goroutine。但在操作网络命名空间时,我们需要确保当前 Goroutine 始终在同一个 OS 线程上运行,以避免在网络命名空间切换时出现不一致。PluginConf: 定义了我们插件的配置结构体。它嵌入了types.NetConf,这样我们就可以直接访问 CNI 规范中的通用字段,如CNIVersion,Name,IPAM等。我们添加了Bridge,IsGW,IPMasq字段来控制自定义网络的行为。parseConfig: 一个辅助函数,用于解析从标准输入传入的 JSON 配置。skel.PluginMain: 这是 CNI 插件的入口点。它会处理 CNI 环境变量和标准输入,然后根据CNI_COMMAND的值调用cmdAdd,cmdCheck或cmdDel函数。cmdAdd,cmdDel,cmdCheck: 这些是核心的 CNI 操作函数。目前,它们只解析配置并委托 IPAM 插件进行 IP 地址管理。数据平面的配置逻辑将在下一节实现。ipam.ExecAdd/ExecDel/ExecCheck: 这些函数用于调用其他 CNI 插件(特别是 IPAM 插件)。它们会设置必要的环境变量,执行目标插件,并解析其输出。current.NewResultFromResult: 将 IPAM 插件返回的通用types.Result转换为 CNI 1.0.0 规范的current.Result,方便我们处理。ns.GetNS: 获取容器的网络命名空间句柄,以便我们可以在其中执行操作。types.PrintResult: 将最终的 CNI 结果以 JSON 格式打印到标准输出,容器运行时会读取这个输出。
6. 深入实现:cmdAdd 的核心逻辑
现在,我们将把 cmdAdd 函数中的占位符替换为实际的网络配置逻辑。这包括创建网桥、veth pair,并将它们连接起来,最后配置容器内的网络。
// ... (之前的 import 和 PluginConf 定义)
// setupBridge 负责创建或获取网桥,并配置其 IP 地址
func setupBridge(brName string, gwIP *net.IPNet, mtu int, ipMasq bool) (*netlink.Bridge, error) {
br, err := netlink.LinkByName(brName)
if err != nil {
// 如果网桥不存在,则创建它
if _, notFound := err.(netlink.LinkNotFoundError); notFound {
br = &netlink.Bridge{
LinkAttrs: netlink.LinkAttrs{
Name: brName,
MTU: mtu,
},
}
if err = netlink.LinkAdd(br); err != nil {
return nil, fmt.Errorf("failed to create bridge %q: %v", brName, err)
}
logrus.Debugf("Bridge %q created.", brName)
} else {
return nil, fmt.Errorf("failed to get bridge %q: %v", brName, err)
}
}
// 确保是网桥类型
if _, isBridge := br.(*netlink.Bridge); !isBridge {
return nil, fmt.Errorf("device %q already exists but is not a bridge", brName)
}
// 设置网桥为 UP 状态
if err = netlink.LinkSetUp(br); err != nil {
return nil, fmt.Errorf("failed to set bridge %q up: %v", brName, err)
}
// 为网桥配置 IP 地址 (作为网关)
addr := &netlink.Addr{IPNet: gwIP, Label: ""}
if err = netlink.AddrAdd(br, addr); err != nil {
// 检查是否是因为地址已存在而报错,如果是则忽略
if !strings.Contains(err.Error(), "file exists") {
return nil, fmt.Errorf("failed to add IP %v to bridge %q: %v", gwIP, brName, err)
}
}
// 启用 IP 转发
if err = ip.EnableIP4Forward(); err != nil {
return nil, fmt.Errorf("failed to enable IP forwarding: %v", err)
}
// 如果需要,开启 IP masquerade
if ipMasq {
// 这里简化处理,实际生产环境需要更精细的 iptables 规则管理
// 可以使用 `iptables-legacy` 或 `nftables`
// 例如:iptables -t nat -A POSTROUTING -s <subnet> ! -d <subnet> -j MASQUERADE
logrus.Warnf("IPMasq is enabled, but not fully implemented in this example. Manual iptables setup may be required.")
}
return br.(*netlink.Bridge), nil
}
// cmdAdd 是 CNI 插件的 ADD 操作实现
func cmdAdd(args *skel.CmdArgs) error {
logrus.Debugf("cmdAdd called with args: %+v", *args)
conf, _, err := parseConfig(args.StdinData, args.Args)
if err != nil {
return err
}
// 委托 IPAM 插件获取 IP 地址
ipamResult, err := ipam.ExecAdd(conf.IPAM.Type, args.StdinData)
if err != nil {
return fmt.Errorf("failed to exec IPAM plugin %q: %v", conf.IPAM.Type, err)
}
ipamResultV1, err := current.NewResultFromResult(ipamResult)
if err != nil {
return fmt.Errorf("failed to convert IPAM result to CNI 1.0.0: %v", err)
}
if len(ipamResultV1.IPs) == 0 {
return fmt.Errorf("IPAM plugin %q returned no IP addresses", conf.IPAM.Type)
}
// 假设我们只处理 IPv4
var containerIP *net.IPNet
var gatewayIP *net.IPNet
for _, ipc := range ipamResultV1.IPs {
if ipc.Version == "4" && ipc.Address.IP.To4() != nil {
containerIP = &ipc.Address
gatewayIP = ipc.Gateway
break
}
}
if containerIP == nil {
return fmt.Errorf("IPAM plugin did not return an IPv4 address")
}
if gatewayIP == nil {
// 如果 IPAM 没有返回网关,则使用 subnet 的第一个可用 IP 作为网关
// 这是一个简化,实际情况 IPAM 应该提供网关
gatewayIP = &net.IPNet{
IP: ipamResultV1.IPs[0].Address.IP.Mask(ipamResultV1.IPs[0].Address.Mask),
Mask: ipamResultV1.IPs[0].Address.Mask,
}
// 增加1,通常是 .1
gatewayIP.IP = ip.NextIP(gatewayIP.IP)
}
// 设置主机网桥
// 注意:这里我们假设网桥的 MTU 和容器接口的 MTU 相同,通常是 1500
mtu := 1500
br, err := setupBridge(conf.Bridge, gatewayIP, mtu, conf.IPMasq)
if err != nil {
return err
}
// 获取容器的网络命名空间
netns, err := ns.GetNS(args.Netns)
if err != nil {
return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
}
defer netns.Close()
// 在容器的网络命名空间中配置网络
var contVeth netlink.Link
err = netns.Do(func(_ ns.NetNS) error {
// 创建 veth pair: 一端在容器内 (args.IfName), 另一端在主机上 (hostVethName)
hostVethName := fmt.Sprintf("%s%s", args.IfName, args.ContainerID[:5]) // 避免名称冲突
veth := &netlink.Veth{
LinkAttrs: netlink.LinkAttrs{
Name: args.IfName, // 容器内的接口名称
Flags: net.FlagUp,
MTU: mtu,
},
PeerName: hostVethName, // 主机上的接口名称
}
if err = netlink.LinkAdd(veth); err != nil {
return fmt.Errorf("failed to create veth pair: %v", err)
}
logrus.Debugf("Veth pair %q <-> %q created.", args.IfName, hostVethName)
contVeth, err = netlink.LinkByName(args.IfName)
if err != nil {
return fmt.Errorf("failed to find container veth %q: %v", args.IfName, err)
}
// 为容器内的 veth 接口配置 IP 地址
addr := &netlink.Addr{IPNet: containerIP, Label: ""}
if err = netlink.AddrAdd(contVeth, addr); err != nil {
return fmt.Errorf("failed to add IP %v to container veth %q: %v", containerIP, args.IfName, err)
}
logrus.Debugf("IP %v added to container interface %q.", containerIP, args.IfName)
// 启用容器内的 veth 接口
if err = netlink.LinkSetUp(contVeth); err != nil {
return fmt.Errorf("failed to set container veth %q up: %v", args.IfName, err)
}
// 设置容器内部的默认路由,指向网桥的 IP 作为网关
defaultRoute := &netlink.Route{
Scope: netlink.RT_SCOPE_UNIVERSE,
Dst: nil, // 默认路由 (0.0.0.0/0)
Gw: gatewayIP.IP,
LinkIndex: contVeth.Attrs().Index,
}
if err = netlink.RouteAdd(defaultRoute); err != nil {
return fmt.Errorf("failed to add default route in container: %v", err)
}
logrus.Debugf("Default route to %v added in container.", gatewayIP.IP)
// 设置环回接口 (lo)
lo, err := netlink.LinkByName("lo")
if err != nil {
return fmt.Errorf("failed to find lo interface: %v", err)
}
if err = netlink.LinkSetUp(lo); err != nil {
return fmt.Errorf("failed to set lo up: %v", err)
}
return nil
})
if err != nil {
return err
}
// 将 veth pair 的主机端连接到网桥
hostVeth, err := netlink.LinkByName(contVeth.Attrs().PeerName) // 获取主机端的 veth 接口
if err != nil {
return fmt.Errorf("failed to find host veth %q: %v", contVeth.Attrs().PeerName, err)
}
if err = netlink.LinkSetMaster(hostVeth, br); err != nil {
return fmt.Errorf("failed to attach host veth %q to bridge %q: %v", hostVeth.Attrs().Name, br.Attrs().Name, err)
}
logrus.Debugf("Host veth %q attached to bridge %q.", hostVeth.Attrs().Name, br.Attrs().Name)
if err = netlink.LinkSetUp(hostVeth); err != nil {
return fmt.Errorf("failed to set host veth %q up: %v", hostVeth.Attrs().Name, err)
}
logrus.Debugf("Host veth %q set up.", hostVeth.Attrs().Name)
// 准备 CNI 结果
result := ¤t.Result{
CNIVersion: current.ImplementedSpecVersion,
IPs: ipamResultV1.IPs,
Routes: ipamResultV1.Routes,
DNS: ipamResultV1.DNS,
Interfaces: []*current.Interface{
{
Name: br.Attrs().Name,
Mac: br.Attrs().HardwareAddr.String(),
Sandbox: "", // 网桥在主机网络命名空间
},
{
Name: hostVeth.Attrs().Name,
Mac: hostVeth.Attrs().HardwareAddr.String(),
Sandbox: "",
},
{
Name: args.IfName,
Mac: contVeth.Attrs().HardwareAddr.String(),
Sandbox: args.Netns,
},
},
}
logrus.Debugf("cmdAdd finished successfully.")
return types.PrintResult(result, conf.CNIVersion)
}
cmdAdd 核心逻辑解释:
-
IPAM 执行与结果解析:
- 首先,调用
ipam.ExecAdd执行配置中指定的 IPAM 插件(例如host-local),获取分配给容器的 IP 地址、网关和路由信息。 - 将结果转换为
current.Result类型,方便后续使用。 - 从
ipamResultV1.IPs中提取容器的 IPv4 地址和网关 IP。如果 IPAM 未提供网关,我们简化处理,将子网的第一个可用 IP 作为网关。
- 首先,调用
-
setupBridge函数:- 这是一个辅助函数,用于创建或获取主机上的 Linux 网桥。
- 它首先尝试通过
netlink.LinkByName查找网桥。如果不存在,则使用netlink.Bridge结构体和netlink.LinkAdd创建一个新的网桥。 - 将网桥设置为
UP状态 (netlink.LinkSetUp)。 - 为网桥接口配置 IP 地址 (
netlink.AddrAdd)。这个 IP 将作为容器的默认网关。 - 启用 IP 转发 (
ip.EnableIP4Forward()),这是实现容器与外部网络通信的关键。 - IP masquerade (
ipMasq): 这是网络地址转换 (NAT) 的一种形式,允许容器通过主机的 IP 地址访问外部网络。在实际场景中,这通常通过iptables规则实现。为了简化,这里只做了一个警告,实际需要手动添加或使用iptables库。
-
网络命名空间操作:
ns.GetNS(args.Netns)获取容器的网络命名空间句柄。netns.Do(func(_ ns.NetNS) error { ... })是一个非常关键的函数。它允许我们在容器的网络命名空间中执行一段代码。所有的netlink操作和网络配置都将作用于该命名空间。
-
创建和配置 Veth Pair:
- 在
netns.Do内部,我们使用netlink.Veth结构体和netlink.LinkAdd创建一个veth pair。 veth.Name是容器内部的接口名称(由args.IfName指定,通常是eth0)。veth.PeerName是主机上的接口名称,我们为其生成一个唯一名称 (eth0+容器ID的前5位)。- 为容器内的
veth接口配置 IP 地址 (netlink.AddrAdd),并将其设置为UP状态 (netlink.LinkSetUp)。 - 配置容器内部路由: 添加一个默认路由 (
netlink.Route),将所有未匹配的数据包发送到网桥的 IP 地址作为网关。 - 确保容器的
lo(环回) 接口也处于UP状态。
- 在
-
连接 Veth Pair 到网桥:
- 回到主机的网络命名空间,获取
veth pair的主机端接口。 - 使用
netlink.LinkSetMaster(hostVeth, br)将主机端的veth接口连接到之前创建的网桥上。 - 将主机端的
veth接口设置为UP状态。
- 回到主机的网络命名空间,获取
-
返回 CNI 结果:
- 构建
current.Result对象,包含 CNI 版本、分配的 IP 地址、路由和接口信息。 types.PrintResult将结果以 JSON 格式输出。
- 构建
7. 实现 cmdDel 和 cmdCheck
cmdDel 负责清理 cmdAdd 创建的所有资源。cmdCheck 负责验证网络的健康状态。
// ... (之前的 import, PluginConf, setupBridge, cmdAdd)
// cmdDel 是 CNI 插件的 DEL 操作实现
func cmdDel(args *skel.CmdArgs) error {
logrus.Debugf("cmdDel called with args: %+v", *args)
conf, _, err := parseConfig(args.StdinData, args.Args)
if err != nil {
return err
}
// 委托 IPAM 插件释放 IP 地址
err = ipam.ExecDel(conf.IPAM.Type, args.StdinData)
if err != nil {
logrus.Errorf("failed to exec IPAM plugin %q DEL: %v", conf.IPAM.Type, err)
// 即使 IPAM DEL 失败,我们仍尝试清理数据平面资源
}
// 获取容器的网络命名空间 (可能已经不存在了)
if args.Netns == "" {
logrus.Debug("Netns path is empty, nothing to delete in container.")
return nil // 如果 netns 路径为空,则无需清理
}
netns, err := ns.GetNS(args.Netns)
if err != nil {
// 如果 netns 已经不存在,可能是容器已经销毁,直接返回 nil
// 否则,如果是其他错误,就返回错误
if _, ok := err.(ns.NSPathNotExistErr); ok {
logrus.Debugf("Netns %q does not exist, nothing to delete.", args.Netns)
return nil
}
return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
}
defer netns.Close()
// 尝试在容器网络命名空间中删除接口
err = netns.Do(func(_ ns.NetNS) error {
if err = ip.DelLinkByName(args.IfName); err != nil {
// 如果接口不存在,也认为是清理成功
if _, ok := err.(netlink.LinkNotFoundError); ok {
logrus.Debugf("Container interface %q not found, already deleted.", args.IfName)
return nil
}
return fmt.Errorf("failed to delete container interface %q: %v", args.IfName, err)
}
logrus.Debugf("Container interface %q deleted.", args.IfName)
return nil
})
if err != nil {
return err
}
// 清理主机端的 veth 接口
// 我们需要知道主机端 veth 的名称。在 cmdAdd 中,我们用 args.IfName + args.ContainerID[:5]
// 作为主机端 veth 的名称。
hostVethName := fmt.Sprintf("%s%s", args.IfName, args.ContainerID[:5])
if err = ip.DelLinkByName(hostVethName); err != nil {
if _, ok := err.(netlink.LinkNotFoundError); ok {
logrus.Debugf("Host veth %q not found, already deleted.", hostVethName)
return nil
}
return fmt.Errorf("failed to delete host veth %q: %v", hostVethName, err)
}
logrus.Debugf("Host veth %q deleted.", hostVethName)
// 注意:我们不会在这里删除网桥,因为它可能是被多个容器共享的。
// 网桥的生命周期通常由集群管理员或特定的组件管理。
logrus.Debugf("cmdDel finished successfully.")
return nil
}
// cmdCheck 是 CNI 插件的 CHECK 操作实现
func cmdCheck(args *skel.CmdArgs) error {
logrus.Debugf("cmdCheck called with args: %+v", *args)
conf, _, err := parseConfig(args.StdinData, args.Args)
if err != nil {
return err
}
// 委托 IPAM 插件检查 IP 地址状态
err = ipam.ExecCheck(conf.IPAM.Type, args.StdinData)
if err != nil {
return fmt.Errorf("failed to exec IPAM plugin %q CHECK: %v", conf.IPAM.Type, err)
}
// 获取容器的网络命名空间
netns, err := ns.GetNS(args.Netns)
if err != nil {
return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
}
defer netns.Close()
// 检查容器内部接口是否存在且配置正确
var contIPNet *net.IPNet
err = netns.Do(func(_ ns.NetNS) error {
link, err := netlink.LinkByName(args.IfName)
if err != nil {
return fmt.Errorf("container interface %q not found in netns %q: %v", args.IfName, args.Netns, err)
}
if link.Attrs().Flags&net.FlagUp == 0 {
return fmt.Errorf("container interface %q is not up", args.IfName)
}
addrs, err := netlink.AddrList(link, netlink.FAMILY_V4)
if err != nil || len(addrs) == 0 {
return fmt.Errorf("no IPv4 address found for container interface %q", args.IfName)
}
contIPNet = addrs[0].IPNet // 假设第一个就是我们想要的
// 检查默认路由
routes, err := netlink.RouteList(link, netlink.FAMILY_V4)
if err != nil || len(routes) == 0 {
return fmt.Errorf("no IPv4 routes found for container interface %q", args.IfName)
}
foundDefaultRoute := false
for _, route := range routes {
if route.Dst == nil { // 0.0.0.0/0
foundDefaultRoute = true
break
}
}
if !foundDefaultRoute {
return fmt.Errorf("no default route found for container interface %q", args.IfName)
}
return nil
})
if err != nil {
return err
}
// 检查主机端 veth 接口是否存在且已连接到网桥
hostVethName := fmt.Sprintf("%s%s", args.IfName, args.ContainerID[:5])
hostVeth, err := netlink.LinkByName(hostVethName)
if err != nil {
return fmt.Errorf("host veth %q not found: %v", hostVethName, err)
}
if hostVeth.Attrs().Flags&net.FlagUp == 0 {
return fmt.Errorf("host veth %q is not up", hostVethName)
}
if hostVeth.Attrs().MasterIndex == 0 {
return fmt.Errorf("host veth %q is not attached to a bridge", hostVethName)
}
// 检查网桥是否存在且配置正确
br, err := netlink.LinkByName(conf.Bridge)
if err != nil {
return fmt.Errorf("bridge %q not found: %v", conf.Bridge, err)
}
if br.Attrs().Flags&net.FlagUp == 0 {
return fmt.Errorf("bridge %q is not up", conf.Bridge)
}
brAddrs, err := netlink.AddrList(br, netlink.FAMILY_V4)
if err != nil || len(brAddrs) == 0 {
return fmt.Errorf("no IPv4 address found for bridge %q", conf.Bridge)
}
// 验证容器 IP 和网桥 IP 是否在同一子网
foundMatchingSubnet := false
for _, brAddr := range brAddrs {
if brAddr.IPNet.Contains(contIPNet.IP) && brAddr.IPNet.Mask.String() == contIPNet.Mask.String() {
foundMatchingSubnet = true
break
}
}
if !foundMatchingSubnet {
return fmt.Errorf("container IP %v is not in the same subnet as bridge %q IPs", contIPNet, conf.Bridge)
}
logrus.Debugf("cmdCheck finished successfully, network state is healthy.")
return nil
}
cmdDel 解释:
- IPAM 清理: 首先调用
ipam.ExecDel释放 IP 地址。即使 IPAM 插件清理失败,我们也会尝试继续清理数据平面资源,确保容器网络接口被移除。 - 网络命名空间检查: 检查
args.Netns是否存在。如果不存在,则表示容器已经销毁,无需进一步操作。 - 容器内部接口清理: 在容器的网络命名空间内,使用
ip.DelLinkByName(args.IfName)删除容器内的veth接口。 - 主机端 Veth 清理: 在主机网络命名空间中,删除
veth pair的主机端接口。我们通过之前生成的命名规则(args.IfName+args.ContainerID[:5])来找到它。 - 网桥不删除: 考虑到网桥可能被多个容器共享,
cmdDel通常不会删除网桥本身。网桥的生命周期由更高级别的组件(如 Kubernetes)管理,或者在所有容器都从该网桥断开后由其他机制进行清理。
cmdCheck 解释:
- IPAM 检查: 调用
ipam.ExecCheck验证 IP 地址的分配状态。 - 容器内部检查:
- 进入容器的网络命名空间。
- 检查容器接口 (
args.IfName) 是否存在,是否处于UP状态。 - 检查容器接口是否分配了 IP 地址,并确保存在默认路由。
- 主机端 Veth 检查:
- 检查主机端的
veth接口是否存在,是否处于UP状态。 - 验证
hostVeth是否已连接到网桥(通过Attrs().MasterIndex != 0判断)。
- 检查主机端的
- 网桥检查:
- 检查网桥是否存在,是否处于
UP状态,是否配置了 IP 地址。 - 最后,验证容器的 IP 地址是否与网桥的 IP 地址在同一个子网内,这是确保二层通信的基础。
- 检查网桥是否存在,是否处于
8. 编译、部署与测试
现在我们已经完成了 CNI 插件的编写,接下来是编译、部署和测试。
8.1 编译插件
cd my-custom-cni
go mod tidy # 确保所有依赖都已下载
GOOS=linux GOARCH=amd64 go build -o my-custom-cni .
这将生成一个名为 my-custom-cni 的可执行文件。GOOS=linux GOARCH=amd64 是交叉编译参数,确保在 Linux 环境下运行。
8.2 部署插件
CNI 插件通常部署在主机的特定目录中,kubelet 或其他容器运行时会在这些目录中查找插件。
默认的 CNI 插件路径是 /opt/cni/bin。
sudo mkdir -p /opt/cni/bin
sudo cp my-custom-cni /opt/cni/bin/
我们还需要 host-local IPAM 插件,因为它被我们的插件所依赖。你可以从 CNI 官方插件仓库编译或下载:
# 下载 CNI 官方插件
wget https://github.com/containernetworking/plugins/releases/download/v1.2.0/cni-plugins-linux-amd64-v1.2.0.tgz
sudo tar -xzf cni-plugins-linux-amd64-v1.2.0.tgz -C /opt/cni/bin ./host-local
确保 host-local 可执行文件也在 /opt/cni/bin 目录下。
8.3 配置 CNI 网络
CNI 插件通过配置文件来描述网络的拓扑和行为。这些配置文件通常存放在 /etc/cni/net.d/ 目录下。
创建一个配置文件 /etc/cni/net.d/10-my-custom-cni.conf:
{
"cniVersion": "1.0.0",
"name": "my-bridge-net",
"type": "my-custom-cni",
"bridge": "mycni0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"subnet": "10.244.0.0/24",
"routes": [
{ "dst": "0.0.0.0/0" }
]
}
}
这里的 type 字段必须与我们插件的可执行文件名 (my-custom-cni) 匹配。bridge 字段是我们自定义的网桥名称。ipam 字段指定使用 host-local 插件来分配 10.244.0.0/24 子网的 IP 地址。
8.4 手动测试插件
我们可以使用 ip netns 命令模拟容器的网络命名空间,并手动调用 CNI 插件进行测试。
-
创建网络命名空间:
sudo ip netns add mycontainer -
设置 CNI 环境变量:
export CNI_COMMAND=ADD export CNI_CONTAINERID=mycontainer123 export CNI_NETNS=/var/run/netns/mycontainer export CNI_IFNAME=eth0 export CNI_PATH=/opt/cni/bin -
通过标准输入传递配置并执行插件:
sudo cat /etc/cni/net.d/10-my-custom-cni.conf | /opt/cni/bin/my-custom-cni如果一切顺利,你会看到一个 JSON 格式的输出,其中包含分配的 IP 地址和网络接口信息。
-
验证网络状态:
- 主机上:
ip a show mycni0 ip link show | grep veth # 应该能看到主机端的 veth 接口 - 容器命名空间内:
sudo ip netns exec mycontainer ip a show eth0 sudo ip netns exec mycontainer ip route sudo ip netns exec mycontainer ping -c 1 8.8.8.8 # 测试网络连通性
- 主机上:
-
执行
DEL操作进行清理:export CNI_COMMAND=DEL sudo cat /etc/cni/net.d/10-my-custom-cni.conf | /opt/cni/bin/my-custom-cni验证
mycontainer命名空间中的eth0和主机上的veth接口是否已被删除。 -
删除网络命名空间:
sudo ip netns del mycontainer
8.5 与 Kubernetes 集成
要在 Kubernetes 中使用你的自定义 CNI 插件,你需要确保:
- 插件和配置文件就位: 你的
my-custom-cni可执行文件和host-local可执行文件必须存在于所有 Kubernetes 节点的/opt/cni/bin目录中。 - CNI 配置文件就位:
/etc/cni/net.d/10-my-custom-cni.conf必须存在于所有 Kubernetes 节点上。 - Kubelet 配置: 确保 Kubelet 配置中没有指定其他 CNI 插件,或者你的插件是唯一的 CNI 配置文件。Kubelet 会按字母顺序加载
/etc/cni/net.d/目录下的 CNI 配置文件。通常,我们只放置一个配置文件。
部署一个简单的 Pod 进行测试:
# test-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: my-test-pod
spec:
containers:
- name: alpine
image: alpine/git
command: ["sleep", "3600"]
# 如果你的主机上有多个 CNI 配置文件,你可能需要指定 networkAnnotations
# annotations:
# k8s.v1.cni.cncf.io/networks: my-bridge-net
部署后,检查 Pod 的 IP 地址 (kubectl get pod my-test-pod -o wide),并进入 Pod 内部 (kubectl exec -it my-test-pod -- sh) 验证网络配置和连通性。
9. 高级话题与优化
我们实现的 CNI 插件是一个基础版本,但 CNI 生态系统和 Linux 网络功能远不止于此。
9.1 CNI Chaining (插件链)
CNI 规范支持插件链。这意味着一个 CNI 插件可以调用另一个 CNI 插件来完成部分工作。例如,一个插件可以负责安全策略,然后将控制权传递给一个负责网络连接的插件。我们的插件已经使用了 IPAM 插件,这可以看作是一种简单的插件链。
9.2 日志与调试
在生产环境中,详细的日志和便捷的调试工具至关重要。
logrus: 我们在示例中使用了logrus,它提供了结构化日志功能,方便日志分析。- 环境变量
CNI_DEBUG: 有些 CNI 插件会根据此环境变量输出更多调试信息。 CNI_ARGS: 可以在配置文件中通过args字段传递自定义调试参数。strace: 跟踪插件的系统调用,了解其与内核的交互。
9.3 性能考量
对于大规模部署,性能是关键。
netlink效率:netlink是与内核交互的最高效方式之一。- MTU (Maximum Transmission Unit): 确保所有接口的 MTU 设置一致,避免分片和性能下降。
- IP masquerade 优化: 大量 Pod 的 NAT 会对主机 CPU 造成压力。考虑使用更高效的方案,如 IPVS (IP Virtual Server) 或更底层的 BPF/XDP。
9.4 安全性
容器网络安全是重中之重。
- 网络策略 (Network Policies): Kubernetes Network Policy 是通过 CNI 插件实现的,它定义了 Pod 之间以及 Pod 与外部网络之间的通信规则。一个生产级别的 CNI 插件需要支持网络策略。
- 隔离: 确保容器之间的网络隔离,防止未经授权的访问。
- 认证与授权: 在多租户环境中,可能需要更复杂的认证和授权机制。
9.5 IPv6 支持
我们的示例主要关注 IPv4。但现代网络环境通常需要 IPv6 支持。CNI 规范和 netlink 库都支持 IPv6,你可以扩展插件以处理 IPv6 地址和路由。
9.6 与其他网络技术结合
- Open vSwitch (OVS): 一个功能丰富的多层虚拟交换机,可以作为 CNI 插件的底层数据平面。
- eBPF/XDP (Extended Berkeley Packet Filter / eXpress Data Path): 允许在内核中注入和运行自定义程序,实现高性能的网络功能、安全策略和可观察性。许多现代 CNI 插件(如 Cilium)都大量使用了 eBPF。
- SR-IOV (Single Root I/O Virtualization): 为虚拟机或容器提供直接访问物理网卡的能力,实现接近裸金属的I/O性能。
这些高级话题超出了本次讲座的范围,但它们代表了自定义 CNI 插件的巨大潜力和发展方向。
展望未来
通过本次讲座,我们不仅学习了 CNI 的核心概念和工作原理,更亲手用 Go 语言实现了一个功能完备的自定义 CNI 插件。我们深入探讨了 Linux 网络命名空间、veth pair 和网桥等底层技术,并掌握了如何使用 netlink 库进行网络编程。
理解 CNI 不仅仅是为了编写插件,更是为了更好地理解和控制容器网络的行为。无论是进行故障排查、性能优化还是构建创新的网络解决方案,本次实践都为你打下了坚实的基础。容器网络的未来充满挑战与机遇,希望大家能将所学应用于实践,不断探索。