01 深入理解 Cni 通用设计规范
CNI是Container Network Interface的缩写,简单地说,就是一个标准的,通用的接口。
已知我们现在有各种各样的容器平台:docker,kubernetes,mesos,我们也有各种各样的容器网络解决方案:flannel,calico,weave,并且还有各种新的解决方案在不断涌现。如果每出现一个新的解决方案,我们都需要对两者进行适配,那么由此带来的工作量必然是巨大的,而且也是重复和不必要的。事实上,我们只要提供一个标准的接口,更准确的说是一种协议,就能完美地解决上述问题(一个抽象的接口层,将容器网络配置方案与容器平台方案解耦。)。一旦有新的网络方案出现,只要它能满足这个标准的协议,那么它就能为同样满足该协议的所有容器平台提供网络功能,而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 库实现两类方法, 一类是创建容器时调用, 一类是删除容器时调用.

说明:
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通过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插件,主要分为三种类型:main,meta和ipam。
- main类型的插件主要提供某种网络功能,比如我们在示例中将使用的brdige,以及loopback,ipvlan,macvlan等等。
- meta类型的插件不能作为独立的插件使用,它通常需要调用其他插件,例如flannel,或者配合其他插件使用,例如portmap。
- ipam类型的插件其实是对所有CNI插件共有的IP管理部分的抽象,从而减少插件编写过程中的重复工作,官方提供的有dhcp和host-local两种类型。
各插件的配置文件文档可参考官方文档。
容器运行时通过设置环境变量以及从标准输入传入的配置文件来向插件传递参数。
CNI_COMMAND:定义期望的操作,可以是ADD,DEL,CHECK或VERSION。CNI_CONTAINERID: 容器ID,由容器运行时管理的容器唯一标识符。CNI_NETNS:容器网络命名空间的路径。(形如/run/netns/[nsname])。CNI_IFNAME:需要被创建的网络接口名称,例如eth0。CNI_ARGS:运行时调用时传入的额外参数,格式为分号分隔的key-value对,例如FOO=BAR;ABC=123CNI_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插件的操作类型只有四种: ADD , DEL , CHECK 和 VERSION。 插件调用者通过环境变量 CNI_COMMAND 来指定需要执行的操作。
ADD 操作负责将容器添加到网络,或对现有的网络设置做更改。具体地说,ADD 操作要么:
- 为容器所在的网络命名空间创建一个网络接口,或者
- 修改容器所在网络命名空间中的指定网络接口
例如通过 ADD 将容器网络接口接入到主机的网桥中。
其中网络接口名称由
CNI_IFNAME指定,网络命名空间由CNI_NETNS指定。
DEL 操作负责从网络中删除容器,或取消对应的修改,可以理解为是 ADD 的逆操作。具体地说,DEL 操作要么:
- 为容器所在的网络命名空间删除一个网络接口,或者
- 撤销
ADD操作的修改
例如通过 DEL 将容器网络接口从主机网桥中删除。
其中网络接口名称由
CNI_IFNAME指定,网络命名空间由CNI_NETNS指定。
CHECK 操作是v0.4.0加入的类型,用于检查网络设置是否符合预期。容器运行时可以通过CHECK来检查网络设置是否出现错误,当CHECK返回错误时(返回了一个非0状态码),容器运行时可以选择Kill掉容器,通过重新启动来重新获得一个正确的网络配置。
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的字典
- Parameters:
- 将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字段,它可以被用于传输一些额外的字段,但也可能会被插件忽略
单个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规范实现容器网络配置,就需要基于libcni库实现CNI,然后就可以按照CNI规范调用CNI插件。此外我们通过手动的方式去模拟容器运行时去调用CNI插件。 容器运行时也是通过对CNI插件的可执行程序的调用实现网络配置。
接下来将演示如何使用CNI插件来为Docker容器设置网络。
为方便起见,我们直接下载可执行文件:
# 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。
在本示例中,我们会直接调用CNI插件,为容器设置eth0接口,为其分配IP地址,并接入主机网桥mynet0。
跟docker默认使用的使用网络模式一样,只不过我们将
docker0换成了mynet0。
- 启动容器
虽然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表示进入对应进程的网络命名空间。
- 添加容器网络接口并连接主机网桥
接下来我们使用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
- 从主机访问验证
由于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
....
- 删除容器网络接口
删除的调用入参跟添加的入参是一样的,除了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中,我们将在示例1的基础上,使用portmap插件为容器添加端口映射。
- 使用cnitool工具
前面的介绍中,我们知道在链式调用过程中,调用方需要转换配置文件,并需要将上一次插件的返回结果插入到本次插件的配置文件中。这是一项繁琐的工作,而libcni已经将这些过程封装好了,在示例2中,我们将使用基于 libcni的命令行工具cnitool来简化这些操作。
示例2将复用示例1中的容器,因此在开始示例2时,请确保已删除示例1中的网络接口。
通过源码编译或go install来安装cnitool:
go install github.com/containernetworking/cni/cnitool@latest
- 配置文件
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插件,bridge和portmap。根据上述的配置文件,cnitool会先为容器添加网络接口并连接到主机mynet0网桥上(就跟示例1一样),然后再调用portmap插件,将容器的80端口映射到主机的8080端口,就跟docker run -p 8080:80 xxx一样。
libnci会基于其配置文件生成多个CNI插件的配置文件,然后在调用各个CNI插件时通过stdin方式传递给CNI插件。
- 设置容器网络
使用cnitool我们还需要设置两个环境变量:
NETCONFPATH: 指定配置文件(*.conflist)的所在路径,默认路径为/etc/cni/net.dCNI_PATH:指定CNI插件的存放路径。
使用cnitool add命令为容器设置网络:
CNI_PATH=~/cni/bin NETCONFPATH=. cnitool add portmap $netnspath
设置成功后,访问宿主机8080端口即可访问到容器的nginx服务。
- 删除网络配置
使用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主要解决:
Pod同节点通信
Pod跨节点通信
Pod与SVC通信
手动实现flanel的hostgw模式。
到目前为止,我们对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