本篇文档主要介绍kubernetes网络模型实现原理。
首先从docker网络模型入手,介绍同宿主机不同容器如何互通(网桥),接着分析不同宿主机的容器是如何通信的(跨主通信)。
然后在有了跨主通信的基础上,转向kubernetes集群如何管理宿主机里面容器的网络(CNI插件)。
总体的实现思路都是围绕着网桥的概念延伸的,在不同宿主机之间搭一个桥(搭什么样的桥?),然后让容器都上这座桥(怎么让容器上桥?),最后容器的沟通都在这桥上进行(容器怎么沟通?)。当然还要考虑,我要沟通的容器在不在这个桥上(怎么知道桥上有谁?)。
容器网络,可以直接声明使用宿主机的网络栈(-net=host),即,不开启Network Namespace:
docker run -d net=host --name nginx-host nginx
这个容器启动后,直接监听宿主机的80端口。像这种直接使用宿主机网络栈的方式,虽然可以为容器提供良好的网络性能,但也会不可避免地引入共享网络资源的问题,比如端口冲突。所以在大多数情况下,希望容器进程能使用自己的Network Namespace里的网络栈,就是拥有自己的IP地址和端口。
问题1:那如何把容器网络与宿主机网络互通,或者如何让容器网络与外界网络通信?
在使用Network Namespace之后,容器自己相当于是一台主机,有一套独立的“网络栈”。那问题就在于这个容器如何与其他Network Namespace沟通。我们都知道主机与主机之间通过网线,连到一台交换机上,然后进行通信。那在Linux中,能够起到虚拟交换机作用的网络设备,就是网桥(Bridge)。一个工作在数据链路层(Data Link)的设备,主要功能是根据MAC地质学习来将数据包转发到网桥的不同端口上。
所以,容器网络实现的第一步,就是Docker项目会默认在宿主机上创建一个名叫docker0的网桥,凡是连到docker0网桥上的容器,就可以通过它来通信。启动容器后,通过指令ifconfig
可以看到docker0这个虚拟网络设备:
有了网桥,那下个问题就是,如何把容器“连接”到docker0网桥上?答案就是Veth Pair虚拟设备。
Veth Pair虚拟设备,它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出现。并且,从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网卡”上,哪怕这两个“网卡”在不同的Network Namespace里。
通过启动一个ubuntu容器来看下Veth Pait设备如何在容器与宿主之间体现的。
docker run -d --name redis-1 -p 6379:6379 redis:latest
进入容器,在容器内执行ifconfig
指令
#如果command not found执行系列指令
$ apt-get update
$ apt install net-tools
从上图可以看到,有张网卡叫eth0,这就是Veth Pair虚拟设备的一端。在通过指令route
查看容器的路由表,可以看到eth0作为容器里的默认路由设备,对172.17.0.0/16网段的请求,也会通过eth0来处理。
按照前面说的,Veth Pair设备有两端,一端已经在容器内了,另一端就是在宿主机上。在宿主机上,通过指令ifconfig
查看。会发现,多了一个veth*******的虚拟网卡设备。
也可以通过指令brctl show
查看有哪些虚拟网卡设备连到docker0网桥上了,因为现在我虚拟机上只有一个容器,所以只会出现一个设备。之后,在这个宿主机上启动其他的容器,都会添加到这个docker0上面。
#如果指令command not found,执行下列指令
$ apt-get update
$ apt install bridge-utils
当然,通常一台宿主机上上会有很多容器,容器会有很多veth开头的虚拟网络设备,可以通过下面方法找到你容器对应的veth虚拟网卡设备:
首先在容器内打印car /sys/class/net/neth0/iflink
这个序号,然后根据这个序号在宿主机上根据指令ip link
上的序号,就可以找到你容器对应的虚拟网卡。
在宿主机上再添加一个redis-2容器,然后redis-1 ping redis-2是默认可以互ping的。
宿主机结构如下:
这个⽬的IP地址会匹配到redis-1容器⾥的第⼆条路由规则。可以看到,这条路由规则的⽹关(Gateway)是0.0.0.0,这就意味着这是⼀条直连规则,即:凡是匹配到这条规则的IP包,应该经过本机的eth0⽹卡,通过⼆层⽹络直接发往⽬的主机。
而通过二层网络到达redis-2容器,就需要有172.17.0.2这个IP地址对应的MAC地址。所以redis-1容器的⽹络协议栈,就需要通过eth0⽹卡发送⼀个ARP⼴播,来通过IP地址查找对应的MAC地址。
所以,在收到这些ARP请求之后,docker0⽹桥就会扮演⼆层交换机的⻆⾊,把ARP⼴播转发到其他被“插”在docker0上的虚拟⽹卡上。这样,同样连接在docker0上的redis-2容器的⽹络协议栈就会收到这个ARP请求,从⽽将172.17.0.3所对应的MAC地址回复给redis-1容器。
有了目的的MAC地址,redis-1容器的eth0网卡就可以将数据包发出去。
⽽根据Veth Pair设备的原理,这个数据包会⽴刻出现在宿主机上的veth703d5b8虚拟⽹卡上。不过,此时这个veth703d5b8⽹卡的⽹络协议栈的资格已经被“剥夺”,所以这个数据包就直接流⼊到了docker0⽹桥⾥。
因为是同宿主机内的容器,所以dockere0根据二层ebtables规则转发该数据包,及转发到veth3571f6b网卡。这个网卡就是redis-2的Veth Pair虚拟网络设备的另一端,所以redis-1发来的数据包就会出现在redis-2容器的eth0网卡上。
这样,redis-2的网络协议栈就会对请求进行处理,返回Pong到redis-1。
通过前面一节,了解到同宿主机下的容器网络通过网桥+Veth Pair的方式来实现网络通信。但是,容器不会永远只和同宿主机的容器通信。
问题2:不同宿主机上的容器如何实现网络通信?
这个问题,其实就是容器的“跨主通信“问题”。在Docker的默认配置下,一台宿主机上的docker0网桥,和其他宿主机上的docker0网桥,没有任何关联,它们互相之间也没办法连通。所以,连接在这些网桥上的容器,自然也没办法进行通信。
在实现同宿主机下容器互通的时候,为了把不容Network Namespace下的容器“连到”同一个网络下,我们创建了docker0网桥。那在实现不同宿主机下的容器互通,我们是否可以效仿呢?答案是肯定的。
通过上图可以看到,通过软件的方式,创建一个整个“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,那容器与容器之间是不是就可以根据IP来互通了。
所以构筑跨主网络的核心在于:我们需要在已有的宿主机网络上,在通过软件构建一个覆盖在已有宿主机网络只上的、可以把所有容器联通在一起的虚拟网络。这种网络就被称为:Overlay Network(覆盖网络)。
当node1的container1要访问node2上的container1时,node1上的“特殊网桥”在收到数据包之后,能够通过某种方式,把数据包转发给正确的容器,node2的container1。
下面通过更详细的网络方案,Flannel,来理解容器“跨主通信”的原理。
Flannel项目是CoreOS公司主推的容器网络方案。Flannel项目本身只是一个框架,真正为我们提供容器网络功能的是Flannel的后端实现。Flannel支持三种后端实现,分别是:
UPD
VXLAN
host-gw
这三种方法代表了三种目前容器跨主网络的主流实现方法。
在宿主机启动Flannel后(一般宿主机已经是kubernetes集群中的一个节点了),首先宿主机上会出现flannel0设备,flannel0是一个TUN设备,Tunnel设备(Network Layerd的虚拟网络设备,在操作系统内核和用户应用程序之间传递IP包)。然后,在宿主机上创建新的路由规则。以下面架构中node1为例,会增加一条转发到flannel0的路由:
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0 proto kernel scope link src 100.96.1.0
100.96.1.0/24 dev docker0 proto kernel scope link src 100.96.1.1
10.168.0.0/24 dev eth0 proto kernel scope link src 10.168.0.2
UDP模式最直接,但是性能最差,目前已经被弃用。不过,我们可以通过UDP模式,来了解容器跨主网络的实现原理,然后再通过某些技术去解决UDP模式的问题,形成新的解决方案。
通过下面这架构来理解UDP的跨主网络:
先看一下这个架构,2个node,分别有2个容器:
node1,etch0网卡IP为10.168.0.2,flannel0设备IP为100.16.1.0,docker0设备IP为100.96.1.1,container1的IP为100.96.1.12
node2,etch0网卡IP为10.168.0.3,flannel0设备IP为100.16.2.0,docker0设备IP为100.96.2.1,container1的IP为100.96.2.3
我们将通过node1发送一个包给node2,通过这个发包整个过程,了解flannel UDP模式下的运作原理。
发包过程
容器发包
node1的container1里的进程发起IP包,源IP为100.96.1.12,目的IP为100.96.2.1。这里步骤在上一节的容器网络有介绍,容器的包如何出现在docker0上,这里就不在赘述。因此,容器发出的IP包就会出现在docker0网桥上。由于目的IP 100.96.2.3不在node1的docker0的网段上,所以IP包的下一个目的地,就取决于宿主机上的路由规则。
IP包发送至flannel设备
在前面我们讲到flannel会事先在宿主机上添加一条路由规则,在ip route结果里:
100.96.0.0/16 dev flannel0 proto kernel scope link src 100.96.1.0
可以看到这个容器发起的IP包匹配这条路由规则,从而进入到flannel0这个设备里。前面介绍过,这个设备是在操作系统内核和用户应用程序之间传递IP包。当IP包发送给flannel0设备后,flannel0就会把这个IP包,交个创建这个设备的应用程序,也就是Flannel进程。这个一个从内核态向用户态的流动方向。flanneld(即Flannel进程)收到IP包后,根据目的IP 100.96.2.3,就把这个IP包发到node2宿主机。
问题3:flanneld进程如何知道IP的转发路径?
这里就不得不提Flannel中一个很重要的概念:子网(subnet)。
在由Flannel管理的容器网络里,一台宿主机上的所有容器,都属于该宿主机被分配的一个“子网”。在我们的例子中,node1的子网是100.96.1.0/24,node1的container1 IP为100.96.1.12。node2的子网是100.96.2.0/24,node2的container1 IP为100.96.2.3。那这些对应关系存在哪呢?怎么让不同宿主机的flanneld进程都知道呢?别忘了,我们现在kubernetes的环境下,kubernetes通过Etcd来存储整个集群的元数据。同样的,子网与宿主机的对应关系,也是保存在Etcd当中。可以通过etcdctl
查看:
$ etcdctl ls /coreos.com/network/subnets
/coreos.com/network/subnets/100.96.1.0-24
/coreos.com/network/subnets/100.96.2.0-24
所以,flanneld进程在处理由flannel0传入的IP包时,就可以根据目的IP的地址,匹配到对应的子网,然后再从Etcd中找到这个子网对应的宿主机的IP是10.168.0.3。
$ etcdctl get /coreos.com/network/subnets/100.96.2.0-24
{
"PublicIP":"10.168.0.3"}
封装UDP
flanneld在收到IP包后,就会把这个IP包直接封装在一个UDP包里,然后发送给node2。源IP为10.168.0.2,即node1的IP地址。目的IP为10.168.0.3,即node2的IP地址。当然,这个发送请求可以成功的原因是,每台宿主机上的flanneld都监听这个一个8285的端口,所以flanneld只要把UDP包发往Node2的8285端口即可。
flanneld把包通过eth0发到node2上,通过宿主网络
解封装UDP
node2上监听8285端口的进程也是flanneld,所以,flanneld就可以从这个UDP包解析出封装在里面的、node1的container1的IP包。接下来,flanneld会直接把这个IP包发送给它所管理的TUN设备,即flannel0设备。
flannel设备转发至docker0
根据前面讲的,这是一个从用户态向内核态的流动方向,所以Linux内核网络栈就会负责处理这个IP包,具体通过本机的路由表来寻找这个IP包下一步要转发到哪里。根据node2的ip route:
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0 proto kernel scope link src 100.96.2.0
100.96.2.0/24 dev docker0 proto kernel scope link src 100.96.2.1
10.168.0.0/24 dev eth0 proto kernel scope link src 10.168.0.3
这个IP包的目的IP是100.96.2.3,匹配第三条,所以这个IP包就会转发至docker0网桥
容器接收IP包
第七步,就和容器网络里面的接收是一样的,docker0网桥会扮演二层交换机的角色,将数据包发送给正确的端口,进而通过Veth Pair设备进入到node2的container1的Network Namespace里。
至此,就是跨主网络flannel UDP模式通信的完整例子。这个例子有个前提,就是docker0网桥的地址范围必须是Flannel为宿主机分配的子网。可以通过下面方式实现:
$ FLANNEL_SUBNET=100.96.1.1/24
$ dockerd --bip=$FLANNEL_SUBNET ...
#在kubernetes里可以通过kubelet的参数--pod-network-cidr设定
可以看到,Flannel UDP模式提供的其实是⼀个三层的Overlay⽹络,即:它⾸先对发出端的IP包进⾏UDP封装,然后在接收端进⾏解封装拿到原始的IP包,进⽽把这个IP包转发给⽬标容器。这就好⽐,Flannel在不同宿主机上的两个容器之间打通了⼀条“隧道”,使得这两个容器可以直接使⽤IP地址进⾏通信,⽽⽆需关⼼容器和宿主机的分布情况。
性能不足分析
在一开始的时候,我们提到UDP模式虽然直接,但是性能不好,现在我们分析下为何性能不好。
实际上,相⽐于两台宿主机之间的直接通信,基于Flannel UDP模式的容器通信多了⼀个额外的步骤,即flanneld的处理过程。⽽这个过程,由于使⽤到了flannel0这个TUN设备,仅在发出IP包的过程中,就需要经过三次⽤户态与内核态之间的数据拷⻉,如下所示:
我们可以看到:
第⼀次:⽤户态的容器进程发出的IP包经过docker0⽹桥进⼊内核态;
第⼆次:IP包根据路由表进⼊TUN(flannel0)设备,从⽽回到⽤户态的flanneld进程;
第三次:flanneld进⾏UDP封包之后重新进⼊内核态,将UDP包通过宿主机的eth0发出去。
此外,我们还可以看到,Flannel进⾏UDP封装(Encapsulation)和解封装(Decapsulation)的过程,也都是在⽤户态完成的。在Linux操作系统中,上述这些上下⽂切换和⽤户态操作的代价其实是⽐较⾼的,这也正是造成Flannel UDP模式性能不好的主要原因。
所以说,我们在进⾏系统级编程的时候,有⼀个⾮常重要的优化原则,就是要减少⽤户态到内核态的切换次数,并且把核⼼的处理逻辑都放在内核态进⾏。
从上面UDP性能分析来看,UDP模式主要问题是在有多次的用户态转内核态,内核态转用户态的操作。因此,Flannel后来提出了VXLAN模式,也是主流的容器网络方案。
VXLAN,即Virtual Extensible LAN(虚拟可扩展局域网),是Linux内核本身就支持的一种网络虚拟化技术。与UDP模式不同的是,VXLAN可以完全在内核态实现UDP封装和解封装的工作,从而通过与前面相似的“隧道”机制,构建出Overlay Network。
VXLAN的覆盖⽹络的设计思想是:在现有的三层⽹络之上,“覆盖”⼀层虚拟的、由内核VXLAN模块负责维护的⼆层⽹络,使得连接在这个VXLAN⼆层⽹络上的“主机”(虚拟机或者容器都可以)之间,可以像在同⼀个局域⽹(LAN)⾥那样⾃由通信。当然,实际上,这些“主机”可能分布在不同的宿主机上,甚⾄是分布在不同的物理机房⾥。
⽽为了能够在⼆层⽹络上打通“隧道”,VXLAN会在宿主机上设置⼀个特殊的⽹络设备作为“隧道”的两端。这个设备就叫作VTEP,即:VXLAN Tunnel End Point(虚拟隧道端点)。VTEP设备的作用,其实跟前面的flanneld进程非常相似。只不过,它进行封装和解封装的对象,是二层数据帧(Ethernet Frame),并且整个工作的执行流程,全部都是在内核里完成的,因为VXLAN本身就是Linux内核中的一个模块。
和UDP模式一样,在启动flannel VXLAN模式后,会在宿主机上创建一个flannel.1的虚拟网络设备,也就是VTEP设备,这个设备既有IP地址也有MAC地址。如果这个Flannel网络有别的宿主机,就会添加一条路由到其他宿主机上。例如,node2是后来添加到Flannel网络的,那node的路由规则就会添加下面这条:
Destination Gateway Genmask Flags Metric Ref Use Iface
100.96.2.0 100.96.2.0 255.255.255.0 UG 0 0 0 flannel.1
VXLAN的架构如下,和UDP模式的很类似:
我们也通过node1的container1发包到node2的container1来了解VXLAN的工作模式,主要封包封装的变化:
容器发包
实现方式和UDP模式的第一个步骤一样,不做赘述。
IP包转发至flannel.1
实现方式和UDP模式的第二个步骤类似,不做赘述。
到这里的包头如下:
封装成为UDP后转发至node2
此时IP包已经到了VTEP设备,即flannel.1。这些VTEP设备之间,就需要想办法组成一个虚拟的二层网络,即通过二层数据帧进行通信。所以,“源VTEP设备”(node1的flannel.1)收到IP包后,就要把IP包加上一个目的MAC地址,封装成一个二层数据帧,然后发送给“目的VTEP设备”(node2的flannel.1)。
目的VTEP设备的MAC地址,早就在node2添加到Flannel网络的时候,自动添加到node1上的ARP记录里。可以用指令ip neigh show dev flannel.1
查看
$ ip neigh show dev flannel.1
100.96.2.0 lladdr 5e:f8:4f:00:e3:37 PERMANENT
有了这个目的VTEP设备的MAC地址,Linux内核就可以开始二层封包工作。Linux内核会把目的VTEP设备的MAC地址,填在Inner Ethernet Header字段,得到一个二层数据帧,此时包头如下:
但是添加的目的VTEP设备MAC地址相对于宿主机来说没有实际意义,因为现在封装好的数据帧,并不能在宿主机的二层网络里传输,也被成为内部数据帧(Inner Ethernet Frame)。为了让内部数据帧转换为外部数据帧,成为宿主机网络里的一个普通数据帧,Linux内核还需要做如下动作。
Linux内核会在“内部数据帧”前面,加一个特殊的VXLAN头,用来表示这是实际上是一个VXLAN要使用的数据帧,此时包头如下:
然后,Linux内核会把这个数据帧封装进一个UDP包发出去。跟UDP模式类似,在宿主机看来,它会以为⾃⼰的flannel.1设备只是在向另外⼀台宿主机的flannel.1设备,发起了⼀次普通的UDP链接。它哪⾥会知道,其实这个UDP包⾥⾯,其实是⼀个完整的⼆层数据帧:
到目前为止,外部数据帧还剩下IP层和数据层还没有封装。从前面得知,flannel.1设备只知道另一端flannel.1设备的MAC地址,却不知道对应的宿主机地址是什么,所以我们要先找到这个UDP包应该发给哪个宿主机。
flannel.1设备实际上要扮演一个“网桥”的角色,在二层网络进行UDP包的转发。而在Linux内核里面,“⽹桥”设备进⾏转发的依据,来⾃于⼀个叫作FDB(Forwarding Database)的转发数据库。因此,flannel.1对应的FDB信息可以通过bridge fdb
找到,这个也是通过flanneld进程维护的:
# 在Node 1上,使⽤“⽬的VTEP设备”的MAC地址进⾏查询
$ bridge fdb show flannel.1 | grep 5e:f8:4f:00:e3:37
5e:f8:4f:00:e3:37 dev flannel.1 dst 10.168.0.3 self permanent
找到目的宿主机的IP之后,就是一个正常的、宿主机网络上的封包工作,外部数据帧的包头如下:
这样封包工作就结束了。
node2收到数据包
node2的内核网络栈发现这个数据帧里有VXLAN Header,所以Linux内核会对它进行拆包,拿到里面的内部数据帧,交给node2的flannel.1设备。
flannel.1设备转发到docker0
flannel.1设备会继续拆包,取出“原始IP包”。根据取得的IP,转发至docker0设备。
容器接收IP包
第六步,就和容器网络里面的接收是一样的,docker0网桥会扮演二层交换机的角色,将数据包发送给正确的端口,进而通过Veth Pair设备进入到node2的container1的Network Namespace里。
VXLAN模式组件的覆盖网络,其实就是一个由不同宿主机上的VTEP设备,也就是flannel.1设备组成的虚拟二层网络。对于VTEP设备来说,它发出的“内部数据帧”就仿佛是⼀直在这个虚拟的⼆层⽹络上流动。这也正是覆盖⽹络的含义。
相较于UDP模式,VXLAN少了flanneld的封装动作,都在Linux内核完成了。
问题4:在前面两节介绍了容器在同宿主机,不同宿主机之间的网络通信,那放到kubernetes上是如何做的呢?
网络插件真正要做的事情,则是通过某种方法,把不同宿主机上的特殊设备联通个,从而达到容器跨主机通信的目的。
实际上,kubernetes对容器网络的主要处理方法。kubernets是通过CNI的接口,维护了一个单独的网桥来代替docker0。这个网桥叫CNI网桥,它在宿主机上的设备名称默认是:cni0。在kubernetes环境里,它的工作方式跟上两节的工作方式没有不同,只不过docker0网桥被替换成CNI网桥。
架构基于Flannel VXLAN模式,如下:
在这⾥,Kubernetes为Flannel分配的⼦⽹范围是10.244.0.0/16。可以在部署的时候指定:
$ kubeadm init --pod-network-cidr=10.244.0.0/16
kubernetes之所以要设置这样⼀个与docker0⽹桥功能⼏乎⼀样的CNI⽹桥,主要原因包括两个方面:
Kubernetes项⽬并没有使⽤Docker的⽹络模型(CNM),所以它并不希望、也不具备配置docker0⽹桥的能⼒;
这还与Kubernetes如何配置Pod,也就是Infra容器的Network Namespace密切相关。
kubernetes在启动Infra容器之后,就可以直接调⽤CNI⽹络插件,为这个Infra容器的Network Namespace,配置符合预期的⽹络栈。
注意:CNI网桥只接管所有CNI插件负责的、即kubernetes创建的容器。如果用docker run单独启动的一个容器,那么Docker项目还是会把这个容器连接到docker0网桥上。这个容器的IP也应该是docker0网桥的网段。
问题5:CNI如何配置容器的网络栈?
部署
在部署Kubernetes的时候,有⼀个步骤是安装kubernetes-cni包,⽬的就是在宿主机上安装CNI插件所需的基础可执行文件。安装完后,可以通过ls -al /opt/cni/bin
查看相关的执行文件:
上图的文件可以分为这几个类:
main插件:用来创建具体的网络设备的二进制文件,有bridge,ipvlan,lookback,macvlan,ptp,vlan
IPAM(IP Address Management)插件:负责分配IP地址的二进制文件,dhcp,host-local
由CNI社区维护的CNI插件:flannel,tuning,portmap,bandwidth。注意,flannel CNI插件内置,Weave,Calico等网络插件需要把对应的CNI插件放到/opt/cni/bin下。
实现
如果要实现一个给kubernetes用的容器网络方案,其实需要做两部分工作,以Flannel项目为例:
⾸先,实现这个网络方案本身。这⼀部分需要编写的,其实就是flanneld进程⾥的主要逻辑。⽐如,创建和配置flannel.1设备、配置宿主机路由、配置ARP和FDB表⾥的信息等等。
然后,实现该⽹络⽅案对应的CNI插件。这⼀部分主要需要做的,就是配置Infra容器⾥⾯的⽹络栈,并把它连接在CNI⽹桥上。
接下来,你就需要在宿主机上安装flanneld(网络方案本身)。⽽在这个过程中,flanneld启动后会在每台宿主机上⽣成它对应的CNI配置⽂件(它其实是⼀个ConfigMap),从⽽告诉Kubernetes,这个集群要使⽤Flannel作为容器⽹络⽅案。加载CNI配置文件的进程是dockershim,它是kubernetes CRI的实现(在kubernetes中,处理容器网络相关的逻辑并不会在kubelet主干代码里执行,而是会在具体的CRI实现里完成)。
以/etc/cni/net.d/10-flannel.conflist
为例:
dockershim会加载上述的CNI配置文件。注意,kubernetes⽬前不⽀持多个CNI插件混⽤。如果你在CNI配置⽬录(/etc/cni/net.d)⾥放置了多个CNI配置⽂件的话,dockershim只会加载按字⺟顺序排序的第⼀个插件。
CNI允许你在一个CNI配置文件里,通过plugins字段,可以定义多个插件进行协作。以上述的CNI配置文件,dockershim会把这个CNI配置文件加载起来,并且把列表里的第一个插件,也就是flannel插件,设置为默认插件。在后⾯的执⾏过程中,flannel和portmap插件会按照定义顺序被调⽤,从⽽依次完成“配置容器⽹络”和“配置端⼝映射”这两步操作。
注意到上面配置文件里面有一个字段叫delegate。Delegate字段的意思是,这个CNI插件并不会自己做事,⽽是会调⽤Delegate指定的某种CNI内置插件来完成。对于Flannel来说,它调⽤的Delegate插件,就是前⾯介绍到的CNI bridge插件。所以说,dockershim对Flannel CNI插件的调⽤,其实就是⾛了个过场。Flannel CNI插件唯⼀需要做的,就是对dockershim传来的Network Configuration进⾏补充。
当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网络里,DEL把容器从CNI网络里移除。
对于⽹桥类型的CNI插件来说,这两个操作意味着把容器以Veth Pair的⽅式“插”到CNI⽹桥上,或者从⽹桥上“拔”掉。
dockershim从CNI配置文件里加载到的、默认插件的配置信息
这个配置信息在CNI中被叫作Network Configuration,dockershim会把Network Configuration以JSON数据的格式,通过标准输入的方式传给Flannel CNI插件。
接下去我们通过这两部分参数实现的ADD操作,来看一下CNI插件如何把一个Infra容器加到CNI网络里。
补充配置文件
在上面讲到因为配置文件中的Delegate字段,所以Flannel CNI会对dockershim传来的Network Configuration会先进行补充,⽐如,将Delegate的Type字段设置为bridge,将Delegate的IPAM字段设置为host-local等:
{
"hairpinMode":true,
"ipMasq":false,
#读取⾃Flannel在宿主机上⽣成的Flannel配置⽂件,即:宿主机上的/run/flannel/subnet.env⽂件。
"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"
}
这就是补充完之后的Delegate字段。
调用CNI bridge插件
Flannel CNI插件就会调用CNI bridge插件,也就是/opt/cni/bin/bridge二进制文件。
调⽤CNI bridge插件需要的两部分参数:
有了这两部分参数,接下来CNI bridge插件就可以“代表”Flannel,进⾏“将容器加⼊到CNI⽹络⾥”这⼀步操作了 。
检查CNI网桥是否存在
如果没有会创建一个CNI网桥,这个步骤在宿主机上执行:
$ ip link add cni0 type bridge
$ ip link set cni0 up
创建Veth Pair设备
CNI bridge插件会通过Infra容器的Network Namespace⽂件,进⼊到这个Network Namespace⾥⾯,然后创建⼀对Veth Pair设备。并且把其中一个Veth Pair“移动”到宿主机上:
#创建⼀对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
这样Veth Pair设备,容器端的eth0,宿主机端的vethb4963f3就创建好了。
Veth Pair连至CNI网桥
在宿主机上,CNI bridge插件要把宿主机端的Veth Pair设备连到CNI网桥上:
$ ip link set vethb4963f3 master cni0
设置Hairpin Mode(发夹模式)
在vethb4963f3连至CNI网桥后,CNI bridge插件会为他设置Hairpin Mode。这是因为,在默认情况下,⽹桥设备是不允许⼀个数据包从⼀个端⼝进来后,再从这个端⼝发出去的。但是,它允许你为这个端⼝开启HairpinMode,从⽽取消这个限制。这个特性,主要⽤在容器需要通过NAT(即:端⼝映射)的⽅式,“⾃⼰访问⾃⼰”的场景下。
调用CNI ipam插件
CNI bridge插件会调⽤CNI ipam插件,从ipam.subnet字段规定的⽹段⾥为容器分配⼀个可⽤的IP地址。然后,CNI bridge插件就会把这个IP地址添加在容器的eth0⽹卡上,同时为容器设置默认路由,在容器里:
$ ip addr add 10.244.0.2/24 dev eth0
$ ip route add default via 10.244.0.1 dev eth0
为CNI网桥添加IP
最后,CNI bridge插件会为CNI网桥添加IP地址,在宿主上:
$ ip addr add 10.244.0.1/24 dev cni0
在执⾏完上述操作之后,CNI插件会把容器的IP地址等信息返回给dockershim,然后被kubelet添加到Pod的Status字段。至此,网桥类型CNI插件的ADD方法实现流程就结束了(非网桥类型的CNI插件会有不同,需要注意)。
通过从docker角度看网络模型,一步一步了解,到从kubernetes角度看网络模型的实现。
容器首先通过网桥与宿主机搭了一座桥,docker0。再创建Veth Pair设备上桥,这样容器的网络和宿主机的网络就可以串在一起了,容器的数据包可以出现在宿主机上进行下一步的转发。之后,为了让不同宿主机之间的容器可以通过容器IP互通,引入了跨主通信的覆盖网络,overlay network。Overlay Network的设计思想也是像网桥一样,只不过搭的桥连通的是不同宿主机上的容器。再通过Flannel项目的UDP模式和VXLAN模式更深入了解跨主通信这座桥是怎么搭的,怎么通的。由于是不同的宿主机,为了能够让桥上的容器都知道这座桥有谁,把这个桥上的容器信息放在etcd上,供查阅。
最后kubernetes集群的网络模型,由于kubernetes的最小调度单位是Pod,而不是docker容器。所以虽然kubernetes也搭桥,但是是用CNI插件搭了一个CNI网桥。也通过Flannel项目的例子,来描述了kubernetes的Pod如何上这个CNI网桥。
网络插件还有很多,但是基本原理都是通过,建一座桥,然后找个管桥的,最后再找个车夫接人上桥。