手把手带你进入 docker 网络的世界

手把手带你进入 docker 网络的世界

作者:张首富
时间:2021-03-04
转载:https://www.infoq.cn/article/MjwIBtwplrOMI5Vv5Joy

前言

使用容器总感觉像变模式一样。对那些了解其内部原理的人来说,他是一种很好的方式;而对于那些不了解其内部原理的人来说,这是一种可怕的方式。

今天我们来尝试解决下容器网络问题。或者,更准确地说是单主机容器网络问题。在本文中,我们将回答以下问题:

  • 1,如何虚拟化网络资源,使容器认为他们中的每一个都有一个专用的网络堆栈?
  • 2,如何将容器变成友好的邻居,防止他们相互干扰,并让他们很好的沟通?
  • 3,怎样从容器内部访问外部世界(如互联网)?
  • 4,如何从外部世界(端口发布-p)访问运行在一台机器上的容器?

单主机容器网络只不过是一些众所周知的 Linux 工具的简单组合:

  • 网络命名空间
  • 虚拟以太网设备(veth)
  • 虚拟网络交换机(网桥)
  • IP 路由和网络地址转换(NAT)

不管怎样,不需要任何代码就可以让网络魔法发生……

前提条件

任何还算不错的 Linux 发行版可能都足矣。本文中的所有例子都是在一个全新的 centos 7.6 虚拟机上完成的:

[root@localhost ~]# cat /etc/redhat-release
CentOS Linux release 7.6.1810 (Core)
[root@localhost ~]# uname -a
Linux localhost.localdomain 3.10.0-957.el7.x86_64 #1 SMP Thu Nov 8 23:39:32 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
[root@localhost ~]#

先提前安装好如下软件包,里面会包含nsenter 命令,后面我们会用到

# yum -y install util-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: <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: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether e0:d5:5e:5a:e2:44 brd ff:ff:ff:ff:ff:ff

> Route table
default via 192.168.7.254 dev enp2s0 proto static metric 100
192.168.4.0/22 dev enp2s0 proto kernel scope link src 192.168.6.100 metric 100

> Iptables rules
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N ROOT_NS

之所以对这个输出感兴趣,是因为我们想确保即将创建的每个容器都将获得一个单独的网络堆栈。你可能已经听说过,用于容器隔离的其中一个 Linux 名称空间是网络命名空间(network namespace)。按照man ip-netns的说法,“网络命名空间在逻辑上是网络堆栈的另一个副本,有自己的路由、防火墙规则和网络设备。” 简单起见,这将是我们在本文中使用的唯一命名空间。与其创建完全隔离的容器,不如将范围限制在网络堆栈中。

创建网络命名空间的一种方法是ip工具——是事实标准iproute2工具集的一部分:

# ip netns add netns0
# ip netns
netns0

如何开始使用刚刚创建的命名空间?有一个可爱的 Linux 命令叫做nsenter。它输入一个或多个指定的名称空间,然后执行给定的程序:

# nsenter --net=/var/run/netns/netns0 bash
// 新创建的bash进程位于netns0中。

# sh 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

//如果想退出这个网络命名空间的 bash 只需要输入 exit 退出去即可
# exit //退出当前这个网络命名空间

从上面的输出可以清楚地看出,在netns0命名空间内运行的 bash 进程看到的是一个完全不同的网络堆栈。没有路由规则,没有自定义 iptables 链,只有一个环回网络设备。到目前为止,一切顺利……

img

使用虚拟以太网设备(veth)将容器连接到主机--解答问题 1

如果我们不能与一个专用的网络堆栈通信,那么它就没那么有用了。幸运的是,Linux 为此提供了一个合适工具——虚拟以太网设备!按照man veth的说法,“veth 设备是虚拟以太网设备。它们可以作为网络命名空间之间的隧道,创建一个连接到另一个命名空间中物理网络设备的桥,但也可以作为独立的网络设备使用。”

虚拟以太网设备总是成对出现。不用担心,让我们看一下创建命令就会明白了:

# ip link add veth0 type veth peer name ceth0

通过这个命令,我们刚刚创建了一对相互连接的虚拟以太网设备。名称veth0ceth0是任意的:

# 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: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether e0:d5:5e:5a:e2:44 brd ff:ff:ff:ff:ff:ff
6: ceth0@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether a2:0c:ee:fa:f9:b1 brd ff:ff:ff:ff:ff:ff
7: veth0@ceth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether da:2c:f1:91:f3:2e brd ff:ff:ff:ff:ff:ff

创建后,veth0ceth0都驻留在主机的网络堆栈(也称为根网络命名空间)上。为了连接根命名空间和netns0命名空间,我们需要将一个设备保留在根命名空间中,并将另一个设备移到netns0中:

# ip link set ceth0 netns netns0
// 把 ceth0 虚拟以太网设备移动到 netns0 网络命名空间中
# 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: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether e0:d5:5e:5a:e2:44 brd ff:ff:ff:ff:ff:ff
7: veth0@if6: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether da:2c:f1:91:f3:2e brd ff:ff:ff:ff:ff:ff link-netnsid 0
[root@localhost ~]#

一旦我们打开设备并分配了正确的 IP 地址,任何出现在其中一台设备上的数据包都会立即出现在连接两个命名空间的对端设备上。让我们从根命名空间开始:

# ip link set veth0 up
#  ip addr add 172.18.0.11/16 dev veth0
# ifconfig  veth0 | grep "inet "
        inet 172.18.0.11  netmask 255.255.0.0  broadcast 0.0.0.0

接下来是etns0

# 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 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
6: ceth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether a2:0c:ee:fa:f9:b1 brd ff:ff:ff:ff:ff:ff link-netnsid 0
# ifconfig ceth0 | grep "inet "
        inet 172.18.0.10  netmask 255.255.0.0  broadcast 0.0.0.0

img

通过 veth 设备连接网络命名空间

现在可以检查下连接了:

// 在 netns0 里面去 ping 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.041 ms
64 bytes from 172.18.0.11: icmp_seq=2 ttl=64 time=0.054 ms

--- 172.18.0.11 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.041/0.047/0.054/0.009 ms

# exit
logout

// 在 root namespace 里面 ping ceth0
[root@localhost ~]# 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.043 ms
64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.056 ms

--- 172.18.0.10 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.043/0.049/0.056/0.009 ms

同时,如果我们试图从netns0命名空间访问任何其他地址,都会失败:

# ip addr show dev enp2s0
2: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether e0:d5:5e:5a:e2:44 brd ff:ff:ff:ff:ff:ff
    inet 192.168.6.100/22 brd 192.168.7.255 scope global noprefixroute enp2s0
       valid_lft forever preferred_lft forever
    inet6 fe80::3b8:68e4:b034:4cae/64 scope link noprefixroute
       valid_lft forever preferred_lft forever
//本机 IP 为 192.168.6.100

# nsenter --net=/var/run/netns/netns0
# ping -c 2 192.168.6.100
connect: Network is unreachable

#  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
...
172.18.0.0/16 dev veth0 proto kernel scope link src 172.18.0.11

现在,我们已经回答了我们的第一个问题。我们现在知道了如何隔离、虚拟化和连接 Linux 网络堆栈。

通过虚拟网络交换机(网桥)实现容器互连

容器化的整个理念可以归结为有效的资源共享。也就是说,每台机器一个容器的情况并不常见。相反,我们的目标是在共享环境中运行尽可能多的隔离进程。那么,如果我们按照上面的veth方法将多个容器放在同一主机上,会发生什么呢?让我们添加第二个容器:

// From root namespace
# 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

# 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 999ms
pipe 2

// 能去不能回
#  ip route
172.18.0.0/16 dev ceth1 proto kernel scope link src 172.18.0.20
# 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 999ms
pipe 2

// From `netns0` we CAN reach `veth1`
[root@localhost ~]# nsenter --net=/var/run/netns/netns0
[root@localhost ~]# 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.071 ms
64 bytes from 172.18.0.21: icmp_seq=2 ttl=64 time=0.039 ms

--- 172.18.0.21 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1000ms
rtt min/avg/max/mdev = 0.039/0.055/0.071/0.016 ms
[root@localhost ~]# exit
logout


// But we still cannot reach `netns1`
[root@localhost ~]# nsenter --net=/var/run/netns/netns1
[root@localhost ~]# 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.035 ms
64 bytes from 172.18.0.20: icmp_seq=2 ttl=64 time=0.050 ms

--- 172.18.0.20 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.035/0.042/0.050/0.009 ms
[root@localhost ~]# exit
logout

有点不对劲……netns1遇到问题。由于某些原因,它不能与根通信,我们也不能从根命名空间访问它。然而,由于两个容器都位于同一个 IP 网络 172.18.0.0/16 中,我们现在可以从netns0容器与主机的veth1进行通信。非常有趣……

我花了些时间才想明白,但显然我们面临的是路由冲突。让我们检查下根命名空间中的路由表:

# 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对后,根的网络堆栈学习到了新的路由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就有问题了。

img

我相信,如果我们为netns1选择另一个 IP 网络,一切就没问题了。然而,多个容器位于一个 IP 网络中是一个合理的用例。因此,我们需要以某种方式调整veth方法…

看看 Linux 网桥——另一种虚拟网络设施!Linux 网桥的行为就像一个网络交换机。它会在连接到它的接口之间转发数据包。因为它是一个交换机,所以它是在 L2(即以太网)层完成这项工作的。

让我们试着操作下吧。但首先,我们需要清理现有的设置,因为到目前为止,我们所做的一些配置更改实际上已经不再需要了。删除网络命名空间就足够了:

# ip netns delete netns0
# ip netns delete netns1
# ip link delete veth0
Cannot find device "veth0"
# ip link delete veth1
Cannot find device "veth1"
# ip link delete ceth0
Cannot find device "ceth0"
# ip link delete ceth1
Cannot find device "ceth1"

快速重建两个容器。注意,我们没有给新的veth0veth1设备分配任何 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
logout
# 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
logout

确保主机上没有新路由:

# ip route
default via 192.168.7.254 dev enp2s0 proto static metric 100
192.168.4.0/22 dev enp2s0 proto kernel scope link src 192.168.6.100 metric 100

最后,创建网桥接口:

# ip link add br0 type bridge
# ip link set br0 up

现在,将veth0veth1两端都连接到网桥上:

# ip link set veth0 master br0
# ip link set veth1 master br0

img

然后检查容器之间的连接:

#  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.109 ms
64 bytes from 172.18.0.20: icmp_seq=2 ttl=64 time=0.076 ms

--- 172.18.0.20 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.076/0.092/0.109/0.019 ms
# exit
logout
# 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.059 ms
64 bytes from 172.18.0.10: icmp_seq=2 ttl=64 time=0.076 ms

--- 172.18.0.10 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.059/0.067/0.076/0.011 ms
# exit
logout

真令人愉快!一切正常。使用这种新方法,我们根本没有配置veth0veth1。我们只在ceth0ceth1端分配了两个 IP 地址。但是,由于它们都在同一个以太网段(记住,我们将它们连接到虚拟交换机),所以 L2 层上有连接:

# nsenter --net=/var/run/netns/netns0
# ip neigh
172.18.0.20 dev ceth0 lladdr e2:be:27:a3:14:ab STALE
# exit
logout
# nsenter --net=/var/run/netns/netns1
# ip neigh
172.18.0.10 dev ceth1 lladdr 4e:c2:a2:98:68:65 STALE
#exit
logout

恭喜,我们学会了如何将容器变成友好的邻居,防止它们相互干扰,并保持连接性。

访问外部世界(IP 路由和伪装)

容器之间可以通信了。但它们可以和主机(即根命名空间)通信吗?

# nsenter --net=/var/run/netns/netns0
# ping -c 2 192.168.6.100
connect: Network is unreachable
# exit

很明显,netns0中没有相应的路由:

# ip route
172.18.0.0/16 dev ceth0 proto kernel scope link src 172.18.0.10

根命名空间也不能和容器通信:

//在 root namespaces
#  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 地址:

# ip addr add 172.18.0.1/16 dev br0

一旦我们给网桥接口分配了 IP 地址,我们的主机路由表上就会多一条路由:

# ip route
default via 192.168.7.254 dev enp2s0 proto static metric 100
172.18.0.0/16 dev br0 proto kernel scope link src 172.18.0.1
192.168.4.0/22 dev enp2s0 proto kernel scope link src 192.168.6.100 metric 100


# 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。我们需要为容器添加默认路由:

# 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`

img

很好,我们将容器与根命名空间连接起来了。现在,让我们尝试将它们与外部世界连接起来。默认情况下,在 Linux 中数据包转发(即路由器功能)是禁用的。我们需要打开它:

// In the root namespace
# sudo bash -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'

又到我最喜欢的部分了,检查连接:

# 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模块,我们只需要一个命令就可以实现:

# iptables -t nat -A POSTROUTING -s 172.18.0.0/16 ! -o br0 -j MASQUERADE

这个命令相当简单。我们正在向POSTROUTING链的nat表添加一条新规则,要求伪装所有源自172.18.0.0/16网络的数据包,但不是通过网桥接口。检查连接:

# 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

# iptables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT

相反,作为一个很好的例子,Docker 默认限制了一切,然后只启用已知路径的路由。以下是在 CentOS 8 机器上(在 5005 端口上暴露了单个容器)Docker 守护进程生成的转储规则:

# 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

从外部访问容器(端口发布)

我们都知道,有一种做法是将容器端口发布到主机的部分(或全部)接口。但端口发布的真正含义是什么?

假设我们有一个在容器内运行的服务器:

# 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
# 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)
# 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拦截桥接网络上的流量

# 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 linksudo docker run -it——rm——network host alpine ip link的输出。想不到,它们居然一模一样!即在host模式下,Docker 不使用网络命名空间隔离,容器工作在根网络命名空间中,并与主机共享网络堆栈。

下一个模式是--network nonesudo docker run -it --rm --network none alpine ip link命令只显示了一个环回网络接口。这与我们对新创建的网络命名空间的观察非常相似。也就是在我们添加任何veth设备之前。

最后但同样重要的是--network bridge(默认)模式。这正是我们在整篇文章中试图再现的。我建议你试用下ipiptables命令,并从主机和容器的角度检查网络堆栈。

附:无根容器和网络

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/

posted @ 2021-03-04 10:03  张首富  阅读(356)  评论(0编辑  收藏  举报