部落格好读版


在上一章中,我们成功实现了 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 会:

  • 将来源 IP(私有位址)转换为 Public IP
  • 记录转换对应关系
  • 接收回应时,根据记录将封包转发给正确的内部设备
  • 这使得使用 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

  • 流程总结

    步骤
    原始 IP 地址
    经 NAT 后的 IP 地址
    请求(容器 → 目标) 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 的运作模式还差了一点点,这部分我们留到下一章节再继续。