部落格好读版
在上一章中,我们成功实现了 container namespace (ns0/ns1) 与 root namespace 之间的网路连通。不过,当 container namespace 尝试连接外部网路时却遇到了问题。接下来让我们深入探讨这个现象背后的原因,并了解解决方案。
容器网路连接外网的问题分析
为了模拟实际环境,我们将使用 AWS VPC 作为测试环境。在这个环境中,VPC CIDR 扮演互联网的角色,而 EC2 instance 的 Private IP 则等同于 Public IP。我们将使用两台 EC2 instance 进行测试,并监控网路封包的流向。
提醒:network-test-1:练习操作的 EC2,简称为 Host1network-test-2:模拟外网位置的 EC2,简称为 Host2
为 EC2 添加 ICMP 规则
由于之前我们建立的 AWS EC2 并没有允许 ICMP 相关的规则,在这里先补上。
- 建立 Security Groups - sg-icmp:
- 分别对 network-test-1、network-test-2 添加 sg-icmp:
使用 tcpdump 来监控封包变化。
- 在视窗的 4 个位置的终端,分别使用以下指令:
# 1. 在 Host1 - ns1 监控 veth1 的 ICMP 封包
sudo ip netns exec ns1 tcpdump -i veth1 -nn icmp
# 2. 在 Host1 - root namespace 监控 docker1 的 ICMP 封包
sudo tcpdump -i docker1 -nn icmp
# 3. 在 Host1 - root namespace 监控 enX0 的 ICMP 封包
sudo tcpdump -i enX0 -nn icmp
# 4. 在 Host2 - 监控 enX0 的 ICMP 封包
sudo tcpdump -i enX0 -nn icmp
接下来我要分别进行两个测试。
1. 从 Host1 - ns1 对外网发起 ICMP Echo Request
sudo ip netns exec ns1 ping -c 1 172.31.45.174
# output
PING 172.31.45.174 (172.31.45.174) 56(84) bytes of data.
--- 172.31.45.174 ping statistics ---
1 packets transmitted, 0 received, 100% packet loss, time 0ms
我们的请求到外网后,就此石沉大海。
2. 从 Host1 - root host 对外网发起 ICMP Echo Request
ping -c 1 172.31.45.174
# output
PING 172.31.45.174 (172.31.45.174) 56(84) bytes of data.
64 bytes from 172.31.45.174: icmp_seq=1 ttl=127 time=0.403 ms
--- 172.31.45.174 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.403/0.403/0.403/0.000 ms
这次我们成功的完成了 ICMP 的交握。
为什么会有这样的差异呢?
- 第1个测试,发送请求的来源是 Private IP 172.18.0.3
- 第2个测试,发送请求的来源是 Public IP 172.31.39.53
这其实是因为,Private IP 在互联网上并不是唯一的,有可能所有的服务器(EC2)都有一个 172.18.0.3。因此,在很多路由设置中,预设 Private IP 是会被丢弃的。
所以想要从 Private IP 对外进行连线,我们需要使用 Public IP 对封包进行伪装一下,也就是 NAT。
NAT 的运作原理
NAT(Network Address Translation)是一种将 Private IP 位址转换为 Public IP 位址的网路技术。当内部网路的设备需要存取外部网路时,NAT 会:
这使得使用 Private IP 的设备得以安全地存取外部网路资源。
其实安装 Docker 时,也一併添加了相关的 NAT 设定。下面就让我们来看看。
分析 Docker 的 NAT 规则
- 使用以下指令,查看 iptables nat 规则:
sudo iptables -t nat -L --line-numbers
# output
Chain PREROUTING (policy ACCEPT)
num target prot opt source destination
1 DOCKER all -- anywhere anywhere ADDRTYPE match dst-type LOCAL
Chain INPUT (policy ACCEPT)
num target prot opt source destination
Chain OUTPUT (policy ACCEPT)
num target prot opt source destination
1 DOCKER all -- anywhere !ip-127-0-0-0.us-west-2.compute.internal/8 ADDRTYPE match dst-type LOCAL
Chain POSTROUTING (policy ACCEPT)
num target prot opt source destination
1 MASQUERADE all -- ip-172-17-0-0.us-west-2.compute.internal/16 anywhere
Chain DOCKER (2 references)
num target prot opt source destination
1 RETURN all -- anywhere anywhere
- 使用 iptables-save 指令,取得比较明确的规则:
sudo iptables-save -t nat
# Generated by iptables-save v1.8.8 (nf_tables) on Wed Jan 8 10:34:30 2025
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
COMMIT
# Completed on Wed Jan 8 10:34:30 2025
跟 DOCKER 相关的规则段落如下:
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
分析 iptables-save 规则
1. PREROUTING 链
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
这条规则将目标地址类型是 LOCAL 的流量转发到 DOCKER chain。
- PREROUTING 链的作用:处理所有进入的流量,在 routing 决策之前。
- --dst-type LOCAL:筛选目标为本地主机的流量(例如针对本地 Docker container 的请求)。
2. OUTPUT 链
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
这条规则将来自本地主机的非 127.0.0.0/8 流量(例如来自容器的流量)转发到 DOCKER chain。
- OUTPUT 链的作用:处理由本地生成的流量(例如容器内的流量)。
3. POSTROUTING 链
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
这条规则针对源地址为 172.17.0.0/16 的流量,并且输出接口不是 docker0 的流量执行 MASQUERADE(源地址转换)。
- POSTROUTING 链的作用:处理在路由决策之后的流量,通常用于 NAT。
- -s 172.17.0.0/16:针对 Docker 默认网桥(docker0)中的容器生成的流量。
- ! -o docker0:排除流向 docker0 网桥的流量(即不对容器之间的通信执行 NAT)。
- MASQUERADE:将流量的源地址转换为主机的外部地址(例如主机的 Public IP),实现源地址隐藏。
4. DOCKER 链
-A DOCKER -i docker0 -j RETURN
这条规则指定来自 docker0 接口的流量直接返回,不进行进一步处理。
- -i docker0:只针对来自 Docker 网桥(docker0 接口)的流量。
- RETURN:结束该链处理,返回到主链。
进一步简化规则:
- PREROUTING 和 OUTPUT:处理本地或进入流量的目标地址,决定是否需要进一步转发到 Docker 的内部链。
- POSTROUTING 的 MASQUERADE:将来自 Docker 网桥的源地址(172.17.0.0/16)转换为主机的 Public IP,实现容器流量的 NAT。
- DOCKER 链的 RETURN:避免对 docker0 接口之间的流量进行多余处理。
分析从 Docker 容器对外网发出请求,NAT 的处理流程
当 Docker 容器中的一个请求(例如来自 172.17.0.2 的 ping 8.8.8.8)被发送时,NAT 的处理逻辑如下:
步骤 1:请求的初始流量
- 来自容器(IP 为 172.17.0.2)的封包首先通过主机的 OUTPUT 和 POSTROUTING 链。
- 请求的源地址是 172.17.0.2,属于 172.17.0.0/16 子网,目标地址是 8.8.8.8。
步骤 2:POSTROUTING 链处理
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
- 当请求通过 POSTROUTING 链时,Docker 的 NAT 规则检查到源地址是 172.17.0.2(属于 172.17.0.0/16),并且请求的出口接口不是 docker0。
- 根据 MASQUERADE 规则,将封包的源地址转换为主机的外部地址(例如,主机的 Public IP 203.0.113.1)。
-
转换后的封包:
- 原始封包:
SRC=172.17.0.2 DST=8.8.8.8 - NAT 后的封包:
SRC=203.0.113.1 DST=8.8.8.8
- 原始封包:
步骤 3:封包进入外网
- 转换后的封包被发送到主机的出口接口(例如 eth0),进入外网。
- 8.8.8.8 接收到来自 203.0.113.1 的请求,而不是来自 172.17.0.2 的请求。
步骤 4:回应处理
- 当 8.8.8.8 回应时,回应封包的目标地址是 203.0.113.1(主机的 Public IP)。
- 回应封包进入主机后,经过 NAT 表的记录,主机将回应的目标地址转换回 172.17.0.2,并将封包转发到正确的容器。
实战:追踪 Docker 容器的 NAT 处理流程
conntrack 是一个用来查看、操作和管理 Linux 系统中网路连线追踪表(Connection Tracking)的工具。网路连线追踪是 Netfilter 防火墙的一部分,负责追踪进出系统的网路连线状态。如果封包有被 NAT 修改过,使用 conntrack 可以清楚的纪录这个过程。
- 使用指令安装 conntrack:
# Amazon Linux 2023 安装指令
sudo dnf install -y conntrack-tools
- 使用以下命令,启动监控条目 8.8.8.8:
sudo conntrack -E -p icmp | grep "8.8.8.8"
-E:启用事件监控模式,实时显示新增的连接。-p icmp:只监控 ICMP 相关的事件。
- 在 Host1 打开另一个 Terminal,使用以下指令建立一个容器,然后执行测试指令:
docker container run --rm -it alpine /bin/sh
# go into the container
/ # ping -c 1 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: seq=0 ttl=116 time=8.404 ms
--- 8.8.8.8 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 8.404/8.404/8.404 ms
- conntrack 纪录以下结果:
[NEW] icmp 1 30 src=172.17.0.2 dst=8.8.8.8 type=8 code=0 id=27 [UNREPLIED] src=8.8.8.8 dst=172.31.39.53 type=0 code=0 id=27
[UPDATE] icmp 1 30 src=172.17.0.2 dst=8.8.8.8 type=8 code=0 id=27 src=8.8.8.8 dst=172.31.39.53 type=0 code=0 id=27
解释 conntrack 条目
[NEW]
[NEW] icmp 1 30 src=172.17.0.2 dst=8.8.8.8 type=8 code=0 id=27 [UNREPLIED] src=8.8.8.8 dst=172.31.39.53 type=0 code=0 id=27
- [NEW]:这是一个新的连接,表示 ICMP Echo Request 刚刚被发送。
- icmp:协议是 ICMP。
-
1 30:
- 1:ICMP 的协议号(固定)。
- 30:连接的剩余时间(秒),表示这条连接跟踪条目会在 30 秒后过期。
- src=172.17.0.2:来源 IP,表示请求来自容器内(Docker 的虚拟网段)。
- dst=8.8.8.8:目标 IP,请求发送到 Google 公共 DNS。
- type=8 code=0:ICMP 类型与代码,表示这是一个 ICMP Echo Request(Ping 请求)。
- id=27:ICMP 的识别码,用于将 Echo Request 和 Echo Reply 匹配。
- [UNREPLIED]:表示目标(8.8.8.8)尚未回应这个请求。
-
src=8.8.8.8 dst=172.31.39.53:
- 回应封包的来源地址是 8.8.8.8。
- 回应封包的目标地址是 172.31.39.53(主机的内部 IP 地址,经 NAT 后的地址)。
[UPDATE]
[UPDATE] icmp 1 30 src=172.17.0.2 dst=8.8.8.8 type=8 code=0 id=27 src=8.8.8.8 dst=172.31.39.53 type=0 code=0 id=27
- [UPDATE]:表示这个连接条目已更新,ICMP Echo Reply 已收到。
- icmp、1 30、src 和 dst:这些字段与第一条类似,描述了 Echo Request 的来源和目标。
- type=0 code=0:ICMP 类型和代码,表示这是一个 ICMP Echo Reply(Ping 回应)。
- id=27:与第一条的 id 相同,用于将回应与请求匹配。
- 无 [UNREPLIED] 标誌:表示目标主机已回应。
流量路径与 NAT 行为
从 conntrack 的记录中,我们可以清楚地看到 NAT 的处理流程:
请求发送时:
- Container 172.17.0.2 发送 Ping,目标是 8.8.8.8
- NAT 将请求的来源地址改为主机的对外 IP 172.31.39.53
回应接收时:
- 8.8.8.8 回应,目标地址是 172.31.39.53(经 NAT 改写后的主机地址)
- 主机的 NAT 将回应的目标地址恢复为 container 的地址 172.17.0.2
流程总结
请求(容器 → 目标) | src=172.17.0.2 → dst=8.8.8.8 | src=172.31.39.53 → dst=8.8.8.8 |
回应(目标 → 容器) | src=8.8.8.8 → dst=172.31.39.53 | src=8.8.8.8 → dst=172.17.0.2 |
iptables NAT 成功完成了地址转换,允许容器与外部网络进行通信。
其实 172.31.39.53 只是我们所在的 AWS EC2 instance 的外网,对于整个互联网来说,这个 IP 还是内网。不过 AWS 会透过 IGW 做转换 - 可以将它部分视为 VPC 层级的 NAT,因为它在处理 Public Subnet 流量时,扮演了类似 NAT 的角色,将 Private IP 替换为 Public IP。这里我就不详细说明了。
为 container namespace 添加 NAT 规则
我们已经知道 Docker 添加了什么样的 NAT 规则,现在我们要将它应用到我们自行建立的 container namespace 网段上。
- 建立 NAT 规则:
sudo iptables -t nat -I POSTROUTING -s 172.18.0.0/24 ! -o docker1 -j MASQUERADE
- 确认规则内容:
sudo iptables-save -t nat
# Generated by iptables-save v1.8.8 (nf_tables) on Wed Jan 8 19:54:37 2025
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A PREROUTING -j TRACE
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.18.0.0/24 ! -o docker1 -j MASQUERADE
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -j TRACE
-A DOCKER -i docker0 -j RETURN
COMMIT
# Completed on Wed Jan 8 19:54:37 2025
- 测试从 ns1 ICMP ECHO Request 到外网:
这次我们成功的连到外网了。
总结
在本章中,我们深入了解了 NAT 的运作原理,并成功实现了让 container namespace 的网路封包能够存取外部网路。然而,这个成功是建立在我们先前将 FORWARD 链策略设为 ACCEPT 的基础上。如果将策略恢复为预设的 DROP,container namespace 就会再次失去与外部网路的连线。
- 将 FORWARD 规则调整回 DROP:
sudo iptables --policy FORWARD DROP
- 测试从 ns1 ICMP ECHO Request 到外网:
sudo ip netns exec ns1 ping -c 1 8.8.8.8
# output
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
--- 8.8.8.8 ping statistics ---
1 packets transmitted, 0 received, 100% packet loss, time 0ms
看起来我们离 Docker 的运作模式还差了一点点,这部分我们留到下一章节再继续。