gitbook/深入剖析Kubernetes/docs/65287.md
2022-09-03 22:05:03 +08:00

21 KiB
Raw Permalink Blame History

33 | 深入解析容器跨主机网络

你好,我是张磊。今天我和你分享的主题是:深入解析容器跨主机网络。

在上一篇文章中我为你详细讲解了在单机环境下Linux容器网络的实现原理网桥模式。并且提到了在Docker的默认配置下不同宿主机上的容器通过IP地址进行互相访问是根本做不到的。

而正是为了解决这个容器“跨主通信”的问题,社区里才出现了那么多的容器网络方案。而且,相信你一直以来都有这样的疑问:这些网络方案的工作原理到底是什么?

要理解容器“跨主通信”的原理就一定要先从Flannel这个项目说起。

Flannel项目是CoreOS公司主推的容器网络方案。事实上Flannel项目本身只是一个框架真正为我们提供容器网络功能的是Flannel的后端实现。目前Flannel支持三种后端实现分别是

  1. VXLAN

  2. host-gw

  3. 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/24container-1的IP地址是100.96.1.2。Node 2的子网是100.96.2.0/24container-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,如下所示:

$ etcdctl get /coreos.com/network/subnets/100.96.2.0-24
{"PublicIP":"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_SUBNET=100.96.1.1/24
$ dockerd --bip=$FLANNEL_SUBNET ...

以上就是基于Flannel UDP模式的跨主通信的基本原理了。我把它总结成了一幅原理图如下所示。

可以看到Flannel UDP模式提供的其实是一个三层的Overlay网络它首先对发出端的IP包进行UDP封装然后在接收端进行解封装拿到原始的IP包进而把这个IP包转发给目标容器。这就好比Flannel在不同宿主机上的两个容器之间打通了一条“隧道”使得这两个容器可以直接使用IP地址进行通信而无需关心容器和宿主机的分布情况。

我前面曾经提到上述UDP模式有严重的性能问题所以已经被废弃了。通过我上面的讲述你有没有发现性能问题出现在了哪里呢

实际上相比于两台宿主机之间的直接通信基于Flannel UDP模式的容器通信多了一个额外的步骤即flanneld的处理过程。而这个过程由于使用到了flannel0这个TUN设备仅在发出IP包的过程中就需要经过三次用户态与内核态之间的数据拷贝如下所示

我们可以看到:

第一次用户态的容器进程发出的IP包经过docker0网桥进入内核态

第二次IP包根据路由表进入TUNflannel0设备从而回到用户态的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会在宿主机上设置一个特殊的网络设备作为“隧道”的两端。这个设备就叫作VTEPVXLAN 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地址这正是ARPAddress Resolution Protocol )表的功能。

而这里要用到的ARP记录也是flanneld进程在Node 2节点启动时自动添加在Node 1上的。我们可以通过ip命令看到它如下所示

# 在Node 1上
$ ip neigh show dev flannel.1
10.1.16.0 lladdr 5e:f8:4f:00:e3:37 PERMANENT

这条记录的意思非常明确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内核里面“网桥”设备进行转发的依据来自于一个叫作FDBForwarding 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 2UDP包要发往的目的地就找到了。

所以接下来的流程,就是一个正常的、宿主机网络上的封包工作。

我们知道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地址的连通性吗为什么呢

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。