目录

Kubernetes | CNI 的基本原理

1. CNI

Kubernetes 之所以要设置这样一个与 docker0 网桥功能几乎一样的 CNI 网桥,主要原因包括两个方面:

  • 一方面,Kubernetes 项目并没有使用 Docker 的网络模型(CNM),所以它并不希望、也不具备配置 docker0 网桥的能力;

  • 另一方面,这还与 Kubernetes 如何配置 Pod,也就是 Infra 容器的 Network Namespace 密切相关。Kubernetes 创建一个 Pod 的第一步,就是创建并启动一个 Infra 容器,用来“hold”住这个 Pod 的 Network Namespace。所以,CNI 的设计思想,就是:Kubernetes 在启动 Infra 容器之后,就可以直接调用 CNI 网络插件,为这个 Infra 容器的 Network Namespace,配置符合预期的网络栈。

    一个 network namespace 的网络栈包括:网卡(network interface)、回环设备(loopback device)、路由表(routing table)和 iptables 规则。

1.1. CNI 插件所需的基础可执行文件

在部署 Kubernetes 的时候,有一个步骤是安装 kubernetes-cni 包,它的目的就是在宿主机上安装 CNI 插件所需的基础可执行文件。这些可执行文件包括(查看 /opt/cni/bin 目录可以看到):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ ls -al /opt/cni/bin/
total 73088
-rwxr-xr-x 1 root root  3890407 Aug 17  2017 bridge
-rwxr-xr-x 1 root root  9921982 Aug 17  2017 dhcp
-rwxr-xr-x 1 root root  2814104 Aug 17  2017 flannel
-rwxr-xr-x 1 root root  2991965 Aug 17  2017 host-local
-rwxr-xr-x 1 root root  3475802 Aug 17  2017 ipvlan
-rwxr-xr-x 1 root root  3026388 Aug 17  2017 loopback
-rwxr-xr-x 1 root root  3520724 Aug 17  2017 macvlan
-rwxr-xr-x 1 root root  3470464 Aug 17  2017 portmap
-rwxr-xr-x 1 root root  3877986 Aug 17  2017 ptp
-rwxr-xr-x 1 root root  2605279 Aug 17  2017 sample
-rwxr-xr-x 1 root root  2808402 Aug 17  2017 tuning
-rwxr-xr-x 1 root root  3475750 Aug 17  2017 vlan

这些 CNI 的基础可执行文件,按照功能可以分为以下三类:

  • 第一类,叫做 Main 插件,它是用来创建具体网络设备的二进制文件。比如,bridge(网桥设备)、ipvlan、loopback(lo设备)、macvlan、ptp(Veth Pari 设备),以及 vlan。

    Flannel、Weave 等项目都属于网桥类型的 CNI 插件。所以在具体实现中,它们往往会调用 bridge 这个二进制文件。

  • 第二类,叫做 IPAM(IP Address Management)插件,它是负责分配 IP 地址的二进制文件。比如,

    • dhcp 会向 DHCP 服务器发起请求;
    • host-local 会使用预先配置的 IP 地址段来进行分配。
  • 第三类,是由 CNI 社区维护的内置 CNI 插件,比如

    • flannel,这就是专门为 Flannel 项目提供的 CNI 插件;
    • tunning,是一个通过 sysctl 调整网络设备参数的二进制文件;
    • portmap 是一个通过 iptables 配置端口映射的二进制文件;
    • bandwidth 是一个使用 Token Bucket Filter(TBF)来进行限流的二进制文件。

从这些二进制文件中,我们可以看到,如果要实现一个给 Kubernetes 用的容器网络方案,其实需要做两部分工作,以 Flannel 项目为例:

  • 首先,实现这个网络方案本身。这一部分需要编写的,其实就是 flanneld 进程里的主要逻辑。比如,创建和配置 flannel.1 设备、配置宿主机路由、配置 ARP 和 FDB 表里的信息等等。
  • 然后,实现该网络方案对应的 CNI 插件。这一部分主要需要做的,就是配置 Infra 容器里面的网络栈,并把它连接在 CNI 网桥上。

Flannel 项目对应的 CNI 插件已经被内置了,所以需要再单独安装 CNI 插件(这里的意思是 Flannel 所需要的插件已经在这个安装包中了)。然而,对于 Weave、Calico 等项目来说,在使用它们的时候,必须要把对应的 CNI 插件的可执行文件放到 /opt/cni/bin 目录中。

其实,对于 Weave、Calico 这些网络方案来说,它们的 DaemonSet 只需要挂载了宿主机的 /opt/cni/bin,那么就可以自己安装插件可执行文件了。

1.2. Flannel 网络方案实现

接下来,就需要在宿主机上安装 flanneld(网络方案本身了)。flanneld 启动后会在每台宿主机上生成对应的 CNI 配置文件(就是一个 ConfigMap),从而告诉 kubernetes 这个集群将使用 Flannel 作为容器网络方案。这个 CNI 配置文件的内容如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$ cat /etc/cni/net.d/10-flannel.conflist 
{
  "name": "cbr0",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}

需要注意的是,在 Kubernetes 中,处理容器网络相关的逻辑并不会在 kubelet 主干代码里执行,而是会在具体的 CRI(Container Runtime Interface,容器运行时接口)实现里完成。对于 Docker 项目来说,它的 CRI 实现叫作 dockershim,你可以在 kubelet 的代码里找到它。对于 containerd 来说,是在 cri-plugin 里。

所以对于 docker 项目来说, dockershim 会加载上述的 CNI 配置文件。但是,Kubernetes 目前不支持多个 CNI 插件混用,所以如果在 /etc/cni/net.d (CNI 配置目录)里放置了多个 CNI 配置文件的话,dockershim 只会加载按字母顺序排序的第一个。但是,CNI 允许你在一个 CNI 配置文件里,通过 plugins 字段,定义多个插件进行协作。比如,上面的配置文件就指定了 flannel 和 portmap 这两个插件。这个时候,dockershim 会把这个 CNI 配置文件加载起来,并且把列表里的第一个插件,也就是 flannel 插件设置为默认插件。在之后的执行中,flannel 和 portmap 插件会按照定义顺序被调用,从而依次完成配置容器网络和配置端口映射。

1.3. CNI 插件的工作原理

当 kubelet 组件需要创建 Pod 的时候,它第一个创建的一定是 Infra 容器,所以在这一步,dockershim 就会调用 Docker API 创建并启动 Infra 容器,紧接着会执行 SetUpPod() 方法,这个方法的作用就是为:

  • CNI 插件准备参数。
  • 然后调用 CNI 插件为 Infra 容器配置网络(比如调用 /opt/cni/bin/flannel)。

而调用这个方法所需要的参数分为两部分:

  • dockershim 设置的一组 CNI 环境变量。其中最重要的环境变量参数叫作:CNI_COMMAND。它的取值只有两种:ADD 和 DEL。这个 ADD 和 DEL 操作,就是 CNI 插件唯一需要实现的两个方法。

    • 其中 ADD 操作的含义是:把容器添加到 CNI 网络里。对于网桥类型的 CNI 插件来说,意味着把容器以 Veth Pair 的方式插到 CNI 网桥上。

      CNI 的 ADD 操作需要的参数:容器里网卡的名字 CNI_IFNAME(如 eth0)、Pod 的 Network Namespace 文件的路径(CNI_NETNS,即 /proc/<容器进程的PID>/ns/net)、容器的 ID(CNI_CONTAINERID)等。这些参数都属于上述环境变量里的内容。此外,在 CNI 环境变量里,还有一个叫做 CNI_ARGS 的参数,通过这个参数,CRI 实现(比如 dockershim)就可以以 key-value 的格式传递自定以信息给网络插件(用户可以使用这个来自定义 CNI 协议)。

    • DEL 操作的含义是把容器从 CNI 网络里移除掉。对于网桥类型的 CNI 插件来说,意味着把容易以 Veth Pair 的方式从网桥上“拔掉”。

  • dockershim 从 CNI 配置文件里加载到的、默认插件的配置信息(这个默认是指配置文件里列表中的第一个插件)。

    这个配置信息在 CNI 中被叫作 Network Configuration(完整定义可查看:https://github.com/containernetworking/cni/blob/master/SPEC.md#network-configuration)。dockershim 会把 Network Configuration 以 JSON 数据的格式,通过标准输入(stdin)的方式传递给 Flannel CNI 插件。

有了这两部分参数之后,Flannel CNI 插件实现 ADD 操作的实现就相当简单了。

需要的是,Flannel 的 CNI 配置文件(/etc/cni/net.d/10-flannel.conflist)里有这么一个字段,叫做 delegate。

1
2
3
4
5
...
     "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }

delegate 字段的意思是,这个 CNI 插件并不会自己做事儿,而是会调用 Delegate 指定的某种内置插件来完成。对于 Flannel 来说,它调用的 Delegata 插件是 CNI bridge 插件。所以说,dockershim 对 Flannel CNI 插件的调用,其实就是对 dockershim 传来的 Network Configuration 进行补充。比如将 Delegate 的 IPAM 字段设置为 host-local 等,最终 Delegate 字段如下所示。其中,ipam 字段里的信息,比如 10.244.1.0/24,读取自 Flannel 在宿主机上生成的 Flannel 配置文件,即:宿主机上的 /run/flannel/subnet.env 文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
    "hairpinMode":true,
    "ipMasq":false,
    "ipam":{
        "routes":[
            {
                "dst":"10.244.0.0/16"
            }
        ],
        "subnet":"10.244.1.0/24",
        "type":"host-local"
    },
    "isDefaultGateway":true,
    "isGateway":true,
    "mtu":1410,
    "name":"cbr0",
    "type":"bridge"
}

接下来,Flannel CNI 插件会调用 CNI bridge 插件,也就是执行:/opt/cni/bin/bridge 二进制文件。这个时候,调用 CNI bridge 插件需要的两部分参数的第一个部分,还是 CNI 环境变量没有变化。而第二部分 Network Configuration,正好是上面补充的 Delegate 字段。Flannel CNI 插件会把 Delegate 字段的内容以标准输入的方式传递给 CNI bridge 插件。此外,Flannel CNI 插件还会把 Delegate 字段以 JSON 文件的方式,保存在 /var/lib/cni/flannel 目录下。这是为了给后面删除容器调用 DEL 操作时使用。

之后,CNI bridge 插件就可以代表 Flannel,将容器加入到 CNI 网络里。

  • 首先 CNI bridge 插件会在宿主机上检查 CNI 网桥是否存在,如果没有,那就创建。

    1
    2
    3
    
    # 在宿主机上
    $ ip link add cni0 type bridge
    $ ip link set cni0 up
    
  • 接下来 CNI bridge 插件会通过 Infra 容器的 Network Namespace 文件,进入到这个 Network Namespace 里面,然后创建一对 Veth Pair 设备。

  • 紧接着,它会把这个 Veth Pair 的其中一端,移动到宿主机上。这相当于在容器里执行如下命令。这样,vethb4963f3 就出现在了宿主机上,而这个 Veth Pair 的另一端,就是容器里的 eth0。

    上述创建 Veth pair 设备的操作,其实在宿主机上也可以执行,然后再把 Veth Pair 的一端放到容器的 Network Space 里,原理是一样的。而之所以这样反着来,是因为 CNI 里对 Namespace 操作函数的设计就是这样反着来的。而这样反着来的原因是因为在编程时,容器的 Namespace 是可以直接通过 Namespace 文件拿到的,而 Host Namespace,则是一个隐含在上下文的参数。所以这样反着来,就是先进入到容器 namespace 里面,然后再反向操作 host namespace,对于编程来说更加方便。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    #在容器里
      
    # 创建一对Veth Pair设备。其中一个叫作eth0,另一个叫作vethb4963f3
    $ ip link add eth0 type veth peer name vethb4963f3
      
    # 启动eth0设备
    $ ip link set eth0 up 
      
    # 将Veth Pair设备的另一端(也就是vethb4963f3设备)放到宿主机(也就是Host Namespace)里
    $ ip link set vethb4963f3 netns $HOST_NS
      
    # 通过Host Namespace,启动宿主机上的vethb4963f3设备
    $ ip netns exec $HOST_NS ip link set vethb4963f3 up 
    
  • 接下来,CNI bridge 插件就可以把 vethb4963f3 设备连接在 CNI 网桥上。相当于在宿主机中,执行

    1
    2
    
    # 在宿主机上
    $ ip link set vethb4963f3 master cni0
    
  • 在将 vethb4963f3 设备连接在 CNI 网桥之后,CNI bridge 插件还会为它设置 Hairpin Mode(发夹模式)。这是因为,在默认情况下,网桥设备是不允许一个数据包从一个端口进来后,再从这个端口发出去的。但是,它允许你为这个端口开启 Hairpin Mode,从而取消这个限制。

    而取消这个限制的原因,主要是考虑到要在容器中通过 NAT (端口映射)的方式,“自己访问自己”的情况。比如执行 docker run -p 8080:80,就是在宿主机上通过 iptables 设置了一条DNAT(目的地址转换)转发规则。这条规则的作用是,当宿主机上的进程访问“< 宿主机的 IP 地址 >:8080”时,iptables 会把该请求直接转发到“< 容器的 IP 地址 >:80”上。也就是说,这个请求最终会经过 docker0 网桥进入容器里面。但如果你是在容器里面访问宿主机的 8080 端口,那么这个容器里发出的 IP 包会经过 vethb4963f3 设备(端口)和 docker0 网桥,来到宿主机上。此时,根据上述 DNAT 规则,这个 IP 包又需要回到 docker0 网桥,并且还是通过 vethb4963f3 端口进入到容器里。所以,这种情况下,我们就需要开启 vethb4963f3 端口的 Hairpin Mode 了。

    所以说,Flannel 插件要在 CNI 配置文件里声明 hairpinMode=true。这样,将来这个集群里的 Pod 才可以通过它自己的 Service 访问到自己。

  • 接下来,CNI bridge 插件会调用 CNI ipam 插件,从 ipam.subnet 字段规定的网段里为容器分配一个可用的 IP 地址。然后,CNI bridge 插件就会把这个 IP 地址添加到容器的 eth0 网卡上,同时为容器设置默认路由。相当于在容器中执行:

    1
    2
    3
    
    # 在容器里
    $ ip addr add 10.244.0.2/24 dev eth0
    $ ip route add default via 10.244.0.1 dev eth0
    
  • 最后 CNI bridge 插件会为 CNI 网桥添加 IP 地址,这相当于在宿主机上执行:

    1
    2
    
    # 在宿主机上
    $ ip addr add 10.244.0.1/24 dev cni0
    

在执行完上述操作之后,CNI 插件会把容器的 IP 地址等信息返回给 dockershim,然后被 kubelet 添加到 Pod 的 Status 字段。

至此,CNI 插件的 ADD 方法就宣告结束了。之后的通信方式就跟之前提过的完全一致了。然而,对于非网桥类型的 CNI 插件,上述“将容器添加到 CNI 网络”的操作流程,以及网络方案本身的工作原理,就都不太一样了。

1.4. 总结

CNI 网络的实现原理,其实就是很容易理解的“kubernetes”网络模型:

  • 所有容器都可以直接使用 IP 地址与其他容器通信,而无需使用 NAT。
  • 所有宿主机都可以直接使用 IP 地址与所有容器通信,而无需使用 NAT。反之亦然。
  • 容器自己“看到”的自己的 IP 地址,和别人(宿主机或者容器)看到的地址是完全一样的。

这个模型用一个字总结就是“通”,并且这个通,还必须直接基于容器和宿主机的 IP 地址来进行的。

2. 巨人的肩膀

  1. https://www.lifewire.com/layers-of-the-osi-model-illustrated-818017
  2. 极客时间.张磊.《深入剖析Kubernetes》