容器网络1:浅谈容器网络

在前面的文章中,我们讨论了数据持久化方面的内容。接下来,我们重点讨论与容器网络相关的内容。

一、从单机容器间如何通信说起

首先,我们需要知道的是,一个 Linux 容器能看见的“网络栈”,实际上是被隔离在它自己的 Network Namespace 当中的。而这里所谓的“网络栈”,就包括了:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。对于一个进程来说,这些要素,其实就构成了它发起和响应网络请求的基本环境。

此外,需要指出的是,作为一个容器,它可以声明直接使用宿主机的网络栈(–net=host),即:不开启 Network Namespace,比如:

1
$ docker run –d –net=host --name container-host container

在这种情况下,这个容器启动后,直接监听的就是宿主机的 80 端口。像这样直接使用宿主机网络栈的方式,虽然可以为容器提供良好的网络性能,但也不可避免地引入了共享网络资源的问题,比如端口冲突。

在大多数情况下,我们都希望容器进程能使用自己 Network Namespace 里的网络栈,即:拥有属于自己的 IP 地址和端口。这时候,一个显而易见的问题就是:

这个被隔离的容器进程,该如何跟其他 Network Namespace 里的容器进程进行交互呢?

为了理解这个问题,我们其实可以把每一个容器看做一台主机,它们都有一套独立的“网络栈”。如果我们想要实现两台主机之间的通信,最直接的办法,就是把它们用一根网线连接起来;而如果我们想要实现多台主机之间的通信,那就需要用网线,把它们连接在一台交换机上。

二、Linux Bridge & Veth Pair

在 Linux 中,能够起到虚拟交换机作用的网络设备,是网桥(Bridge)。它是一个工作在数据链路层(Data Link)的设备,主要功能是根据 MAC 地址学习来将数据包转发到网桥的不同端口(Port)上。

为了实现上述目的,Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连接在 docker0 网桥上的容器,就可以通过它来进行通信。

可是,我们又该如何把一个容器“连接”到 docker0 这个网桥上呢?

这时候,我们就需要使用一个名叫 Veth Pair 的虚拟设备了。Veth Pair 设备的特点是:它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出现。并且,从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网卡”上,哪怕这两个“网卡”在不同的 Network Namespace 里。这就使得 Veth Pair 常常被用作为连接不同 Network Namespace 的“网线”。

下面,我们通过一个简单例子,来看一看Veth Pair设备的“样子”。

2.1、启动容器1

首先,我们启动一个叫作 container-1 的容器:

1
$ docker run -d --rm --name container-1 busybox sleep 14400

2.2、查看容器1的网络

然后进入到这个容器中查看一下它的网络设备:

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
26
27
28
# 在宿主机上
$ docker exec -it container-1 /bin/sh

# 在容器里
# 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 命令查看这个容器的路由表,我们可以看到,这个 eth0 网卡是这个容器里的默认路由设备;所有对 172.17.0.0/16 网段的请求,也会被交给 eth0 来处理(第二条 172.17.0.0 路由规则)。

2.3、查看容器1在宿主机上的网络

而这个 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

通过 ifconfig 命令的输出,我们可以看到,这个容器对应的 Veth Pair 设备,在宿主机上是一张虚拟网卡。它的名字叫作 veth9c02e56。并且,通过 brctl show 的输出,我们可以看到这张网卡被“插”在了 docker0 上。

2.4、启动容器2

这时候,如果我们在这台宿主机上启动另一个 Docker 容器,比如 container-2:

1
2
3
4
5
$ docker run -d --rm --name container-2 busybox sleep 14400
$ brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242d8e4dfc1 no veth9c02e56
vethb4963f3

我们就会发现一个新的、名叫 vethb4963f3 的虚拟网卡,也被“插”在了 docker0 网桥上。此时,如果我们在 container-1 容器里 ping 一下 container-2 容器的 IP 地址(172.17.0.3),就会发现同一宿主机上的两个容器默认就是相互连通的。

三、单机容器间网络通信的流程

此时,我们可能会有这样的疑问,同一台主机上的容器之间是如何做到互相通信的?

这其中的原理也非常简单,我们来详细谈论一下。

3.1、路由

当我们在 container-1 容器里访问 container-2 容器的 IP 地址(比如 ping 172.17.0.3)的时候,这个目的 IP 地址会匹配到 container-1 容器里的第二条路由规则:

1
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0

可以看到,这条路由规则的网关(Gateway)是 0.0.0.0,这就意味着这是一条直连规则,即:凡是匹配到这条规则的 IP 包,应该经过本机的 eth0 网卡,通过二层网络直接发往目的主机。

3.2、ARP广播

要通过二层网络到达 container-2 容器,就需要有 172.17.0.3 这个 IP 地址对应的 MAC 地址。所以 container-1 容器的网络协议栈,就需要通过 eth0 网卡发送一个 ARP 广播,来通过 IP 地址查找对应的 MAC 地址。

我们知道,容器的 eth0 网卡,是一个 Veth Pair 设备,它的一端在这个 container-1 容器的 Network Namespace 里,而另一端则位于宿主机上(Host Namespace),并且被“插”在了宿主机的 docker0 网桥上。

一个虚拟网卡一旦被“插”在网桥上,它就会变成该网桥的“从设备”。从设备会被“剥夺”调用网络协议栈处理数据包的资格,从而“降级”成为网桥上的一个端口。而这个端口唯一的作用是:接收流入的数据包,然后把这些数据包的“生杀大权”(比如转发或者丢弃),全部交给对应的网桥。

所以,在收到这些 ARP 请求之后,docker0 网桥就会扮演二层交换机的角色,把 ARP 广播转发到其他被“插”在 docker0 上的虚拟网卡。这样,同样连接在 docker0 上的 container-2 容器的网络协议栈就会收到这个 ARP 请求,从而将 172.17.0.3 所对应的 MAC 地址回复给 container-1 容器。

3.3、发送数据包

在有了这个目的 MAC 地址后,container-1 容器的 eth0 网卡就可以将数据包发出去。而根据 Veth Pair 设备的原理,eth0 网卡发出的数据包会立刻出现在宿主机上的 veth9c02e56 虚拟网卡上。不过,此时这个 veth9c02e56 虚拟网卡的网络协议栈的资格已经被“剥夺”,所以这个数据包就直接流入到了 docker0 网桥里。

3.4、转发数据包

在这里 docker0 会扮演二层交换机的角色。此时,docker0 网桥根据数据包的目的 MAC 地址(也就是 container-2 容器的 MAC 地址),在它的 CAM 表(即交换机通过 MAC 地址学习维护的端口和 MAC 地址的对应表)里查到对应的端口(Port)为:vethb4963f3,然后把数据包发往这个端口。

而这个端口,正是 container-2 容器“插”在 docker0 网桥上的一个虚拟网卡,当然,它也是一个 Veth Pair 设备。这样,数据包会就进入到 container-2 容器的 Network Namespace 里。

3.5、响应数据包

所以,container-2 容器看到的情况是,它自己的 eth0 网卡上出现了流入的数据包。这样,container-2 的网络协议栈就会对请求进行处理,最后将响应(Pong)返回给 container-1。

3.6、小结

我们可以把同一个宿主机上的不同容器通过 docker0 网桥进行通信的流程,总结成一幅示意图,如下所示:

image-20201006092259324

需要注意的是,在实际的数据传递过程中,上述数据的传递在网络协议栈的不同层次上,都有 Linux 内核 Netfilter 的参与。我们可以通过打开 iptables 的 TRACE 功能查看到数据包的传输过程,具体方法如下所示:

1
2
3
# 在宿主机上执行
$ sudo iptables -t raw -A PREROUTING -p icmp -j TRACE
$ sudo iptables -t raw -A OUTPUT -p icmp -j TRACE

通过上述设置,我们就可以在 /var/log/syslog 里看到数据包的传输日志了。

在熟悉了 docker0 网桥的工作方式后,我们就会明白:默认情况下,被限制在 Network Namespace 里的容器进程,实际上是通过 Veth Pair 设备 + 宿主机网桥的方式,实现跟同其他容器的数据交换。

与之类似地,当我们在一台宿主机上,访问该宿主机上的容器的 IP 地址时,这个请求的数据包,也是先根据路由规则到达 docker0 网桥,然后被转发到对应的 Veth Pair 设备,最后出现在容器里。这个过程的示意图,如下所示:

image-20201006100043222

四、跨主机容器间通信问题

当一个容器想连接到另外一个宿主机时,比如:ping 10.168.0.3,它发出的请求数据包,首先经过 docker0 网桥出现在宿主机上。然后根据宿主机的路由表里的直连路由规则(10.168.0.0/24 via eth0)),对 10.168.0.3 的访问请求就会交给宿主机的 eth0 处理。

所以接下来,这个数据包就会经宿主机的 eth0 网卡转发到宿主机网络上,最终到达 10.168.0.3 对应的宿主机上。当然,这个过程的实现要求这两台宿主机本身是连通的。这个过程的示意图,如下所示:

image-20201006100256766

因此,当我们遇到容器连不通“外网”时,应该先试试能否ping通docker网桥,然后查看一下跟docker0和Veth Pair设备相关的iptables规则是否有异常。

这里,我们可能已经联想到了这样一个问题:如果在另外一台宿主机(比如:10.168.0.3)上,也有一个 Docker 容器。那么,我们的 container-1 容器又该如何访问它呢?

这个问题,其实就是容器的“跨主机通信”问题。在 Docker 的默认配置下,一台宿主机上的 docker0 网桥,和其他宿主机上的 docker0 网桥,没有任何关联,它们互相之间也没办法连通。所以,连接在这些网桥上的容器,自然也没办法进行通信了。

不过,万变不离其宗。如果我们通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,不就可以相互通信了吗?这样一来,我们整个集群里的容器网络就会类似于下图所示的样子:

image-20201006100658954

可以看到,构建这种容器网络的核心在于:我们需要在已有的宿主机网络上,再通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以,这种技术就被称为:Overlay Network(覆盖网络)。

而这个 Overlay Network 本身,可以由每台宿主机上的一个“特殊网桥”共同组成。比如,当 Node 1 上的 Container 1 要访问 Node 2 上的 Container 3 的时候,Node 1 上的“特殊网桥”在收到数据包之后,能够通过某种方式,把数据包发送到正确的宿主机,比如 Node 2 上。而 Node 2 上的“特殊网桥”在收到数据包后,也能够通过某种方式,把数据包转发给正确的容器,比如 Container 3。

甚至,每台宿主机上,都不需要有一个特殊的网桥存在,而仅仅是通过某种方式配置宿主机的路由表,就能够把数据包转发到正确的宿主机上。

五、小结

在这篇文章中,我们主要讨论了如下概念:

  • Linux Bridge是一个二层设备,相当于一个虚拟的交换机
  • Veth Pair则是一条可以将容器连接到Linux Bridge的“网线”。
  • 单机容器间借助Linux Bridge + Veth Pair就能实现数据交换。
  • 实现跨主机容器间通信的关键是,在已有的宿主机网络之上,构建一个可以把所有容器连接在一起的虚拟网络。

容器网络1:浅谈容器网络
https://kuberxy.github.io/2020/10/06/容器网络1:浅谈容器网络/
作者
Mr.x
发布于
2020年10月6日
许可协议