1、为什么会有CNI?

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

 

2、什么是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的插件对容器进行相应的配置,从而允许一个容器能够加入多个容器网络。

 

3、怎么用CNI?

在进一步探索CNI之前,我觉得首先来看看CNI是怎么使用的,先对CNI有一个直观的认识,是很有必要的,这对我们之后的理解也将非常有帮助。现在,我们将依次执行如下步骤来演示如何使用CNI,并对每一步的操作进行必要的说明。

(1)、编译安装CNI的官方插件

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

接着执行如下命令,完成插件的下载,编译,安装工作:



$ mkdir -p $GOPATH/src/github.com/containernetworking/plugins

$ git clone https://github.com/containernetworking/plugins.git  $GOPATH/src/github.com/containernetworking/plugins

$ cd $GOPATH/src/github.com/containernetworking/plugins

$ ./build.sh


  

最终所有的插件都将以可执行文件的形式存在在目录$GOPATH/src/github.com/containernetworking/plugins/bin之下。

 

(2)、创建配置文件,对所创建的网络进行描述

工作目录"/etc/cni/net.d"是CNI默认的网络配置文件目录,当没有特别指定时,CNI就会默认对该目录进行查找,从中加载配置文件进行容器网络的创建。至于对配置文件各个字段的详细描述,我将在后续章节进行说明。

现在我们只需要执行如下命令,描述一个我们想要创建的容器网络"mynet"即可。为了简单起见,我们的NetworkList中仅仅只有"mynet"这一个network。



$ mkdir -p /etc/cni/net.d

$ cat >/etc/cni/net.d/10-mynet.conflist <<EOF
{
        "cniVersion": "0.3.0",
        "name": "mynet",
        "plugins": [
          {
                "type": "bridge",
                "bridge": "cni0",
                "isGateway": true,
                "ipMasq": true,
                "ipam": {
                        "type": "host-local",
                        "subnet": "10.22.0.0/16",
                        "routes": [
                                { "dst": "0.0.0.0/0" }
                        ]
                }
          }
        ]
}
EOF

$ cat >/etc/cni/net.d/99-loopback.conf <<EOF
{
	"cniVersion": "0.3.0",
	"type": "loopback"
}
EOF



  

(3)、模拟CNI的执行过程,创建network namespace,加入上文中描述的容器网络"mynet"

首先我们从github上下载、编译CNI的源码,最终将在bin目录下生成一个名为"cnitool"的可执行文件。事实上,可以认为cnitool是一个模拟程序,我们先创建一个名为ns的network namespace,用来模拟一个新创建的容器,再调用cnitool对该network namespace进行网络配置,从而模拟一个新建的容器加入一个容器网络的过程。

从cnitool的执行结果来看,它会返回一个包含了interface,IP,路由等等各种信息的json串,事实上它正是CNI对容器进行网络配置后生成的结果信息,对此我们将在后续章节进行详细的描述。

最终,我们可以看到network namespace内新建的网卡eth0的IP地址为10.22.0.5/16,正好包含在容器网络"mynet"的子网范围10.22.0.0/16之内,因此我们可以认为容器已经成功加入了容器网络之中,演示成功。



$ git clone https://github.com/containernetworking/cni.git $GOPATH/src/github.com/containernetworking/cni

$ cd $GOPATH/src/github.com/containernetworking/cni

$ ./build.sh

$ cd $GOPATH/src/github.com/containernetworking/cni/bin

$ export CNI_PATH=$GOPATH/src/github.com/containernetworking/plugins/bin

$ ip netns add ns

$ ./cnitool add mynet /var/run/netns/ns 
{
    "cniVersion": "0.3.0",
    "interfaces": [
        {
            "name": "cni0",
            "mac": "0a:58:0a:16:00:01"
        },
        {
            "name": "vetha418f787",
            "mac": "c6:e3:e9:1c:2f:20"
        },
        {
            "name": "eth0",
            "mac": "0a:58:0a:16:00:05",
            "sandbox": "/var/run/netns/ns"
        }
    ],
    "ips": [
        {
            "version": "4",
            "interface": 2,
            "address": "10.22.0.5/16",
            "gateway": "10.22.0.1"
        }
    ],
    "routes": [
        {
            "dst": "0.0.0.0/0"
        }
    ],
    "dns": {}
}

$ ip netns exec ns ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 10.22.0.5  netmask 255.255.0.0  broadcast 0.0.0.0
        inet6 fe80::646e:89ff:fea6:f9b5  prefixlen 64  scopeid 0x20<link>
        ether 0a:58:0a:16:00:05  txqueuelen 0  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 8  bytes 648 (648.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0



  

4、怎么配CNI?

从上文中我们可以知道,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的字典
  • 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字段,它可以被用于传输一些额外的字段,但也可能会被插件忽略

 

5、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操作也不会太难。

  

参考链接:

[1] CNI源码:https://github.com/containernetworking/cni

[2] CNI plugin源码:https://github.com/containernetworking/plugins

[3] 《Kubernetes指南》:https://feisky.gitbooks.io/kubernetes/network/cni/

[4] 《CNI:容器网络接口》:http://cizixs.com/2017/05/23/container-network-cni