首页
/ Moby Docker Engine:禁用 userland-proxy 时用户定义桥接网络端口映射的 nftables 规则解析

Moby Docker Engine:禁用 userland-proxy 时用户定义桥接网络端口映射的 nftables 规则解析

2026-09-06 14:25:16作者:田桥桑Industrious

本文基于 moby 仓库中自动生成的 nftables 文档,讲解以 --userland-proxy=false 启动 Docker 守护进程后,用户定义桥接网络(user-defined bridge network)上发布端口(published port)所生成的 ip docker-bridges nftables 表规则:完整规则集、与启用 userland proxy 场景的三处关键差异,以及每条差异规则在 nftabler 源码中的生成逻辑。读完你可以掌握该场景下 DNAT、hairpin masquerade 与 loopback 端口映射在内核态的处理方式,并能对照源码验证规则来源。

需要预先说明两点(来自该文档集的 index.md):

  • 该文档集面向开发用途——Docker 的 nftables 规则结构在各版本之间可能变化,不是稳定接口
  • 文档由测试 TestBridgeNftablesDoc 通过真实启动 daemon、创建网络与容器、抓取 nftables 输出后,与 templates/usernet-portmap-noproxy.md 模板合并生成,并与 generated/usernet-portmap-noproxy.md 做 golden diff,规则一旦变化测试即失败。
  • IPv6 规则与 IPv4 规则模式相同,只是位于不同的表(ip docker-bridgesip6 docker-bridges),因此文档只展示 IPv4 规则;表在每次 Docker 启动时都会重建。

场景复现:等效命令

该场景等效于以下操作序列(引自文档原文,完整保留):

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
  • docker network create 通过选项 com.docker.network.bridge.name 指定网桥名为 bridge1,并用 --subnet 192.0.2.0/24 --gateway 192.0.2.1 固定子网与网关(192.0.2.0/24 是文档测试专用地址段,见 nftablesdoc_linux_test.go 中的 docNetworks 定义);
  • docker run -p 8080:80 把容器 c1(分配 IP 192.0.2.2)的 80 端口发布到宿主机 8080 端口;
  • dockerd --userland-proxy=false 关闭用户态代理(docker-proxy)。该 flag 定义于 config_unix.go--userland-proxy,描述为 "Use userland proxy for loopback traffic"),默认值在 config_linux.go 中被初始化为 true,且默认路径会查找 docker-proxy 二进制。

在集成测试中,该场景由 index 里的 section{ name: "usernet-portmap-noproxy.md", noUserlandProxy: true, ... } 描述,测试据此以 --userland-proxy=false 参数在独立网络命名空间内启动 daemon(见 nftablesdoc_linux_test.gorunTestNet)。测试有明确的前提条件:firewalld 未运行、非 rootless、且防火墙后端为 nftables。

该场景的完整 nftables 规则

启用 proxy 时,绝大部分规则与 用户定义网络 + 发布端口(启用 proxy) 场景相同。本文档给出的完整 ip docker-bridges 表如下(摘自 generated/usernet-portmap-noproxy.md):

table ip docker-bridges {
	map filter-forward-in-jumps {
		type ifname : verdict
		elements = { "docker0" : jump filter-forward-in__docker0,
			     "bridge1" : jump filter-forward-in__bridge1 }
	}

	map filter-forward-out-jumps {
		type ifname : verdict
		elements = { "docker0" : jump filter-forward-out__docker0,
			     "bridge1" : jump filter-forward-out__bridge1 }
	}

	map nat-postrouting-in-jumps {
		type ifname : verdict
		elements = { "docker0" : jump nat-postrouting-in__docker0,
			     "bridge1" : jump nat-postrouting-in__bridge1 }
	}

	map nat-postrouting-out-jumps {
		type ifname : verdict
		elements = { "docker0" : jump nat-postrouting-out__docker0,
			     "bridge1" : jump nat-postrouting-out__bridge1 }
	}

	chain filter-FORWARD {
		type filter hook forward priority filter; policy accept;
		oifname vmap @filter-forward-in-jumps
		iifname vmap @filter-forward-out-jumps
	}

	chain nat-OUTPUT {
		type nat hook output priority dstnat; policy accept;
		fib daddr type local counter jump nat-prerouting-and-output
	}

	chain nat-POSTROUTING {
		type nat hook postrouting priority srcnat; policy accept;
		iifname vmap @nat-postrouting-out-jumps
		oifname vmap @nat-postrouting-in-jumps
	}

	chain nat-PREROUTING {
		type nat hook prerouting priority dstnat; policy accept;
		fib daddr type local counter jump nat-prerouting-and-output
	}

	chain nat-prerouting-and-output {
		tcp dport 8080 counter dnat to 192.0.2.2:80 comment "DNAT"
	}

	chain raw-PREROUTING {
		type filter hook prerouting priority raw; policy accept;
		ip daddr 192.0.2.2 iifname != "bridge1" counter drop comment "DROP DIRECT ACCESS"
	}

	chain filter-forward-in__docker0 {
		ct state established,related counter accept
		iifname "docker0" counter accept comment "ICC"
		counter drop comment "UNPUBLISHED PORT DROP"
	}

	chain filter-forward-out__docker0 {
		ct state established,related counter accept
		counter accept comment "OUTGOING"
	}

	chain nat-postrouting-in__docker0 {
		fib saddr type local counter masquerade comment "MASQUERADE FROM HOST"
	}

	chain nat-postrouting-out__docker0 {
		oifname != "docker0" ip saddr 172.17.0.0/16 counter masquerade comment "MASQUERADE"
	}

	chain filter-forward-in__bridge1 {
		ct state established,related counter accept
		iifname "bridge1" counter accept comment "ICC"
		ip daddr 192.0.2.2 tcp dport 80 counter accept
		counter drop comment "UNPUBLISHED PORT DROP"
	}

	chain filter-forward-out__bridge1 {
		ct state established,related counter accept
		counter accept comment "OUTGOING"
	}

	chain nat-postrouting-in__bridge1 {
		fib saddr type local counter masquerade comment "MASQUERADE FROM HOST"
		ip saddr 192.0.2.2 ip daddr 192.0.2.2 tcp dport 80 counter masquerade comment "MASQ TO OWN PORT"
	}

	chain nat-postrouting-out__bridge1 {
		oifname != "bridge1" ip saddr 192.0.2.0/24 counter masquerade comment "MASQUERADE"
	}
}

整体结构可以按三层理解:

  1. 四个 ifname : verdict 跳转映射 + filter-FORWARD / nat-POSTROUTING 钩子链filter-FORWARDoifname vmap @filter-forward-in-jumpsiifname vmap @filter-forward-out-jumps 按出/入接口把包分派到每个网桥专属的链;nat-POSTROUTING 同理按 iifname(进入网桥方向)与 oifname(离开网桥方向)分派到 nat-postrouting-in__<bridge>nat-postrouting-out__<bridge>
  2. 每个网桥一对 filter 链(如 filter-forward-in__bridge1):先放行 established,related 的已有连接;iifname "bridge1" 规则允许桥内容器间通信(ICC);针对发布端口放行 ip daddr 192.0.2.2 tcp dport 80;末尾 counter drop comment "UNPUBLISHED PORT DROP" 丢弃其余入向流量。
  3. NAT 相关nat-PREROUTING 对目的为本地地址(fib daddr type local)的包跳转共享链 nat-prerouting-and-output 做 DNAT;nat-postrouting-out__<bridge> 对离开网桥的容器网段流量做 masquerade;raw-PREROUTING 中的 DROP DIRECT ACCESS 规则保证默认网关模式 nat 下容器 IP 不可从宿主机外部直接访问。

另外,index.md 说明:filter-INPUT 钩子不被 Docker 使用——来自宿主机物理网络或宿主机自身的包经路由进入桥接网络后命中的是 filter-FORWARDfilter-OUTPUT 同理。

与启用 proxy 场景的三处关键差异

文档的核心价值在于解释“禁用 userland proxy 后规则有什么不同”。这些差异都指向同一个设计事实:没有 docker-proxy 兜底时,原本由用户态代理处理的流量必须全部改由内核 NAT 规则处理

差异一:nat-OUTPUT 对 loopback 目的地址也跳入 DNAT 链

chain nat-OUTPUT {
	type nat hook output priority dstnat; policy accept;
	fib daddr type local counter jump nat-prerouting-and-output
}

文档原话:nat-OUTPUT 到 nat-prerouting-and-output 的跳转对 loopback 地址也会发生,目的是 DNAT“从某个网络发往、由另一网络中容器发布到 loopback 地址的端口”的包——因为没有 proxy 去捕获它们。

对照启用 proxy 的场景(见 generated/usernet-portmap.md),同一链的规则是:

chain nat-OUTPUT {
	type nat hook output priority dstnat; policy accept;
	ip daddr != 127.0.0.0/8 fib daddr type local counter jump nat-prerouting-and-output
}

即启用 proxy 时显式排除了 127.0.0.0/8——因为发布在 loopback 上的端口由 docker-proxy 进程在用户态监听并转发,内核 NAT 不再处理;禁用 proxy 后,-p 127.0.0.1:8080:80 这类绑定只能靠内核 DNAT 完成,于是排除条件消失。

差异二:DNAT 规则不再限制入向接口(hairpin 由内核完成)

本场景:

chain nat-prerouting-and-output {
	tcp dport 8080 counter dnat to 192.0.2.2:80 comment "DNAT"
}

文档解释:这条“宿主机端口 → 容器端口”的 DNAT 规则没有限制为“来自发布端口所在网络的包”,同样因为没有 proxy 去捕获它们。对照启用 proxy 场景,同一链是:

chain nat-prerouting-and-output {
	iifname != "bridge1" tcp dport 8080 counter dnat to 192.0.2.2:80 comment "DNAT"
}

启用 proxy 时,iifname != "bridge1" 把来自 bridge1 内部的流量排除在外——容器访问宿主机上自己发布的端口(所谓 hairpin/回绕访问)由 docker-proxy 负责;禁用 proxy 后,这条内核 DNAT 规则对任何来源(包括 bridge1 内的容器自身)生效。

这个“有无 iifname != <bridge> 前缀”的开关,在源码中由 port.gosetPerPortDNAT 直接生成:

func (n *network) setPerPortDNAT(pbs []types.PortBinding, updater func(nftables.Obj), ipv nftables.Family) {
	var proxySkip string
	if !n.fw.config.Hairpin {
		proxySkip = fmt.Sprintf("iifname != %s ", n.config.IfName)
	}
	...
}

只有当 Hairpinfalse(即用户态 proxy 在运行)时才加上 iifname != <bridge> 跳过条件。而 “Hairpin” 与 userland proxy 的对应关系在仓库中有两处明确注释:

  • firewaller.go// Hairpin means the userland proxy will not be running.(Hairpin 为真 ⇔ 用户态 proxy 不运行)
  • bridge_linux.go 构建防火墙配置时:Hairpin: !config.EnableProxy

因此 --userland-proxy=false 会直接把防火墙层推进 “hairpin 模式”,这正是上述规则差异的来源。

差异三:nat-postrouting-in 链新增 masquerade 规则

文档指出:本场景的 nat-postrouting-in 链带有针对“来自本地地址的包”的 masquerade 规则;且 bridge1(即拥有发布端口容器的网桥)还额外有一条“容器访问其发布在宿主机上的自身端口”的 masquerade 规则:

chain nat-postrouting-in__docker0 {
	fib saddr type local counter masquerade comment "MASQUERADE FROM HOST"
}

chain nat-postrouting-in__bridge1 {
	fib saddr type local counter masquerade comment "MASQUERADE FROM HOST"
	ip saddr 192.0.2.2 ip daddr 192.0.2.2 tcp dport 80 counter masquerade comment "MASQ TO OWN PORT"
}

对照启用 proxy 场景,这两条 nat-postrouting-in__* 链是空的。两类规则的作用可以从规则组合推断:

  • MASQ TO OWN PORT(hairpin masquerade):容器 c1(192.0.2.2)访问宿主机 8080 端口时,包被差异二的 DNAT 规则改写到 192.0.2.2:80——目的就是容器自己。若不处理,源地址 192.0.2.2 与目的 192.0.2.2:80 的“自己发给自己”连接无法正常建立与回程,故对该连接做 masquerade(改写源地址为网桥网关),使回绕访问在纯内核路径下可用。这条规则由 port.gosetPerPortHairpinMasq 生成,其注释明确写着“allows containers to access their own published ports on the host when hairpin is enabled (no docker-proxy)”,且仅在 n.fw.config.Hairpin 为真时添加——与文档场景严格对应。
  • MASQUERADE FROM HOSTfib saddr type local 匹配“源为宿主机本地地址”的入桥流量并 masquerade。从规则组合看,其意义在于:禁用 proxy 后,宿主机发起的连接(包括经 nat-OUTPUT DNAT 的 loopback 端口映射)到达容器时可能携带宿主机本地源地址(如 127.0.0.1),容器无法直接路由回应给这类地址,改写为网桥网关源地址后回程路径才成立。启用 proxy 时,docker-proxy 以宿主机的桥接网关地址直接连接容器,因此不需要这条规则。

与启用 proxy 场景保持一致的部分

除上述三处差异外,规则与 启用 proxy 的端口映射文档 一致,主要包括:

  • 发布端口的转发放行filter-forward-in__bridge1 中的 ip daddr 192.0.2.2 tcp dport 80 counter accept,由 port.gosetPerPortForwarding 为每个端口绑定逐条生成(注释说明了多宿主端口映射到同一容器端口时的去重策略)。
  • 外部直达拦截raw-PREROUTINGip daddr 192.0.2.2 iifname != "bridge1" counter drop comment "DROP DIRECT ACCESS"——因为网络采用默认网关模式 nat,容器 IP 不应从宿主机外部直接可达。
  • 出站 masqueradenat-postrouting-out__bridge1oifname != "bridge1" ip saddr 192.0.2.0/24 counter masquerade,使容器访问外部时源地址改写为网桥网关。
  • 默认桥 docker0 的 ICC 与端口丢弃filter-forward-in__docker0 仅放行 established/related 与桥内 ICC,其余丢弃(UNPUBLISHED PORT DROP)。

文档生成机制与验证方式

该文档不是手写的,理解其生成机制有助于判断可信边界:

  1. nftablesdoc_linux_test.goTestBridgeNftablesDoc 在每个独立网络命名空间中启动一个 dockerd(noproxy 小节附加 --userland-proxy=false),按 section 定义创建 bridge1 网络(192.0.2.0/24,网关 192.0.2.1)并运行映射 80/tcp -> 8080 的容器;
  2. 然后执行 nft -s list table ip docker-bridges 抓取带计数器的规则输出,runNftables 函数把输出按 map/chain 拆块,存入模板可引用的键(如 {{index . "chain nat-OUTPUT"}}{{index . "Ruleset4"}});
  3. templates/ 下对应的 text/template 渲染出完整 markdown,与 generated/ 下的 golden 文件做 diff,不一致则测试失败。文件头部也标注了 <!-- This is a generated file; DO NOT EDIT. -->
  4. 测试注释说明更新流程:规则变化时先检查 diff,再更新对应模板描述,最后以 TESTFLAGS='-update' 重新生成参考文档。

此外,纯 Go 单元层面还有一组 nftables 规则生成的测试数据:daemon/libnetwork/drivers/bridge/internal/nftabler/testdata/TestNftabler/ 下的 golden 文件按 hairpin=gwm=(nat / nat-unprotected / routed)、icc=bindlh=wsl2mirrored= 等参数组合覆盖了 hairpin=true(即无 proxy)与各网关模式的规则快照,可用来交叉核对本文规则。

小结

  • --userland-proxy=false 会把网桥防火墙切入 “hairpin 模式”(源码中 Hairpin: !config.EnableProxy),原本由 docker-proxy 处理的三类流量全部落到内核 nftables:loopback 端口映射(nat-OUTPUT 不排除 127.0.0.0/8)、跨网络经 loopback 发布端口的访问、以及容器回绕访问自身发布端口(DNAT 不再排除 iifname "bridge1",并配套 MASQ TO OWN PORT masquerade)。
  • 同时,nat-postrouting-in__<bridge> 链新增 MASQUERADE FROM HOST 规则,保证宿主本地地址发起的连接在容器侧有可用的回程路径。
  • 其余 ICC、发布端口放行、外部直达拦截与出站 masquerade 规则与启用 proxy 场景一致。
  • 该文档集为开发用途、非稳定接口;引用规则时应以当前仓库的 generated/usernet-portmap-noproxy.mdnftabler 包测试 golden 数据为准。
登录后查看全文
热门项目推荐
相关项目推荐