和容器打交道感觉就像魔法。理解的人就会得心应手,不理解的会觉得很难。幸运的是,我们已经对容器技术有一定的掌握,甚至发现容器只是被隔离和限制的Linux进程。运行容器不一定需要镜像,相反构建镜像的时候反而需要运行容器。
今天我们来说说容器网络问题。或者,更准确地说,是单主机容器网络问题。在本文中,我们将回答以下几个问题:
- 如何虚拟化网络资源,使得单个容器工作在独立的网络栈空间内。
- 如何使容器之间友好隔离,避免相互影响,并且可以互相通信。
- 容器内部如何与主机外网络通信。
- 外界如何与主机内容器中的应用通信。
在回答以上问题时,我们将使用Linux工具从零开始创建容器网络。因此,很明显,单主机容器网络只不过是各种Linux工具的简单组合:
- 网络命名空间
- 虚拟以太网(veth)
- 虚拟机交换机(bridge)
- IP路由和网络地址转换NAT
不管怎样,不需要任何代码就可以实现容器网络魔法……
准备工作
任何Linux发行版就足够了。本文中的所有示例都是在一个全新的Linux虚拟机上完成的:
接下来所有的命令在root用户下执行
# uname -a
Linux instance-20220709-1624 5.4.17-2136.308.9.el8uek.x86_64 #2 SMP Mon Jun 13 20:36:40 PDT 2022 x86_64 x86_64 x86_64 GNU/Linux
为了简化示例,在本文中,我们不打算依赖任何成熟的容器化解决方案(例如docker或podman)。相反,我们将专注于基本概念,并使用最简单的工具来实现我们的学习目标。
网络命名空间隔离容器网络
Linux网络栈由什么组成?很明显,网络设备的集合。还有什么呢?比如,路由规则的集合。不要忘记,还有一组netfilter钩子,包括由iptables规则定义的钩子。
我们可以快速写一个不全面的inspect-net-stack.sh脚本:
#!/usr/bin/env bash
echo "> Network devices"
ip link
echo -e "\n> Route table"
ip route
echo -e "\n> Iptables rules"
iptables --list-rules
在运行脚本之前,我们稍微修改一下iptables规则,使它们易于识别:
# iptables -N ROOT_NS
接着,在我的虚拟机上执行inspect脚本会产生以下输出:
# sh inspect-net-stack.sh
> Network devices
1: lo: mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens3: mtu 9000 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 02:00:17:01:ae:0d brd ff:ff:ff:ff:ff:ff
> Route table
default via 10.0.0.1 dev ens3
default via 10.0.0.1 dev ens3 proto dhcp metric 100
10.0.0.0/21 dev ens3 proto kernel scope link src 10.0.2.13
10.0.0.0/21 dev ens3 proto kernel scope link src 10.0.2.13 metric 100
169.254.0.0/16 dev ens3 scope link
169.254.0.0/16 dev ens3 proto dhcp scope link metric 100
> Iptables rules
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N BareMetalInstanceServices
-N ROOT_NS
我们对这个输出很感兴趣,因为我们希望确保即将创建的每个容器网络都将得到一个独立的网络堆栈。
您可能已经听说过,用于容器隔离的Linux命名空间之一被称为网络命名空间。在man ip-netns中,“网络命名空间在逻辑上是网络堆栈的另一个副本,具有自己的路由、防火墙规则和网络设备。”为了简单起见,该命名空间是本文中使用的惟一命名空间。与其创建完全隔离的容器,我们宁愿将范围限制为仅网络栈。
创建网络命名空间的一种方法是ip工具——是标准iproute2集合的一部分。
# ip netns
# ip netns
netns0
如何使用刚刚创建的命名空间?有一个实用的Linux命令叫做nsenter。它能进入一个或多个指定的命名空间,然后执行给定的程序:
# nsenter --net=/var/run/netns/netns0 bash
# ls
inspect-net-stack.sh
# sh inspect-net-stack.sh
> Network devices
1: lo: mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
> Route table
> Iptables rules
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
从上面的输出可以清楚地看到,在netns0名称空间中运行的bash进程看到的是一个完全不同的网络堆栈。没有路由规则,没有自定义iptables链,只有一个环回网络设备。到目前为止,一切顺利……
注意:上面的nsenter命令在netns0网络名称空间中启动了一个嵌套的bash会话。不要忘记退出它,或者使用一个新的终端继续。
使用虚拟机以太网设备Veth连接容器和主机网络
如果我们不能与专用的网络栈进行通信,那么该网络栈就没什么用了。幸运的是,Linux为此提供了合适的工具—虚拟以太网设备veth。从man veth命令中可知,它们可以作为网络命名空间之间的隧道,创建到另一个命名空间中的物理网络设备的桥梁,但也可以用作独立的网络设备。
虚拟以太网设备总是成对使用。我们看一下创建命令就会清楚了:
# ip link add veth0 type veth peer name ceth0
通过这个命令,我们创建了一对相互连接的虚拟以太网设备。名称veth0和ceth0是任意选择的:
# ip link
1: lo: mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens3: mtu 9000 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 02:00:17:01:ae:0d brd ff:ff:ff:ff:ff:ff
3: ceth0@veth0: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 66:8a:fe:30:be:72 brd ff:ff:ff:ff:ff:ff
4: veth0@ceth0: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether c6:87:96:fe:97:6b brd ff:ff:ff:ff:ff:ff
创建后的veth0和ceth0都驻留在主机的网络堆栈上(也称为root网络命名空间)。为了连接root命名空间和netns0命名空间,我们需要在root命名空间中保留一个设备,并将另一个设备移动到netns0中:
# ip link set ceth0 netns netns0
# ip link
1: lo: mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens3: mtu 9000 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 02:00:17:01:ae:0d brd ff:ff:ff:ff:ff:ff
4: veth0@if3: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether c6:87:96:fe:97:6b brd ff:ff:ff:ff:ff:ff link-netns netns0
一旦我们激活设备并分配适当的IP地址,任何发送到其中一个设备上的数据包都会立即出现在连接两个名称空间的对等设备上。让我们从root命名空间开始:
# ip link set veth0 up
# ip addr add 172.18.0.11/16 dev veth0
然后在netns0命名空间也执行类似命令激活ceth0:
# nsenter --net=/var/run/netns/netns0
# ip link set lo up
# ip link set ceth0 up
# ip addr add 172.18.0.10/16 dev ceth0
# ip addr
1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
3: ceth0@if4: mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 66:8a:fe:30:be:72 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.18.0.10/16 scope global ceth0
valid_lft forever preferred_lft forever
inet6 fe80::648a:feff:fe30:be72/64 scope link
valid_lft forever preferred_lft forever
我们检查网络连接:
#从netns0, ping root命名空间的veth0
ping -c 2 172.18.0.11
PING 172.18.0.11 (172.18.0.11) 56(84) bytes of data.
64 bytes from 172.18.0.11: icmp_seq=1 ttl=64 time=0.065 ms
64 bytes from 172.18.0.11: icmp_seq=2 ttl=64 time=0.023 ms
--- 172.18.0.11 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1008ms
rtt min/avg/max/mdev = 0.023/0.044/0.065/0.021 ms
# 退出 `netns0`
# exit
# 从root命名空间ping ceth0
# ping -c 2 172.18.0.10
PING 172.18.0.10 (172.18.0.10) 56(84) bytes of data.
64 bytes from 172.18.0.10: icmp_seq=1 ttl=64 time=0.045 ms
64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.025 ms
--- 172.18.0.10 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1039ms
rtt min/avg/max/mdev = 0.025/0.035/0.045/0.010 ms
同时,如果我们试图从netns0命名空间访问其他地址是不会成功的:
# 在root命名空间执行以下命令
# ip addr show dev ens3
2: ens3: mtu 9000 qdisc pfifo_fast state UP group default qlen 1000
link/ether 02:00:17:01:ae:0d brd ff:ff:ff:ff:ff:ff
inet 10.0.2.13/21 brd 10.0.7.255 scope global dynamic ens3
valid_lft 83445sec preferred_lft 83445sec
inet6 fe80::17ff:fe01:ae0d/64 scope link
valid_lft forever preferred_lft forever
# 记住ip:10.0.2.13
# nsenter --net=/var/run/netns/netns0 #进入netns0名空间
# ping 10.0.2.13
connect: Network is unreachable
#试试其他公网IP
# ping 8.8.8.8
connect: Network is unreachable
不过,这很容易解释。netns0路由表中根本没有针对此类数据包的路由。唯一的一条路有显示了如何到达172.18.0.0/16网络:
# 在netns0命名空间执行以下命令
# ip route
172.18.0.0/16 dev ceth0 proto kernel scope link src 172.18.0.10
Linux有很多增加路由表的方法。其中一种方法是从直接连接的网络接口中提取路由。记住,netns0中的路由表在创建名称空间之后是空的。但随后我们在那里添加了ceth0设备,并为其分配了一个172.18.0.10/16的IP地址。由于我们使用的不是一个简单的IP地址,而是地址和网络掩码的组合,网络栈会从中提取路由信息。每个去往172.18.0.0/16网络的报文都要通过ceth0设备发送。但其他数据包会被丢弃。类似地,在root命名空间中有一个新的路由:
# ip route
# ...省略部分...
172.18.0.0/16 dev veth0 proto kernel scope link src 172.18.0.11
到这里,我们已经准备好回答第一个问题了。我们已经知道了如何隔离、虚拟化和连接Linux网络栈。
使用虚拟交换机(bridge)连接容器
容器化的整个理念归结为高效的资源共享。也就是说,每台机器只有一个容器是不合理的。相反,我们的目标是在共享环境中运行尽可能多的独立进程。那么,如果我们按照上面的虚拟以太网veth方法在同一个主机上配置多个容器会发生什么呢?让我们添加第二个容器:
#ip netns add netns1
# ip link add veth1 type veth peer name ceth1
# ip link set ceth1 netns netns1
# ip link set veth1 up
# ip addr add 172.18.0.21/16 dev veth1
#进入netns1名空间
# nsenter --net=/var/run/netns/netns1
# ip link set lo up
# ip link set ceth1 up
# ip addr add 172.18.0.20/16 dev ceth1
我最喜欢的部分是检查连通性:
#从netns1名空间ping不通veth1
# ping -c 2 172.18.0.21
PING 172.18.0.21 (172.18.0.21) 56(84) bytes of data.
From 172.18.0.20 icmp_seq=1 Destination Host Unreachable
From 172.18.0.20 icmp_seq=2 Destination Host Unreachable
--- 172.18.0.21 ping statistics ---
2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 1030ms
pipe 2
##但是netns1中存在一条路有
# ip route
172.18.0.0/16 dev ceth1 proto kernel scope link src 172.18.0.20
# 退出`netns1`
# exit
#从root名空间也ping不通ceth1
# ping -c 2 172.18.0.20
PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.
From 172.18.0.11 icmp_seq=1 Destination Host Unreachable
From 172.18.0.11 icmp_seq=2 Destination Host Unreachable
--- 172.18.0.20 ping statistics ---
2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 1062ms
pipe 2
#从`netns0`可以ping通veth1
# nsenter --net=/var/run/netns/netns0
# ping -c 2 172.18.0.21
PING 172.18.0.21 (172.18.0.21) 56(84) bytes of data.
64 bytes from 172.18.0.21: icmp_seq=1 ttl=64 time=0.066 ms
64 bytes from 172.18.0.21: icmp_seq=2 ttl=64 time=0.033 ms
--- 172.18.0.21 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1062ms
rtt min/avg/max/mdev = 0.033/0.049/0.066/0.017 ms
有点不对劲……netns1现象和netns0不一样。由于某些原因,它不能与root通信,从root名空间我们也不能访问它。但是,由于两个容器都位于同一个IP网络172.18.0.0/16中,我们现在可以通过netns0名空间与主机的veth1通信。这确实有点意思...
我花了些时间才弄明白,很显然我们遇到了路由冲突。让我们检查一下root命名空间中的路由表:
# ip route
#...省略部分内容...
172.18.0.0/16 dev veth0 proto kernel scope link src 172.18.0.11
172.18.0.0/16 dev veth1 proto kernel scope link src 172.18.0.21
尽快在添加第二个veth对后,root名空间网络栈学习到新的路由172.18.0.0/16 dev veth1 proto kernel scope link src 172.18.0.21,但之前已经有一条路由生效了。当第二个容器尝试ping veth1设备时,根据路由匹配顺序会选择第一条路由,从而中断连接。如果我们删除第一个路由sudo ip route delete 172.18.0.0/16 dev veth0 proto kernel scope link src 172.18.0.11,并重新检查连通性,情况将变成netns1的连接将被恢复,但netns0将处于不确定状态。
我相信如果我们为netns1选择另一个IP子网络,一切都会工作正常。但是,在一个IP子网络中有多个容器是合理的。因此,我们需要以某种方式调整veth方法……
看看Linux网桥吧——这是另一种虚拟网络工具!Linux网桥就像一个网络交换机。实现与它相连的接口之间转发数据包。由于它是一个交换机,它在L2(即以太网)网络层工作。
让我们来一起试试网桥功能。但首先,我们需要清理现前面设置,到目前为止我们所做的一些配置更改实际上已经不需要了。删除网络命名空间就足够了:
# ip netns delete netns0
# ip netns delete netns1
快速重新创建两个容器网络。注意,我们没有为新的veth0和veth1设备分配任何IP地址:
# ip netns add netns0
# ip link add veth0 type veth peer name ceth0
# ip link set veth0 up
# ip link set ceth0 netns netns0
# nsenter --net=/var/run/netns/netns0
# ip link set lo up
# ip link set ceth0 up
# ip addr add 172.18.0.10/16 dev ceth0
# exit
#----------
# ip netns add netns1
# ip link add veth1 type veth peer name ceth1
# ip link set veth1 up
# ip link set ceth1 netns netns1
# nsenter --net=/var/run/netns/netns1
# ip link set lo up
# ip link set ceth1 up
# ip addr add 172.18.0.20/16 dev ceth1
# exit
确保主机上没有新的路由:
# ip route
default via 10.0.0.1 dev ens3
default via 10.0.0.1 dev ens3 proto dhcp metric 100
10.0.0.0/21 dev ens3 proto kernel scope link src 10.0.2.13
10.0.0.0/21 dev ens3 proto kernel scope link src 10.0.2.13 metric 100
169.254.0.0/16 dev ens3 scope link
169.254.0.0/16 dev ens3 proto dhcp scope link metric 100
接着,创建网桥:
# ip link add br0 type bridge
# ip link set br0 up
下面,将veth0和veth1虚拟设备挂载到网桥上:
# ip link set veth0 master br0
# ip link set veth1 master br0
再测试下连通性:
# nsenter --net=/var/run/netns/netns0
# ping -c 2 172.18.0.20
PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.
64 bytes from 172.18.0.20: icmp_seq=1 ttl=64 time=0.069 ms
64 bytes from 172.18.0.20: icmp_seq=2 ttl=64 time=0.030 ms
--- 172.18.0.20 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1047ms
rtt min/avg/max/mdev = 0.030/0.049/0.069/0.020 ms
# nsenter --net=/var/run/netns/netns1
# ping -c 2 172.18.0.10
PING 172.18.0.10 (172.18.0.10) 56(84) bytes of data.
64 bytes from 172.18.0.10: icmp_seq=1 ttl=64 time=0.037 ms
64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.025 ms
--- 172.18.0.10 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1035ms
rtt min/avg/max/mdev = 0.025/0.031/0.037/0.006 ms
一切工作正常。使用这种新方法,我们根本没有配置veth0和veth1。我们分配的仅有两个IP地址在ceth0和ceth1两端。但由于它们都在同一个以太网段上(记住,我们将它们连接到虚拟交换机),因此在L2层上有连接:
# nsenter --net=/var/run/netns/netns0
# ip neigh
172.18.0.20 dev ceth0 lladdr 7a:63:31:c0:53:0b STALE
# nsenter --net=/var/run/netns/netns1
# ip neigh
172.18.0.10 dev ceth1 lladdr 76:02:44:fa:2d:2f STALE
恭喜你,学会了如何把容器变成友好的邻居,防止它们干扰,但保持连接。
容器和外界通信(IP路由和伪装)
现在容器之间可以相互通信。但是它们能与主机(即root名空间)通信吗?
# nsenter --net=/var/run/netns/netns0
# ping 10.0.2.13
connect: Network is unreachable
这很明显,netns0中没有路由:
# ip route
172.18.0.0/16 dev ceth0 proto kernel scope link src 172.18.0.10
root命名空间也不能与容器通信:
# exit
logout
[root@instance-20220709-1624 opc]# ping -c 2 172.18.0.10
PING 172.18.0.10 (172.18.0.10) 56(84) bytes of data.
--- 172.18.0.10 ping statistics ---
2 packets transmitted, 0 received, 100% packet loss, time 1048ms
# ping -c 2 172.18.0.20
PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.
--- 172.18.0.20 ping statistics ---
2 packets transmitted, 0 received, 100% packet loss, time 1023ms
为了建立root命名空间和容器名称空间之间的连接,我们需要为网桥分配IP地址:
# ip addr add 172.18.0.1/16 dev br0
# ping -c 2 172.18.0.20
PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.
64 bytes from 172.18.0.20: icmp_seq=1 ttl=64 time=0.091 ms
64 bytes from 172.18.0.20: icmp_seq=2 ttl=64 time=0.028 ms
--- 172.18.0.20 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1049ms
一旦我们给网桥分配了IP地址,我们就在主机路由表中得到了一条路由:
# ip route
# ...省略部分内容...
172.18.0.0/16 dev br0 proto kernel scope link src 172.18.0.1
容器能够ping桥接接口,但它们仍然无法连接到主机的eth0。我们需要为容器添加默认路由:
# nsenter --net=/var/run/netns/netns0
# ip route add default via 172.18.0.1
# ping -c 2 10.0.2.13
PING 10.0.2.13 (10.0.2.13) 56(84) bytes of data.
64 bytes from 10.0.2.13: icmp_seq=1 ttl=64 time=0.050 ms
64 bytes from 10.0.2.13: icmp_seq=2 ttl=64 time=0.040 ms
--- 10.0.2.13 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1022ms
rtt min/avg/max/mdev = 0.040/0.045/0.050/0.005 ms
这个改变基本上把主机变成了路由器,网桥变成了容器的默认网关。
很好,我们将容器与root命名空间连接起来。现在,让我们试着把他们和外界联系起来。缺省情况下,Linux系统关闭报文转发功能,即路由器功能。我们需要打开它:
# 在root命名空间
# bash -c "echo 1 > /proc/sys/net/ipv4/ip_forward"
还是看看连通性:
# nsenter --net=/var/run/netns/netns0
[root@instance-20220709-1624 opc]# ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
# 长时间卡住
还是无法连接主机外部网络,难道我们还少了什么没做?如果容器发送数据到外部,目标服务器无法发送返回包到容器,因为容器IP是局域网IP。也就是说只有主机内部才有路由规则到容器IP。而且全网存在大量容器使用相同的局域网IP:172.18.0.10地址。解决局域网访问外部网络的方法是NAT网络地址转换。数据包在出主机之前,由容器发出的数据包的源IP地址将被主机的外部接口地址取代。主机还将跟踪所有局域网IP的映射关系,在到达时,它将恢复源IP地址,然后将数据包转发给容器。听起来很复杂,但我有好消息告诉你!感谢iptables模块,我们只需要一个命令就可以实现:
# sudo iptables -t nat -A POSTROUTING -s 172.18.0.0/16 ! -o br0 -j MASQUERADE
这个命令相当简单。我们在POSTROUTING链的nat表中添加了一条新规则,要求伪装所有来自172.18.0.0/16网络的报文,但不是通过网桥。
外部网络访问容器内部服务
将容器端口映射到主机的一些(或全部)接口是一种已知的实践。暴露容器端口究竟意味着什么?
假设容器内部运行着一个web服务:
# nsenter --net=/var/run/netns/netns0
# python3 -m http.server --bind 172.18.0.10 5000
如果我们试图从主机向这个服务器进程发送一个HTTP请求,一切都会正常工作(好吧,root名空间和所有容器接口之间有一个连接)。
# From root namespace
$ curl 172.18.0.10:5000
# ... 省略部分内容 ...
但是,如果我们从外部世界访问这个服务器,我们将使用什么IP地址?我们可能知道的唯一IP地址是主机的外部接口地址ens3:
# curl 10.0.2.13:5000
curl: (7) Failed to connect to 10.0.2.13 port 5000: Connection refused
因此,我们需要找到一种方法,将到达主机ens3接口上端口5000的任何包转发到172.18.0.10:5000目的地。或者,换句话说,我们需要在主机的ens3接口上发布容器的端口5000。又是Iptables来拯救!
# 外部流量
# iptables -t nat -A PREROUTING -d 10.0.2.13 -p tcp -m tcp --dport 5000 -j DNAT --to-destination 172.18.0.10:5000
# 本地流量
# iptables -t nat -A OUTPUT -d 10.0.2.13 -p tcp -m tcp --dport 5000 -j DNAT --to-destination 172.18.0.10:5000
另外,我们需要启用iptables在网桥网络上拦截流量:
modprobe br_netfilter
测试时间:
# curl 10.0.2.13:5000
# ... 忽略部分内容 ...
看看服务端输出:
# python3 -m http.server --bind 172.18.0.10 5000
Serving HTTP on 172.18.0.10 port 5000 (http://172.18.0.10:5000/) ...
172.18.0.1 - - [09/Jul/2022 12:56:07] "GET / HTTP/1.1" 200 -
10.0.2.13 - - [09/Jul/2022 12:58:43] "GET / HTTP/1.1" 200 -