本文最初发布于 Ivan Velichko 的个人博客,经原作者授权由 InfoQ 中文站翻译并分享。
使用容器总是感觉像变魔术一样。对那些了解其内部原理的人来说,它是一种很好的方式;而对那些不了解其内部原理的人来说,这是一种可怕的方式。
幸运的是,我们研究容器化技术的内部原理已经很长一段时间了。我们甚至发现,容器只是隔离的、受限制的Linux进程,镜像并不是运行容器所必须的,相反——要构建一个镜像,我们需要运行一些容器。
现在,让我们来解决下容器网络问题。或者,更准确地说,是单主机容器网络问题。在本文中,我们将回答以下问题:
如何虚拟化网络资源,使容器认为它们中的每一个都有一个专用的网络堆栈?
如何将容器变成友好的邻居,防止它们相互干扰,并教它们如何很好地沟通?
怎样从容器内部访问外部世界(比如互联网)?
如何从外部世界(即端口发布)访问运行在一台机器上的容器?
单主机容器网络只不过是一些众所周知的 Linux 工具的简单组合:
网络命名空间
虚拟以太网设备(veth)
虚拟网络交换机(网桥)
IP 路由和网络地址转换(NAT)
不管怎样,不需要任何代码就可以让网络魔法发生……
前提条件
任何还算不错的 Linux 发行版可能都足矣。本文中的所有例子都是在一个全新的 vagrant CentOS 8 虚拟机上完成的:
$ vagrant init centos/8
$ vagrant up
$ vagrant ssh
[vagrant@localhost ~]$ uname -a
Linux localhost.localdomain 4.18.0-147.3.1.el8_1.x86_64
复制代码
简单起见,在本文中,我们不打算依赖任何成熟的容器化解决方案(例如 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 规则,让其更容易识别:
$ sudo iptables -N ROOT_NS
复制代码
之后,在我的机器上执行 inspect 脚本会产生以下输出:
$ sudo ./inspect-net-stack.sh
> Network devices
1: lo: <LOOPBACK,UP,LOWER_UP> 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: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
link/ether 52:54:00:e3:27:77 brd ff:ff:ff:ff:ff:ff
> Route table
default via 10.0.2.2 dev eth0 proto dhcp metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
> Iptables rules
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N ROOT_NS
复制代码
之所以对这个输出感兴趣,是因为我们想确保即将创建的每个容器都将获得一个单独的网络堆栈。你可能已经听说过,用于容器隔离的其中一个 Linux 名称空间是网络命名空间(network namespace)。按照man ip-netns
的说法,“网络命名空间在逻辑上是网络堆栈的另一个副本,有自己的路由、防火墙规则和网络设备。” 简单起见,这将是我们在本文中使用的唯一命名空间。与其创建完全隔离的容器,不如将范围限制在网络堆栈中。
创建网络命名空间的一种方法是ip
工具——是事实标准iproute2工具集的一部分:
$ sudo ip netns add netns0
$ ip netns
netns0
复制代码
如何开始使用刚刚创建的命名空间?有一个可爱的 Linux 命令叫做nsenter
。它输入一个或多个指定的名称空间,然后执行给定的程序:
$ sudo nsenter --net=/var/run/netns/netns0 bash
# The newly created bash process lives in netns0
$ sudo ./inspect-net-stack.sh
> Network devices
1: lo: <LOOPBACK> 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 链,只有一个环回网络设备。到目前为止,一切顺利……
使用虚拟以太网设备(veth)将容器连接到主机
如果我们不能与一个专用的网络堆栈通信,那么它就没那么有用了。幸运的是,Linux 为此提供了一个合适工具——虚拟以太网设备!按照man veth
的说法,“veth 设备是虚拟以太网设备。它们可以作为网络命名空间之间的隧道,创建一个连接到另一个命名空间中物理网络设备的桥,但也可以作为独立的网络设备使用。”
虚拟以太网设备总是成对出现。不用担心,让我们看一下创建命令就会明白了:
$ sudo ip link add veth0 type veth peer name ceth0
复制代码
通过这个命令,我们刚刚创建了一对相互连接的虚拟以太网设备。名称veth0
和ceth0
是任意的:
$ ip link
1: lo: <LOOPBACK,UP,LOWER_UP> 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: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
link/ether 52:54:00:e3:27:77 brd ff:ff:ff:ff:ff:ff
5: ceth0@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 66:2d:24:e3:49:3f brd ff:ff:ff:ff:ff:ff
6: veth0@ceth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 96:e8:de:1d:22:e0 brd ff:ff:ff:ff:ff:ff
复制代码
创建后,veth0
和ceth0
都驻留在主机的网络堆栈(也称为根网络命名空间)上。为了连接根命名空间和netns0
命名空间,我们需要将一个设备保留在根命名空间中,并将另一个设备移到netns0
中:
$ sudo ip link set ceth0 netns netns0
# List all the devices to make sure one of them disappeared from the root stack
$ ip link
1: lo: <LOOPBACK,UP,LOWER_UP> 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: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
link/ether 52:54:00:e3:27:77 brd ff:ff:ff:ff:ff:ff
6: veth0@if5: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 96:e8:de:1d:22:e0 brd ff:ff:ff:ff:ff:ff link-netns netns0
复制代码
一旦我们打开设备并分配了正确的 IP 地址,任何出现在其中一台设备上的数据包都会立即出现在连接两个命名空间的对端设备上。让我们从根命名空间开始:
$ sudo ip link set veth0 up
$ sudo ip addr add 172.18.0.11/16 dev veth0
复制代码
接下来是etns0
:
$ sudo nsenter --net=/var/run/netns/netns0
$ ip link set lo up # whoops
$ ip link set ceth0 up
$ ip addr add 172.18.0.10/16 dev ceth0
$ ip link
1: lo: <LOOPBACK,UP,LOWER_UP> 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
5: ceth0@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 66:2d:24:e3:49:3f brd ff:ff:ff:ff:ff:ff link-netnsid 0
复制代码
通过 veth 设备连接网络命名空间
现在可以检查下连接了:
# From `netns0`, ping root's 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.038 ms
64 bytes from 172.18.0.11: icmp_seq=2 ttl=64 time=0.040 ms
--- 172.18.0.11 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 58ms
rtt min/avg/max/mdev = 0.038/0.039/0.040/0.001 ms
# Leave `netns0`
$ exit
# From root namespace, 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.073 ms
64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.046 ms
--- 172.18.0.10 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 3ms
rtt min/avg/max/mdev = 0.046/0.059/0.073/0.015 ms
复制代码
同时,如果我们试图从netns0
命名空间访问任何其他地址,都会失败:
# Inside root namespace
$ ip addr show dev eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 52:54:00:e3:27:77 brd ff:ff:ff:ff:ff:ff
inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute eth0
valid_lft 84057sec preferred_lft 84057sec
inet6 fe80::5054:ff:fee3:2777/64 scope link
valid_lft forever preferred_lft forever
# Remember this 10.0.2.15
$ sudo nsenter --net=/var/run/netns/netns0
# Try host's eth0
$ ping 10.0.2.15
connect: Network is unreachable
# Try something from the Internet
$ ping 8.8.8.8
connect: Network is unreachable
复制代码
不过,这很容易解释。对于这样的数据包,在netns0
的路由表中没有路由。其中,唯一的条目显示了如何到达172.18.0.0/16
网络:
# From `netns0` namespace:
$ ip route
172.18.0.0/16 dev ceth0 proto kernel scope link src 172.18.0.10
复制代码
Linux 有很多方法来填充路由表。其中之一是从直接连接的网络接口提取路由。记住,在命名空间创建后,netns0
的路由表是空的。但随后我们添加了ceth0
设备,并为它分配了一个 IP 地址172.18.0.10/16
。由于我们使用的不是一个简单的 IP 地址,而是地址和网络掩码的组合,网络堆栈会设法从中提取路由信息。每个发往172.18.0.0/16
网络的数据包将通过ceth0
设备发送。但是任何其他的包都会被丢弃。类似地,在根命名空间中有一条新路由:
# From `root` namespace:
$ ip route
# ... omited lines ...
172.18.0.0/16 dev veth0 proto kernel scope link src 172.18.0.11
复制代码
现在,我们已经回答了我们的第一个问题。我们现在知道了如何隔离、虚拟化和连接 Linux 网络堆栈。
通过虚拟网络交换机(网桥)实现容器互连
容器化的整个理念可以归结为有效的资源共享。也就是说,每台机器一个容器的情况并不常见。相反,我们的目标是在共享环境中运行尽可能多的隔离进程。那么,如果我们按照上面的veth
方法将多个容器放在同一主机上,会发生什么呢?让我们添加第二个容器:
# From root namespace
$ sudo ip netns add netns1
$ sudo ip link add veth1 type veth peer name ceth1
$ sudo ip link set ceth1 netns netns1
$ sudo ip link set veth1 up
$ sudo ip addr add 172.18.0.21/16 dev veth1
$ sudo 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
复制代码
我最喜欢的部分,检查连接:
# From `netns1` we cannot reach the root namespace!
$ 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 55ms
pipe 2
# But there is a route!
$ ip route
172.18.0.0/16 dev ceth1 proto kernel scope link src 172.18.0.20
# Leaving `netns1`
$ exit
# From root namespace we cannot reach the `netns1`
$ 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 23ms
pipe 2
# From `netns0` we CAN reach `veth1`
$ sudo 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.037 ms
64 bytes from 172.18.0.21: icmp_seq=2 ttl=64 time=0.046 ms
--- 172.18.0.21 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 33ms
rtt min/avg/max/mdev = 0.037/0.041/0.046/0.007 ms
# But we still cannot reach `netns1`
$ 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.10 icmp_seq=1 Destination Host Unreachable
From 172.18.0.10 icmp_seq=2 Destination Host Unreachable
--- 172.18.0.20 ping statistics ---
2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 63ms
pipe 2
复制代码
有点不对劲……netns1
遇到问题。由于某些原因,它不能与根通信,我们也不能从根命名空间访问它。然而,由于两个容器都位于同一个 IP 网络 172.18.0.0/16 中,我们现在可以从netns0
容器与主机的veth1
进行通信。非常有趣……
我花了些时间才想明白,但显然我们面临的是路由冲突。让我们检查下根命名空间中的路由表:
$ ip route
# ... omited lines ... #
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
对后,根的网络堆栈学习到了新的路由172.18.0.0/16 dev veth1 proto kernel scope link src 172.18.0.21
,但是,现有的路由中已经有一条针对同一网络的路由。当第二个容器试图 pingveth1
设备时,将选择第一个路由,这会破坏连接。如果我们删除第一条路由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(即以太网)层完成这项工作的。
让我们试着操作下吧。但首先,我们需要清理现有的设置,因为到目前为止,我们所做的一些配置更改实际上已经不再需要了。删除网络命名空间就足够了:
$ sudo ip netns delete netns0
$ sudo ip netns delete netns1
# But if you still have some leftovers...
$ sudo ip link delete veth0
$ sudo ip link delete ceth0
$ sudo ip link delete veth1
$ sudo ip link delete ceth1
复制代码
快速重建两个容器。注意,我们没有给新的veth0
和veth1
设备分配任何 IP 地址:
$ sudo ip netns add netns0
$ sudo ip link add veth0 type veth peer name ceth0
$ sudo ip link set veth0 up
$ sudo ip link set ceth0 netns netns0
$ sudo 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
$ sudo ip netns add netns1
$ sudo ip link add veth1 type veth peer name ceth1
$ sudo ip link set veth1 up
$ sudo ip link set ceth1 netns netns1
$ sudo 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.2.2 dev eth0 proto dhcp metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
复制代码
最后,创建网桥接口:
$ sudo ip link add br0 type bridge
$ sudo ip link set br0 up
复制代码
现在,将veth0
和veth1
两端都连接到网桥上:
$ sudo ip link set veth0 master br0
$ sudo ip link set veth1 master br0
复制代码
然后检查容器之间的连接:
$ sudo 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.259 ms
64 bytes from 172.18.0.20: icmp_seq=2 ttl=64 time=0.051 ms
--- 172.18.0.20 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 2ms
rtt min/avg/max/mdev = 0.051/0.155/0.259/0.104 ms
复制代码
$ sudo 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.089 ms
--- 172.18.0.10 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 36ms
rtt min/avg/max/mdev = 0.037/0.063/0.089/0.026 ms
复制代码
真令人愉快!一切正常。使用这种新方法,我们根本没有配置veth0
和veth1
。我们只在ceth0
和ceth1
端分配了两个 IP 地址。但是,由于它们都在同一个以太网段(记住,我们将它们连接到虚拟交换机),所以 L2 层上有连接:
$ sudo nsenter --net=/var/run/netns/netns0
$ ip neigh
172.18.0.20 dev ceth0 lladdr 6e:9c:ae:02:60:de STALE
$ exit
$ sudo nsenter --net=/var/run/netns/netns1
$ ip neigh
172.18.0.10 dev ceth1 lladdr 66:f3:8c:75:09:29 STALE
$ exit
复制代码
恭喜,我们学会了如何将容器变成友好的邻居,防止它们相互干扰,并保持连接性。
访问外部世界(IP 路由和伪装)
容器之间可以通信了。但它们可以和主机(即根命名空间)通信吗?
$ sudo nsenter --net=/var/run/netns/netns0
$ ping 10.0.2.15 # eth0 address
connect: Network is unreachable
复制代码
很明显,netns0
中没有相应的路由:
$ ip route
172.18.0.0/16 dev ceth0 proto kernel scope link src 172.18.0.10
复制代码
根命名空间也不能和容器通信:
# Use exit to leave `netns0` first:
$ ping -c 2 172.18.0.10
PING 172.18.0.10 (172.18.0.10) 56(84) bytes of data.
From 213.51.1.123 icmp_seq=1 Destination Net Unreachable
From 213.51.1.123 icmp_seq=2 Destination Net Unreachable
--- 172.18.0.10 ping statistics ---
2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 3ms
$ ping -c 2 172.18.0.20
PING 172.18.0.20 (172.18.0.20) 56(84) bytes of data.
From 213.51.1.123 icmp_seq=1 Destination Net Unreachable
From 213.51.1.123 icmp_seq=2 Destination Net Unreachable
--- 172.18.0.20 ping statistics ---
2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 3ms
复制代码
为了在根命名空间和容器命名空间之间建立连接,我们需要为网桥网络接口分配 IP 地址:
$ sudo ip addr add 172.18.0.1/16 dev br0
复制代码
一旦我们给网桥接口分配了 IP 地址,我们的主机路由表上就会多一条路由:
$ ip route
# ... omitted lines ...
172.18.0.0/16 dev br0 proto kernel scope link src 172.18.0.1
$ 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.036 ms
64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.049 ms
--- 172.18.0.10 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 11ms
rtt min/avg/max/mdev = 0.036/0.042/0.049/0.009 ms
$ 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.059 ms
64 bytes from 172.18.0.20: icmp_seq=2 ttl=64 time=0.056 ms
--- 172.18.0.20 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 4ms
rtt min/avg/max/mdev = 0.056/0.057/0.059/0.007 ms
复制代码
容器可能还具有 ping 网桥接口的能力,但它们仍然无法连接到主机的eth0
。我们需要为容器添加默认路由:
$ sudo nsenter --net=/var/run/netns/netns0
$ ip route add default via 172.18.0.1
$ ping -c 2 10.0.2.15
PING 10.0.2.15 (10.0.2.15) 56(84) bytes of data.
64 bytes from 10.0.2.15: icmp_seq=1 ttl=64 time=0.036 ms
64 bytes from 10.0.2.15: icmp_seq=2 ttl=64 time=0.053 ms
--- 10.0.2.15 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 14ms
rtt min/avg/max/mdev = 0.036/0.044/0.053/0.010 ms
# And repeat the change for `netns1`
复制代码
这项更改基本上把主机变成了路由器,网桥接口成了容器的默认网关。
很好,我们将容器与根命名空间连接起来了。现在,让我们尝试将它们与外部世界连接起来。默认情况下,在 Linux 中数据包转发(即路由器功能)是禁用的。我们需要打开它:
# In the root namespace
sudo bash -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'
复制代码
又到我最喜欢的部分了,检查连接:
$ sudo nsenter --net=/var/run/netns/netns0
$ ping 8.8.8.8
# hangs indefinitely long for me...
复制代码
还是不行。我们漏了什么吗?如果容器向外部世界发送数据包,那么目标服务器将不能将数据包发送回容器,因为容器的 IP 地址是私有的。也就是说,只有本地网络才知道特定 IP 的路由规则。世界上有很多容器共享完全相同的私有 IP 地址172.18.0.10
。
解决这个问题的方法叫做网络地址转换(NAT)。在进入外部网络前,由容器发出的数据包将其源 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
网络的数据包,但不是通过网桥接口。检查连接:
$ sudo nsenter --net=/var/run/netns/netns0
$ ping -c 2 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=61 time=43.2 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=61 time=36.8 ms
--- 8.8.8.8 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 2ms
rtt min/avg/max/mdev = 36.815/40.008/43.202/3.199 ms
复制代码
注意,我们遵循的是默认允许(by default - allow)策略,这在现实世界中可能相当危险。对于每个链,主机默认的 iptables 策略都是ACCEPT
:
sudo iptables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
复制代码
相反,作为一个很好的例子,Docker 默认限制了一切,然后只启用已知路径的路由。以下是在 CentOS 8 机器上(在 5005 端口上暴露了单个容器)Docker 守护进程生成的转储规则:
$ sudo iptables -t filter --list-rules
-P INPUT ACCEPT
-P FORWARD DROP
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-ISOLATION-STAGE-1
-N DOCKER-ISOLATION-STAGE-2
-N DOCKER-USER
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 5000 -j ACCEPT
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
-A DOCKER-USER -j RETURN
$ sudo iptables -t nat --list-rules
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P POSTROUTING ACCEPT
-P OUTPUT ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 5000 -j MASQUERADE
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 5005 -j DNAT --to-destination 172.17.0.2:5000
$ sudo iptables -t mangle --list-rules
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
$ sudo iptables -t raw --list-rules
-P PREROUTING ACCEPT
-P OUTPUT ACCEPT
复制代码
从外部访问容器(端口发布)
我们都知道,有一种做法是将容器端口发布到主机的部分(或全部)接口。但端口发布的真正含义是什么?
假设我们有一个在容器内运行的服务器:
$ sudo nsenter --net=/var/run/netns/netns0
$ python3 -m http.server --bind 172.18.0.10 5000
复制代码
如果我们试图从主机向这个服务器进程发送一个 HTTP 请求,一切都没问题(好吧,根命名空间和所有容器接口之间都有连接,为什么没有呢?):
# From root namespace
$ curl 172.18.0.10:5000
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
# ... omited lines ...
复制代码
但是,如果我们要从外部访问该服务器,我们将使用哪个 IP 地址?我们知道的唯一 IP 地址可能是主机的外部接口地址eth0
:
$ curl 10.0.2.15:5000
curl: (7) Failed to connect to 10.0.2.15 port 5000: Connection refused
复制代码
因此,我们需要找到一种方法,将任何到达主机eth0
接口 5000 端口的数据包转发到目的地172.18.0.10:5000
。或者,换句话说,我们需要在主机的eth0
接口上发布容器的 5000 端口。iptables 拯救了我们!
# External traffic
sudo iptables -t nat -A PREROUTING -d 10.0.2.15 -p tcp -m tcp --dport 5000 -j DNAT --to-destination 172.18.0.10:5000
# Local traffic (since it doesn't pass the PREROUTING chain)
sudo iptables -t nat -A OUTPUT -d 10.0.2.15 -p tcp -m tcp --dport 5000 -j DNAT --to-destination 172.18.0.10:5000
复制代码
此外,我们需要启用iptables拦截桥接网络上的流量:
sudo modprobe br_netfilter
复制代码
测试时间!
curl 10.0.2.15:5000
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
# ... omited lines ...
复制代码
理解 Docker 网络驱动
好的,先生,我们能用这些无用的知识做什么呢?例如,我们可以试着理解一些Docker网络模式!
让我们从--network host
模式开始。试着比较下命令ip link
和sudo docker run -it——rm——network host alpine ip link
的输出。想不到,它们居然一模一样!即在host
模式下,Docker 不使用网络命名空间隔离,容器工作在根网络命名空间中,并与主机共享网络堆栈。
下一个模式是--network none
。sudo docker run -it --rm --network none alpine ip link
命令只显示了一个环回网络接口。这与我们对新创建的网络命名空间的观察非常相似。也就是在我们添加任何veth
设备之前。
最后但同样重要的是--network bridge
(默认)模式。这正是我们在整篇文章中试图再现的。我建议你试用下ip
和iptables
命令,并从主机和容器的角度检查网络堆栈。
附:无根容器和网络
podman
容器管理器的一个很好的特性是针对无根容器的。然而,你可能已经注意到,我们在本文中使用了大量sudo
升级。换句话说,权限就不可能配置网络。Podman的rootfull网络方法和 docker 非常接近。但是当涉及到无根容器时,podman 依赖于slirp4netns项目:
从 Linux 3.8 开始,非特权用户可以创建 network_namespaces(7)和 user_namespaces(7)了。但是,非特权网络命名空间并不是很有用,因为在主机和网络命名空间之间创建 veth(4)对仍然需要 root 特权。(即没有网络连接)
通过将网络命名空间中的 TAP 设备连接到用户模式 TCP/IP 堆栈(“slirp”),slirp4netns 允许以完全非特权的方式将网络命名空间连接到网络。
无根网络有很大的局限性:“从技术上讲,容器本身没有 IP 地址,因为没有根权限,网络设备关联就无法实现。此外,无根容器无法 ping,因为它缺少 ping 命令所需的 CAP_NET_RAW 安全能力。”但这总比完全没有连接好。
小结
本文探讨的组织容器网络的方法只是其中一种可能的方法(可能是使用最广泛的一种)。还有很多其他的方法,通过官方或第三方插件实现,但它们都严重依赖于Linux网络可视化工具。因此,容器化可以被视为虚拟化技术。
原文链接:
https://iximiuz.com/en/posts/container-networking-is-simple/
评论