前言
本来打算这篇文章是分析 Docker Overlay 网络是如何建立以及如何手动实现 Docker 的跨主机通信的。但是在完成了上一篇文章之后,打算找一些文章或者书籍印证我的文章是否正确。这时看了一下案头的《Docker源码分析》,翻到 Docker 容器网络部分,然后快速过了一遍,发现了有一段和我的观点不太一致。
有问题的部分是 95 页的一段话,摘录如下[1]:
5)宿主机将经过 SNAT 处理后的报文通过请求的目的IP地址(宿主机以外世界的IP地址)发送至外界。
在这里,很多人肯定要问:对于 Docker 容器内部主动发起的网络请求,请求到达宿主机进行 SNAT 处理发给外界之后,当外界响应请求时,响应报文中的目的IP地址肯定是 Docker Daemon 所在宿主机的 IP 地址,那响应报文回到宿主机的时候,宿主机又是如何转给 Docker 容器的呢?关于这样的响应,由于没有做相应的 DNAT 转换,原则上不会被发送到容器内部。为什么说对于这样的响应,不会做 DNAT 转换呢。原因很简单,DNAT 转换是针对特定容器内部服务监听的特定端口做的,该端口是供服务监听使用,而容器内部发起的请求报文中,源端口肯定不会占用服务监听的端口,故容器内部发起请求的响应不会在宿主机上经过 DNAT 处理。
其实,这一环节的关键在于 iptables,具体的 iptables 规则如下:iptables -I FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
...
所以这篇文章将会从逻辑,理论,实验来分析这段文字是否正确。
逻辑性和理论性分析
逻辑性分析
按照这段文字,如果我没有理解错的话,作者的意思是 Docker 容器通过 SNAT 向外发送请求,但是此时宿主机的 iptables 没有配置 DNAT 那么在不配置 DNAT 的情况下必须要通过 iptables -I FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
来保证响应能够正确返回到 Docker 容器中。首先分析 iptables -I FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
,这里可以直接引用作者的分析[1]
此 iptables 规则的意思是:在宿主机发往 docker0 网桥的网络数据报文,如果该数据报文所处的连接已经建立,则无条件接受,并且有 Linux 内核转发到原来的连接上,即回到 Docker 容器内部。
这里的意思有些偏颇,但不影响逻辑分析。这里要知道关于路由转发的一些知识:数据包会发往 docker0 网桥的充分必要条件是路由表上由能将数据包正确转发到 docker0 网桥的项。有这些作为基础就可以开始逻辑性分析了:
假设条件:
- Docker 容器发往外网的请求响应地址是本机 ip 地址
- 本机没有配置 DNAT ,数据包目标地址没有被改写为容器 IP
- 通过设置 iptables 的 FORWARD 链,放行发往 docker0 网桥的数据包
结论:
- Docker 容器收到了数据包
如果按照没有 DNAT 就不会将目标地址改写为容器 IP 这个逻辑的话,数据包会直接进入到 INPUT 链而不是进入到 FORWARD 链[5],就算进入到 FORWARD 链数据包目标地址不是目标容器的 IP ,数据包也不会经 docker0 输出,此时 -o docker0
的 iptables 也不会生效,所以作者认为的关键 iptables 规则并没有生效,因而在这里作者的逻辑是不通的。
理论性分析
作者在这里对 SNAT, DNAT 和 iptables 的理论基础还不够扎实,至少在写作的时候还不扎实。这里会补充一些作者在写作的时候遗漏的一些知识。
无论是 SNAT 还是 DNAT,Linux 在实现 NAT 的时候都会生成一个 NAT 转换表[3],用来对发送的请求和接受的响应进行转换,SNAT 映射的转换也会在 PREROUTING 链后 FORWARD 链前进行。以 Docker 容器 SNAT 的场景举例, Docker 容器通过 iptables -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
实现 SNAT。在发生 SNAT 转换的时候 iptables 就会保存一个映射表。以 Docker 容器访问远程数据库为例,容器使用 172.17.0.2:1234 发出请求到 14.215.177.39:3306,当容器请求的数据包进入到 POSTROUTING 链之后被伪装成主机的 192.168.0.2:4567 端口,这时候 iptables 保存了 172.17.0.2:1234<->192.168.0.2:4567
的映射。远程服务器收到的是 192.168.0.2:4567 的请求,然后 14.215.177.39:3306 返回响应给 192.168.0.2:4567 ,根据映射规则 iptables 将响应数据包的目标地址改写为 172.17.0.2 将目标端口改写为 1234,由于 172.17.0.2 地址不属于宿主机,数据包进入 FORWARD 链处理。
DNAT 的例子可以自己类推,重点是 DNAT 的映射也会在 POSTROUTING 链之后进行转换。不然发送的数据包的目标地址没有改写为外网地址,数据包就无法发往外网。
接着是 iptables 的处理流程,iptables 的处理流程如下图所示
详细的可以查看参考资料[5],请求通过 PREROUTING 之后会判断数据包的目标地址是不是本机的 IP 地址,如果属于本机 IP 地址的话就跳转到 INPUT 链处理,通过 INPUT 链进入到用户空间进行处理,否则进入 FORWARD 链,交由内核进行路由转发。
接着是 iptables
命令的 -i
和 -o
参数,-i
参数是指在知道数据包由某端口(在路由交换设备上称为端口,对应服务器的网卡)传入的时候该 iptables 规则才生效,例如 -i eth0
是数据包由 eth0 传入的时候该规则生效,如何知道数据包由 eth0 传入呢?目标地址和 eth0 的 ip 地址相同,那么数据包肯定是通过 eth0 传入的;那么 -i docker0
的情况呢?数据包的源地址址的网段和 docker0 IP 地址属于同一个网段,数据包就是从 docker0 传入的,例如主机接收到一个数据包,源地址为 172.17.0.2/16,和 docker0 的 IP 地址 172.17.0.1/16属于同一个网段,那么这个数据包是通过 docker0 传入的。
-o
同理,-o
参数就是是在知道数据包要发往哪个端口时 iptables 规则才生效。iptables 怎么知道数据包要发往哪个端口呢?一个是路由表,一个是网段。如果路由表中指定了目标地址符合什么要求就发往某端口, 那么就能根据路由表知道数据包发往哪个端口。如果路由表没有指定那么可以根据主机自身已有的端口所属的网段来判断发往哪个端口,如数据包的目标地址为 172.17.0.2/16 和 docker0 的网段 172.17.0.0/16 一致,那么就发往 docker0 端口。
实验分析
实验主要围绕两部分验证:
iptables -I FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
这条规则是不是 Docker 容器接收到响应的关键点- 只指定了 SNAT 没有指定 DNAT,接收到的数据包的目标地址会不会被改写
增加实验内容
- 指定了 SNAT 后,外网响应数据包的目标地址的改写是发生在进入 PREROUTING 链前还是出 PREROUTING 链后
- 指定了 DNAT 后,响应外网的数据包的源地址的改写是发生在进入 POSTROUTING 链前还是出 POSTROUTING 链后
1.1 验证 iptables -I FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
是否数据包发往容器的关键点
这个实验在理解 Docker 网络(一) -- Docker 对宿主机网络环境的影响已经做过一次类似的。实验过程很简单,首先在宿主机上执行下面的命令删除该规则
iptables -D FORWARD -o docker0 -m conntrack --ctsate RELATED,ESTABLISHED -j ACCEPT
然后创建容器并执行简单的 ping 指令
docker run --rm debian:stretch-slim bash -c "
echo 'deb http://mirrors.163.com/debian/ stretch main non-free contrib' > /etc/apt/sources.list
&& apt-get update > /dev/null
&& apt-get install -y inetutils-ping >/dev/null 2>&1
&& ping -c4 www.baidu.com"
执行结果如下
可见是不能获取正确的响应的。但这就说明这条规则是关键了吗,请继续往下看
接下来为了简化实验步骤,这里不对 DOCKER 链进行处理,只对 FORWARD 链进行处理。由于 iptables 默认的 FORWARD 规则是 DROP 此时任何的数据包都不会被转发,只要执行下面命令将 FORWARD 链的默认规则设置为 ACCEPT
iptables -P FORWARD ACCEPT
再次执行创建容器并执行简单 ping 指令
docker run --rm debian:stretch-slim bash -c "
echo 'deb http://mirrors.163.com/debian/ stretch main non-free contrib' > /etc/apt/sources.list
&& apt-get update > /dev/null
&& apt-get install -y inetutils-ping >/dev/null 2>&1
&& ping -c4 www.baidu.com"
执行结果如下
可见 Docker 容器能够正确的接收响应了。
所以得出 iptables -I FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
并不是数据包能发往容器的关键点,这条规则也没有作者所说的“Linux 内核转发到原来的连接上” 这个功能。
1.2 验证只指定了 SNAT 没有指定 DNAT,接收到的数据包的目标地址会不会被改写
首先我们执行两条指令将 iptables 复原
iptables -I FORWARD -o docker0 -m conntrack --ctsate RELATED,ESTABLISHED -j ACCEPT
iptables -P FORWARD DROP
在这里需要用到抓包工具和数据包分析工具,抓包工具使用 Linux 的 TCPdump,数据包分析工具使用 Windows 的 Wireshark
TCPdump 需要抓取宿主机外网网络设备(在这里是 enp0s3,一般是 eth0)的数据包和 docker0 的数据包。在这里开启三个终端。
先执行 ip a
查看主机的网络设备信息
可以得出主机 enp0s3 网卡的 IP 地址是 10.0.2.15,docker0 网卡的 IP 地址是 172.17.0.1,推算出 Docker 容器的 IP 地址应该符合 172.17.0.0/16 的模式
终端一中监听 enp0s3 ,执行命令
tcpdump -i enp0s3 -w ./enp0s3.cap
终端二中监听 docker0 ,执行命令
tcpdump -i docker0 -w ./docker0.cap
终端三中创建容器并执行 ping 命令
docker run --rm debian:stretch-slim bash -c "
echo 'deb http://mirrors.163.com/debian/ stretch main non-free contrib' > /etc/apt/sources.list
&& apt-get update > /dev/null
&& apt-get install -y inetutils-ping >/dev/null 2>&1
&& ping -c4 www.baidu.com"
等 ping 命令执行完之后,结束终端一和终端二的命令。在 Windows 上使用 Wireshark 分析 enp0s3.cap 和 docker0.cap,在这里可以直接查看最后一个数据包。
通过 Data 段可以得出这两个数据包的内容是一致的, Internet Protocol Version 4段可以得出两个包的源IP Src 都为 14.215.177.38,而目标IP Dst 不一样。enp0s3 中抓取的数据包 Dst 为 10.0.2.15 与 enp0s3 的 IP 地址一致。 docker0 抓取的数据包 Dst 为 172.17.0.2 IP 符合 172.17.0.0/16 的模式。
可以看出数据包的目标 IP 被重写了,而这时并没有设置 DNAT。因此可以得出,只设置了 SNAT 没有设置 DNAT ,接收到的数据包的目标地址一样会被重写。
2.1 指定了 SNAT 后,外网响应的数据包的目标地址的改写是发生在进入 PREROUTING 链前还是出 PREROUTING 链后
为了确定在 POSTROUTING 指定了 SNAT 之后,外网响应的数据包目标地址的改写出现在 PREROUTING 之前还是之后,只需要在 PREROUTING 链上加上规则
iptables -t nat -A PREROUTING -d 172.17.0.0/16 -j DNAT --to 10.0.2.15
如果目标地址改写发生在 PREROUTING 之前,那么响应数据包的目标地址将会被重写为本机的 enp0s3 地址,因而数据包不会进入到 FORWARD 流程,容器就不能收到响应数据包。
docker run --rm debian:stretch-slim bash -c "
echo 'deb http://mirrors.163.com/debian/ stretch main non-free contrib' > /etc/apt/sources.list
&& apt-get update > /dev/null
&& apt-get install -y inetutils-ping >/dev/null 2>&1
&& ping -c4 www.baidu.com"
执行结果如下
可以得出指定 SNAT 后, 外网响应数据包目标地址的改写是发生在数据包出 PREROUTING 链之后进入 FORWARD 链之前。
2.2 指定了 DNAT 后,响应外网的数据包的源地址的改写是发生在进入 POSTROUTING 链前还是出 POSTROUTING 链后
这里先恢复 PREROUTING 链
iptables -t nat -A PREROUTING -d 172.17.0.0/16 -j DNAT --to 10.0.2.15
然后使用端口映射创建一个 nginx 容器
docker run --name nginx -d -p 8080:80 nginx
接着修改 POSTROUTING 链,这里要做的修改有,将 Docker 自带的 SNAT 和创建端口映射容器产生的 SNAT 删除
iptables -t nat -D POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
iptables -t nat -D POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE
然后增加一条新的 SNAT
iptables -t nat -D POSTROUTING -p tcp -m tcp --dport 8080 -j SNAT --to 172.17.0.2
打开一个新终端,使用 tcpdump 监听连接外网网卡的信息(这里使用的是 enp0s8)
tcpdump -i enp0s8 -w ./enp0s8.cap
接着外网访问 8080 端口,结束 tcpdump ,使用 Wireshark 打开 enp0s8.cap
可以看到从 enp0s8 端口输出的数据包的 Src 都是 192.168.99.1 。
这说明指定了 DNAT 没有指定 SNAT 的时候在 POSTROUTING 链之后也会对数据包进行源地址和源端口重写。
总结
这篇文章主要从逻辑,理论和实验分析了《Docker 源码分析》中关于 Docker 容器网络 SNAT,DNAT 和 iptables 的一些错误。考虑到书籍作者 @孙宏亮 也在知乎上,希望作者在确认了问题后下版能够做出相应的修改。如果已经有人提出过这个问题并已经打算修正那么大可以忽略这篇文章。
这篇文章主要是分析了 SNAT 和 DNAT 在 Docker 容器中的作用,使用 tcpdump + Wireshark 的工具组合抓取和分析数据包。这本来是三言两语就能够说出对错的问题,但为了增加文章的篇幅特地的将问题分解为逻辑分析,理论分析和实验验证三个步骤,希望对大家有用。
参考资料
- [1] 《Docker 源码分析》
- [2] SNAT 与 DNAT
- [3] 聊聊 NAT 技术那些事
- [4] 理解 Docker 网络(一) -- Docker 对宿主机网络环境的影响
- [5] iptables详解(1): iptables概念