0%

容器网络原理分析

容器,是一种沙盒技术。它本质上是宿主机上的一个普通的进程,但是使用了 Namespace 实现了资源隔离,使用 Cgroups 实现了对进程可用资源的限制。这样,进程之间因为有了边界和限制而能够避免相互干扰,并且能利用容器镜像,能够实现容器的“搬运”。在网络上,容器使用 Network Namespace 实现对网络资源的隔离,被隔离的进程只能看到当前 Namespace 里的网络栈和配置。

所谓“网络栈”,包括了:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。对于进程来说,这些组件构成了它发出和响应网络请求的基础环境。虽然可以容器可以在启动的时候通过传入 -net=host 方式直接使用宿主机的网络栈,即不开启 Network Namespace,这样虽然可以提供良好的性能,但是却要需要提前规划好每个容器监听的端口号,否则存在端口冲突的风险。所以,大多数时候,我们希望容器尽可能使用自己的 Network Namespace 中的网络栈,拥有自己的 IP 地址和端口号。

这样做自然就使我们面临一个问题:被隔离的进程,如何与另外一个 Network Namespace 中的进程通信呢?

同主机容器间通信

为了理解不同容器的通信问题,我们可以想象,对于多台宿主机,使它们通信的最直接的方法就是使用网线或交换机将它们连接。在 Linux 中,网桥(Bridge)能够实现虚拟交换机的作用。网桥工作在数据链路层(Data Link),可以根据 MAC 地址学习并将数据包发送到网桥不同的端口上。在 Docker 项目中,会默认在宿主机上创建一个名为 docker0 的网桥,同宿主机上的容器可以通过连接到网桥而实现通信。

而将容器连接到网桥的方式,是使用一种名叫 Veth Pair 的虚拟设备。Veth Pair 的特点是,它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出现。并且,从其中一张“网卡”发出的数据包会直接出现在与之对应的另一张“网卡”上,即使它们在不同的 Network Namespace 中。因为有这样的特点,Veth Pair 通常被当做虚拟的“网线”,用于连接不同的 Network Namespace。

这样,同一个宿主机上的不同容器通过 docker0 网桥通信的流程就可以用下图表示:

我们进入 Container1 容器:

1
docker exec -it Container sh

查看它的网络设备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 在容器中
root:/ ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.2 netmask 255.255.0.0 broadcast 0.0.0.0
inet6 fe80::42:acff:fe11:2 prefixlen 64 scopeid 0x20<link>
ether 02:42:ac:11:00:02 txqueuelen 0 (Ethernet)
RX packets 364 bytes 8137175 (7.7 MiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 281 bytes 21161 (20.6 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
inet6 ::1 prefixlen 128 scopeid 0x10<host>
loop txqueuelen 1000 (Local Loopback)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

$ route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
default 172.17.0.1 0.0.0.0 UG 0 0 0 eth0
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0

可以看到,这个容器里有一张 eth0 网卡,它正是一个 Veth Pair 设备在容器里这一段。通过 route 命令查看 Container1 容器的路由表,可以看到这个 eth0 网卡是这个容器的默认路由设备:所有对 172.17.0.0/16 网段的请求,都会交给 eth0 来处理。而这个 Veth Pair 的另一端则在宿主机上,可以通过查看宿主机的网络设备看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 在宿主机上
$ ifconfig
docker0 Link encap:Ethernet HWaddr 02:42:d8:e4:df:c1
inet addr:172.17.0.1 Bcast:0.0.0.0 Mask:255.255.0.0
inet6 addr: fe80::42:d8ff:fee4:dfc1/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:309 errors:0 dropped:0 overruns:0 frame:0
TX packets:372 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:18944 (18.9 KB) TX bytes:8137789 (8.1 MB)
veth9c02e56 Link encap:Ethernet HWaddr 52:81:0b:24:3d:da
inet6 addr: fe80::5081:bff:fe24:3dda/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:288 errors:0 dropped:0 overruns:0 frame:0
TX packets:371 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:21608 (21.6 KB) TX bytes:8137719 (8.1 MB)

$ brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242d8e4dfc1 no veth9c02e56

可以看到 Container1 对应的 Veth Pair 设备,在宿主机上是一张虚拟网卡,它的名字是 veth9c02e56。而且通过 brctl 的输出,可以看到该网卡被插在 docker0 上。同样的, Container2 容器对应的 Veth Pair 的另一端也以相同的方式插在 docker0 网桥上。这是如果在 Container1 容器里 ping 一下 Container2 的 IP(172.17.0.3),就可以发现同一宿主机上的两个容器默认相互连通。

这样同宿主机上两个容器的通信过程为:当在 Container1 中访问 Container2 的 IP 地址是,172.17.0.3 这个目的地址会匹配到 Container1 容器中的第二条路由规则,该规则的网管是 0.0.0.0,意味着是一条直连规则,即:凡是匹配到这条规则的 IP 包,都应该经过 eth0 网卡,通过二层网络直接发往目的主机。

而要通过二层网络抵达 Container2 容器,就需要 172.17.0.3 这个 IP 对应的 MAC 地址。那么,Container1 的网络协议栈,就需要通过 eth0 网卡发送一个 ARP(Address Resolution Protocol) 广播,通过 IP 地址查到对应的 MAC 地址。这个 eth0 网卡是一个 Veth Pair,它的一端在该容器的 Network Namespace 里,而另一端位于宿主机上,并被插在宿主机的 docker0 网桥上,成功为了网桥的“从设备”。这时,网卡降级为网桥的一个端口,它唯一的作用就是接受流入的数据包,交给网桥。

在收到 ARP 请求后,docker0 就扮演二层交换机的角色,把 ARP 广播到其他被插在 docker0 上的虚拟网卡上。这样同样连接在 docker0 上的 Container2 容器的网络协议栈就会收到这个 ARP 请求,并返回对应的 MAC 地址给 Container1。有了这个目的 MAC 地址,Container1 容器的 eth0 网卡就可以将数据包发出去。该数据包会立刻直接流入 docker0 网桥,网桥继续扮演二层交换机的角色,根据数据包的目的 MAC 地址,在它的 CAM 表中查到对应的端口,并发送数据包。这样,数据包就通过 Container2 的 Veth Pair 进入了新的容器。所以 Container2 容器看到的情况是自己的 eth0 网卡出现了流入的数据包,这样 Container2 就会对请求进行处理,并返回响应。

所以,被限制在 Network Namespace 中的容器进程,实际上是通过 Veth Pair 设备 + 宿主机网桥的方式,实现了与其他容器的数据交换。当用户在宿主机上访问容器的 IP 地址时,请求也是先到达 docker0 网桥,然后被转发到对应的 Veth Pair 设备,最后出现在容器里。

同样的,当容器驶入连接到另外一个宿主机是,如 ping 10.168.0.3,它的请求先经过 docker0 出现在宿主机上,然后根据宿主机的路由表的直连规则将请求交给宿主机的 eth0 处理。接下来,这个数据包经宿主机的网络到达 10.168.0.3 对应的宿主机上,当然,这要求两台宿主机是连通的。

跨主机容器间通信

在了解了同一宿主机下不同容器的通信方式后,我们自然要思考,不同宿主机的容器要如何通信呢?这就是“跨主机通信问题”。

在 Docker 的默认配置下,一台宿主机的 docker0 网桥和其他宿主机上的 docker0 网桥是没有关联的,它们之间也没有办法连通。所以不同宿主机的容器之间自然也就无法通信了。

不过,我们能够通过软件方式,创建一个整个集群公用的网桥,然后将集群中所有的容器都连接到这个网桥上。这种在已有的宿主机网络上,通过软件构建一个覆盖的、可以把所有容器连通在一起的虚拟网络,被称为 Overlay Network(覆盖网络)。其结构如下图:

这个 Overlay Network 本身,可以由每台宿主机上的一个特殊网桥共同组成。比如,当 Node1 上的 Container1 要访问 Node2 上 Container2 时,Node1 上的特殊网桥在收到数据包后,可以将数据包发送到正确的宿主机;而 Node2 上的特殊网桥在收到数据包后,也能通过某种方式将数据包转发给正确的容器。甚至,每台宿主机上,都不需要有这样一个特殊的网桥,而仅仅通过某种方式,将数据包转发给正确的容器。甚至,不需要特殊网桥,仅仅通过配置路由表就能转发到正确的宿主机上。

目前,社区中存在多种为解决“跨主机通信”问题而出现的容器网络方案。为了理解其原理,我们从 Flannel 这个项目说起。Flannel 项目是 CoreOS 主推的容器网络方案。该项目本身只是一个框架,真正为我们提供容器网络功能的,是 Flannel 的后端实现。Flannel 支持三种后端实现,分别是:

  1. UDP
  2. VXLAN
  3. host-gw

Flannel 的 UDP 实现

假设现在有两台宿主机:

  • 宿主机 Node1 上有一个容器 Container1,它的 IP 地址是 100.96.1.2,网桥地址是 100.96.1.1/24;
  • 宿主机 Node2 上有一个容器 Container2,它的 IP 地址是 100.96.2.3,网桥地址是 100.96.2.1/24;

现在我们希望 Container1 能够访问 Container2。

这时,Container1 容器里的进程发起的 IP 包,其源地址是 100.96.1.2,目的地址是 100.96.2.3。由于目的地址 100.96.2.3 并不在 Node1 的 docker0 网桥的网段,所以这个 IP 包会被交给默认路由规则,通过容器的网关进入 docker0 网桥从而出现宿主机上。这时候,这个 IP 包的下一个目的地,就取决于宿主机上的路由规则。此时,Flannel 已提前在宿主机创建了一系列的路由规则,在 Node1 上,规则如下所示:

1
2
3
4
5
6
# 在 Node1 上
$ 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 设备)。TUN 设备是一种工作在三层(Network Layer)的虚拟设备。它的功能就是在操作系统内核和用户程序之间传递 IP 包。

对于 flannel0,当操作系统将一个 IP 包发送给 flannel0 设备之后,flannel0 就会把这个 IP 包交给创建这个设备的应用程序,即 Flannel 进程。这个过程中, IP 包从内核态流向了用户态。反之,如果 Flannel 进程想 flannel0 设备发送一个 IP 包,那么这个 IP 包就会出现在宿主机网络栈中,然后根据路由表进行下一步处理。这时, IP 包由用户态流向内核态。

这样,当 IP 包从容器经过 docker0 出现在宿主机,然后又根据路由表进入 flannel0 设备后,宿主机上的 flanneld 进程,就会收到这个 IP 包。根据这个 IP 包的目的地址,将它发送给 Node2 宿主机。那么,flanneld 是如何知道该 IP 地址对应的容器在 Node2 上面呢?

Flannel 项目中子网(Subnet)的概念非常重要。由 Flannel 管理的容器网络中,一台宿主机上面的所有容器,都属于该宿主机被分配的一个 “子网”。在上面的例子汇总, Node1 的子网是 100.96.1.0/24,Container1 的 IP 地址是 100.96.1.2。Node2 的子网是 100.96.2.0/24,Container2 的 IP 地址是 100.96.2.3。而这些子网与宿主机的对应关系都保存在 Etcd 中:

1
2
3
4
$ 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 的地址,匹配到对应的子网,从 Etcd 中找到这个子网对应的宿主机的 IP 地址是 10.169.0.3:

1
2
$ etcdctl get /coreos.com/network/subnets/100.96.2.0-24
{"PublicIP":"10.168.0.3"}

而对于 flanneld 来说,只要 Node1 和 Node2 是互通的,那么 flanneld 作为 Node1 上的一个普通进程,就一定可以通过上述 IP 地址访问到 Node2。所以,flanneld 在收到 Container1 发送给 Container2 的 IP 包后,就会把这个 IP 包直接封装在一个 UDP 包中,然后发送给 Node2。UDP 包的源地址是 Node1 的地址,目的地址是 Node2 的地址。这样通过一个 UDP 通信,一个 UDP 包就由 Node1 到达了 Node2。Node2 上的 flanneld 就会从 UDP 包中解析出封装在里面的 Container1 发出的原 IP 包。接下来,就如同虚机上的容器网络,IP 包经 docker0 网桥通过 Veth Pair 设备进入到 Container2 的 Network Namespace 中。需要主机的是,上述流程还有一个重要的前提就是 docker0 网桥的地址范围必须是 Flannel 为宿主机分配的子网。以上就是 Flannel UDP 模式的跨主机通信的原理,可以用下图来表示:

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

目前,Flannel UDP 方式已经被废弃了,因为它存在严重的性能问题。相比于两台宿主机之间的直接通信,基于 Flannel UDP 模式的容器通信多了一个额外的步骤,即 flanneld 的处理过程。而这个过程,由于使用了 flannel0 这个 TUN 设备,仅在发出 IP 包的过程中就需要经过三次用户态与内核态之间的数据拷贝:

  1. 用户态的容器进程发出的 IP 包经过 docker0 网桥进入内核态;
  2. IP 包根据路由表进入 TUN(flannel0)设备,从而回到用户态的 flanneld 进程;
  3. flanneld 进行 UDP 封包之后重新进入内核态,将 UDP 包通过宿主机的 eth0 发出去;

另外 Flannel 进行 UDP 封装和解封装的过程也都是在用户态完成的。在 Linux 操作系统中,上述这些上下文切换和用户态操作的代价其实都是比较高的,这也是 Flannel UDP 性能不好的原因。所以,在进行系统级编程的时候,一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行。正因为此,Flannel 支持的 VXLAN 模式逐渐成为了主流的容器网络方案。

Flannel 的 VXLAN 实现

VXLAN,即 Virtual Extensible LAN(虚拟可拓展局域网),是 Linux 内核本身就支持的网络虚拟化技术。所以 VXLAN 可以完全在内核态实现上述封装和解封装的工作,从而通过与前面相似的“隧道”机制,构建出 Overlay 网络。

VXLAN 的覆盖网络的设计思想是:在现有的三层网络上,“覆盖”一层虚拟的、由内核 VXLAN 模块维护的二层网络,使得连接在这个 VXLAN 二层网络上的主机之前可以像在一个局域网中那样自由通信。当然,实际上这些“主机”可能分布在不同的宿主机上,甚至是分布在不同的物理机房中。

为了在二层网络上打通“隧道”,VXLAN 在宿主机上设置一个特殊的网络设备作为隧道的两端,该设备叫做 VTEP,即 VXLAN Tunnel End Point。VTEP 设备的作用其实跟前面 flanneld 进程非常相似。只不过,它进行封装和解封装的对象,是二层数据帧(Ethernet frame);而且这个工作的执行流程,全部是在内核中完成的。所以,基于 VTEP 设备进行隧道通信的流程,可以用下图标识:

可以看到,每台宿主机上名为 flannel.1 的设备,就是 VXLAN 所需的 VTEP 设备,它既有 IP 地址,也有 MAC 地址。与 UDP 模式的流程类似,当 Container1 发出请求之后,这个目的地址是 10.1.16.3 的 IP 包,会先出现在 docker0 网桥,然后被路由到本机的 flannel.1 设备进行处理。也就是说,来到了“隧道”的入口。为了能将“原始 IP 包”封装并且发送到正确的宿主机,VXLAN 就需要找到这条“隧道”的出口,即:目的宿主机的 VTEP 设备。而这个设备的信息,正是每台宿主机上的 flanneld 进程维护的。VTEP 设备之间需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信。所以“源 VTEP 设备”收到“原始 IP 包”后,就要想办法把“原始 IP 包”加上一个目的 MAC 地址,封装成一个二层数据帧,然后发送给“目的 VTEP 设备”。“目的 VTEP 设备”的 MAC 地址由 ARP 表记录,而这里的 ARP 记录不是通过发送 ARP 请求获取,而是有 flanneld 在 Node2 节点启动时,自动添加在 Node1 上的。有了这个“目的 VTEP 设备”的 MAC 地址,Linux 内核就可以开始二层封包工作了。

Linux 内核会把“目的 VTEP 设备”的 MAC 地址,填写在图中额 Inner Ethernet Header 字段,二道一个二层数据帧。这些 VTEP 设备的 MAC 地址,对于宿主机来说并没有什么实际意义。所以上面封装的这个数据帧,并不能在宿主机的二层网络中传输,所以,我们将它称为“内部数据帧”。所以接下来,Linux 还需要把“内部数据帧”进一步封装称为宿主机网络里的一个普通的数据帧,好让它承载“内部数据帧”通过宿主机的 eth0 网卡进行传输。我们把这次要封装出来的、宿主机对应的数据帧称为“外部数据帧”。

Linux 内核还会在内部数据帧前面,添加一个特殊的 VXLAN 头,用来表示一个 VXLAN 要使用的数据帧。VXLAN 头里面有个重要的标志叫做 VNI,它是 VTEP 设备识别某个数据帧是不是应该归属于自己处理的标志。Flannel 中,VNI 默认都是 1。然后,Linux 内核会把这个数据帧封装进一个 UDP 包里发出去。所以,在宿主机看来,它只是向另外一台宿主机的 flannel.1 设备,发起了一次普通的 UDP 链接。

不过,一个 flannel.1 设备只知道另一端的 flannel.1 设备的 MAC 地址,却不知道对应的宿主机地址。这种场景下,flannel.1 设备其实要扮演一个“网桥”的角色,在二层网络进行 UDP 包的转发。而 Linux 内核中,“网桥”设备进行转发的一句,来自 FDB 转发数据库,其中的信息也有由 flanneld 进程负责维护。

所以接下来就是一个正常的宿主机网络的封包工作。Linux 在它前面加上一个 IP 头,组成一个 IP 包。在 IP 头里,会填上查询得到的目的主机的 IP 地址。然后,Linux 内核再在这个 IP 包前面再加上二层数据帧头,并填入 Node2 的 MAC 地址。这个 MAC 地址本身是 Node1 的 ARP 表要学习的内容,无需 flannel 维护。接下来,Node 1 上的 flannel.1 设备就可以把这个数据帧从 eth0 网卡发出去。

以上就是 Flannel VXLAN 的工作原理了。