33 深入解析容器跨主机网络
你好,我是张磊。今天我和你分享的主题是:深入解析容器跨主机网络。
在上一篇文章中,我为你详细讲解了在单机环境下,Linux容器网络的实现原理(网桥模式)。并且提到了,在Docker的默认配置下,不同宿主机上的容器通过IP地址进行互相访问是根本做不到的。
而正是为了解决这个容器“跨主通信”的问题,社区里才出现了那么多的容器网络方案。而且,相信你一直以来都有这样的疑问:这些网络方案的工作原理到底是什么?
要理解容器“跨主通信”的原理,就一定要先从Flannel这个项目说起。
Flannel项目是CoreOS公司主推的容器网络方案。事实上,Flannel项目本身只是一个框架,真正为我们提供容器网络功能的,是Flannel的后端实现。目前,Flannel支持三种后端实现,分别是:
- VXLAN;
- host-gw;
- UDP。
这三种不同的后端实现,正代表了三种容器跨主网络的主流实现方法。其中,host-gw模式,我会在下一篇文章中再做详细介绍。
而UDP模式,是Flannel项目最早支持的一种方式,却也是性能最差的一种方式。所以,这个模式目前已经被弃用。不过,Flannel之所以最先选择UDP模式,就是因为这种模式是最直接、也是最容易理解的容器跨主网络实现。
所以,在今天这篇文章中,我会先从UDP模式开始,来为你讲解容器“跨主网络”的实现原理。
在这个例子中,我有两台宿主机。
- 宿主机Node 1上有一个容器container-1,它的IP地址是100.96.1.2,对应的docker0网桥的地址是:100.96.1.1/24。
- 宿主机Node 2上有一个容器container-2,它的IP地址是100.96.2.3,对应的docker0网桥的地址是:100.96.2.1/24。
我们现在的任务,就是让container-1访问container-2。
这种情况下,container-1容器里的进程发起的IP包,其源地址就是100.96.1.2,目的地址就是100.96.2.3。由于目的地址100.96.2.3并不在Node 1的docker0网桥的网段里,所以这个IP包会被交给默认路由规则,通过容器的网关进入docker0网桥(如果是同一台宿主机上的容器间通信,走的是直连规则),从而出现在宿主机上。
这时候,这个IP包的下一个目的地,就取决于宿主机上的路由规则了。此时,Flannel已经在宿主机上创建出了一系列的路由规则,以Node 1为例,如下所示:
# 在Node 1上
$ 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
可以看到,由于我们的IP包的目的地址是100.96.2.3,它匹配不到本机docker0网桥对应的100.96.1.0/24网段,只能匹配到第二条、也就是100.96.0.0/16对应的这条路由规则,从而进入到一个叫作flannel0的设备中。
而这个flannel0设备的类型就比较有意思了:它是一个TUN设备(Tunnel设备)。
在Linux中,TUN设备是一种工作在三层(Network Layer)的虚拟网络设备。TUN设备的功能非常简单,即:在操作系统内核和用户应用程序之间传递IP包。
以flannel0设备为例:像上面提到的情况,当操作系统将一个IP包发送给flannel0设备之后,flannel0就会把这个IP包,交给创建这个设备的应用程序,也就是Flannel进程。这是一个从内核态(Linux操作系统)向用户态(Flannel进程)的流动方向。
反之,如果Flannel进程向flannel0设备发送了一个IP包,那么这个IP包就会出现在宿主机网络栈中,然后根据宿主机的路由表进行下一步处理。这是一个从用户态向内核态的流动方向。
所以,当IP包从容器经过docker0出现在宿主机,然后又根据路由表进入flannel0设备后,宿主机上的flanneld进程(Flannel项目在每个宿主机上的主进程),就会收到这个IP包。然后,flanneld看到了这个IP包的目的地址,是100.96.2.3,就把它发送给了Node 2宿主机。
等一下,flanneld又是如何知道这个IP地址对应的容器,是运行在Node 2上的呢?
这里,就用到了Flannel项目里一个非常重要的概念:子网(Subnet)。
事实上,在由Flannel管理的容器网络里,一台宿主机上的所有容器,都属于该宿主机被分配的一个“子网”。在我们的例子中,Node 1的子网是100.96.1.0/24,container-1的IP地址是100.96.1.2。Node 2的子网是100.96.2.0/24,container-2的IP地址是100.96.2.3。
而这些子网与宿主机的对应关系,正是保存在Etcd当中,如下所示:
$ etcdctl ls /coreos.com/network/subnets
/coreos.com/network/subnets/100.96.1.0-24
/coreos.com/network/subnets/100.96.2.0-24
/coreos.com/network/subnets/100.96.3.0-24
所以,flanneld进程在处理由flannel0传入的IP包时,就可以根据目的IP的地址(比如100.96.2.3),匹配到对应的子网(比如100.96.2.0/24),从Etcd中找到这个子网对应的宿主机的IP地址是10.168.0.3,如下所示:
而对于flanneld来说,只要Node 1和Node 2是互通的,那么flanneld作为Node 1上的一个普通进程,就一定可以通过上述IP地址(10.168.0.3)访问到Node 2,这没有任何问题。
所以说,flanneld在收到container-1发给container-2的IP包之后,就会把这个IP包直接封装在一个UDP包里,然后发送给Node 2。不难理解,这个UDP包的源地址,就是flanneld所在的Node 1的地址,而目的地址,则是container-2所在的宿主机Node 2的地址。
当然,这个请求得以完成的原因是,每台宿主机上的flanneld,都监听着一个8285端口,所以flanneld只要把UDP包发往Node 2的8285端口即可。
通过这样一个普通的、宿主机之间的UDP通信,一个UDP包就从Node 1到达了Node 2。而Node 2上监听8285端口的进程也是flanneld,所以这时候,flanneld就可以从这个UDP包里解析出封装在里面的、container-1发来的原IP包。
而接下来flanneld的工作就非常简单了:flanneld会直接把这个IP包发送给它所管理的TUN设备,即flannel0设备。
根据我前面讲解的TUN设备的原理,这正是一个从用户态向内核态的流动方向(Flannel进程向TUN设备发送数据包),所以Linux内核网络栈就会负责处理这个IP包,具体的处理方法,就是通过本机的路由表来寻找这个IP包的下一步流向。
而Node 2上的路由表,跟Node 1非常类似,如下所示:
# 在Node 2上
$ 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包的目的地址是100.96.2.3,它跟第三条、也就是100.96.2.0/24网段对应的路由规则匹配更加精确。所以,Linux内核就会按照这条路由规则,把这个IP包转发给docker0网桥。
接下来的流程,就如同我在上一篇文章《浅谈容器网络》中和你分享的那样,docker0网桥会扮演二层交换机的角色,将数据包发送给正确的端口,进而通过Veth Pair设备进入到container-2的Network Namespace里。
而container-2返回给container-1的数据包,则会经过与上述过程完全相反的路径回到container-1中。
需要注意的是,上述流程要正确工作还有一个重要的前提,那就是docker0网桥的地址范围必须是Flannel为宿主机分配的子网。这个很容易实现,以Node 1为例,你只需要给它上面的Docker Daemon启动时配置如下所示的bip参数即可:
以上,就是基于Flannel UDP模式的跨主通信的基本原理了。我把它总结成了一幅原理图,如下所示。
可以看到,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模式性能不好的主要原因。
所以说,我们在进行系统级编程的时候,有一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行。这也是为什么,Flannel后来支持的VXLAN模式,逐渐成为了主流的容器网络方案的原因。
VXLAN,即Virtual Extensible LAN(虚拟可扩展局域网),是Linux内核本身就支持的一种网络虚似化技术。所以说,VXLAN可以完全在内核态实现上述封装和解封装的工作,从而通过与前面相似的“隧道”机制,构建出覆盖网络(Overlay Network)。
VXLAN的覆盖网络的设计思想是:在现有的三层网络之上,“覆盖”一层虚拟的、由内核VXLAN模块负责维护的二层网络,使得连接在这个VXLAN二层网络上的“主机”(虚拟机或者容器都可以)之间,可以像在同一个局域网(LAN)里那样自由通信。当然,实际上,这些“主机”可能分布在不同的宿主机上,甚至是分布在不同的物理机房里。
而为了能够在二层网络上打通“隧道”,VXLAN会在宿主机上设置一个特殊的网络设备作为“隧道”的两端。这个设备就叫作VTEP,即:VXLAN Tunnel End Point(虚拟隧道端点)。
而VTEP设备的作用,其实跟前面的flanneld进程非常相似。只不过,它进行封装和解封装的对象,是二层数据帧(Ethernet frame);而且这个工作的执行流程,全部是在内核里完成的(因为VXLAN本身就是Linux内核中的一个模块)。
上述基于VTEP设备进行“隧道”通信的流程,我也为你总结成了一幅图,如下所示:
可以看到,图中每台宿主机上名叫flannel.1的设备,就是VXLAN所需的VTEP设备,它既有IP地址,也有MAC地址。
现在,我们的container-1的IP地址是10.1.15.2,要访问的container-2的IP地址是10.1.16.3。
那么,与前面UDP模式的流程类似,当container-1发出请求之后,这个目的地址是10.1.16.3的IP包,会先出现在docker0网桥,然后被路由到本机flannel.1设备进行处理。也就是说,来到了“隧道”的入口。为了方便叙述,我接下来会把这个IP包称为“原始IP包”。
为了能够将“原始IP包”封装并且发送到正确的宿主机,VXLAN就需要找到这条“隧道”的出口,即:目的宿主机的VTEP设备。
而这个设备的信息,正是每台宿主机上的flanneld进程负责维护的。
比如,当Node 2启动并加入Flannel网络之后,在Node 1(以及所有其他节点)上,flanneld就会添加一条如下所示的路由规则:
$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
...
10.1.16.0 10.1.16.0 255.255.255.0 UG 0 0 0 flannel.1
这条规则的意思是:凡是发往10.1.16.0/24网段的IP包,都需要经过flannel.1设备发出,并且,它最后被发往的网关地址是:10.1.16.0。
从图3的Flannel VXLAN模式的流程图中我们可以看到,10.1.16.0正是Node 2上的VTEP设备(也就是flannel.1设备)的IP地址。
为了方便叙述,接下来我会把Node 1和Node 2上的flannel.1设备分别称为“源VTEP设备”和“目的VTEP设备”。
而这些VTEP设备之间,就需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信。
所以在我们的例子中,“源VTEP设备”收到“原始IP包”后,就要想办法把“原始IP包”加上一个目的MAC地址,封装成一个二层数据帧,然后发送给“目的VTEP设备”(当然,这么做还是因为这个IP包的目的地址不是本机)。
这里需要解决的问题就是:“目的VTEP设备”的MAC地址是什么?
此时,根据前面的路由记录,我们已经知道了“目的VTEP设备”的IP地址。而要根据三层IP地址查询对应的二层MAC地址,这正是ARP(Address Resolution Protocol )表的功能。
而这里要用到的ARP记录,也是flanneld进程在Node 2节点启动时,自动添加在Node 1上的。我们可以通过ip命令看到它,如下所示:
这条记录的意思非常明确,即:IP地址10.1.16.0,对应的MAC地址是5e:f8:4f:00:e3:37。
可以看到,最新版本的Flannel并不依赖L3 MISS事件和ARP学习,而会在每台节点启动时把它的VTEP设备对应的ARP记录,直接下放到其他每台宿主机上。
有了这个“目的VTEP设备”的MAC地址,Linux内核就可以开始二层封包工作了。这个二层帧的格式,如下所示:
可以看到,Linux内核会把“目的VTEP设备”的MAC地址,填写在图中的Inner Ethernet Header字段,得到一个二层数据帧。
需要注意的是,上述封包过程只是加一个二层头,不会改变“原始IP包”的内容。所以图中的Inner IP Header字段,依然是container-2的IP地址,即10.1.16.3。
但是,上面提到的这些VTEP设备的MAC地址,对于宿主机网络来说并没有什么实际意义。所以上面封装出来的这个数据帧,并不能在我们的宿主机二层网络里传输。为了方便叙述,我们把它称为“内部数据帧”(Inner Ethernet Frame)。
所以接下来,Linux内核还需要再把“内部数据帧”进一步封装成为宿主机网络里的一个普通的数据帧,好让它“载着”“内部数据帧”,通过宿主机的eth0网卡进行传输。
我们把这次要封装出来的、宿主机对应的数据帧称为“外部数据帧”(Outer Ethernet Frame)。
为了实现这个“搭便车”的机制,Linux内核会在“内部数据帧”前面,加上一个特殊的VXLAN头,用来表示这个“乘客”实际上是一个VXLAN要使用的数据帧。
而这个VXLAN头里有一个重要的标志叫作VNI,它是VTEP设备识别某个数据帧是不是应该归自己处理的重要标识。而在Flannel中,VNI的默认值是1,这也是为何,宿主机上的VTEP设备都叫作flannel.1的原因,这里的“1”,其实就是VNI的值。
然后,Linux内核会把这个数据帧封装进一个UDP包里发出去。
所以,跟UDP模式类似,在宿主机看来,它会以为自己的flannel.1设备只是在向另外一台宿主机的flannel.1设备,发起了一次普通的UDP链接。它哪里会知道,这个UDP包里面,其实是一个完整的二层数据帧。这是不是跟特洛伊木马的故事非常像呢?
不过,不要忘了,一个flannel.1设备只知道另一端的flannel.1设备的MAC地址,却不知道对应的宿主机地址是什么。
也就是说,这个UDP包该发给哪台宿主机呢?
在这种场景下,flannel.1设备实际上要扮演一个“网桥”的角色,在二层网络进行UDP包的转发。而在Linux内核里面,“网桥”设备进行转发的依据,来自于一个叫作FDB(Forwarding Database)的转发数据库。
不难想到,这个flannel.1“网桥”对应的FDB信息,也是flanneld进程负责维护的。它的内容可以通过bridge fdb命令查看到,如下所示:
# 在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
可以看到,在上面这条FDB记录里,指定了这样一条规则,即:
发往我们前面提到的“目的VTEP设备”(MAC地址是5e:f8:4f:00:e3:37)的二层数据帧,应该通过flannel.1设备,发往IP地址为10.168.0.3的主机。显然,这台主机正是Node 2,UDP包要发往的目的地就找到了。
所以接下来的流程,就是一个正常的、宿主机网络上的封包工作。
我们知道,UDP包是一个四层数据包,所以Linux内核会在它前面加上一个IP头,即原理图中的Outer IP Header,组成一个IP包。并且,在这个IP头里,会填上前面通过FDB查询出来的目的主机的IP地址,即Node 2的IP地址10.168.0.3。
然后,Linux内核再在这个IP包前面加上二层数据帧头,即原理图中的Outer Ethernet Header,并把Node 2的MAC地址填进去。这个MAC地址本身,是Node 1的ARP表要学习的内容,无需Flannel维护。这时候,我们封装出来的“外部数据帧”的格式,如下所示:
这样,封包工作就宣告完成了。
接下来,Node 1上的flannel.1设备就可以把这个数据帧从Node 1的eth0网卡发出去。显然,这个帧会经过宿主机网络来到Node 2的eth0网卡。
这时候,Node 2的内核网络栈会发现这个数据帧里有VXLAN Header,并且VNI=1。所以Linux内核会对它进行拆包,拿到里面的内部数据帧,然后根据VNI的值,把它交给Node 2上的flannel.1设备。
而flannel.1设备则会进一步拆包,取出“原始IP包”。接下来就回到了我在上一篇文章中分享的单机容器网络的处理流程。最终,IP包就进入到了container-2容器的Network Namespace里。
以上,就是Flannel VXLAN模式的具体工作原理了。
总结
在本篇文章中,我为你详细讲解了Flannel UDP和VXLAN模式的工作原理。这两种模式其实都可以称作“隧道”机制,也是很多其他容器网络插件的基础。比如Weave的两种模式,以及Docker的Overlay模式。
此外,从上面的讲解中我们可以看到,VXLAN模式组建的覆盖网络,其实就是一个由不同宿主机上的VTEP设备,也就是flannel.1设备组成的虚拟二层网络。对于VTEP设备来说,它发出的“内部数据帧”就仿佛是一直在这个虚拟的二层网络上流动。这,也正是覆盖网络的含义。
备注:如果你想要在我们前面部署的集群中实践Flannel的话,可以在Master节点上执行如下命令来替换网络插件。
第一步,执行$ rm -rf /etc/cni/net.d/*
;
第二步,执行$ kubectl delete -f "https://cloud.weave.works/k8s/net?k8s-version=1.11"
;
第三步,在/etc/kubernetes/manifests/kube-controller-manager.yaml
里,为容器启动命令添加如下两个参数:
--allocate-node-cidrs=true
--cluster-cidr=10.244.0.0/16
第四步, 重启所有kubelet;
第五步, 执行$ kubectl create -f https://raw.githubusercontent.com/coreos/flannel/bc79dd1505b0c8681ece4de4c0d86c5cd2643275/Documentation/kube-flannel.yml
。
思考题
可以看到,Flannel通过上述的“隧道”机制,实现了容器之间三层网络(IP地址)的连通性。但是,根据这个机制的工作原理,你认为Flannel能负责保证二层网络(MAC地址)的连通性吗?为什么呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
- 何先生 👍(15) 💬(16)
为什么要使用UDP呢,UDP不是不可靠的吗
2018-11-12 - 勤劳的小胖子-libo 👍(13) 💬(2)
Flannel中的UDP需要Etcd来维护相关的子网与宿主机的对应关系,在VxLan中也需要Etcd吗?老师,什么时候可以讲下Etcd在k8s中的原理与应用?
2018-11-12 - jssfy 👍(4) 💬(1)
请问bridge fdb看到的其它节点vtep的对应关系vtep是各节点主动注册的?
2018-11-26 - 月亮上看星星 👍(3) 💬(1)
磊哥,问你个docker的问题,镜像load到主机上,镜像里的文件是不是存储在主机的某个目录上,起来的容器访问这个文件,实际上就是访问主机上的这个文件?如果是这样的话,创建个数据卷放这个文件,让容器去访问这个数据卷里的这个文件,其实也是访问主机某个目录里的文件,不知道这两种方式有什么区别呢?请磊哥指导下呢
2018-11-08 - 艾斯Z艾穆 👍(2) 💬(1)
您好,我遇到一个问题 我在一个被service选中的app里访问不了这个service的port可以ping通,用pod的ip可以访问到端口 别的pod可以访问到service的port 这个pod也可以访问别的service的port kube-proxy用的模式是ipvs
2018-11-08 - 陆培尔 👍(1) 💬(1)
老师能说一下最近很火的cilium吗,BPF的内部机制
2018-11-09 - LEON 👍(1) 💬(1)
老师,请问内核态和用户态具体指的是什么呢?该如何理解?
2018-11-08 - 宝爷 👍(206) 💬(6)
这里使用UDP,不需要可靠,因为可靠性不是这一层需要考虑的事情,这一层需要考虑的事情就是把这个包发送到目标地址。如果出现了UDP丢包的情况,也完全没有关系,这里UDP包裹的是我们真正的数据包,如果我们上层使用的是TCP协议,而Flannel使用UDP包裹了我们的数据包,当出现丢包了,TCP的确认机制会发现这个丢包而重传,从而保证可靠。
2019-02-16 - Maiza 👍(55) 💬(2)
分分钟变成了计算机网络课程 😄
2018-11-07 - 虎虎❤️ 👍(44) 💬(1)
我又思考了一下,二层网络可能无法连通。因为flannel网络中,node上的fdb是靠配置而非学习得到。当node1上的容器1想要通过mac地址直连node2上的容器2时,由于node1中的fdb中没有配置容器2的mac地址相关记录,所以flannel.1无法找到VTEP出口的ip。故而二层网络没有连通性。 如果想要有连通性,需要配置VTEP为学习模式,并且把所有的VTEP加入一个分组中。这样在fdb表中查询不到mac地址时,vxlan在VTEP组中发起广播(或者组播?这个概念我有点混淆)。VTEP也可作为一个网桥设备,继续向docker0,进而目的node上的所有容器广播。正确的目的地址会响应请求,而且刚才途经的设备fdb都学习到了源容器的mac 地址映射,所以响应数据可以顺利返回源容器。这样就完成了二层网络的连通。
2018-11-08 - 俊釆 👍(42) 💬(0)
读了至少6遍了,每次都有新的收获,感谢磊哥给力的分享。
2019-09-23 - 大杏仁儿 👍(17) 💬(1)
这个和一般的网络传输不同,一般的网络传输会直接请求ip地址,根据ip地址,发送ARP请求来获取下一跳设备的mac地址,获取mac地址之后会缓存下来,这是一般路由器或者网桥做的事情。但在容器网络里我们只知道目标容器的ip地址,并不知道宿主机的ip地址,需要将容器的ip地址和宿主机的ip地址关联起来。这个关系由路由规则, fdb存储的数据和ARP记录下来,这些都是在创建node节点的时候,由各个节点的flanneld创建出来。通过路由规则知道了该将数据包发送到flannel.1这个vtep设备处理并且知道了目地vtep的ip地址,通过vtep IP地址ip neigh show dev flannel.1 找到了对端的vtep mac地址,通过对端vtep mac地址查询 bridge fdb list 查到了这个mac地址对应的宿主机地址。 这样封装出包之后按照vxlan逻辑进行正常发送。
2021-03-03 - 阿鹏 👍(17) 💬(1)
我看很多文章都推荐calico,老师觉得呢
2018-11-07 - 凌 👍(12) 💬(5)
flannel进程负责子网->VTEP mac,VTEP mac->节点ip(fdb)的维护,为什么不直接维护子网->节点ip表更直接,中间的二层网络感觉多此一举的
2019-01-18 - 坤 👍(11) 💬(0)
好深的功底
2019-06-20