Moby Bridge 网络端口映射 iptables 规则详解:禁用 Userland Proxy 的 Hairpin NAT 场景
本篇技术文章基于 Moby(Docker Engine 上游项目)仓库中 integration/network/bridge/iptablesdoc/templates/usernet-portmap-noproxy.md 文档模板及其自动生成的规则快照,完整解析「用户自定义 bridge 网络 + 发布端口 + 禁用 userland proxy」场景下 Docker 在 iptables 中生成的全部规则。读完后,你将能够独立读懂并验证 Docker Engine 在禁用用户态代理后,如何通过 hairpin(发夹式)NAT 规则让宿主机直接访问容器发布端口,并理解每条规则对应的源码实现位置。
文档背景:这是一套自动生成的 iptables 快照
该模板位于仓库 integration/network/bridge/iptablesdoc/ 目录,属于 Docker Engine 的 iptables 使用文档套件的一部分。根据 index.md 的说明,这套文档由测试用例 TestBridgeIptablesDoc 自动生成:测试会真实启动一个 dockerd 守护进程,按场景创建网络和容器,然后抓取 iptables 输出,再把抓取结果与 templates/ 目录下的文本模板(text/template)合并,最后与 generated/ 目录中的产物做 diff——如果生成的规则与仓库中的快照不一致,测试就会失败。
原文档同时给出两点重要前提,这里必须继承:
- 仅供开发参考,不是稳定接口:Docker 的 iptables(以及 ip6tables)规则结构会在版本之间变化,不属于稳定接口,不应依赖具体规则布局编写防火墙脚本。
- ip6tables 规则与 iptables 模式一致:文档只展示 IPv4 规则。
- 规则顺序不保证稳定:bridge 驱动在初始化时会删除自建链并在网络恢复时重建规则(见 bridge_linux.go 的
configure),而 filter 表的 FORWARD 链不会被清空,且网络重建顺序与创建顺序不同,因此守护进程重启后规则排列可能变化。firewalld 重载会清空 iptables,守护进程通过 dbus 监听其重载事件并重建规则。 - filter 表的 INPUT/OUTPUT 链不被 Docker 使用:来自宿主机物理网络或宿主机自身的包,会被路由进 bridge 网络后命中 filter 表的 FORWARD 链。
当前仓库中,本场景的模板 usernet-portmap-noproxy.md 与生成产物 usernet-portmap-noproxy.md 一一对应,生成产物内嵌了真实的 iptables 表格,本文据此展开。仓库中还有同族场景文档可对照阅读:带 userland proxy 的 usernet-portmap.md、发布到 loopback 的 usernet-portmap-lo.md、禁用容器间通信的 usernet-portmap-noicc.md 等。
场景搭建:禁用 userland proxy 后发布端口
文档定义的操作步骤等价于以下命令:
dockerd --userland-proxy=false
docker network create \
-o com.docker.network.bridge.name=bridge1 \
--subnet 192.0.2.0/24 --gateway 192.0.2.1 bridge1
docker run --network bridge1 -p 8080:80 --name c1 busybox
要点说明:
--userland-proxy=false:禁用 userland proxy。启用时,Docker 会为每个发布端口启动一个用户态代理进程(docker-proxy),由它监听宿主机端口并把连接转发进容器;禁用后,端口发布完全依赖内核的 DNAT(hairpin NAT)路径,宿主机访问192.0.2.2:80(或宿主机 IP 的8080端口)的包直接由 iptables 完成地址转换。-o com.docker.network.bridge.name=bridge1:把自定义网络绑定到名为bridge1的网桥设备,便于规则中直观看到网桥名。--subnet 192.0.2.0/24 --gateway 192.0.2.1:使用文档约定的 192.0.2.0/24 测试网段(TEST-NET-1),容器c1得到地址192.0.2.2。- 端口映射
-p 8080:80:宿主机 8080 → 容器 80(tcp)。
在源码层面,「是否启用 userland proxy」直接影响 iptables 的配置结构。从 bridge_linux.go 第 199 行可以看到,防火墙配置中的 Hairpin 字段就是由 proxy 开关取反得到的:
Hairpin: !config.EnableProxy,
而 firewaller.go 第 27~28 行的注释明确解释了二者的等价关系:
// Hairpin means the userland proxy will not be running.
Hairpin bool
也就是说,禁用 userland proxy 等价于开启 hairpin 模式:发布端口的 DNAT/MASQUERADE 规则需要让「宿主机自己发出的包」也能被转换和转发,这正是下文 nat 表差异的根源。
filter 表:与启用 userland proxy 时完全相同
原文档结论:filter 表与启用 userland proxy 时一致——用户态代理只影响 nat 表(以及一个 proxy 进程本身),不改变过滤规则。生成产物中捕获到的完整 filter 表如下(保留原文全貌):
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER-USER all -- any any anywhere anywhere
2 0 0 DOCKER-FORWARD all -- any any anywhere anywhere
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
Chain DOCKER (2 references)
num pkts bytes target prot opt in out source destination
1 0 0 ACCEPT tcp -- !bridge1 bridge1 anywhere 192.0.2.2 tcp dpt:http
2 0 0 DROP all -- !docker0 docker0 anywhere anywhere
3 0 0 DROP all -- !bridge1 bridge1 anywhere anywhere
Chain DOCKER-BRIDGE (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER all -- any docker0 anywhere anywhere
2 0 0 DOCKER all -- any bridge1 anywhere anywhere
Chain DOCKER-CT (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 ACCEPT all -- any docker0 anywhere anywhere ctstate RELATED,ESTABLISHED
2 0 0 ACCEPT all -- any bridge1 anywhere anywhere ctstate RELATED,ESTABLISHED
Chain DOCKER-FORWARD (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER-CT all -- any any anywhere anywhere
2 0 0 DOCKER-INTERNAL all -- any any anywhere anywhere
3 0 0 DOCKER-BRIDGE all -- any any anywhere anywhere
4 0 0 ACCEPT all -- docker0 any anywhere anywhere
5 0 0 ACCEPT all -- bridge1 any anywhere anywhere
Chain DOCKER-INTERNAL (1 references)
num pkts bytes target prot opt in out source destination
Chain DOCKER-USER (1 references)
num pkts bytes target prot opt in out source destination
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-BRIDGE
-N DOCKER-CT
-N DOCKER-FORWARD
-N DOCKER-INTERNAL
-N DOCKER-USER
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A DOCKER -d 192.0.2.2/32 ! -i bridge1 -o bridge1 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER ! -i docker0 -o docker0 -j DROP
-A DOCKER ! -i bridge1 -o bridge1 -j DROP
-A DOCKER-BRIDGE -o docker0 -j DOCKER
-A DOCKER-BRIDGE -o bridge1 -j DOCKER
-A DOCKER-CT -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-CT -o bridge1 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A DOCKER-FORWARD -j DOCKER-CT
-A DOCKER-FORWARD -j DOCKER-INTERNAL
-A DOCKER-FORWARD -j DOCKER-BRIDGE
-A DOCKER-FORWARD -i docker0 -j ACCEPT
-A DOCKER-FORWARD -i bridge1 -j ACCEPT
对应规则的含义(与源码 port.go 中 setPerPortForwarding 的构造逻辑一致):
DOCKER链第 1 条:-A DOCKER -d 192.0.2.2/32 ! -i bridge1 -o bridge1 -p tcp -m tcp --dport 80 -j ACCEPT—— 以容器 IP192.0.2.2为目的地址、从网桥bridge1出去的 80 端口包放行,实现发布端口的「开放端口」访问;DOCKER链第 2、3 条DROP:进入docker0/bridge1的包若未从该网桥进入则丢弃,防止绕过上述放行规则直接访问网桥;DOCKER-CT:对已建立/相关连接(RELATED,ESTABLISHED)出网桥方向直接放行,加速返回路径;DOCKER-FORWARD以DOCKER-CT → DOCKER-INTERNAL → DOCKER-BRIDGE的固定顺序串联各子链,最后无条件放行从docker0、bridge1进入的包。
nat 表:hairpin 模式下的完整规则
这是本场景的核心。原文档给出的完整 nat 表如下:
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER all -- any any anywhere anywhere ADDRTYPE match dst-type LOCAL
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER all -- any any anywhere anywhere ADDRTYPE match dst-type LOCAL
Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 MASQUERADE all -- any bridge1 anywhere anywhere ADDRTYPE match src-type LOCAL
2 0 0 MASQUERADE all -- any !bridge1 192.0.2.0/24 anywhere
3 0 0 MASQUERADE all -- any docker0 anywhere anywhere ADDRTYPE match src-type LOCAL
4 0 0 MASQUERADE all -- any !docker0 172.17.0.0/16 anywhere
5 0 0 MASQUERADE tcp -- any any 192.0.2.2 192.0.2.2 tcp dpt:http
Chain DOCKER (2 references)
num pkts bytes target prot opt in out source destination
1 0 0 DNAT tcp -- any any anywhere anywhere tcp dpt:http-alt to:192.0.2.2:80
等价的 iptables 命令行(原文档以折叠块形式提供,此处完整保留):
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -o bridge1 -m addrtype --src-type LOCAL -j MASQUERADE
-A POSTROUTING -s 192.0.2.0/24 ! -o bridge1 -j MASQUERADE
-A POSTROUTING -o docker0 -m addrtype --src-type LOCAL -j MASQUERADE
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 192.0.2.2/32 -d 192.0.2.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE
-A DOCKER -p tcp -m tcp --dport 8080 -j DNAT --to-destination 192.0.2.2:80
结合源码可以逐条定位这些规则的来源:
- PREROUTING/OUTPUT → DOCKER 的跳转规则:由 iptabler.go 中的
addNATJumpRules编程(约第 194 行),是否覆盖 loopback 正是由Hairpin(即无 proxy)开关决定的,见下文差异一。 - POSTROUTING 的 LOCAL 源地址 MASQUERADE:
-A POSTROUTING -o bridge1 -m addrtype --src-type LOCAL -j MASQUERADE由 network.go 第 306 行写入,标记为MASQ LOCAL HOST,且仅在Hairpin为真时生效(enable && n.ipt.config.Hairpin)——这就是原文档所称的setupIPTablesInternal(旧代码路径,现已重构进internal/iptabler包)行为。 - 每端口 DNAT + hairpin MASQUERADE:由 port.go 的
setPerPortNAT(第 79~121 行)生成,对应差异三与差异四。
与启用 userland proxy 时的四项差异
原文档的核心结论部分列出了与 usernet-portmap 场景(userland proxy 启用)的 4 点差异。下面逐条继承并结合当前仓库源码展开。
差异一:OUTPUT 链对 loopback 地址也会跳转到 DOCKER 链
原文:The jump from the OUTPUT chain to DOCKER happens even for loopback addresses.
nat 表中的 -A OUTPUT -m addrtype --dst-type LOCAL -j DOCKER 意味着:宿主机进程访问本机任意本地地址(包括 127.0.0.1:8080 这种 loopback 目标)时,包在 OUTPUT 阶段就进入 DOCKER 链做 DNAT。启用 proxy 时,127.0.0.1:8080 上的监听者是 proxy 进程本身,内核不需要为 loopback 目标做 DNAT,因此该跳转被限制;禁用 proxy 后没有任何用户态监听者,只有靠内核 DNAT 才能让 curl 127.0.0.1:8080 到达容器,所以规则放开到 loopback。
差异二:容器访问自己发布端口时的 MASQUERADE
原文:A MASQUERADE rule is added for packets sent from the container to one of its own published ports on the host.
对应 POSTROUTING 第 5 条:
-A POSTROUTING -s 192.0.2.2/32 -d 192.0.2.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE
这是典型的 hairpin NAT 需求:容器 c1(192.0.2.2)访问宿主机映射的 8080 端口时,包经 DNAT 后源/目的都是 192.0.2.2:80,若不把源地址伪装掉,容器网络栈无法正确完成回环路径。该规则在 port.go 第 109~118 行构造,且明确以 n.ipt.config.Hairpin && enable 为条件——只有禁用 userland proxy 时才会被写入,与原文档结论完全吻合。
差异三:POSTROUTING 中包含 LOCAL 源地址的 MASQUERADE
原文:A MASQUERADE rule for packets from a LOCAL source address is included in POSTROUTING.
对应 POSTROUTING 第 1 条(针对 bridge1,另有 docker0 的同类规则第 3 条):
-A POSTROUTING -o bridge1 -m addrtype --src-type LOCAL -j MASQUERADE
它的作用:当宿主机自身发出的包(源地址属于宿主机 LOCAL 地址)被 DNAT 进容器后,出网桥时再做一次 MASQUERADE,把源地址改成网关 192.0.2.1,容器回包才能被路由回来。启用 proxy 时宿主机流量由 proxy 进程发起并被单独处理,此规则不必要;hairpin 模式下则是必需。其源码位置见 network.go 第 306 行(MASQ LOCAL HOST 标记,同样受 Hairpin 门控)。
差异四:DOCKER 链的 DNAT 规则不再限定目的网桥
原文:In the DOCKER chain's DNAT rule, there's no destination bridge.
对比两种场景的最终 DNAT 规则:
- 有 proxy:
-A DOCKER ! -i bridge1 -p tcp --dport 8080 -j DNAT --to-destination 192.0.2.2:80(带! -i bridge1,排除从bridge1进入的包); - 无 proxy:
-A DOCKER -p tcp --dport 8080 -j DNAT --to-destination 192.0.2.2:80(没有接口条件)。
源码依据在 port.go 第 98~100 行:
if !n.ipt.config.Hairpin {
args = append(args, "!", "-i", n.config.IfName)
}
即 Hairpin=false(有 proxy)时才追加 ! -i <网桥名> 排除条件。去掉该条件后,从 bridge1 进来的包(例如容器访问宿主机映射端口的 hairpin 流量)同样会命中 DNAT,这正是差异二、三共同支撑的「无代理自访问」路径的入口。
小结与验证方式
本场景的关键在于理解一个源码级事实:在 Moby 的 bridge 驱动内部,「禁用 userland proxy」与「开启 Hairpin」是同一个布尔量的两面(bridge_linux.go 第 199 行 Hairpin: !config.EnableProxy,firewaller.go 第 27 行注释)。由此派生出 nat 表的全部差异:
| 差异点 | 有 userland proxy | 无 userland proxy(本文场景) |
|---|---|---|
| OUTPUT → DOCKER 跳转 | 不覆盖 loopback | 覆盖 loopback |
| 容器访问自身发布端口 | 无对应规则 | 增加源/目的均为容器 IP 的 MASQUERADE |
| POSTROUTING LOCAL 源 MASQUERADE | 无 | 每个网桥一条 -m addrtype --src-type LOCAL |
| DOCKER 链 DNAT | 带 ! -i <网桥> 条件 |
无接口条件,hairpin 流量也命中 DNAT |
| filter 表 | 相同 | 相同 |
验证方式(与文档生成机制一致):在具备 iptables 权限的 Linux 环境按「场景搭建」一节操作后,执行 iptables-save -t filter 与 iptables-save -t nat,与 生成产物 中的表格逐条比对;仓库内的 TestBridgeIptablesDoc 测试(入口见 iptablesdoc_linux_test.go)即以此机制在 CI 中守护规则快照。单元测试层面的规则编程行为(含 hairpin 开关的对照结果)可参考 iptabler_test.go 中按 hairpin=%v 生成的 golden 文件。
最后重申原文档的两条边界:该规则结构是开发参考而非稳定接口,跨版本不可依赖;ip6tables 规则模式与 iptables 相同,本文只展示 IPv4。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00