CNI 实现:以 flannel 为例解析 CNI 插件的实现

文章目录

在《K8s dockershim CNI 实现解析》文章中,我们解释了 CNI 在 K8s 侧的一些实现,包括涉及到的 dockershim 的一些数据结构、CNI 仓库 libcni 的一些数据结构,以及 CNI 调用具体插件的参数、参数传递方式等。在本文中,我们将以 flannel 为例大概介绍一下 cni 插件的实现,其实flannel实现涉及到好几个项目:

  • cni-plugin:这个就是我们要解析的项目,这个项目生成的是一个二进制文件 flannel,并且放到目录 /opt/cni/bin/目录,供kubelet 消费。但是光有这个项目是不行的,或者说这个项目做的事情比较简单。
  • flanneld:这个是一个daemonset,运行在每个节点上,这个项目作用为:在生成每个节点的 /run/flannel/subnet.env 文件,供上面的 cni-plugin 消费,这个文件是根据 node.spec.podCIDR 实现的,关于这个项目本文不做详细介绍,后面希望会有文章介绍这个。
  • 公共 cni plugins:flannel 其实把这个项目 fork 了一份https://github.com/flannel-io/plugins,可能做了一些修改,这里我们只介绍官方的 cni 实现,主要是两个插件bridgeipam

CNI 插件实现框架

CNI 官方提供了一些库github.com/containernetworking/cni,在这些库的支持下,实现一个插件还是非常简单的,只要实现三个参数即可:cmdAddcmdCheckcmdDel

func main() {
	skel.PluginMain(cmdAdd, cmdCheck, cmdDel, cni.All, fullVer)
}

低版本的 CNI 只需实现 add 和 del 就好了,下面以 flannel 为例介绍 add 和 del 的实现。首先贴上 flannel 网络的配置文件,在下面文件中,关于 flannel 的配置只有几行,首先是 type: "flannel",这个是说 dockershim 在调用 cni 插件时,首先调用 flannel 这个二进制文件(plugins 是一个数组,其中的插件是顺序调用的),然后有两条 delegate 的配置,配置比较简单,但其实很多值都用了默认值,我们在其实现中将会看到。

{
  "name": "cbr0",
  "cniVersion":"0.3.1",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}

cmdAdd 配置网络

我们在《K8s dockershim CNI 实现解析》中介绍过,执行 cni 二进制文件时,有两种方式传入数据,一种是环境变量,另一种是标准输入,其中标准输入是一大包数据,包含 cni 官方的数据,自定义的数据(字段);也可以划分为运行时数据(如网络ns、pod 名字等)或者网络配置数据。对于自定义的字段,在反序列化为 cni 官方的数据结构时会丢失,但是反序列化为自定义的数据结构就没问题了,一般都是在官方数据结构types.NetConf外面再包一层。

下面的loadFlannelNetConf方法就是反序列化标准输入为 flannel 自定义的数据结构NetConf,其中嵌入包含 cni 的表示数据结构types.NetConf

// flannel 自定义的数据结构
type NetConf struct {
	// 嵌入了 cni 库的标准数据结构,网络配置
	types.NetConf

	IPAM          map[string]interface{} `json:"ipam,omitempty"` // 这个IPAM是map
	SubnetFile    string                 `json:"subnetFile"`
	DataDir       string                 `json:"dataDir"`
	Delegate      map[string]interface{} `json:"delegate"`
	// 容器运行时配置
	RuntimeConfig map[string]interface{} `json:"runtimeConfig,omitempty"`
}
// cni 库中的标准数据结构
type NetConf struct {
	CNIVersion string `json:"cniVersion,omitempty"`

	Name         string          `json:"name,omitempty"`
	Type         string          `json:"type,omitempty"`
	Capabilities map[string]bool `json:"capabilities,omitempty"`
	IPAM         IPAM            `json:"ipam,omitempty"` // 这个IPAM是个struct
	DNS          DNS             `json:"dns"`

	RawPrevResult map[string]interface{} `json:"prevResult,omitempty"`
	PrevResult    Result                 `json:"-"`
}

这些 prevResult 等字段,我们之前看过,在标准输入里都是有的。注意这里 cni的标准数据结构和 flannel自定义的数据结构有个同名但是类型不同的字段 IPAM,这个是不冲突的,访问内部的字段时,带上内部嵌入的变量即可。

loadFlannelSubnetEnv 这个方法就是读取 flanneld 项目配置的subnetenv,这个配置文件的例子如下,Network以及subnet都是用CIDR的方式表示的,在解析文件时,都用了net.ParseCIDR来返回用net.IPNet表示的网络(网络可以理解为网段,而非单个IP),例如10.42.0.1/24网络通过net.ParseCIDR表示为ip:10.42.0.0, mask:ffffff00

FLANNEL_NETWORK=10.42.0.0/16
FLANNEL_SUBNET=10.42.0.1/24
FLANNEL_MTU=1450
FLANNEL_IPMASQ=true

上面配置的作用是确定这个节点上的子网的,这个文件是由 flanneld 通过监听node资源生成,并由 flannel cni 插件消费的。IPAM 分配地址的时候要用。

func cmdAdd(args *skel.CmdArgs) error {
	n, err := loadFlannelNetConf(args.StdinData)
	if err != nil {
		return err
	}
	fenv, err := loadFlannelSubnetEnv(n.SubnetFile)
	if err != nil {
		return err
	}
	if n.Delegate == nil {
		n.Delegate = make(map[string]interface{})
	} else {
		if hasKey(n.Delegate, "type") && !isString(n.Delegate["type"]) {
			return fmt.Errorf("'delegate' dictionary, if present, must have (string) 'type' field")
		}
		if hasKey(n.Delegate, "name") {
			return fmt.Errorf("'delegate' dictionary must not have 'name' field, it'll be set by flannel")
		}
		// 在使用 flannel 插件时,不能手动设置在 Delegate 中配置 ipam 字段
		// 这个字段只能由 flannel 配置
		if hasKey(n.Delegate, "ipam") {
			return fmt.Errorf("'delegate' dictionary must not have 'ipam' field, it'll be set by flannel")
		}
	}
	if n.RuntimeConfig != nil {
		n.Delegate["runtimeConfig"] = n.RuntimeConfig
	}
	return doCmdAdd(args, n, fenv)
}

上面代码解析了一些输入,然后校验了一下 Delegate 的配置,然后就调用 doCmdAdd 这个方法了。我们将部分代码解析放在了注释中。总的来说,doCmdAdd方法就是在配置 flannel 插件的 Delegate,委托给了 bridge 插件来执行,并进行了 ipam 相关配置。

func doCmdAdd(args *skel.CmdArgs, n *NetConf, fenv *subnetEnv) error {
	n.Delegate["name"] = n.Name
	// 默认 flannel 的 delegate 配置只有两个,是没有这个 type 字段的,所以从这里看
	// flannel 是 delegate 了 bridge 插件来做网桥以及虚拟网卡的配置。
	if !hasKey(n.Delegate, "type") {
		n.Delegate["type"] = "bridge"
	}
	// ipMasq、mtu 配置先不考虑
    // 这是设置 Gateway
	if n.Delegate["type"].(string) == "bridge" {
		if !hasKey(n.Delegate, "isGateway") {
			n.Delegate["isGateway"] = true
		}
	}
	// 获取 ipam 的配置,这里主要有三个配置:
	// 1. ipam 的 type,这个type,就是二进制插件的名字,默认为 `host-local`
	// 2. ipam 的 ranges,这个就是subnetwork的网段,通过CIDR表示
	// 3. ipam 的 routes,路由,包括两部分,一部分是在 ipam 配置文件中配置的,另一部分是在 subnet配置文件里的 FLANNEL_NETWORK 配置的。
	ipam, err := getDelegateIPAM(n, fenv)
	if err != nil {
		return fmt.Errorf("failed to assemble Delegate IPAM: %w", err)
	}
	n.Delegate["ipam"] = ipam
	fmt.Fprintf(os.Stderr, "\n%#v\n", n.Delegate)

	return delegateAdd(args.ContainerID, n.DataDir, n.Delegate)
}

上面代码有种很多配置 ipam 的操作,注意 ipam 字段跟 type 字段是并列的,也就是跟 bridge 是并列的,可以认为 ipam 是这个 bridge 插件配置的一部分。所以这段代码结尾调用的方法delegateAdd方法,其实就是将插件的执行委托给了 bridge 插件。

在调用 delegateAdd 时,有三个参数,我们先解释一下:

  • ContainerID:容器ID,比较容易理解
  • DataDir: 这个是 flannel 的配置,默认为/var/lib/cni/flannel,是存放cni插件配置的地方,在这个目录下面有很多以容器ID命名(就是第一个参数)的文件,每个文件中,都放置了这个容器的 flannel插件的 delegate 配置,就是下面内容,通过下面内容,我们也很直观的的看到doCmdAdd这个方法都干了什么。下面节点中,subnet 的配置为10.42.9.0/24是因为当前节点的子网配置为FLANNEL_SUBNET=10.42.9.1/24
    {
      "cniVersion": "0.3.1",
      "hairpinMode": true,
      "ipMasq": false,
      "ipam": {
          "routes": [{
              "dst": "10.42.0.0/16"
          }],
          "subnet": "10.42.9.0/24",
          "type": "host-local"
      },
      "isDefaultGateway": true,
      "isGateway": true,
      "mtu": 1450,
      "name": "cbr0",
      "type": "bridge"
    }
    
  • Delegate,就是 flannel插件的delegate 配置,注意这里只传递了 flannel 的 delegate 配置。

delegateAdd 方法中,主要就是调用 bridge 来配置网络了,其输入就是上面的配置文件。

result, err := invoke.DelegateAdd(context.TODO(), netconf["type"].(string), netconfBytes, nil)

bridge 插件配置网卡

这个已经不属于 flannel 的范畴了,这个是 cni 官方提供的插件,其实现在https://github.com/containernetworking/plugins仓库中, 其实现主要有配置 cbr0 bridge,设置 veth pair等。我们不详细介绍这部分了。

cmdDel 网络清理

网络清理操作相对于配置网络简单一些,我们在上面介绍delegateAdd的时候,flannel delegate 给 bridge 时,在/var/lib/cni/flannel目录生成了一个配置文件,这个配置文件就是在删除网络的时候消费的,从代码上看,也是直接把这部分数据交给了 bridge 插件。

自定义 cni 插件

自定义插件可以自己编写一个 binary,delegate 部分功能给已有插件比如 bridge、ipam host local等。也可以直接 fork bridge 的代码进行一点修改。在之前的一篇文章中,我们介绍过一个自定义 cni 插件的配置,如下:

{
    "cniVersion": "0.2.0",
    "name": "mynet",
    "type": "bridge",
    "bridge": "br0",
    "isGateway": false,
    "ipMasq": false,
    "hairpinMode": true,
    "ipam": {
        "type": "ipam",
        "token": "e463937d15576af58ae7a7040807c018",
        "serverURL": "http://192.168.245.112:30111"
    }
}

这个配置文件需要以.conf结尾,而不是.conflist,因为是一个单个插件,而不是插件列表。从这个配置看,其实就是配置了一个 bridge 插件,只不过对 bridge 插件进行了二次开发,在 ipam 部分有一些自定义的配置,很明显这些自定义的配置 cni 的标准数据结构 types.NetConf 是不识别的,不过不用慌,我们可以跟 flannel 一样,在 types.NetConf 上再包一层数据结构,然后再进行反序列化,另外我们之前也强调过,所有在配置文件中的内容,都会以 args.StdinData 的方式传入给插件,我们拿到这些数据之后,基本就可以根据自己的配置随便处理了。

bridge插件一般有三个功能:1)创建网桥,2)创建 veth 设备,3)委托 ipam 分配 ip。前面两个功能听上去简单,不影响我们对cni流程的认识,但是有很多实现上的细节,后面希望写一篇文章来单独介绍一些这些细节。

上面配置中,ipam 的配置是 delegate 给了 ipam 插件,在 delegate 时,将上述配置文件作为标准参数传递给 ipam 插件。ipam 的执行就是获得一个可用的 ip,并打印到标准输出,ipam 的调用者,会收集这些输出,并进行反序列化,比如,我们可以将下列数据结构返打印到标准输出:

// cni库标准数据结构:types.Result
result := IPamResult{
	&IPConfig{
		ip, // string,ipam 申请到的ip
		gw, // string, ip 的网管
		[]Route{Route{"0.0.0.0/0", ""}},
	},
	nil,
	DNS{},
}
b, err := json.Marshal(result)
if err != nil {
	return err
}
// 打印到标准输出
fmt.Print(string(b))

实现就暂且分析到这里,也只是梳理了一个大概的流程,以理解实现流程为主。后面希望有文章分析 bridge/ipam 插件的实现细节,这个可能涉及到很多网络知识了。