ljzsdut
GitHubToggle Dark/Light/Auto modeToggle Dark/Light/Auto modeToggle Dark/Light/Auto modeBack to homepage

01 深入理解 Cni 通用设计规范

为什么要有CNI

CNI是Container Network Interface的缩写,简单地说,就是一个标准的,通用的接口。

已知我们现在有各种各样的容器平台:docker,kubernetes,mesos,我们也有各种各样的容器网络解决方案:flannel,calico,weave,并且还有各种新的解决方案在不断涌现。如果每出现一个新的解决方案,我们都需要对两者进行适配,那么由此带来的工作量必然是巨大的,而且也是重复和不必要的。事实上,我们只要提供一个标准的接口,更准确的说是一种协议,就能完美地解决上述问题(一个抽象的接口层,将容器网络配置方案与容器平台方案解耦。)。一旦有新的网络方案出现,只要它能满足这个标准的协议,那么它就能为同样满足该协议的所有容器平台提供网络功能,而CNI正是这样的一个标准接口协议。

什么是CNI

通俗地讲,CNI是一个接口协议,用于连接容器管理系统和网络插件。前者提供一个容器所在的network namespace(从网络的角度来看,network namespace和容器是完全等价的),后者负责将network interface插入该network namespace中(比如veth的一端),并且在宿主机做一些必要的配置(例如将veth的另一端加入bridge中),最后对namespace中的interface进行IP和路由的配置。

那么CNI的工作其实主要是从容器管理系统处获取运行时信息,包括network namespace的路径,容器ID以及network interface name,再从容器网络的配置文件中加载网络配置信息,再将这些信息传递给对应的插件,由插件进行具体的网络配置工作,并将配置的结果再返回到容器管理系统中。

最后,需要注意的是,在之前的CNI版本中,网络配置文件只能描述一个network,这也就表明了一个容器只能加入一个容器网络。但是在后来的CNI版本中,我们可以在配置文件中定义一个所谓的NetworkList,事实上就是定义一个network序列,CNI会依次调用各个network的插件对容器进行相应的配置,从而允许一个容器能够加入多个容器网络。

可见,CNI的接口并不是指HTTP、gRPC接口,而是指对可执行程序的调用(exec)。这些可执行程序称之为CNI插件,以K8S为例,K8S节点默认的CNI插件路径为 /opt/cni/bin ,在K8S节点上查看该目录,可以看到可供使用的CNI插件:

$ ls /opt/cni/bin/
bandwidth  bridge  dhcp  firewall  flannel  host-device  host-local  ipvlan  loopback  macvlan  portmap  ptp  sbr  static  tuning  vlan

kubelet中实现了CNI,CNI插件(可执行文件)会被kubelet调用。kubelet --network-plugin=cni表示启用cni插件,--cni-conf-dir 指定networkconfig配置路径,默认路径是:/etc/cni/net.d--cni-bin-dir指定plugin可执行文件路径,默认路径是:/opt/cni/bin

CNI plugin 只需要通过 CNI 库实现两类方法, 一类是创建容器时调用, 一类是删除容器时调用.

img

说明:

Docker并没有采用CNI标准,而是在CNI创建之初同步开发了CNM(Container Networking Model)标准。但由于技术和非技术原因,CNM模型并没有得到广泛的应用。

在组成上,CNI可以认为由libcni库和plugins组成。即CNI=libcni+plugins。libcni的默认配置文件为/etc/cni/net.d/*.conflist,而各个插件需要的配置文件是libcni生成的,libcni会为每个CNI插件都生成独立的配置文件,然后在调用各个CNI插件时通过stdin方式传递给CNI插件。

CNI的工作过程

CNI的工作过程大致如下图所示:

P1BMno-1658573700664

CNI通过json格式的配置文件来描述网络配置,当需要设置容器网络时,由容器运行时负责执行CNI插件,并通过CNI插件的标准输入(stdin)来传递json网络配置文件信息,通过标准输出(stdout)接收插件的执行结果。

图中的 libcni 是CNI提供的一个go package,封装了一些符合CNI规范的标准操作,便于容器运行时和网络插件对接CNI标准。

从上图可知,各种容器运行时如果要使用CNI,就需要基于libcni库开发对各种网络插件的调用实现。容器运行时中的libcni库,会通过环境变量和json网络配置文件传递给具体到网络插件,各个网络插件实现具体的容器网络操作。

其中下文中的cnitool就是类似于容器运行时的一种基于libcni的实现,可以去调用cni插件。

举一个CNI插件被调用的直观的例子,假如我们要调用bridge插件将容器接入到主机网桥,则调用的命令看起来长这样:

# CNI_COMMAND=ADD 顾名思义表示创建。
# XXX=XXX 其他参数定义见下文。
# < config.json 表示从标准输入传递配置文件
CNI_COMMAND=ADD XXX=XXX ./bridge < config.json

容器运行时调用CNI插件时,就是执行类似上面格式的命令。

CNI插件

现在官方提供了多个CNI插件,主要分为三种类型:main,meta和ipam。

  • main类型的插件主要提供某种网络功能,比如我们在示例中将使用的brdige,以及loopback,ipvlan,macvlan等等。
  • meta类型的插件不能作为独立的插件使用,它通常需要调用其他插件,例如flannel,或者配合其他插件使用,例如portmap。
  • ipam类型的插件其实是对所有CNI插件共有的IP管理部分的抽象,从而减少插件编写过程中的重复工作,官方提供的有dhcp和host-local两种类型。

各插件的配置文件文档可参考官方文档

CNI插件入参

容器运行时通过设置环境变量以及从标准输入传入的配置文件来向插件传递参数。

环境变量

  • CNI_COMMAND :定义期望的操作,可以是ADD,DEL,CHECK或VERSION。
  • CNI_CONTAINERID : 容器ID,由容器运行时管理的容器唯一标识符。
  • CNI_NETNS:容器网络命名空间的路径。(形如 /run/netns/[nsname] )。
  • CNI_IFNAME :需要被创建的网络接口名称,例如eth0。
  • CNI_ARGS :运行时调用时传入的额外参数,格式为分号分隔的key-value对,例如 FOO=BAR;ABC=123
  • CNI_PATH : CNI插件可执行文件的路径,例如/opt/cni/bin

配置文件

注意:此处的配置文件是各个插件的配置文件,不是libcni的配置文件。

文件示例:

{
  "cniVersion": "0.4.0", // 表示希望插件遵循的CNI标准的版本。
  "name": "dbnet",  // 表示网络名称。这个名称并非指网络接口名称,是便于CNI管理的一个表示。应当在当前主机(或其他管理域)上全局唯一。
  "type": "bridge", // 插件类型,即插件二进制文件的名字
  "bridge": "cni0", // bridge插件的参数,指定网桥名称。
  "ipam": { // IP Allocation Management,管理IP地址分配。
    "type": "host-local", // ipam插件的类型。
    // ipam 定义的参数
    "subnet": "10.1.0.0/16",
    "gateway": "10.1.0.1"
  }
}

公共定义部分

配置文件分为公共部分和插件定义部分。公共部分在CNI项目中使用结构体NetworkConfig定义:

type NetworkConfig struct {
   Network *types.NetConf
   Bytes   []byte
}
...
// NetConf describes a network.
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"`
   DNS          DNS             `json:"dns"`

   RawPrevResult map[string]interface{} `json:"prevResult,omitempty"`
   PrevResult    Result                 `json:"-"`
}
  • cniVersion 表示希望插件遵循的CNI标准的版本。
  • name 表示网络名称。这个名称并非指网络接口名称,是便于CNI管理的一个表示。应当在当前主机(或其他管理域)上全局唯一。
  • type 表示插件的名称,也就是插件对应的可执行文件的名称。
  • bridge 该参数属于bridge插件的参数,指定主机网桥的名称。
  • ipam 表示IP地址分配插件的配置,ipam.type 则表示ipam的插件类型。

更详细的信息,可以参考官方文档

插件定义部分

上文提到,配置文件最终是传递给具体的CNI插件的,因此插件定义部分才是配置文件的“完全体”。公共部分定义只是为了方便各插件将其嵌入到自身的配置文件定义结构体中,举bridge插件为例:

type NetConf struct {
	types.NetConf // <-- 嵌入公共部分
  // 底下的都是插件定义部分
	BrName       string `json:"bridge"`
	IsGW         bool   `json:"isGateway"`
	IsDefaultGW  bool   `json:"isDefaultGateway"`
	ForceAddress bool   `json:"forceAddress"`
	IPMasq       bool   `json:"ipMasq"`
	MTU          int    `json:"mtu"`
	HairpinMode  bool   `json:"hairpinMode"`
	PromiscMode  bool   `json:"promiscMode"`
	Vlan         int    `json:"vlan"`

	Args struct {
		Cni BridgeArgs `json:"cni,omitempty"`
	} `json:"args,omitempty"`
	RuntimeConfig struct {
		Mac string `json:"mac,omitempty"`
	} `json:"runtimeConfig,omitempty"`

	mac string
}

各插件的配置文件文档可参考官方文档

插件操作类型

CNI插件的操作类型只有四种: ADDDELCHECKVERSION。 插件调用者通过环境变量 CNI_COMMAND 来指定需要执行的操作。

ADD

ADD 操作负责将容器添加到网络,或对现有的网络设置做更改。具体地说,ADD 操作要么:

  • 为容器所在的网络命名空间创建一个网络接口,或者
  • 修改容器所在网络命名空间中的指定网络接口

例如通过 ADD 将容器网络接口接入到主机的网桥中。

其中网络接口名称由 CNI_IFNAME 指定,网络命名空间由 CNI_NETNS 指定。

DEL

DEL 操作负责从网络中删除容器,或取消对应的修改,可以理解为是 ADD 的逆操作。具体地说,DEL 操作要么:

  • 为容器所在的网络命名空间删除一个网络接口,或者
  • 撤销 ADD 操作的修改

例如通过 DEL 将容器网络接口从主机网桥中删除。

其中网络接口名称由 CNI_IFNAME 指定,网络命名空间由 CNI_NETNS 指定。

CHECK

CHECK 操作是v0.4.0加入的类型,用于检查网络设置是否符合预期。容器运行时可以通过CHECK来检查网络设置是否出现错误,当CHECK返回错误时(返回了一个非0状态码),容器运行时可以选择Kill掉容器,通过重新启动来重新获得一个正确的网络配置。

VERSION

VERSION 操作用于查看插件支持的版本信息。

$ CNI_COMMAND=VERSION /opt/cni/bin/bridge
{"cniVersion":"0.4.0","supportedVersions":["0.1.0","0.2.0","0.3.0","0.3.1","0.4.0"]}

插件配置文件参数详解

从上文中我们可以知道,CNI只支持三种操作:ADD, DEL,VERSION,而三种操作所需要配置的参数和结果如下:

  • 将container加入network(Add):
    • Parameters:
      • Version:CNI版本信息
      • Container ID: 这个字段是可选的,但是建议使用,在容器活着的时候要求该字段全局唯一的。比如,存在IPAM的环境可能会要求每个container都分配一个独立的ID,这样每一个IP的分配都能和一个特定的容器相关联。在appc implementations中,container ID其实就是pod ID
      • Network namespace path:这个字段表示要加入的network namespace的路径。例如,/proc/[pid]/ns/net或者对于该目录的bind-mount/link。
      • Network configuration: 这是一个JSON文件用于描述container可以加入的network,具体内容在下文中描述
      • Extra arguments:该字段提供了一种可选机制,从而允许基于每个容器进行CNI插件的简单配置
      • Name of the interface inside the container:该字段提供了在container (network namespace)中的interface的名字;因此,它也必须符合Linux对于网络命名的限制
    • Result:
      • Interface list:根据插件的不同,这个字段可以包括sandbox (container or hypervisor) interface的name,以及host interface的name,每个interface的hardware address,以及interface所在的sandbox(如果存在的话)的信息。
      • IP configuration assigned to each interface:IPv4和/或者IPv6地址,gateways以及为sandbox或host interfaces中添加的路由
      • DNS inormation:包含nameservers,domains,search domains和options的DNS information的字典
  • 将container从network中删除(Delete):
  • Parameter:
    • Version:CNI版本信息
    • ContainerID:定义同上
    • Network namespace path:定义同上
    • Network configuration:定义同上
    • Extra argument:定义同上
    • Name of the interface inside the container:定义同上
  • 版本信息
    • Parameter:无
    • Result:返回插件支持的所有CNI版本  

在上文的叙述中我们省略了对Network configuration的描述。事实上,它的内容和上文演示实例中的"/etc/cni/net.d/10-mynet.conf"网络配置文件是一致的,用于描述容器了容器需要加入的网络,下面是对其中一些重要字段的描述:

  • cniVersion(string):cniVersion以Semantic Version 2.0的格式指定了插件使用的CNI版本
  • name (string):Network name。这应该在整个管理域中都是唯一的
  • type (string):插件类型,也代表了CNI插件可执行文件的文件名
  • args (dictionary):由容器运行时提供的可选的参数。比如,可以将一个由label组成的dictionary传递给CNI插件,通过在args下增加一个labels字段来实现
  • ipMasqs (boolean):可选项(如果插件支持的话)。为network在宿主机创建IP masquerade。如果需要将宿主机作为网关,为了能够路由到容器分配的IP,这个字段是必须的
  • ipam:由特定的IPAM值组成的dictionary
    • type (string):IPAM插件的类型,也表示IPAM插件的可执行文件的文件名
  • dns:由特定的DNS值组成的dictionary
    • nameservers (list of strings):一系列对network可见的,以优先级顺序排列的DNS nameserver列表。列表中的每一项都包含了一个IPv4或者一个IPv6地址
    • domain (string):用于查找short hostname的本地域
    • search (list of strings):以优先级顺序排列的用于查找short domain的查找域。对于大多数resolver,它的优先级比domain更高
    • options(list of strings):一系列可以被传输给resolver的可选项

插件可能会定义它们自己能接收的额外的字段,但是遇到一个未知的字段可能会产生错误。例外的是args字段,它可以被用于传输一些额外的字段,但也可能会被插件忽略

libcni的链式调用

单个CNI插件的职责是单一的,比如bridge插件负责网桥的相关配置, firewall插件负责防火墙相关配置, portmap 插件负责端口映射相关配置。因此,当网络设置比较复杂时,通常需要调用多个插件来完成。CNI支持插件的链式调用,可以将多个插件组合起来,按顺序调用。例如先调用 bridge 插件设置容器IP,将容器网卡与主机网桥连通,再调用portmap插件做容器端口映射。容器运行时可以通过在配置文件设置plugins数组达到链式调用的目的:

{
  "cniVersion": "0.4.0",
  "name": "dbnet",
  "plugins": [
    {
      "type": "bridge",
      // type (plugin) specific
      "bridge": "cni0"
      },
      "ipam": {
        "type": "host-local",
        // ipam specific
        "subnet": "10.1.0.0/16",
        "gateway": "10.1.0.1"
      }
    },
    {
      "type": "tuning",
      "sysctl": {
        "net.core.somaxconn": "500"
      }
    }
  ]
}

细心的读者会发现,plugins这个字段并没有出现在上文描述的配置文件结构体中。的确,CNI使用了另一个结构体NetworkConfigList来保存链式调用的配置:

type NetworkConfigList struct {
   Name         string
   CNIVersion   string
   DisableCheck bool
   Plugins      []*NetworkConfig 
   Bytes        []byte
}

但CNI插件是不认识这个配置类型的。实际上,在调用CNI插件时,需要将NetworkConfigList转换成对应插件的配置文件格式,再通过标准输入(stdin)传递给CNI插件。例如在上面的示例中,实际上会先使用下面的配置文件调用 bridge 插件:

{
  "cniVersion": "0.4.0",
  "name": "dbnet",
  "type": "bridge",
  "bridge": "cni0",
  "ipam": {
    "type": "host-local",
    "subnet": "10.1.0.0/16",
    "gateway": "10.1.0.1"
  }
}

再使用下面的配置文件调用tuning插件:

{
  "cniVersion": "0.4.0",
  "name": "dbnet",
  "type": "tuning",
  "sysctl": {
    "net.core.somaxconn": "500"
  },
  "prevResult": { // 调用bridge插件的返回结果
     ...
  }
}

需要注意的是,当插件进行链式调用的时候,不仅需要对NetworkConfigList做格式转换,而且需要将前一次插件的返回结果添加到配置文件中(通过prevResult字段),不得不说是一项繁琐而重复的工作。不过幸好libcni 已经为我们封装好了,容器运行时不需要关心如何转换配置文件,如何填入上一次插件的返回结果,只需要调用 libcni 的相关方法即可。

CNI使用示例

各种容器运行时如果要使用CNI规范实现容器网络配置,就需要基于libcni库实现CNI,然后就可以按照CNI规范调用CNI插件。此外我们通过手动的方式去模拟容器运行时去调用CNI插件。 容器运行时也是通过对CNI插件的可执行程序的调用实现网络配置。

接下来将演示如何使用CNI插件来为Docker容器设置网络。

下载CNI插件

为方便起见,我们直接下载可执行文件:

# wget https://github.com/containernetworking/plugins/releases/download/v0.9.1/cni-plugins-linux-amd64-v0.9.1.tgz
# mkdir -p  ~/cni/bin
# tar zxvf cni-plugins-linux-amd64-v0.9.1.tgz -C ./cni/bin
# chmod +x ~/cni/bin/*
# ls ~/cni/bin/
bandwidth  bridge  dhcp  firewall  flannel  host-device  host-local  ipvlan  loopback  macvlan  portmap  ptp  sbr  static  tuning  vlan  vrfz

如果你是在K8S节点上实验,通常节点上已经有CNI插件了,不需要再下载,但要注意将后续的 CNI_PATH 修改成/opt/cni/bin

示例1:调用单个插件

在本示例中,我们会直接调用CNI插件,为容器设置eth0接口,为其分配IP地址,并接入主机网桥mynet0

跟docker默认使用的使用网络模式一样,只不过我们将docker0换成了mynet0

  1. 启动容器

虽然Docker不使用CNI规范,但可以通过指定 --net=none 的方式让Docker不设置容器网络。以nginx镜像为例:

contid=$(docker run -d --net=none --name nginx nginx) # 容器ID
pid=$(docker inspect -f '{{ .State.Pid }}' $contid) # 容器进程ID
netnspath=/proc/$pid/ns/net # 命名空间路径

启动容器的同时,我们需要记录一下容器ID,命名空间路径,方便后续传递给CNI插件。容器启动后,可以看到除了lo网卡,容器没有其他的网络设置:

nsenter -t $pid -n ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever

nsenter是namespace enter的简写,顾名思义,这是一个在某命名空间下执行命令的工具。-t表示进程ID, -n表示进入对应进程的网络命名空间。

  1. 添加容器网络接口并连接主机网桥

接下来我们使用bridge插件为容器创建网络接口,并连接到主机网桥。创建bridge.json配置文件,内容如下:

{
    "cniVersion": "0.4.0",
    "name": "mynet",
    "type": "bridge",
    "bridge": "mynet0",
    "isDefaultGateway": true,
    "forceAddress": false,
    "ipMasq": true,
    "hairpinMode": true,
    "ipam": {
        "type": "host-local",
        "subnet": "10.10.0.0/16"
    }
}

调用bridge插件ADD操作:

CNI_COMMAND=ADD CNI_CONTAINERID=$contid CNI_NETNS=$netnspath CNI_IFNAME=eth0 CNI_PATH=~/cni/bin ~/cni/bin/bridge < bridge.json

调用成功的话,会输出类似的返回值:

{
    "cniVersion": "0.4.0",
    "interfaces": [
        ....
    ],
    "ips": [
        {
            "version": "4",
            "interface": 2,
            "address": "10.10.0.2/16", //给容器分配的IP地址
            "gateway": "10.10.0.1" 
        }
    ],
    "routes": [
        .....
    ],
    "dns": {}
}

再次查看容器网络设置:

nsenter -t $pid -n ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
5: eth0@if40: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether c2:8f:ea:1b:7f:85 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.10.0.2/16 brd 10.10.255.255 scope global eth0
       valid_lft forever preferred_lft forever

可以看到容器中已经新增了eth0网络接口,并在ipam插件设定的子网下为其分配了IP地址。host-local类型的 ipam插件会将已分配的IP信息保存到文件,避免IP冲突,默认的保存路径为/var/lib/cni/network/$NETWORK_NAME

ls /var/lib/cni/networks/mynet/
10.10.0.2  last_reserved_ip.0  lock
  1. 从主机访问验证

由于mynet0是我们添加的网桥,还未设置路由,因此验证前我们需要先为容器所在的网段添加路由:

ip route add 10.10.0.0/16 dev mynet0 src 10.10.0.1 # 添加路由
curl -I 10.10.0.2 # IP换成实际分配给容器的IP地址
HTTP/1.1 200 OK
....
  1. 删除容器网络接口

删除的调用入参跟添加的入参是一样的,除了CNI_COMMAND要替换成DEL

CNI_COMMAND=DEL CNI_CONTAINERID=$contid CNI_NETNS=$netnspath CNI_IFNAME=eth0 CNI_PATH=~/cni/bin ~/cni/bin/bridge < bridge.json

注意,上述的删除命令并未清理主机的mynet0网桥。如果你希望删除主机网桥,可以执行ip link delete mynet0 type bridge命令删除。

示例2:链式调用

在示例2中,我们将在示例1的基础上,使用portmap插件为容器添加端口映射。

  1. 使用cnitool工具

前面的介绍中,我们知道在链式调用过程中,调用方需要转换配置文件,并需要将上一次插件的返回结果插入到本次插件的配置文件中。这是一项繁琐的工作,而libcni已经将这些过程封装好了,在示例2中,我们将使用基于 libcni的命令行工具cnitool来简化这些操作。

示例2将复用示例1中的容器,因此在开始示例2时,请确保已删除示例1中的网络接口。

通过源码编译或go install来安装cnitool

go install github.com/containernetworking/cni/cnitool@latest
  1. 配置文件

libcni会读取.conflist后缀的配置文件,我们在当前目录创建portmap.conflist

{
  "cniVersion": "0.4.0",
  "name": "portmap",
  "plugins": [
    {
      "type": "bridge",
      "bridge": "mynet0",
      "isDefaultGateway": true, 
      "forceAddress": false, 
      "ipMasq": true, 
      "hairpinMode": true,
      "ipam": {
        "type": "host-local",
        "subnet": "10.10.0.0/16",
        "gateway": "10.10.0.1"
      }
    },
    {
      "type": "portmap",
      "runtimeConfig": {
        "portMappings": [
          {"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}
        ]
      }
    }
  ]
}

从上述的配置文件定义了两个CNI插件,bridgeportmap。根据上述的配置文件,cnitool会先为容器添加网络接口并连接到主机mynet0网桥上(就跟示例1一样),然后再调用portmap插件,将容器的80端口映射到主机的8080端口,就跟docker run -p 8080:80 xxx一样。

libnci会基于其配置文件生成多个CNI插件的配置文件,然后在调用各个CNI插件时通过stdin方式传递给CNI插件。

  1. 设置容器网络

使用cnitool我们还需要设置两个环境变量:

  • NETCONFPATH: 指定配置文件(*.conflist)的所在路径,默认路径为 /etc/cni/net.d
  • CNI_PATH :指定CNI插件的存放路径。

使用cnitool add命令为容器设置网络:

CNI_PATH=~/cni/bin NETCONFPATH=.  cnitool add portmap $netnspath

设置成功后,访问宿主机8080端口即可访问到容器的nginx服务。

  1. 删除网络配置

使用cnitool del命令删除容器网络:

CNI_PATH=~/cni/bin NETCONFPATH=.  cnitool del portmap $netnspath

注意,上述的删除命令并未清理主机的mynet0网桥。如果你希望删除主机网桥,可以执行ip link delete mynet0 type bridge命令删除。

总结

至此,CNI的工作原理我们已基本清楚。CNI的工作原理大致可以归纳为:

  • 通过JSON配置文件定义网络配置;
  • 通过调用可执行程序(CNI插件)来对容器网络执行配置;
  • 通过链式调用的方式来支持多插件的组合使用。

CNI不仅定义了接口规范,同时也提供了一些内置的标准实现,以及libcni这样的“胶水层”,大大降低了容器运行时与网络插件的接入门槛。

手动模拟CNI插件

CNI主要解决:

  1. Pod同节点通信

  2. Pod跨节点通信

  3. Pod与SVC通信

手动实现flanel的hostgw模式。

CNI的源码分析

到目前为止,我们对CNI的使用、配置和原理都已经有了基本的认识,所以也是时候基于源码来对CNI做一个透彻的理解了。下面,我们将以上文中的演示实例作为线索,以模拟程序cnitool作为切入口,来对整个CNI的执行过程进行详尽的分析。

(1)、加载容器网络配置信息

首先我们来看一下容器网络配置的数据结构表示:

type NetworkConfigList struct {
    Name       string
    CNIVersion string
    Plugins    []*NetworkConfig
    Bytes      []byte
}
 
type NetworkConfig struct {
    Network *types.NetConf
    Bytes   []byte
}
 
// NetConf describes a network.
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         struct {
        Type string `json:"type,omitempty"`
    } `json:"ipam,omitempty"`
    DNS DNS `json:"dns"`
}

经过粗略的分析之后,我们可以发现,数据结构表示的内容和演示实例中的json配置文件基本是一致的。因此,这一步的源码实现很简单,基本流程如下:

  • 首先确定配置文件所在的目录netdir,如果没有特别指定,则默认为"/etc/cni/net.d"
  • 调用netconf, err := libcni.LoadConfList(netdir, os.Args[2]),其中参数os.Args[2]为用户指定的想要加入的network的名字,在演示示例中即为"mynet"。该函数首先会查找netdir中是否有以".conflist"作为后缀的配置文件,如果有,且配置信息中的"Name"和参数os.Args[2]一致,则直接用配置信息填充并返回NetConfigList即可。否则,查找是否存在以".conf"或".json"作为后缀的配置文件。同样,如果存在"Name"一致的配置,则加载该配置文件。由于".conf"或".json"中都是单个的网络配置,因此需要将其包装成仅有一个NetConfig的NetworkConfigList再返回。到此为止,容器网络配置加载完成。

(2)、配置容器运行时信息

同样,我们先来看一下容器运行时信息的数据结构:

type RuntimeConf struct {
    ContainerID string
    NetNS       string
    IfName      string
    Args        [][2]string
    // A dictionary of capability-specific data passed by the runtime
    // to plugins as top-level keys in the 'runtimeConfig' dictionary
    // of the plugin's stdin data.  libcni will ensure that only keys
    // in this map which match the capabilities of the plugin are passed
    // to the plugin
    CapabilityArgs map[string]interface{}
}

其中最重要的字段无疑是"NetNS",它指定了需要加入容器网络的network namespace路径。而Args字段和CapabilityArgs字段都是可选的,用于传递额外的配置信息。具体的内容参见上文中的配置说明。在上文的演示实例中,我们并没有对Args和CapabilityArgs进行任何的配置,为了简单起见,我们可以直接认为它们为空。因此,cnitool对RuntimeConf的配置也就极为简单了,只需要将参数指定的netns赋值给NetNS字段,而ContainerID和IfName字段随意赋值即可,默认将它们分别赋值为"cni"和"eth0",具体代码如下:

rt := &libcni.RuntimeConf{
    ContainerID:    "cni",
    NetNS:          netns,
    IfName:         "eth0",
    Args:           cniArgs,
    CapabilityArgs: capabilityArgs,
}

  

(3)、加入容器网络

根据加载的容器网络配置信息和容器运行时信息,执行加入容器网络的操作,并将执行的结果打印输出

switch os.Args[1] {
case CmdAdd:
    result, err := cninet.AddNetworkList(netconf, rt)
    if result != nil {
        _ = result.Print()
    }
    exit(err)
    ......
}

接下来我们进入AddNetworkList函数中

// AddNetworkList executes a sequence of plugins with the ADD command
func (c *CNIConfig) AddNetworkList(list *NetworkConfigList, rt *RuntimeConf) (types.Result, error) {
    var prevResult types.Result
    for _, net := range list.Plugins {
        pluginPath, err := invoke.FindInPath(net.Network.Type, c.Path)
                .....
        newConf, err := buildOneConfig(list, net, prevResult, rt)
                ......
        prevResult, err = invoke.ExecPluginWithResult(pluginPath, newConf.Bytes, c.args("ADD", rt))
                ......
    }
 
    return prevResult, nil
} 

从函数上方的注释我们就可以了解到,该函数的作用就是按顺序对NetworkList中的各个network执行ADD操作。该函数的执行过程也非常清晰,利用一个循环遍历NetworkList中的各个network,并对每个network进行如下三步操作:

  • 首先,调用FindInPath函数,根据newtork的类型,在插件的存放路径,也就是上文中的CNI_PATH中查找是否存在对应插件的可执行文件。若存在则返回其绝对路径pluginPath
  • 接着,调用buildOneConfig函数,从NetworkList中提取分离出当前执行ADD操作的network的NetworkConfig结构。这里特别需要注意的是preResult参数,它是上一个network的操作结果,也将被编码进NetworkConfig中。需要注意的是,当我们在执行NetworkList时,必须将前一个network的执行结果作为参数传递给当前正在进行执行的network。并且在buildOneConfig函数构建每个NetworkConfig时会默认将其中的"name"和"cniVersion"和NetworkList中的配置保持一致,从而避免冲突。
  • 最后,调用invoke.ExecPluginWithResult(pluginPath, netConf.Bytes, c.args(“ADD”, rt))真正执行network的ADD操作。这里我们需要注意的是netConf.Bytes和c.args(“ADD”, rt)这两个参数。其中netConf.Bytes用于存放NetworkConfig中的NetConf结构以及例如上文中的prevResult进行json编码形成的字节流。而c.args()函数用于构建一个Args类型的实例,其中主要存储容器运行时信息,以及执行的CNI操作的信息,例如"ADD"或"DEL",和插件的存储路径。

事实上ExecPluginWithResult仅仅是一个包装函数,它仅仅只是调用了函数defaultPluginExec.WithResult(pluginPath, netconf, args)之后,就直接返回了。

func (e *PluginExec) WithResult(pluginPath string, netconf []byte, args CNIArgs) (types.Result, error) {
    stdoutBytes, err := e.RawExec.ExecPlugin(pluginPath, netconf, args.AsEnv())
        .....
    // Plugin must return result in same version as specified in netconf
    versionDecoder := &version.ConfigDecoder{}
    confVersion, err := versionDecoder.Decode(netconf)
        ....
    return version.NewResult(confVersion, stdoutBytes)
}

可以看得出WithResult函数的执行流也是非常清晰的,同样也可以分为以下三步执行:

  • 首先调用e.RawExec.ExecPlugin(pluginPath, netconf, args.AsEnv())函数执行具体的CNI操作,对于它的具体内容,我们将在下文进行分析。此处需要注意的是它的第三个参数args.AsEnv(),该函数做的工作其实就是获取已有的环境变量,并且将args内的信息,例如CNI操作命令,以环境变量的形式保存起来,以例如"CNI_COMMAND=ADD"的形式传输给插件。由此我们可以知道,容器运行时信息、CNI操作命令以及插件存储路径都是以环境变量的形式传递给插件的
  • 接着调用versionDecoder.Decode(netconf)从network配置中解析出CNI版本信息
  • 最后,调用version.NewResult(confVersion, stdoutBytes),根据CNI版本,构建相应的返回结果

最后,我们来看看e.RawExecPlugin函数是如何操作的,代码如下所示:

func (e *RawExec) ExecPlugin(pluginPath string, stdinData []byte, environ []string) ([]byte, error) {
    stdout := &bytes.Buffer{}
 
    c := exec.Cmd{
        Env:    environ,
        Path:   pluginPath,
        Args:   []string{pluginPath},
        Stdin:  bytes.NewBuffer(stdinData),
        Stdout: stdout,
        Stderr: e.Stderr,
    }
    if err := c.Run(); err != nil {
        return nil, pluginErr(err, stdout.Bytes())
    }
 
    return stdout.Bytes(), nil
}

在看完代码之后,我们可能有些许的失望。因为这个理论上最为核心的函数却出乎意料的简单,它所做的工作仅仅只是exec了插件的可执行文件。话虽如此,我们仍然有以下几点需要注意:

  • 容器运行时信息以及CNI操作命令等都是以环境变量的形式传递给插件的,这点在上文中已经有所提及
  • 容器网络的配置信息是通过标准输入的形式传递给插件的
  • 插件的运行结果是以标准输出的形式返回给CNI的

到此为止,整个CNI的执行流已经非常清楚了。简单地说,一个CNI插件就是一个可执行文件,我们从配置文件中获取network配置信息,从容器管理系统处获取运行时信息,再将前者以标准输入的形式,后者以环境变量的形式传递传递给插件,最终以配置文件中定义的顺序依次调用各个插件,并且将前一个插件的执行结果包含在network配置信息中传递给下一个执行的插件,整个过程就是这样。鉴于篇幅所限,本文仅仅只分析了CNI的ADD操作,不过相信有了上文的基础之后,理解DEL操作也不会太难。

参考

https://blog.csdn.net/qq_29648159/article/details/119614573

https://blog.csdn.net/weixin_30815469/article/details/97419281

https://blog.csdn.net/zhonglinzhang/article/details/82697524