NAT 现场实录——从一个容器 ping 滚出换源、换目的与回程台账
Linux 板块 · 第 4 篇
上一篇:《tcpdump 抓包》(本文两侧抓包用的兵器,在那篇练手)
下一篇:《手搓迷你容器网络》(本文规则的表/链体系与 netns 动手篇;读完再进 Docker 系列最顺)
开头:包是在哪被换的头
第 2 篇结尾留了个没拆完的疑问:容器 IP 是 172.17.0.5 这样的私有地址,公网上没有任何人认识它,可它 ping 223.5.5.5 就是通了——上一篇 tcpdump 结尾的思考题也问了:这时 eth0 上抓到的源地址会是谁?
64 bytes from 223.5.5.5: seq=0 ttl=113 time=5.168 ms这个包离开容器后必然被换过头面才活着到达公网,回来时又被换了回来。再看反方向的日常:Windows 浏览器敲 http://172.22.212.111:18080/ 能打开容器里的 Nginx——宿主机的 18080 是怎么「转接」到容器 80 的?为什么不开 -p,外面就一根毛都摸不着?
根因一句话:这两个方向的包,都路过了一个会改写 IP 头地址字段的网关——出网的包被改「从哪来」,进门的包被改「送到哪」,改完还得记一笔账,回程才找得回去。
本篇不先背 iptables 语法。用上一篇练好的「两侧同拍」手法,把同一条连接在两块网卡上的变身一路滚下去看:
| 雪球 | 你加上去的 | 当场能看见的效果 |
|---|---|---|
| 1 | 两台 tcpdump + 一次容器 ping | 同一个包过两块网卡,源地址 172.17.0.5 → 172.22.212.111,时间戳只差 20 微秒 |
| 2 | nat 表里的 MASQUERADE 规则 | 抓到换源的「凶手」,规则原文逐段读懂 |
| 3 | 反方向踢铁板 + -p 开门 | Windows curl 18080 得到 HTTP 200;读到 --to-destination |
| 4 | 进门的包也两侧同拍 | 目的地址 172.22.212.111.18080 → 172.17.0.4.80,源地址没动 |
| 5 | conntrack 连接跟踪台账 | 一行两半:原始方向 + 翻译规则,回程包按账还人 |
| 6 🧗 | 出与进合体,再看 NAT 的代价 | 「能出不能进」一张表说清;P2P 要打洞、台账有上限 |
固定实验环境:复用前几篇的老演员——那台发布过 -p 18080:80 的 Nginx(第 1 篇 nsenter、第 3 篇 tcpdump 用的都是它)。还在跑就直接用;没了就一条命令重建:
docker run -d --name lab-net-web -p 18080:80 nginx:alpine出网实验用的是即用即删的 busybox(--rm),不留垃圾。环境指纹:WSL2 Ubuntu-22.04 + 原生 Docker Engine 29.1.3,iptables v1.8.7(legacy 后端),tcpdump 4.99.1。你的机器号码会不同(容器 IP 是从地址池里领的,见第 2 篇),结构一致即可对照。连接跟踪表直接读内核的 /proc/net/nf_conntrack(无需装 conntrack 命令);文中 iptables、tcpdump 都需要 root(本环境默认用户即 root)。官方入口:Docker Docs · Packet filtering and firewalls、netfilter conntrack 文档、RFC 3022。
雪球 1:先看换源——同一个包过两块网卡,源地址被换了头
光听结论不过瘾,直接抓现行。思路:一边在 docker0 上抓、一边在 eth0 上抓,然后让容器 ping 公网——同一个包过两张网卡的模样对比(tcpdump 输出去掉了开头 banner,数据行原样):
$ tcpdump -ni docker0 icmp -c 4 & # ① 网桥侧
$ tcpdump -ni eth0 'icmp and host 223.5.5.5' -c 4 & # ② 出口侧
$ docker run --rm busybox ping -c 2 223.5.5.5docker0 侧(容器网段内部,原始模样):
13:36:53.641545 IP 172.17.0.5 > 223.5.5.5: ICMP echo request, id 1, seq 0, length 64
13:36:53.646613 IP 223.5.5.5 > 172.17.0.5: ICMP echo reply, id 1, seq 0, length 64
13:36:54.641789 IP 172.17.0.5 > 223.5.5.5: ICMP echo request, id 1, seq 1, length 64
13:36:54.647881 IP 223.5.5.5 > 172.17.0.5: ICMP echo reply, id 1, seq 1, length 64eth0 侧(离开宿主机时,已换头):
13:36:53.641565 IP 172.22.212.111 > 223.5.5.5: ICMP echo request, id 1, seq 0, length 64
13:36:53.646595 IP 223.5.5.5 > 172.22.212.111: ICMP echo reply, id 1, seq 0, length 64
13:36:54.641800 IP 172.22.212.111 > 223.5.5.5: ICMP echo request, id 1, seq 1, length 64
13:36:54.647871 IP 223.5.5.5 > 172.22.212.111: ICMP echo reply, id 1, seq 1, length 64对照着看,证据就在时间戳里:
| docker0 侧(13:36:53.641545) | eth0 侧(13:36:53.641565) | |
|---|---|---|
| 去程源地址 | 172.17.0.5(容器) | 172.22.212.111(WSL 的 eth0) |
| 回程目的地址 | 172.17.0.5 | 172.22.212.111 |
同一个包(时间戳只差 20 微秒),过手一次,源地址从 172.17.0.5 变成 172.22.212.111。而回程包到达 eth0 时目的地址是 172.22.212.111,又能被改回 172.17.0.5 送到 docker0——这个「改回去」是谁记的账?坑先记下,雪球 5 补;先抓下一个问题:去程这一下是谁下的手——雪球 2 抓现行。
刚冒出来的名字,立刻钉成一张表:
| 名词 | 白话 | 在这一球的位置 |
|---|---|---|
| NAT | 网关在包过手时改写 IP 头地址字段的技术 | 两次「换头」的总称 |
| SNAT | 改「从哪来」(源地址) | 去程 172.17.0.5 → 172.22.212.111 |
| DNAT | 改「送到哪」(目的地址) | 回程改回目的地;-p 映射的正菜(雪球 3) |
| MASQUERADE | SNAT 的「动态版」,具体下一球见 | 本球现象的制造者 |
为什么要干这事:IPv4 约 43 亿个号,全球设备远超此数。解法:内网随便用私有地址(RFC 1918 那三段,第 2 篇讲过),出门时网关把源地址改成自己的出口地址——一整个内网共享一个公网身份,号就够用了。
白话类比:小区收发室。住户(内网设备)不对外暴露门牌;所有快递由收发室(网关)以自己的名义寄出,收到后再分拣转交。外面的人只认识收发室,不知道也不需要知道里面住了谁——回程的「分拣转交」,靠的就是雪球 5 那本台账。
背景:NAT 最早为缓解地址枯竭而生(RFC 1631,1994),后来标准化为 RFC 3022。它本来是个「临时救急」方案,结果活成了互联网基础设施——今天你家路由器、公司出口、云平台、Docker 全在用它。
雪球 2:抓到凶手——nat 表里那条 MASQUERADE 规则
换头不是玄学,是一条登记在案的规则。Docker 装好时就在宿主机 iptables 的 nat 表里写好了规则。看本机(多条规则里挑默认 bridge 那条):
$ iptables -t nat -S POSTROUTING | grep 172.17
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE逐段读:
| 片段 | 含义 |
|---|---|
-t nat -S POSTROUTING | 列出 nat 表 POSTROUTING 链的规则(包即将从网卡发出前经过这里) |
-s 172.17.0.0/16 | 来源在默认 bridge 的网段里(= 从容器出来) |
! -o docker0 | 且不从 docker0 网卡出(= 要离开容器网段,去外面) |
-j MASQUERADE | 动作:把源地址改写成出口网卡当前的地址 |
MASQUERADE 是 SNAT 的「动态版」:普通 SNAT 写死改写后的地址,MASQUERADE 自动用出口网卡此刻的地址——家庭宽带拨号 IP 会变,也能用。对照雪球 1:规则说「改成出口网卡当前的地址」,出口是 eth0,所以 172.17.0.5 才变成了 172.22.212.111,规则与现场对上了。
两个刚出现的名字着落一下:nat 表是「放改地址规则的地方」,POSTROUTING 链是「包即将发出前经过的检查点」——iptables 完整的表/链体系是下一篇的正菜,本篇只需要这两句。
雪球 3:再看换目的——外面为什么进不来,-p 开的是什么门
现在反过来:公网(或局域网里别的机器)想主动连 172.17.0.5——连不了。两个原因:
- 路由不通:私有地址在公网上不可路由,包根本到不了你机器;
- 没人翻译:即便包到了宿主机,目的地址写的是宿主机自己,内核没有任何「该转给哪个容器」的登记——SNAT 那套翻译表只在你主动发起连接时才建立。
这就是上一篇说的「进出不对称」的底层原因:出方向网关主动替你换头,进方向没人替你换。想让人进来,就得显式登记一条 DNAT 规则——Docker 的 -p 干的就是这个。
老演员 lab-net-web 发布过 -p 18080:80,iptables 里对应的真身(nat 表 DOCKER 链):
$ iptables -t nat -S DOCKER | grep 18080
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 18080 -j DNAT --to-destination 172.17.0.4:80逐段读:
| 片段 | 含义 |
|---|---|
! -i docker0 | 从 docker0 以外的网卡进来(= 从外部来的包;容器之间互访不走这条) |
-p tcp --dport 18080 | TCP,目的端口 18080 |
-j DNAT --to-destination 172.17.0.4:80 | 动作:把目的地址改写成容器 172.17.0.4 的 80 端口 |
(172.17.0.4 是本机当时 lab-net-web 领到的号;你机器上重建后可能是别的号,规则里的地址会跟着变。)对照雪球 2 那条,两个方向的规则并排看:
| SNAT/MASQUERADE | DNAT | |
|---|---|---|
| 改什么 | 「从哪来」(源地址) | 「送到哪」(目的地址) |
| 谁建的 | 容器出网自动生效 | -p 时 Docker 显式写入 |
| 在哪见过 | 雪球 2 的 POSTROUTING 链 | 本球的 DOCKER 链 |
验收效果:Windows 侧执行 curl http://172.22.212.111:18080/,返回 HTTP 200——门开了。但「转接」这个词到底改了包里的哪个字段?下一球同拍给你看。
雪球 4:进门的包也同拍——目的地址换了头,源地址没动
雪球 1 的手法原样再来一遍,方向反过来:宿主机(Windows)直接 curl WSL 的 18080 端口,两侧同时抓:
$ tcpdump -ni eth0 tcp port 18080 -c 6 & # ① 进宿主机时(DNAT 前)
$ tcpdump -ni docker0 tcp port 80 -c 6 & # ② 到容器时(DNAT 后)Windows 侧执行 curl http://172.22.212.111:18080/,返回 HTTP 200。抓到的包:
eth0 侧(进宿主机时,目的地还是宿主机):
13:37:09.862301 IP 172.22.208.1.14004 > 172.22.212.111.18080: Flags [SEW], seq 813594299, win 64240, options [mss 1460,nop,wscale 14,nop,nop,sackOK], length 0
13:37:09.862386 IP 172.22.212.111.18080 > 172.22.208.1.14004: Flags [S.], seq 1321109193, ack 813594300, win 64240, options [mss 1460,nop,wscale 7], length 0docker0 侧(送进容器网段时,目的地已换成容器):
13:37:09.862331 IP 172.22.208.1.14004 > 172.17.0.4.80: Flags [SEW], seq 813594299, win 64240, options [mss 1460,nop,wscale 14,nop,nop,sackOK], length 0
13:37:09.862381 IP 172.17.0.4.80 > 172.22.208.1.14004: Flags [S.], seq 1321109193, ack 813594300, win 64240, options [mss 1460,nop,wscale 7], length 0
13:37:09.885983 IP 172.22.208.1.14004 > 172.17.0.4.80: Flags [P.], seq 1:85, ack 1, win 128, length 84: HTTP: GET / HTTP/1.1
13:37:09.886211 IP 172.17.0.4.80 > 172.22.208.1.14004: Flags [P.], seq 1:239, ack 85, win 502, length 238: HTTP: HTTP/1.1 200 OK(SEW = SYN+ECN+CWR 握手包,S. = SYN+ACK 应答,P. = 携带数据的包。)四个看点:
- 目的地址换头:
172.22.212.111.18080→172.17.0.4.80,时间戳相差 30 微秒,同一个包——雪球 3 那条--to-destination规则的现场; - 源地址没动:外部来的包不需要改「从哪来」,容器直接看得见真实客户端
172.22.208.1; - 回程也在换:对比两边的第二行——docker0 上容器的应答是
172.17.0.4.80 > …,到 eth0 上变成了172.22.212.111.18080 > …(seq、ack 一字不差,同一个包):容器回包的源地址又被改回了宿主门面,不然 Windows 会认为自己在跟一个陌生人说话。谁记的账?——雪球 5 补; - 客户端是谁:
172.22.208.1——正是上一篇里 WSL 的默认网关(Windows 宿主)。这次请求的完整旅程:Windows → WSL 的 DNAT 门 → 容器。
雪球 5:最后看台账——conntrack 一行两半,回程按账还人
雪球 1、雪球 4 各欠一笔账:网关同时替成百上千个容器/设备改写,回程包到了它怎么知道该还给谁?靠内核的连接跟踪表(conntrack)——换头时顺手记下「原来长什么样、回包长什么样时怎么翻回去」。
直接读它。重新跑一次雪球 1 的 ping,抓完立刻读表(坑:ICMP 条目约 30 秒后过期,要看趁早):
$ docker run --rm busybox ping -c 2 223.5.5.5
$ grep 223.5.5.5 /proc/net/nf_conntrack
ipv4 2 icmp 1 26 src=172.17.0.5 dst=223.5.5.5 type=8 code=0 id=1 src=223.5.5.5 dst=172.22.212.111 type=0 code=0 id=1 mark=0 zone=0 use=2一条记录分两半读:
- 前半(原始方向):
src=172.17.0.5 dst=223.5.5.5 type=8——容器发出的 echo request - 后半(应答的翻译规则):
src=223.5.5.5 dst=172.22.212.111 type=0——凡是符合这个模样的回包,改写成前半的镜像(还给172.17.0.5)
| 台账的半张 | 记的是什么 | 对应哪一球 |
|---|---|---|
| 前半 | 你发出去时包的原始模样 | 雪球 1 docker0 侧、雪球 4 docker0 侧 |
| 后半 | 回包长这样时,按镜像翻回去 | 雪球 1 eth0 侧的回程、雪球 4 的 SYN+ACK |
nat 表只管「第一个包」的改写决策,之后同一连接的每个包都按 conntrack 里登记的翻译规则自动往返——雪球 1 回程 ICMP 的目的地被改回 172.17.0.5、雪球 4 容器应答的源地址被改回 172.22.212.111.18080,用的都是这本账。没有这张表,NAT 改完就找不回去了。
雪球 6:合体——「能出不能进」是一体两面
六个球的证据摆齐,NAT 的性格就完整了:
| 方向 | 技术 | 谁触发 | 效果 | 哪一球拍的 |
|---|---|---|---|---|
| 内网 → 外网 | SNAT/MASQUERADE | 任何内网设备发包即自动生效 | 透明、无感知,「天生能上网」 | 雪球 1、2 |
| 外网 → 内网 | DNAT | 必须管理员显式登记(-p) | 「不请自来进不来」,进来必经登记的门 | 雪球 3、4 |
这不是缺陷,是 NAT 结构自带的默认安全:外面的人根本不知道、也无法定位内网里的具体设备。家用路由器「不开端口映射就连不进家里摄像头」、Docker「不 -p 就连不进容器」,是同一个机制。
🧗 进阶:NAT 的代价。外网无法主动发起,P2P、语音通话这类「双方都要主动」的应用就麻烦,于是有了 STUN 打洞、TURN 中继这些补丁方案(了解即可,本文不展开)。另外 NAT 依赖连接跟踪表,连接数有上限(每条占内存,内核有 nf_conntrack_max 限额),高并发 NAT 网关要调这个值——先把名字记住,遇到再说。
怎么记:每条命令在哪一球用过
| 命令 / 写法 | 干什么 | 哪一球用过 |
|---|---|---|
docker run --rm busybox ping -c 2 223.5.5.5 | 制造一条出网连接 | 1、5 |
tcpdump -ni docker0 icmp | 网桥侧看原始模样 | 1 |
tcpdump -ni eth0 'icmp and host 223.5.5.5' | 出口侧看换头后 | 1 |
iptables -t nat -S POSTROUTING | 出方向改写规则(MASQUERADE) | 2 |
iptables -t nat -S DOCKER | -p 的 DNAT 真身 | 3 |
curl http://宿主IP:18080/(Windows 侧) | 从外面敲门验证 -p | 3、4 |
tcpdump -ni eth0 tcp port 18080 / -ni docker0 tcp port 80 | 进门包两侧同拍 | 4 |
grep <IP> /proc/net/nf_conntrack | 读回程台账 | 5 |
读表的口诀跟着雪球走:先看换源(1-2),再看换目的(3-4),最后看台账(5)——三个名词各配一次同拍证据。
历史包袱:legacy、nft 与三套名字
- 本机 iptables v1.8.7 实测走的是 legacy 后端;较新的发行版多切到 nft 后端(命令照敲,
iptables-nft兼容转换),再新的工具叫 nftables——三套名字,本质都是内核 netfilter 的钩子,Docker 文档对此有说明。看到别人机器上iptables-save的输出长得不一样,先想后端差异,别急着怀疑规则没生效。 - 上面 nat 表里的
DOCKER链不是 iptables 自带的,是 Docker 安装/启动时写入的;systemctl stop docker再看,规则会跟着撤掉——别当成系统预置。 - 读连接跟踪表本文用的是内核原生文件
/proc/net/nf_conntrack;各教程常见的conntrack -L需要另装conntrack工具包,本机没装也照样能看。
和系列其它篇
| 相关篇 | 在这一路上出现的位置 |
|---|---|
| 第 2 篇 IP、网段与网关 | 开头疑问的出处;网关是「决策者」(出网段先交给我),NAT 是它手里的「动作」(换头 + 查表还包),套娃图里每层网关做的事本文拆开看到了内部 |
| 第 3 篇 tcpdump | 两侧同拍的兵器全在那篇练的;时间戳对「同一个包」、Flags 字母表也都讲过 |
| 第 5 篇 netns 与 iptables | 本文只读了两条代表性规则;表/链体系、徒手搓容器网络是下一篇的正菜 |
| Docker 第 11 篇 网络 | -p 的完整链在那篇展开:FORWARD 链过滤、自定义网络、容器互访为何不走 DNAT、docker-proxy 进程;雪球 1 起的 lab-net-web 就是那篇雪球 1 的同一台 |
小结
同一条连接滚了六个球,每个球一个新因素:
- 雪球 1 换源现场:两侧同拍证明同一个包(时间戳差 20µs)过手一次,源地址
172.17.0.5→172.22.212.111;NAT = 网关改写 IP 头地址,改源叫 SNAT、改目的叫 DNAT,动机是 43 亿个 IPv4 号不够用; - 雪球 2 凶手现行:
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE——MASQUERADE 是 SNAT 动态版,自动用出口网卡此刻的地址; - 雪球 3 换目的的门:外面进不来 = 路由不通 + 没人翻译;
-p 18080:80的真身是DNAT --to-destination 172.17.0.4:80,显式登记; - 雪球 4 换目的现场:目的地址
172.22.212.111.18080→172.17.0.4.80(差 30µs 同一个包),源地址不动,容器看得见真实客户端; - 雪球 5 回程台账:conntrack 一行两半——前半原始方向、后半翻译规则;nat 只管第一个包,之后按账自动往返,没这本账 NAT 就找不回去;
- 雪球 6 合体:能出不能进是 NAT 一体两面,也是默认安全;代价是 P2P 要打洞、连接跟踪表有
nf_conntrack_max上限。
规则存放处:iptables -t nat(本机 legacy 后端;新系统多为 nft 后端,命令兼容)。
思考题:
- 雪球 2 的 MASQUERADE 规则里有
! -o docker0——如果去掉这个条件,容器 A 访问同网段容器 B 时会发生什么?- 雪球 4 里容器看到的客户端是
172.22.208.1(Windows 宿主)。若两个容器经-p互相访问对方发布端口,容器看到的目的地和源分别长什么样、会经过 DNAT 吗?(提示:规则里的! -i docker0。)
参考资料
- RFC 3022 · Traditional IP Network Address Translator — NAT 的标准文档(前身为 RFC 1631)
- Docker Docs · Packet filtering and firewalls — Docker 写入 iptables 的规则全景与 nftables 说明
- netfilter conntrack 文档 — 连接跟踪机制
- 本机实测环境:WSL2 Ubuntu-22.04 + Docker Engine 29.1.3(iptables v1.8.7 legacy 后端,tcpdump 4.99)