K8s 网络之从 0 实现一个 CNI 网络插件

引言

源码地址

gayhub

先导知识

在上一篇文章中:《K8s 网络之深入理解 CNI》 我们简单介绍了一下 k8s 的 CNI 网络,然后又简单地看了一下 k8s 源码中调用一个 CNI 插件的大概流程。最后结尾处,我们说过下一次要尝试自己实现一个 CNI 插件,今儿我们就来干这件事儿~


集群环境:

  1. 集群环境:ubuntu20.04 虚拟机
  2. k8s version:v1.23.0
  3. go version:go1.16 linux/amd64
  4. gcc version: 9.3.0(go test 需要使用 gcc)
  5. etcd version:3.2.26
  6. etcd API version:3.2

节点环境:

  1. 三台虚拟机节点
  2. 节点 ip:
    1. master:192.168.98.143
    2. node-1:192.168.98.144
    3. node-2:192.168.98.145
  3. 节点 hostname(可通过 hostnamectl 进行设置,改成别的也好,自己记住就行):
    1. master:ding-net-master
    2. node-1:ding-net-node-1
    3. node-2:ding-net-node-2
  4. 三台虚拟机需要可以互相 ping 通
  5. etcd 地址:https://192.168.98.143:2379

注:记得每台节点上都要把其他节点的 hostname 和 ip 添加到 /etc/hosts 中

注:集群可使用 kubeadm 快速搭建,搭建方法网上很多,这里不再赘述了


知识回顾

在开始之前我们先简单回顾一下上篇文章中的内容:

首先 kubelet 会启动 CRI Runtime,也就是 container runtime interface,这个简单来讲就是在 kubelet 端启动一个 grpc 的客户端,然后需要有个对应的 CRI Server 端,这里一般实现就是 containerd,也就是说 containerd 会启动一个 grpc 的服务端,接收来自 kubelet 的 “创建 Pod” 的请求。

在接到这个请求后,containerd 会调用“创建 Sandbox”的接口,所谓创建 sandbox 就是给 Pod 中的容器们提前启动一个具有稳定网络资源以及存储资源的隐藏 pause 容器。但是 containerd 属于“高级运行时”,真正去拉起容器的地方在 OCI 中,也叫“低级运行时”,一般实现可能有 runc 或者 kata 等,最常用的可能就是 runc。然后由于为了给 sandbox 创建网络资源,会先去 /etc/cni/net.d 目录下加载网络配置文件,然后根据配置文件中的 type 字段,去 /opt/cni/bin 目录下找对应的二进制插件,随后把容器的运行时信息作为环境变量,再把配置文件作为标准输出,对这个二进制进行调用。

二进制插件调用的过程主要就是要实现三个点:1. pod ip 的管理,2. 通主机 pod 间通信,3. 不同主机间 pod 通信。等以上三点都实现后,需要插件在标准输出上打印点东西,CRI 读取这些信息做后续的操作。

大概 CNI 就是如此,如果有不清晰的地方,大家可以移步上一篇文章中去看看再~


正文: 从 0 实现一个 CNI 网络插件

整体架构

在开始动工之前,我们可以先简单看一下其他网络插件是怎么实现的,比如下面这个:plugins/bridge.go

官方实现了一些基础的插件,比如 ipam,bridge 等,上面链接中的就是 bridge。我们可以简单看下 bridge 源码:

代码还蛮长的,不过我们可以只重点关注 main 方法,可以看到它里面调用了一个 skel.PluginMain 方法并把 cmdAdd 以及 cmdCheck 还有 cmdDel 等函数作为参数传进去了。

我们还可以再看个其他官方实现的插件,比如 bridge 统计目录下的 ipvlan:

可以看到它基本上的架子也是这么实现的,都是直接在 main 中调用了 skel.PluginMain 方法。

那么可见这个方法就是实现 cni 插件的一个关键,我们简单看下其源码,该方法源码在官方仓库的 pkg/skel 中:

该方法贼呼啦简单,主要逻辑就是做一下 Version 的校验,还记得在 /etc/cni/net.d 下的配置文件中有个 cniVersion 么,就是校验那个参数是否填对了的,具体有哪些版本可以看这里 cni version

然后做完 Version 的校验后,就会根据是 Add/Del/Check 哪个指令来走不同的分支以执行不同的函数,这些什么 cmdAdd 之类的方法就是在上面插件的 main 方法中传进来的。CRI 会在不同的时机去执行不同的指令,一般 kubelet 要创建 pod 的时候会先执行 Version 指令,Version 校验成功后会发出 Add 指令。等要删除 pod 的时候则会发出 Del 指令。

看完这些,我们大概就可以明白,想实现一个 CNI 插件的整体步骤,其实就是实现 cmdAdd 方法以及 cmdDel 还有 cmdCheck 方法(check 方法甚至可以不实现,直接 return 个 nil 就 ok)。

所以我们要做的第一件事儿就是按照这个套路去创建一套架子:

func cmdAdd(args *skel.CmdArgs) error {
	utils.WriteLog("进入到 cmdAdd")
	utils.WriteLog(
		"这里的 CmdArgs 是: ", "ContainerID: ", args.ContainerID,
		"Netns: ", args.Netns,
		"IfName: ", args.IfName,
		"Args: ", args.Args,
		"Path: ", args.Path,
		"StdinData: ", string(args.StdinData))

	return nil
}

func cmdDel(args *skel.CmdArgs) error {
	utils.WriteLog("进入到 cmdDel")
	utils.WriteLog(
		"这里的 CmdArgs 是: ", "ContainerID: ", args.ContainerID,
		"Netns: ", args.Netns,
		"IfName: ", args.IfName,
		"Args: ", args.Args,
		"Path: ", args.Path,
		"StdinData: ", string(args.StdinData))
	// 这里的 del 如果返回 error 的话, kubelet 就会尝试一直不停地执行 StopPodSandbox
	// 直到删除后的 error 返回 nil 未知
	// return errors.New("test cmdDel")
	return nil
}

func cmdCheck(args *skel.CmdArgs) error {
	utils.WriteLog("进入到 cmdCheck")
	utils.WriteLog(
		"这里的 CmdArgs 是: ", "ContainerID: ", args.ContainerID,
		"Netns: ", args.Netns,
		"IfName: ", args.IfName,
		"Args: ", args.Args,
		"Path: ", args.Path,
		"StdinData: ", string(args.StdinData))
	return nil
}

func main() {
	skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.All, bv.BuildString("testcni"))
}
复制代码

这里我们自己实现了一个 utils.WriteLog 方法,该方法非常简单,就是内置了一个日志输出地址,然后将参数的字符串输出到对应的地址中,以便我们查看 cni 插件的执行日志。

那为啥不能直接 fmt.Print 呢?一个是因为插件是直接由 k8s 在后台进行调用的,在前台不好查看(可通过 journalctl -xeu kubelet 查看),另一个原因就是在 cni 执行完毕后,要直接在便准输出上打印一些 json 信息,k8s 要读取这些信息,因此不能让日志信息乱了这些 json 信息的格式,否则 kubelet 的日志中一定会报类似 “非法字符串” 之类的错。

其实如果现在把这个架子 go build 一下,然后扔到 /opt/cni/bin 下也已经能跑了,就是不会有任何效果而已。

另外注意一点在 cmdDel 方法中一定要返回 nil,如果返回了什么类似 errors.New() 之类的,CRI 会一直给这个插件发送 Del 指令,直到 cmdDel 返回 nil。


获取必要的信息

在上面我们已经初始化了整体的一个 cni 插件的架子了,接下来我们需要获取一些必要的信息了。

我们之前说过,必要的信息一共有两部分组成,第一个是在 /etc/cni/net.d 下的配置文件会被以标准输出的方式传送给 cni,第二个是容器运行时信息会以环境变量的方式由 cni 使用。

那么我们首先就要从这两个地方拿到这些关键信息。不过关键的点其实不用我们自己动手,还记得 main 中使用的那个 skel.PluginMain 函数么,它里头其实已经帮我们处理好了很多信息:

这个函数会从环境变量中获取容器运行时信息,主要包括 containerID,netns,ifName(容器内的网卡名),args(从 CNI_ARGS 环境变量中获取的),path(CNI 的二进制文件地址),stdinData(配置文件的标准输出内容,[]byte 类型)

随后 cmdArgs 结构会被作为参数传递给 cmdAdd 方法,也就是我们主要需要实现的方法。

此时的配置文件信息只是个字节数组而已,所以我们还需要给它进行结构体的转换,不过在声明结构体之前,由于这个结构体要根据配置文件做转换,所以我们要先在 /etc/cni/ 定义配置文件:

{
        "cniVersion": "0.3.0",
        "name": "testcni",
        "type": "testcni",
        "bridge": "testcni0",
        "subnet": "10.244.0.0/16"
}
复制代码

这个配置问价我们不需要搞得想 calico 之类的那么复杂,只需要满足我们的需要的功能就好。

其中必须要指定就是 cniVersion,name,还有 type,其中 type 会用被用来拿着去 /opt/cni/bin 下找对应的二进制。

由于每个二进制插件实现都不一样,配置文件也就都不一样,所以我们需要自己在插件代码中声明结构体:

type PluginConf struct {
	// NetConf 里头指定了一个 plugin 的最基本的信息, 比如 CNIVersion, Name, Type 等, 当然还有在 containerd 中塞进来的 PrevResult
	types.NetConf

	// 这个 runtimeConfig 是可以在 /etc/cni/net.d/xxx.conf 中配置一个
	// 类似 "capabilities": {"xxx": true, "yyy": false} 这样的属性
	// 表示说要在运行时开启 xxx 的能力, 不开启 yyy 的能力
	// 然后等容器跑起来之后(或者被拉起来之前)可以直接通过设置环境变量 export CAP_ARGS='{ "xxx": "aaaa", "yyy": "bbbb" }'
	// 来开启或关闭某些能力
	// 然后通过 stdin 标准输入读进来的数据中就会多出一个 RuntimeConfig 属性, 里面就是 runtimeConfig: { "xxx": "aaaa" }
	// 因为 yyy 在 /etc/cni/net.d/xxx.conf 中被设置为了 false
	// 官方使用范例: https://kubernetes.feisky.xyz/extension/network/cni
	// cni 源码中实现: /cni/libcni/api.go:injectRuntimeConfig
	RuntimeConfig *struct {
		TestConfig map[string]interface{} `json:"testConfig"`
	} `json:"runtimeConfig"`

	// 这里可以自由定义自己的 plugin 中配置了的参数然后自由处理
	Bridge string `json:"bridge"`
	Subnet string `json:"subnet"`
}
复制代码

内容比较简单,其中 types.NetConf 里包含了配置文件的最基本信息包括插件名或者版本之类的,是直接由 cni 的 repo 提供的,我们可以拿来即用。RuntimeConfig 可有可无,作用在上面代码注释中已经写清了。最后的 Bridge 和 Subnet 就是我们自己的插件需要用到的属性,我们自己定义在这里就好,如果你的插件还需要其他信息的话,也可以直接写在这里。

这里简单说一下 Bridge 和 Subnet 参数的作用。我们在配置文件中声明 Bridge 属性,用它来表示每台节点上的网桥的名字(网桥干啥的一会儿再说),Subnet 表示我们要给 Pod 赋予 IP 时的子网网段,这里我们给 Pods 们 10.244.0.0/16 这个网段。

之后我们可以回到 cmdAdd 函数中:

func cmdAdd(args *skel.CmdArgs) error {
	utils.WriteLog("进入到 cmdAdd")
	utils.WriteLog(
		"这里的 CmdArgs 是: ", "ContainerID: ", args.ContainerID,
		"Netns: ", args.Netns,
		"IfName: ", args.IfName,
		"Args: ", args.Args,
		"Path: ", args.Path,
		"StdinData: ", string(args.StdinData))

	pluginConfig := &PluginConf{}
	if err := json.Unmarshal(args.StdinData, pluginConfig); err != nil {
		utils.WriteLog("args.StdinData 转 pluginConfig 失败")
		return err
	}
        return nil
}
复制代码

上面说过由 CRI 传来的标准输出已经由 skel.PluginMain 处理过并直接传给了 cmdAdd 方法,我们就可以在 args 参数中的 StdinData 中获取到该配置文件的标准输出,并通过刚刚声明的 PluginConf 做一下数据结构的转换了。


现在我们已经拿到了完成插件功能必须的一些基本信息了。

现在我们再来回顾一下插件需要实现的功能:

  1. podIP 地址管理
  2. 节点内 pod 间通信
  3. 不同节点的 pod 间通信

我们一步步来哈~

设置 etcd 客户端

首先我们要实现 Pod IP 地址管理的功能,该功能在 calico 或者 flannel 的实现中,都是通过自定义 crd,然后插件通过 api-server 去操作这些 crd 实现的(当然本质上还是存在 etcd 中):calico.yaml

就比如这个 crd 中,通过 cidr 指定了 podIP 所在的网段,然后每使用一个 ip 地址都会在这些 crd 中做记录。

在我们的场景中,不用这么复杂的设计,我们只需简单实现 ip 地址的管理就好。

但是问题是我们应该怎么设计 IPAM(ip address manager)呢?

首先我们是一个分布式的集群,集群中有很多节点,所以我们一定要考虑当某台节点上的某个 pod 使用了某个 ip 地址时,别的节点或者本节点的上其他 pod 就不能再使用了该 ip 了,因此一定需要有个地方能记录 ip 地址的使用情况,这个地方在整个集群中一定是唯一的。

所以我们自然就想到了 etcd,毕竟像 calico 这种通过 crd 的方式其实本质也是存储在 etcd 中。

首先我们创建 etcd 的客户端:

import (
        etcd "go.etcd.io/etcd/client/v3"
)
func newEtcdClient(config *EtcdConfig) (*etcd.Client, error) {
	var etcdLocation []string
	if config.EtcdAuthority != "" {
		etcdLocation = []string{config.EtcdScheme + "://" + config.EtcdAuthority}
	}
	if config.EtcdEndpoints != "" {
		etcdLocation = strings.Split(config.EtcdEndpoints, ",")
	}

	if len(etcdLocation) == 0 {
		return nil, errors.New("找不到 etcd")
	}

	tlsInfo := transport.TLSInfo{
		CertFile:      config.EtcdCertFile,
		KeyFile:       config.EtcdKeyFile,
		TrustedCAFile: config.EtcdCACertFile,
	}

	tlsConfig, err := tlsInfo.ClientConfig()

	client, err := etcd.New(etcd.Config{
		Endpoints:   etcdLocation,
		TLS:         tlsConfig,
		DialTimeout: clientTimeout,
	})

	if err != nil {
		return nil, err
	}

	return client, nil
}

func GetEtcdClient() (*EtcdClient, error) {
        // 省略一些操作
	if _client != nil {
                // 这里给初始化过的 client 做个缓存
		return _client, nil
	} else {
		client, err := newEtcdClient(&EtcdConfig{
			EtcdEndpoints:  "https://192.168.98.143:2379",
			EtcdCertFile:   "/etc/kubernetes/pki/etcd/healthcheck-client.crt",
			EtcdKeyFile:    "/etc/kubernetes/pki/etcd/healthcheck-client.key",
			EtcdCACertFile: "/etc/kubernetes/pki/etcd/ca.crt",
	})

		// 省略一些代码......
                return client, nil
	}
	return nil, errors.New("初始化 etcd client 失败")
}
复制代码

连接 etcd 非常简单,我们只需要调用 etcd 官方提供的 etcd 包,再给它个配置就可以连接了。这里的连接地址我写死成了我测试环境中的 etcd master 地址,大家自己玩的时候可以自行更改。更具体的操作可以参考源代码(地址:k8s-cni-test/client.go)

不过需要注意的是,必须得将 k8s 的 ca 根证书,以及 etcd 客户端需要使用的证书和私钥作为配置项传给 etcd 包,不然无法连接到 https 的 etcd 集群中。

注:可以在集群节点上 apt install etcd-clinet,然后就可以以如下的命令行方式访问 etcd 了。虽然命令长了一点,但是好使!

ETCDCTL_API=3 etcdctl --endpoints https://192.168.98.143:2379:2379 --cacert /etc/kubernetes/pki/etcd/ca.crt --cert /etc/kubernetes/pki/etcd/healthcheck-client.crt --key /etc/kubernetes/pki/etcd/healthcheck-client.key get / --prefix --keys-only
复制代码

创建 IPAM(IP 地址管理)服务

我在实现这个 IPAM 服务的代码时,由于是自己的项目可以随便玩儿,所以就想尝试各种编程方法,因此我将这个 IPAM 服务的代码封装为了链式调用的方式,可能由于水平不够,所以实现出来的代码比较冗长,因此就不给大家一一介绍实现了。

这里简单给大家介绍一下我实现 IPAM 的思路。

首先我需要在 etcd 中创建一个 IP 地址池:

根据 /etc/cni/net.d 下的配置中的 subnet 字段,在 etcd 的 /testcni/ipam/ s u b n e t / {subnet}/ {网段号}/pool 下提前将配置中的 subnet 字段下,能使用的所有 ip 地址网段都写在这个 key 下(注意是把所有的网段写在这儿,而不是把所有 ip 写在这儿,如果直接写 ip 的话那就太多了,如果网段号是 16 的话,就意味着直接存 ip 的话得存 255*255 个地址)

设置这个 pool 的目的就是每当我们有一个节点加入到集群中,一旦该节点上的 kubelet 调用了咱们的插件,也就会触发在插件中使用到的 ipam 服务,ipam 服务在节点上初始化的时候,就会去从这个 pool 中拿出一个未使用的网段作为自己这台节点的网段。

在上图中我们可以看到,通过 etcdctl 命令获取这个 key 下的可用网段是从 10.244.3.0 开始的,这就是因为我这边的测试集群中有三台节点,这三台节点已经将前三个 10.244.0.0/10.244.1.0/10.244.2.0 给占用了,所以如果再有节点加进来,这个 pool 里就又要少一个。


除了需要在 etcd 中设置一个 pool 用来保存网段之外,我还在 etcd 中为每个节点设置了一个用来记录“当前节点已使用的 ip”的 key:

当某个节点上又有新的 pod 被创建的时候,就去遍历这个 key 下已经使用了的所有 ip,然后选择最后一个 ip + 1 的 ip 地址作为当前新 pod 的 ip,并将这个 ip 地址重新写回到这个 key 下。


最后,在 etcd 上我还记录了每台节点被分配到的网段分别是啥:

之所以记录网段,是因为在后面实现插件不同节点的 pod 互通时,彼此需要感知到除了自己以外的节点所在网段,因此这里提前使用一个 key 用来记录下来。


因为这部分代码实现的比较冗长,所以没法一一给大家解释,但是可以通过查看 ipam 的测试文件来看我实现了哪些方法(源码地址: k8s-cni-test/ipam_test.go):

func TestIpam(t *testing.T) {
	test := assert.New(t)

	// 初始化 ipam 的, 当初始化完成的一瞬间
	// ipam 就会在 etcd 上完成 IP 网段 pool 的初始化
	// 当然如果其他节点已经初始化过了就不必再来一遍了
	// 初始化 pool 之后还会直接在 etcd 上找到一个未使用的网段并注册在 etcd 上
	Init("10.244.0.0", "16")
	is, err := GetIpamService()
	if err != nil {
		fmt.Println("ipam 初始化失败: ", err.Error())
		return
	}

	fmt.Println("成功: ", is.MaskIP)
	test.Equal(is.MaskIP, "255.255.0.0")

	fmt.Println("成功: ", is.MaskIP)
	test.Equal(is.MaskIP, "255.255.0.0")
	// 通过 Get().CIDR() 可以获取某个节点的 pod 被分配到的网段
	cidr, _ := is.Get().CIDR("ding-net-master")
	test.Equal(cidr, "10.244.0.0/24")
	cidr, _ = is.Get().CIDR("ding-net-node-1")
	test.Equal(cidr, "10.244.1.0/24")
	cidr, _ = is.Get().CIDR("ding-net-node-2")
	test.Equal(cidr, "10.244.2.0/24")

	// 通过 Get().UnusedIP() 可以直接获取到当前节点的 pod 所在的网段内还没有被使用过的下一个 IP 地址
	newIp, err := is.Get().UnusedIP()
	if err != nil {
		fmt.Println("这里的 err 是: ", err.Error())
		return
	}
	fmt.Println("这里获取到的还未使用的 ip 是: ", newIp)

	// 通过 Release().IPs() 可以批量释放当前节点的 pod 们所在网段的 ip 地址
	err = is.Release().IPs(newIp)
	if err != nil {
		fmt.Println("释放 ip 失败")
	}

	// 通过 Get().NodeNames() 可以获取集群中全部节点的 hostname
	names, err := is.Get().NodeNames()
	if err != nil {
		fmt.Println("这里的 err 是: ", err.Error())
		return
	}

	test.Equal(len(names), 3)

	for _, name := range names {
		// 通过 Get().NodeIP() 可以根据 hostname 获取到某台节点所在的 ip
		// 注意这个方法不是获取 pod 所在网段或者 pod ip 的方法
		// 而是获取节点的 ip 的
		// 就比如我的集群中获取到的就是 192.168.98.143/144/145 之类的
		// 而 pod 们的网段由于配置文件中的配置, 所以都是 10.244 开头的
		ip, err := is.Get().NodeIp(name)
		if err != nil {
			fmt.Println("这里的 err 是: ", err.Error())
			return
		}
		fmt.Println("这里的 ip 是: ", ip)
	}

	// Get().AllHostNetwork() 方法可以直接获取每台节点的 hostname、pod 所在网段、节点所在 ip 等
	nets, err := is.Get().AllHostNetwork()
	if err != nil {
		fmt.Println("这里的 err 是: ", err.Error())
		return
	}
	fmt.Println("集群全部网络信息是: ", nets)

	for _, net := range nets {
		fmt.Println("其他主机的网络信息是: ", net)
	}

	// Get().HostNetwork() 方法可以获取当前代码运行的节点的 ip 地址、pod 网段、主机名、网卡名等信息
	currentNet, err := is.Get().HostNetwork()
	if err != nil {
		fmt.Println("这里的 err 是: ", err.Error())
		return
	}
	fmt.Println("本机的网络信息是: ", currentNet)
}
复制代码

至于 IPAM 服务的代码实现,感兴趣的朋友可以自行查阅源码:k8s-cni-test/ipam.go


好,实现完 IPAM,约等于我们实现 CNI 插件的第一步“实现 IP 地址管理”的功能基本上已经完成了,剩下的就是要给 pod 塞入 ip 了。

接下来我们就尝试实现让同一台主机上的 pod 之间可以互相通信。

想实现同一台节点上,互相隔离的两个 pod 之间通信,一般采用的办法就是 veth pair + bridge。

由于网上已经有很多讲解 veth pair 设备和 bridge 设备的文章了,大家一搜就能搜出一大堆,这里我就不再死乞白赖地说了,就简单解释一下原理:

  1. 首先 linux 支持创建 veth pair 设备,这是一对儿虚拟设备,大家可以简单地想象成它就是一根儿网线,网线两头各自有个插头
  2. 这个插头可以自由插入 linux 下 net ns 或者其他虚拟网络设备,然后从某一头发送的消息可以送达到另外一头儿
  3. bridge 也是个 linux 下支持创建的虚拟网络设备,作用就和物理设备的网桥差不多,或者说它更像交换机,同时具备二层转发和三层路由的能力
  4. 我们将要创建一个 bridge
  5. 然后再创建一对儿 veth pair
  6. 将 veth 的一头儿插入到 bridge 上,将另一头儿插入到 pod 所在的 netns 中(其实 pod 所谓的网络资源隔离就是单纯地通过 netns 做的,因此只需要将 veth 插到这个 netns 中就 ok 了。而这个 netns 是在 CRI 调用插件时就传过来了)
  7. 利用 ipam 从 etcd 中获取当前节点上的 pod 所在网段,比如是 10.244.0.0,然后我们默认使用这个 网段+ 1 的 IP 作为 bridge 的 ip 地址,比如 master 节点的网段是 10.244.0.0,则让网桥地址是 10.244.0.1,其他节点也是如此,分别塞上 10.244.1.1/10.244.2.1/10.244.3.1/......
  8. 将这个 10.244.0.1 塞给网桥后,当将 ip 塞给网桥的瞬间,网桥就具备的三层的路由能力,然后它就可以作为所有连在它身上的网络设备的网关了
  9. 给 pod 内的 netns 设置默认路由,网关为 bridge,让 pod 内部可以 ping 通 10.244.0.0 之外的网段
  10. 当有新的 pod 被创建时,重复上述 5 、6、7、9 的动作

下面我们来简单看下代码实现:

func cmdAdd(args *skel.CmdArgs) error {
	pluginConfig := &PluginConf{}
	if err := json.Unmarshal(args.StdinData, pluginConfig); err != nil {
		utils.WriteLog("args.StdinData 转 pluginConfig 失败")
		return err
	}

	// 使用 kubelet(containerd) 传过来的 subnet 地址初始化 ipam
	ipam.Init(pluginConfig.Subnet)
	ipamClient, err := ipam.GetIpamService()

	// 根据 subnet 网段来得到网关, 表示所有的节点上的 pod 的 ip 都在这个网关范围内
	gateway, err := ipamClient.Get().Gateway()

	// 获取网关+网段号
	gatewayWithMaskSegment, err := ipamClient.Get().GatewayWithMaskSegment()

	// 获取网桥名字
	bridgeName := pluginConfig.Bridge
	if bridgeName != "" {
		bridgeName = "testcni0"
	}

	// 这里如果不同节点间通信的方式使用 vxlan 的话, 这里需要变成 1460
	// 因为 vxlan 设备会给报头中加一个 40 字节的 vxlan 头部
	mtu := 1500
	// 获取 containerd 传过来的网卡名, 这个网卡名要被插到 net ns 中
	ifName := args.IfName
	// 根据 containerd 传过来的 netns 的地址获取 ns
	netns, err := ns.GetNS(args.Netns)

	// 从 ipam 中拿到一个未使用的 ip 地址
	podIP, err := ipamClient.Get().UnusedIP()

	// 走到这儿的话说明这个 podIP 已经在 etcd 中占上坑位了
	// 占坑的操作是直接在 Get().UnusedIP() 的时候就做了
	// 后续如果有什么 error 的话可以再 release

	// 这里拼接 pod 的 cidr
	// podIP = podIP + "/" + ipamClient.MaskSegment
	podIP = podIP + "/" + "24"

	/**
	 * 准备操作做完之后就可以调用网络工具来创建网络了
	 * nettools 主要做的事情:
	 *		1. 根据网桥名创建一个网桥
	 *		2. 根据网卡名儿创建一对儿 veth
	 *		3. 把叫做 IfName 的怼到 pod(netns) 上
	 *		4. 把另外一个干到主机的网桥上
	 *		5. set up 网桥以及这对儿 veth
	 *		6. 在 pod(netns) 里创建一个默认路由, 把匹配到 0.0.0.0 的 ip 都让其从 IfName 那块儿 veth 往外走
	 *		7. 设置主机的 iptables, 让所有来自 bridgeName 的流量都能做 forward(因为 docker 可能会自己设置 iptables 不让转发的规则)
	 */

	err = nettools.CreateBridgeAndCreateVethAndSetNetworkDeviceStatusAndSetVethMaster(bridgeName, gatewayWithMaskSegment, ifName, podIP, mtu, netns)

        // 省略之后的代码......
}
复制代码

大致的流程都和上面简单介绍的原理差不多,就是上来先获取到一些必要的信息比如网关地址,网桥名,mtu,netns 地址,未使用的 podIP 等。

都获取完了我们调用了一个看上去很骚气的函数“CreateBridgeAndCreateVethAndSetNetworkDeviceStatusAndSetVethMaster”,虽然名字骚气,但是干啥的也一目了然,我函数上面的注释里头也写了具体作用,这里不再赘述作用,直接简单看下实现:

func CreateBridgeAndCreateVethAndSetNetworkDeviceStatusAndSetVethMaster(
	brName, gw, ifName, podIP string, mtu int, netns ns.NetNS,
) error {
	// 先创建网桥
	br, err := CreateBridge(brName, gw, mtu)

	err = netns.Do(func(hostNs ns.NetNS) error {
		// 创建一对儿 veth 设备
		containerVeth, hostVeth, err := CreateVethPair(ifName, mtu)
	
		// 把随机起名的 veth 那头放在主机上
		err = SetVethNsFd(hostVeth, hostNs)

		// 然后把要被放到 pod 中的那头 veth 塞上 podIP
		err = SetIpForVeth(containerVeth, podIP)

		// 然后启动它
		err = SetUpVeth(containerVeth)
	
		// 启动之后给这个 netns 设置默认路由 以便让其他网段的包也能从 veth 走到网桥
		// TODO: 实测后发现还必须得写在这里, 如果写在下面 hostNs.Do 里头会报错目标 network 不可达(why?)
		gwNetIP, _, err := net.ParseCIDR(gw)

		// 给 pod(net ns) 中加一个默认路由规则, 该规则让匹配了 0.0.0.0 的都走上边创建的那个 container veth
		err = SetDefaultRouteToVeth(gwNetIP, containerVeth)

		hostNs.Do(func(_ ns.NetNS) error {
			// 重新获取一次 host 上的 veth, 因为 hostVeth 发生了改变
			_hostVeth, err := netlink.LinkByName(hostVeth.Attrs().Name)
			hostVeth = _hostVeth.(*netlink.Veth)
		
			// 启动它
			err = SetUpVeth(hostVeth)

			// 把它塞到网桥上
			err = SetVethMaster(hostVeth, br)

			// 都完事儿之后理论上同一台主机下的俩 netns(pod) 就能通信了
			// 如果无法通信, 有可能是 iptables 被设置了 forward drop
			// 需要用 iptables 允许网桥做转发
			err = SetIptablesForBridgeToForwardAccept(br)

			return nil
		})

		return nil
	})

	if err != nil {
		return err
	}
	return nil
}
复制代码

其中我省略了一些错误处理,但是主要流程都暴露出来了,其实和上面介绍的原理基本一样。

这里有一点需要注意的是,如果主机上安装了 docker 的话,docker 会自动设置 iptables,把网桥的 ip_forward 功能给都给 drop 掉,所以这里尝试给它改成 accpet,否则就算网桥有了 ip,但是仍然不能做 ip 转发。

再注意,如果 iptables 开了还不好使的话,那需要检查 /proc/sys/net/ipv4/ip_forward 是否是 1

这是个内核参数,如果是 0 的话就代表内核在发现 ip 需要转发的时候都会直接丢弃

当上面的操作都成功之后,同一台节点上的 pod 应该已经都可以互相 ping 通了,因为它们都通过 veth pair 插在了同一块儿网桥上。

此时可以通过使用 cni 官方提供的 cnitool 或者本文源代码中提供的一些测试方法去检查,具体测试方法可查看地址 k8s-cni-test/README.md 的 README.md。


实现不同节点的 pod 互通

实现了同节点之间的互通后,我们接下来要尝试实现让不同节点之间的设备进行互通了。

要实现让不同节点的设备互通方法有很多,不过总的归类来讲,大致有四种:

  1. SDN 网络,这种我个人不太了解,不多说了
  2. 走主机的静态路由,这种方式相对简单,我们就采用这种方式
  3. overlay 网络,也叫隧道网络
  4. 动态路由网络

像类似第二种方式,在 flannel 里叫 gw-host,第三种方式可以使用 vxlan 或者其他隧道网络技术,第四种是 calico 主要采用的方式,使用 BGP 协议在多台主机上做动态路由设置。

我们使用第二种方式实现,该种方式较为简单,也最好理解(后续如果有时间的话,可能会尝试实现隧道网络模式相关)。


首先我们简单介绍一下主机路由的原理,其实也非常简单:

  1. 从 etcd 中获取到除了本节点以外的所有节点的 pod 所在网段地址
  2. 从 etcd 中获取除了本节点以外的所有节点的 ip 地址
  3. 将这些网段地址设置在本机的路由表中,其他节点网段地址为 dst,其他节点的 ip 为 gw,或者叫下一跳(via)
  4. 设置主机网卡 iptables 为允许做 ip forward

简单来讲就是让从当前节点的 pod 中出去的流量走到主机上时,能通过路由表知道目标 pod 的下一跳地址,下一跳地址就是其他节点的真实 ip,这样就能把这个流量包给送到对应的节点上了,然后其他节点接收到之后,再根据本机的路由表以及流量包中的目标 ip 地址,把流量转发给自己主机上的 bridge,随后网桥就能把流量送进连接着自己的 veth,也就能送到对应的 nets(pods) 中了。

下面我们来实现一些代码:

func cmdAdd(args *skel.CmdArgs) error {
         
        // 省略之前的代码......

	// 首先通过 ipam 获取到 etcd 中存放的集群中所有节点的相关网络信息
	networks, err := ipamClient.Get().AllHostNetwork(

	// 然后获取一下本机的网卡信息
	currentNetwork, err := ipamClient.Get().HostNetwork()

	// 这里面要做的就是把其他节点上的 pods 的 cidr 和其主机的网卡 ip 作为一条路由规则创建到当前主机上
	err = nettools.SetOtherHostRouteToCurrentHost(networks, currentNetwork)

	link, err := netlink.LinkByName(currentNetwork.Name)
        
        // 设置当前主机对外网卡的 iptables 规则,要允许做 forward
	err = nettools.SetIptablesForDeviceToFarwordAccept(link.(*netlink.Device))

        // 最后获取一些必要的信息如网关信息
	_gw := net.ParseIP(gateway)
        // 还有本次给 pod 设置的 ip 等信息
	_, _podIP, _ := net.ParseCIDR(podIP)
        
        // 最后做成一个结构体
	result := &current.Result{
		CNIVersion: pluginConfig.CNIVersion,
		IPs: []*current.IPConfig{
			{
				Address: *_podIP,
				Gateway: _gw,
			},
		},
	}
     
        // 把这个结构体打印到标准输出中
	types.PrintResult(result, pluginConfig.CNIVersion)

	return nil
}
复制代码

逻辑很简单,就和上面介绍的一毛一样,只要把其他主机的 pod 网段和 ip 地址设置到当前主机的路由表中就 ok 了。

最后别忘了将其打印到标准输出上,因为 K8S 会从标准输出中读取 json 格式的信息以做后续操作。


走一波!

当上述代码都实现完成之后,理论上我们的 cni 插件的“添加网络”功能就实现得差不多了,当然最后还要实现 cmdDel 用来清除网络,清除网络就比较简单了,只需要反向操作,把之前创建的 veth pair 啥的都给干掉就行了。记得留住 bridge,因为之后的新 pod 可能还会用到。


然后我们运行:

go build main.go & mv main /opt/cni/bin/testcni
复制代码

会生成二进制的可执行文件并且将其 mv 到 /opt/cni/bin 目录下并重命名为 testcni。当然这个名字可以改,只要和配置文件中的 type 对应上就行了。

再然后,我们手动把 /etc/cni/net.d 下的配置文件和 /opt/cni/bin/ 下的 testcni 都给 scp 到其他所有节点上。

这么手动拷贝有点 low 哈,大家也可以给它们干成 DaemonSet,然后利用 k8s 的能力在每台节点上都在 pod 中执行命令的方式把这些文件拷贝到对应的目录下~


最后我们来尝试跑俩 pod,然后 ping 一下子。

我在本文源码中提供了一个简单的 busybox 的测试用 deploy(地址:k8s-cni-test/test-busybox.yaml):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: busybox
spec:
  selector:
    matchLabels:
      app: busybox
  replicas: 2
  template:
    metadata:
      labels:
        app: busybox
    spec:
      containers:
      - name: busybox
        image: busybox
        command:
          - sleep
          - "36000"
        imagePullPolicy: IfNotPresent
复制代码

然后在集群中:

k apply -f test-busybox.yaml
复制代码

之后查看 pod 状态,并通过进入到 pod:

k exec -it pods/xxxxx -- sh
复制代码

最后互相 ping 一下:

可以看到,busybox 启动了俩副本,一个在 node-1 上一个在 node-2 上,并且俩处于不同网段,一个 ip 是 10.244.1.2 另一个是 10.244.2.2(都从 2 开始是因为咱们把 10.244.x.1 作为每台节点上网桥的网关了)

之后进入到某一个 pod 中去,然后尝试 ping 另外一台节点上的 pod,发现已经可以 ping 通了!

OK!打完收工!


到这里我们就从 0 开始,逐步实现了一个简单的 CNI 网络插件。其中有诸多实现不到位的地方,如果各位大佬发现欢迎随时指正。


化缘

最后再贴一次源码地址: k8s-cni-test

如果感觉对大家有些许帮助的话,还请麻烦点颗小星星,感谢~

Guess you like

Origin juejin.im/post/7049610194224939038