本文将带领读者探索 Docker 桥接网络模型的内部机制,通过手动实现 veth pair、bridge、iptables 等关键技术,揭示网络背后的运作原理。


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅


1. 概述

Docker 有多种网络模型,对于单机上运行的多个容器,可以使用缺省的
bridge 网络驱动

我们按照下图创建网络拓扑,让容器之间网络互通,从容器内部可以访问外部资源,同时,容器内可以暴露服务让外部访问。

桥接网络的一个拓扑结构如下:

![Docker Bridge 网络拓扑][docker bridge 网络拓扑]

上述网络拓扑实现了:让容器之间网络互通,从容器内部可以访问外部资源,同时,容器内可以暴露服务让外部访问。

根据网络拓扑图可以看到,容器内的数据通过 veth pair 设备传递到宿主机上的网桥上,最终通过宿主机的 eth0 网卡发送出去(或者再通过 veth pair 进入到另外的容器),而接收数据的流程则恰好相反。

2. 预备知识

这里对本文会用到的相关网络知识做一个简单介绍。

veth pair

相关笔记:
veth-pair

Veth
是成对出现的两张虚拟网卡,从一端发送的数据包,总会在另一端接收到

利用
Veth
的特性,我们可以将一端的虚拟网卡"放入"容器内,另一端接入虚拟交换机。这样,接入同一个虚拟交换机的容器之间就实现了网络互通。

即:通过 veth 来突破 network namespace 的封锁

bridge

相关笔记:
Linux bridge

我们可以认为
Linux bridge
就是
虚拟交换机
,连接在同一个
bridge
上的容器组成局域网,不同的
bridge
之间网络是隔离的。

docker network create [NETWORK NAME]
实际上就是创建出虚拟交换机。

交换机是工作在数据链路层的网络设备,它转发的是二层网络包。最简单的转发策略是将到达交换机输入端口的报文,广播到所有的输出端口。当然更好的策略是在转发过程中进行学习,记录交换机端口和 MAC 地址的映射关系,这样在下次转发时就能够根据报文中的 MAC 地址,发送到对应的输出端口。

NAT

相关笔记:
iptables

NAT(Network Address Translation),是指网络地址转换。

因为容器中的 IP 和宿主机的 IP 是不一样的,为了保证发出去的数据包能正常回来,需要对 IP 层的源 IP/目的 IP 进行转换。

  • SNAT:源地址转换
  • DNAT:目的地址转换

SNAT

Source Network Address Translation,源地址转换,用于修改数据包中的源地址。

比如上图中的 eth0 ip 是
183.69.215.18
,而容器 dockerA 的 IP 却是
172.187.0.2

因此容器中发出来的数据包,
源IP
肯定是
172.187.0.2
,如果就这样不处理直接发出去,那么接收方处理后发回来的响应数据包的
目的IP
自然就会填成
172.187.0.2
,那么我们肯定接收不到这个响应了。

因此在将容器中的数据包通过 eth0 网卡发送出去之前,需要进行 SNAT 把源 ip 改为 eth0 的 ip,也就是
183.69.215.18

这样接收方响应时将源 IP
183.69.215.18
作为目的 IP,这样我们才能收到返回的数据。

DNAT

Destination Network Address Translation:目的地址转换,用于修改数据包中的目的地址。

如果发出去做了 SNAT,源 IP 改成了宿主机的
183.69.215.18
,那么回来的响应数据包目的 IP 自然就是
183.69.215.18
,我们(宿主机)可以成功收到这个响应。

但是如果直接把源 IP 是
183.69.215.18
的数据包发到容器里面去,由于容器 IP 是
172.187.0.2
,那肯定不会处理这个包,所以宿主机收到响应包需要进行 DNAT,将目的 IP 地址从
183.69.215.18
改成容器中的
172.187.0.2

这样容器才能正常处理该数据。

3. 演示

实验环境 Ubuntu 20.04

环境准备

首先需要创建对应的容器,veth pair 设备以及 bridge 设备 并分配对应 IP。

创建“容器”

todo

从前面的背景知识了解到,容器的本质是
Namespace + Cgroups + rootfs
。因此本实验我们可以仅仅创建出
Namespace
网络隔离环境来模拟容器行为:

$ sudo ip netns add ns1
$ sudo ip netns add ns2
$ sudo ip netns show
ns2
ns1

创建 Veth pairs

sudo ip link add veth0 type veth peer name veth1
sudo ip link add veth2 type veth peer name veth3

查看一下:

$ ip link show
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: ens3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether fa:16:3e:9b:9b:33 brd ff:ff:ff:ff:ff:ff
3: veth1@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 9a:45:4c:f9:77:eb brd ff:ff:ff:ff:ff:ff
4: veth0@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether fe:5a:a1:3b:94:9b brd ff:ff:ff:ff:ff:ff
5: veth3@veth2: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 96:d2:e4:ea:9a:1d brd ff:ff:ff:ff:ff:ff
6: veth2@veth3: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000

将 Veth 的一端放入“容器”

将 veth 的一端移动到对应的
Namespace
就相当于把这张网卡加入到’容器‘里了。

sudo ip link set veth0 netns ns1
sudo ip link set veth2 netns ns2

查看宿主机上的网卡

$ ip link show
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: ens3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether fa:16:3e:9b:9b:33 brd ff:ff:ff:ff:ff:ff
3: veth1@if4: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 9a:45:4c:f9:77:eb brd ff:ff:ff:ff:ff:ff link-netns ns1
5: veth3@if6: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 96:d2:e4:ea:9a:1d brd ff:ff:ff:ff:ff:ff link-netns ns2

发现少了两个,然后进入容器对应
Namespace
查看一下容器中的网卡:

$ sudo ip netns exec ns1 ip link show
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
4: veth0@if3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether fe:5a:a1:3b:94:9b brd ff:ff:ff:ff:ff:ff link-netnsid 0
$ sudo ip netns exec ns2 ip link show
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
6: veth2@if5: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 8e:6a:e4:a0:50:ce brd ff:ff:ff:ff:ff:ff link-netnsid 0

可以看到,
veth0

veth2
确实已经放到“容器”里去了。

创建 bridge

一般使用
brctl
进行管理,不是自带的工具,需要先安装一下:

sudo apt-get install bridge-utils

创建 bridge
br0

sudo brctl addbr br0
  • 将 Veth 的另一端接入 bridge
sudo brctl addif br0 veth1
sudo brctl addif br0 veth3

查看接入效果:

$ sudo brctl show
bridge name     bridge id               STP enabled     interfaces
br0             8000.361580fa3c8b       no              veth1
                                                        veth3

可以看到,两个网卡
veth1

veth3
已经“插”在
bridge
上。

至此,veth pair 已经一端在容器里,一端在宿主机网桥上了,大致拓扑结构完成。

分配 IP 并启动

  • 为 bridge 分配 IP 地址,激活上线
sudo ip addr add 172.18.0.1/24 dev br0
sudo ip link set br0 up
  • 为"容器“内的网卡分配 IP 地址,并激活上线

docker0 容器:

sudo ip netns exec ns1 ip addr add 172.18.0.2/24 dev veth0
sudo ip netns exec ns1 ip link set veth0 up

docker1 容器:

sudo ip netns exec ns2 ip addr add 172.18.0.3/24 dev veth2
sudo ip netns exec ns2 ip link set veth2 up
  • Veth 另一端的网卡激活上线
sudo ip link set veth1 up
sudo ip link set veth3 up

至此,整个拓扑结构搭建完成,且所有设备都分配好 ip 并上线。

测试

容器互通

测试从容器
docker0
ping 容器
docker1
,测试之前先用 tcpdump 抓包,等会好分析:

sudo tcpdump -i br0 -n

在新窗口执行 ping 命令:

sudo ip netns exec ns1 ping -c 3 172.18.0.3

br0
上的抓包数据如下:

tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on br0, link-type EN10MB (Ethernet), capture size 262144 bytes
12:35:18.285705 ARP, Request who-has 172.18.0.3 tell 172.18.0.2, length 28
12:35:18.285903 ARP, Reply 172.18.0.3 is-at e2:31:15:64:bd:39, length 28
12:35:18.285908 IP 172.18.0.2 > 172.18.0.3: ICMP echo request, id 13829, seq 1, length 64
12:35:18.286034 IP 172.18.0.3 > 172.18.0.2: ICMP echo reply, id 13829, seq 1, length 64
12:35:19.309392 IP 172.18.0.2 > 172.18.0.3: ICMP echo request, id 13829, seq 2, length 64
12:35:19.309589 IP 172.18.0.3 > 172.18.0.2: ICMP echo reply, id 13829, seq 2, length 64
12:35:20.349350 IP 172.18.0.2 > 172.18.0.3: ICMP echo request, id 13829, seq 3, length 64
12:35:20.349393 IP 172.18.0.3 > 172.18.0.2: ICMP echo reply, id 13829, seq 3, length 64
12:35:23.309404 ARP, Request who-has 172.18.0.2 tell 172.18.0.3, length 28
12:35:23.309517 ARP, Reply 172.18.0.2 is-at 2e:93:7e:33:b0:ed, length 28

可以看到,先是
172.18.0.2
发起的
ARP
请求,询问
172.18.0.3

MAC
地址,然后是
ICMP
的请求和响应,最后是
172.18.0.3
的 ARP 请求。

因为接在同一个 bridge
br0
上,所以是二层互通的局域网。

同样,从容器
docker1
ping
容器
docker0
也是通的:

sudo ip netns exec ns2 ping -c 3 172.18.0.2

宿主机访问容器

在“容器”
docker0
内启动服务,监听 80 端口:

sudo ip netns exec ns1 nc -lp 80

在宿主机上执行 telnet,可以连接到
docker0
的 80 端口:

$ telnet 172.18.0.2 80
Trying 172.18.0.2...
Connected to 172.18.0.2.
Escape character is '^]'.

可以联通。

容器访问外网

这部分稍微复杂一些,需要配置 NAT 规则。

1)配置容器内路由

需要配置容器内的路由,这样才能把网络包从容器内转发出来。

具体就是:
将 bridge 设置为“容器”的缺省网关
。让非
172.18.0.0/24
网段的数据包都路由给
bridge
,这样数据就从“容器”跑到宿主机上来了。

sudo ip netns exec ns1 ip route add default via 172.18.0.1 dev veth0
sudo ip netns exec ns2 ip route add default via 172.18.0.1 dev veth2

查看“容器”中的路由规则

$ sudo ip netns exec ns1 ip route
default via 172.18.0.1 dev veth0
172.18.0.0/24 dev veth0 proto kernel scope link src 172.18.0.2

可以看到,非 172.18.0.0 网段的数据都会走默认规则,也就是发送给网关 172.18.0.1。

2)宿主机开启转发功能并配置转发规则

在宿主机上配置内核参数,允许 IP forwarding,这样才能把网络包转发出去。

sudo sysctl net.ipv4.conf.all.forwarding=1

还有就是要配置 iptables FORWARD 规则

首先确认
iptables FORWARD
的缺省策略:

$ sudo iptables -t filter -L FORWARD
Chain FORWARD (policy ACCEPT)
target     prot opt source               destination

一般都是 ACCEPT,如果如果缺省策略是
DROP
,需要设置为
ACCEPT

sudo iptables -t filter -P FORWARD ACCEPT

3)宿主机配置 SNAT 规则

sudo iptables -t nat -A POSTROUTING -s 172.18.0.0/24 ! -o br0 -j MASQUERADE

上面的命令的含义是:在
nat
表的
POSTROUTING
链增加规则,当数据包的源地址为
172.18.0.0/24
网段,出口设备不是
br0
时,就执行
MASQUERADE
动作。

MASQUERADE
也是一种源地址转换动作,它会动态选择宿主机的一个 IP 做源地址转换,而
SNAT
动作必须在命令中指定固定的 IP 地址。

测试能否访问外网:

$ sudo ip netns exec ns1 ping -c 3 114.114.114.114
PING 114.114.114.114 (114.114.114.114) 56(84) bytes of data.
64 bytes from 114.114.114.114: icmp_seq=1 ttl=80 time=21.1 ms
64 bytes from 114.114.114.114: icmp_seq=2 ttl=89 time=19.5 ms
64 bytes from 114.114.114.114: icmp_seq=3 ttl=86 time=19.2 ms

外部访问容器

外部访问容器需要进行 DNAT,把目的 IP 地址从宿主机地址转换成容器地址。

sudo iptables -t nat -A PREROUTING  ! -i br0 -p tcp -m tcp --dport 80 -j DNAT --to-destination 172.18.0.2:80

上面命令的含义是:在
nat
表的
PREROUTING
链增加规则,当输入设备不是
br0
,目的端口为 80 时,做目的地址转换,将宿主机 IP 替换为容器 IP。

测试一下

在“容器”docker0 内启动服务:

sudo ip netns exec ns1 nc -lp 80


和宿主机同一个局域网的远程主机
访问宿主机 IP:80

telnet 192.168.2.110 80

确认可以访问到容器内启动的服务。

不过由于只在
PREROUTING
链上做了 DNAT,因此直接在宿主机上访问是不行,需要本机访问的话可以添加下面这个 iptables 规则,直接在 OUTPUT 链上增加 DNAT 规则:

这样其他节点来的流量和本机直接访问流量都可以正常 DNAT 了。

sudo iptables -t nat -A OUTPUT -p tcp -m tcp --dport 80 -j DNAT --to-destination 172.18.0.2:80

添加后再本机直接测试:

telnet 192.168.2.110 80

这下可以成功连上了。

环境恢复

删除虚拟网络设备

sudo ip link set br0 down
sudo brctl delbr br0
sudo ip link  del veth1
sudo ip link  del veth3

iptablers

Namesapce
的配置在机器重启后被清除。

4. 小结

本文主要通过 Linux 提供的各种虚拟设备以及 iptables 模拟出了
docker bridge 网络模型
,并测试了几种场景的网络互通。实际上
docker network
就是使用了
veth

Linux bridge

iptables
等技术,帮我们创建和维护网络。

具体分析一下:

  • 首先 docker 就是一个进程,主要利用 Linux Namespace 进行隔离。
  • 为了跨 Namespace 通信,就用到了 Veth pair。
  • 然后多个容器都使用 Veth pair 互相连通的话,就会有 m*n 条线,不好管理,所以加入了 Linux Bridge,所有 veth 都直接和 bridge 连接,这样就好管理多了。
  • 然后容器和外部网络要进行通信,于是又要用到 iptables 的 NAT 规则进行地址转换。


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅


5. 参考

iptables 笔记

veth-pair 笔记

Docker bridge networks

Docker 单机网络模型动手实验

标签: none

添加新评论