基本原理
Linux 容器假如使用了 network namespace,那么容器将会有自己的一个网络栈,而这个网络栈,就包括了:网卡、回环设备、路由表和 iptables 规则等。这些要素,其实就构成了进程发起和响应网络请求的基本环境,拥有了属于自己的 IP 地址和端口。虽然容器可以直接使用宿主机网络栈的方式,从而为容器提供良好的网络性能,但是这样也会不可避免地引入共享网络资源的问题,比如端口冲突。
但是被隔离在自己 network namespace 中的容器如何跟同一宿主机上但处于不同 network namespace 里的容器进程进行通信呢?如何跟容器所在宿主机进行通信呢?如何访问外部主机的?或者提供外部可访问的服务呢?
图片
把每一个容器都看作一台主机,它们有一套独立的“网络栈”。那么容器与同一台上宿主机的通信,或者与宿主机的通信,其实就相当于两台主机之间的通信。而实现两台主机之间通信最直接的方式,就是用一根网线将两台主机连接起来。如果想要实现多台主机之间的通信,那就需要用网线,把它们连接在一台交换机上。
Linux 提供了 bridge 虚拟网络设备(类似交换机的能力),并且提供了 Veth Pair 虚拟设备(Veth Pair 的虚拟设备被创建出来之后,总是以两张虚拟网卡(Veth Peer)的形式成对出现。并且从其中一个网卡发出的数据包,可以直接出现在与它对应的另一张网卡上,哪怕这两张网卡在不同的 network namespace 中)。
因此,只需要通过上述虚拟设备,将 Veth Pair 一端连在 Container 上,作为 Container 的 eth0,另外一端连接在网桥上,同时给网桥分配一个 IP 地址(此时网桥也作为了宿主机的一个网络接口),就可以实现容器与同一台上宿主机的通信,或者与宿主机的通信。如图所示,
之后在上述的基础之上,在宿主机上通过路由和 NAT 的方式,就可以实现容器访问外部服务,或者为外部提供服务。
图片
除了 bridge 的方式之外,还可以通过其他方式来实现,但是目前普遍的方式是采用上述的方式。比如,Docker 项目默认就是使用上述方式实现的。除了 bridge+veth pair 的方式之外,容器还可以选择其他的网络配置方式。
- 比如 Docker 的文档中就提到了 macvlan 的配置方式,和 macvlan 很类似的方式还有 ipvlan。它们的相同点就是都可以在一个物理的网络接口上再配置几个虚拟的网络接口。在这些虚拟的网络接口上,都可以配置独立的 IP,并且这些 IP 可以属于不同的 Namespace。而不同点是,对于 macvlan,每个虚拟网络接口都有自己独立的 mac 地址;而 ipvlan 的虚拟网络接口是和物理网络接口共享同一个 mac 地址。由于 ipvlan/macvlan 网络接口直接挂载在物理网络接口上,此时数据包发送的过程中只会经过容器内的协议栈配置,比如容器自己的 iptables 规则,而不会经过宿主机的 iptables 规则。因此,对于会用到宿主机 iptables 规则的容器,比如 Kubernetes 里使用 service 的容器,就不能工作了。
图片
bridge 网络模型通信举例
容器->容器(同一宿主机)
- 在容器 container1 中查看相应的路由:这个容器里有一张叫作 eth0 的网卡,它正是一个 Veth Pair 设备在容器里的这一端。所有对 172.17.0.0/16 网段的请求,都会被交给 eth0 来处理(第二条 172.17.0.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
而这个 Veth Pair 设备的另一端,则在宿主机上。你可以通过查看宿主机的网络设备看到它,如下所示。可以看到,container1 对应的 Veth Pair 设备,在宿主机上是一张虚拟网卡。它的名字叫作 veth9c02e56。并且,通过 brctl show 的输出,你可以看到这张网卡被“插”在了 docker0 上。
# 在宿主机上
$ 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 容器里访问 container2 容器的 IP 地址(比如 ping 172.17.0.3)的时候,这个目的 IP 地址会匹配到 container1 容器里的路由规则,相应的路由规则的网关(gateway)是 0.0.0.0,这就意味着这是一条直连规则:即凡是匹配到这条规则的 IP 包,应该经过本机的 eth0 网卡,通过二层网络直接发往目的主机。
此时则需要知道 172.17.0.3 这个 IP 地址对应的 MAC 地址,因此 container1 容器的网络协议栈,会通过 eth0 网卡发送一个 ARP 广播,来通过 IP 地址查找对应的 MAC 地址。由于一张虚拟网卡被“插”在网桥上后,它就会变成该网桥的“从设备”。从设备会被“剥夺”调用网络协议栈处理数据包的资格,从而“降级”成为网桥上的一个端口。这个端口唯一的作用,就是接收流入的数据包,而这些数据包的转发或者丢弃则全交由对应的网桥处理(container1 中的 eth0 网卡对应的 Veth Pair 其实就相当于一个网络端口,而 eth0 和它对应的 veth pari 的连接就相当于一根网线,这根“网线”将 containerd 连到了网桥上。
所以,在收到这些 ARP 请求之后,docker0 网桥就会扮演二层交换机的角色,把 ARP 广播转发到其他被“插”在 docker0 上的虚拟网卡上。因此,container2 容器的 eth0 就会收到这个 ARP 请求,从而将 172.17.0.3 所对应的 MAC 地址回复给 container1 容器。 - 有了这个目的 MAC 地址,container1 容器的 eth0 网卡就可以将数据包发出去。docker0 处理转发的过程,则继续扮演二层交换机的角色。此时,docker0 网桥根据数据包的目的 MAC 地址(也就是 container2 容器的 MAC 地址),在它的 CAM 表(即交换机通过 MAC 地址学习维护的端口和 MAC 地址的对应表)里查到对应的端口(Port)为:vethb4963f3,然后把数据包发往这个端口。这个端口对应着 container2 中的 eth0 网卡(相当于有根网线将他们连起来)。因此数据包就进入到了 container2 容器的 Network Namespace 里。所以,container2 容器看到的情况是,它自己的 eth0 网卡上出现了流入的数据包。这样,container2 的网络协议栈就会对请求进行处理,最后将响应(Pong)返回到 containerd1。
图片
需要注意的是,在实际的数据传递时,上述数据的传递过程在网络协议栈的不同层次,都有 Linux 内核 Netfilter 参与其中。所以,如果感兴趣的话,你可以通过打开 iptables 的 TRACE 功能查看到数据包的传输过程,具体方法如下所示,通过上述设置,你就可以在 /var/log/syslog 里看到数据包传输的日志了。
# 在宿主机上执行
$ iptables -t raw -A OUTPUT -p icmp -j TRACE
$ iptables -t raw -A PREROUTING -p icmp -j TRACE
宿主机->容器
当在主机上访问容器的时候,访问请求的数据包,
- 首先根据宿主机的路由规则到达 docker0 网桥。
$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 docker0
- 然后再被转发到对应的 Veth Pair 设备,最后出现在容器里。
图片
容器->外部主机
- 当一个容器试图连接到另外一个宿主机时,比如:ping 10.168.0.3,它发出的请求数据包,首先经过 docker0 网桥出现在宿主机上。
- 之后根据宿主机的路由表里的路由规则(10.168.0.0/24 via eth0)),将对 10.168.0.3 的访问请求交给宿主机的 eth0 处理。
- 同时对数据包进行源地址替换(SNAT)。这是因为容器通常使用的是一个私有的、非路由的 IP 地址(如172.17.0.2),这些地址在宿主机的网络之外是不可见的。宿主机网络之外用于通信的地址,还得是宿主机。如果不进行 SNAT 的话,那么在外部网络中无法进行正确的路由。下面是 netfilter POSTROUTING 阶段的规则,包含了一条规则:source 如果是 172.17.0.0/16,但是出口用的网络接口不是 docker 的话,则进行 SNAT。
Chain POSTROUTING (policy ACCEPT 2469K packets, 176M bytes)
pkts bytes target prot opt in out source destination
0 0 MASQUERADE all -- * !docker0 172.17.0.0/16 0.0.0.0/0
- 接下来的数据包就会经宿主机的 eth0 网卡转发到宿主机网络上,最终到达 10.168.0.3 对应的宿主机上(当然这两台宿主机本身是要连通的)。
当遇到容器连不通“外网”的时候,你都应该先试试 docker0 网桥能不能 ping 通,然后查看一下跟 docker0 和 Veth Pair 设备相关的 iptables 规则是不是有异常,往往就能够找到问题的答案了。
图片
外部主机->容器
如果一个容器想要对外提供服务的话,此时需要使用 NAT 的能力,将提供服务的端口映射到 Docker 宿主机端口上。在实现原理上,其实就是在 netfilter PREROUTING 阶段进行目的地址转换(DNAT)。
- 当外部主机访问宿主机的 8090 端口,也就是访问 10.168.0.2:8090 的时候。
- 访问数据包会先到宿主机网络,先经过 netfilter PREROUTING 阶段。PREROUTING 中有一条规则是对于目的端口是 8090 的数据包来说,它的目的地址和端口会被替换为 172.17.0.2:8090。因此,任何访问宿主机 8090 端口的数据包,都会被转发到容器 172.17.0.2:8090 上。
Chain PREROUTING (policy ACCEPT 2587K packets, 122M bytes)
pkts bytes target prot opt in out source destination
2637K 125M DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain DOCKER (2 references)
pkts bytes target prot opt in out source destination
0 0 RETURN all -- docker0 * 0.0.0.0/0 0.0.0.0/0
37821 2032K DNAT tcp -- !docker0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8090 to:172.17.0.2:8090
bridge 网络模拟创建
当我们在使用容器的时候,容器引擎比如 Docker,会自动搭建好上述的网络拓扑。下面从头开始搭建上述的网络拓扑。主要用到的是 ip netns 这个命令,通过它来对 network namespace 进行操作。
- 首先,我们先启动一个不带网络配置的容器。可以看到,在启动的容器中,Network Namespace 里就只有 loopback 一个网络设备,而没有了 eth0 网络设备了。
# docker run -d --name if-test --network none centos:8.1.1911 sleep 36000
cf3d3105b11512658a025f5b401a09c888ed3495205f31e0a0d78a2036729472
# docker exec -it if-test ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> 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
- 完成刚才的设置以后,在这个容器的 Network Namespace 里建立 veth。可以执行一下这个脚本完成 veth 的创建和通信。
pid=$(ps -ef | grep "sleep 36000" | grep -v grep | awk '{print $2}')
echo $pid
ln -s /proc/$pid/ns/net /var/run/netns/$pid
# Create a pair of veth interfaces
ip link add name veth_host type veth peer name veth_container
# Put one of them in the new net ns
ip link set veth_container netns $pid
# In the container, setup veth_container
ip netns exec $pid ip link set veth_container name eth0
ip netns exec $pid ip addr add 172.17.1.2/16 dev eth0
ip netns exec $pid ip link set eth0 up
ip netns exec $pid ip route add default via 172.17.0.1
# In the host, set veth_host up
ip link set veth_host up
完成这些操作之后,就创建了一对 veth 虚拟设备接口,示意图如下所示。
假如 veth_host 上加上一个 IP,172.17.1.1/16,就可以从容器里就可以 ping 通这个 IP 了。这也证明了从容器到宿主机可以利用这对 veth 接口来通讯了。
首先呢,我们先找到这个容器里运行的进程"sleep 36000"的 pid,通过 "/proc/$pid/ns/net"这个文件得到 Network Namespace 的 ID,这个 Network Namespace ID 既是这个进程的,也同时属于这个容器。
然后我们在"/var/run/netns/"的目录下建立一个符号链接,指向这个容器的 Network Namespace。完成这步操作之后,在后面的"ip netns"操作里,就可以用 pid 的值作为这个容器的 Network Namesapce 的标识了。
接下来呢,我们用 ip link 命令来建立一对 veth 的虚拟设备接口,分别是 veth_container 和 veth_host。从名字就可以看出来,veth_container 这个接口会被放在容器 Network Namespace 里,而 veth_host 会放在宿主机的 Host Network Namespace。
之后,用 ip link set veth_container netns $pid 把 veth_container 这个接口放入到容器的 Network Namespace 中。
再然后我们要把 veth_container 重新命名为 eth0,因为这时候接口已经在容器的 Network Namesapce 里了,eth0 就不会和宿主机上的 eth0 冲突了。
最后对容器内的 eht0,我们还要做基本的网络 IP 和缺省路由配置。因为 veth_host 已经在宿主机的 Host Network Namespace 了,就不需要我们做什么了,这时我们只需要 up 一下这个接口就可以了。
- 完成上述的 veth 设备创建之后,就可以让数据包从容器的 network namespace 发送到 host network namespace 了。到了 Host Network Namespace 之后就需要考虑,如何将数据包继续从 eth0 接口发送出去。
- 首先将第一步中建立的 veth_host 这个设备,接入到 docker0 这个 bridge 上即可。如果之前你在 veth_host 上设置了 IP 的,就需先运行一下"ip addr delete 172.17.1.1/16 dev veth_host",把 IP 从 veth_host 上删除。
# ip addr delete 172.17.1.1/16 dev veth_host
ip link set veth_host master docker0
上述完成之后,网络拓扑如下所示。容器和 docker0 组成了一个子网,docker0 上的 IP 就是这个子网的网关 IP。
- 此时,数据包是在宿主机网络中。由于要访问外网的一个 IP 地址,因此此时宿主机要做的工作是转发。因此,需要打开宿主机上的转发功能。
$ iptables -P FORWARD ACCEPT
$ cat /proc/sys/net/ipv4/ip_forward
0
$ echo 1 > /proc/sys/net/ipv4/ip_forward
- 完成上述的配置之后,可以从容器中 ping 通外网的 IP 地址了。
# docker exec -it if-test ping 39.106.233.176
PING 39.106.233.176 (39.106.233.176) 56(84) bytes of data.
64 bytes from 39.106.233.176: icmp_seq=1 ttl=77 time=359 ms
64 bytes from 39.106.233.176: icmp_seq=2 ttl=77 time=346 ms
^C
--- 39.106.233.176 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1ms
rtt min/avg/max/mdev = 345.889/352.482/359.075/6.593 ms
相关链接
- The Layers of the OSI Model Illustrated:https://www.lifewire.com/layers-of-the-osi-model-illustrated-818017
- 极客时间.张磊.《深入剖析Kubernetes》
- 容器网络(一):https://morven.life/posts/networking-4-docker-sigle-host/