首页
/ Moby 中 nat-unprotected 桥接网络的 nftables 规则解析:发布端口的"无保护"模式

Moby 中 nat-unprotected 桥接网络的 nftables 规则解析:发布端口的"无保护"模式

2026-09-06 14:16:43作者:廉彬冶Miranda

本文以 Moby(Docker 引擎)仓库中由集成测试自动生成的参考文档 usernet-portmap-natunprot.md 为主体,完整解读在"发布端口"的容器位于 nat-unprotected 模式桥接网络时,Docker 实际下发的 nftables 规则集。读完本文,你能:准确理解 gateway_mode_ipv4=nat-unprotected 与默认 nat 模式在防火墙规则上的具体差异(按端口放行规则缺失、"DROP DIRECT ACCESS" 规则缺失);掌握该场景下完整的 docker-bridges 表结构,并能结合仓库中 bridge 驱动的 nftabler 源码定位每条规则的生成位置。

场景定义:一个 nat-unprotected 网络上运行带发布端口的容器

该文档描述的场景等价于以下操作(在禁用 userland proxy 的守护进程上执行):

docker network create \
  -o com.docker.network.bridge.name=bridge1 \
  -o com.docker.network.bridge.gateway_mode_ipv4=nat-unprotected \
  --subnet 192.0.2.0/24 --gateway 192.0.2.1 bridge1
docker run --network bridge1 -p 8080:80 --name c1 busybox

三个网络选项的含义:

选项 取值 作用
com.docker.network.bridge.name bridge1 固定桥接设备名,便于在 nftables 表中对照
com.docker.network.bridge.gateway_mode_ipv4 nat-unprotected IPv4 网关模式:保留 NAT,但不对未发布端口和外部直接访问施加过滤规则
--subnet / --gateway 192.0.2.0/24 / 192.0.2.1 显式指定子网与网关,保证规则文档可复现

在源码中,网关模式是 bridge 驱动的一个枚举常量。bridge_linux.go 中定义了五种模式:

const (
	gwModeDefault   gwMode = ""
	gwModeNAT       gwMode = "nat"
	gwModeNATUnprot gwMode = "nat-unprotected"
	gwModeRouted    gwMode = "routed"
	gwModeIsolated  gwMode = "isolated"
)

nat-unprotected 最终被解析为 firewaller.NetworkConfigFam.Unprotected 布尔位(见同文件约 L491)。firewaller.go 对该字段的注释给出了最权威的定义:

// Unprotected is true if no rules to filter unpublished ports or direct access from
// any remote host are required.
Unprotected bool

即:不需要"过滤未发布端口"的规则,也不需要"阻止任意远程主机直接访问容器 IP"的规则。这正是该模式与 routed(容器 IP 直接可达、不经 NAT)的关键区别——nat-unprotected 的 DNAT 和 masquerade 依然生效,只是放宽了入站方向的默认保护。

与默认 nat 模式规则集的公共部分

文档明确指出,绝大部分规则与 nat 模式的发布端口场景usernet-portmap.md)相同。完整表如下(摘自生成文档,IPv4 docker-bridges 表):

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;
		ip daddr != 127.0.0.0/8 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 {
		iifname != "bridge1" tcp dport 8080 counter dnat to 192.0.2.2:80 comment "DNAT"
	}

	chain raw-PREROUTING {
		type filter hook prerouting priority raw; policy accept;
	}

	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 {
	}

	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"
		counter accept comment "UNPROTECTED"
	}

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

	chain nat-postrouting-in__bridge1 {
	}

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

这张表值得注意的结构特征(与 nftablesdoc 总览 的说明一致):

  • 按接口分派的 vmap 跳转filter-FORWARD 不直接写死规则,而是通过 oifname vmap @filter-forward-in-jumps(目的接口)与 iifname vmap @filter-forward-out-jumps(源接口)两张 ifname : verdict 映射表,把每个桥接设备路由到各自的 filter-forward-in__<bridge> / filter-forward-out__<bridge> 链。这样每个网络一条链,互不干扰,删除网络时只需删对应链。
  • NAT 方向对称:入站 DNAT 挂在 nat-PREROUTING(外部到达)与 nat-OUTPUT(本机发起)共用的 nat-prerouting-and-output 链上;出站 SNAT 由 nat-POSTROUTING 经两张 nat-postrouting 跳转分派。
  • IPv4/IPv6 平行:IPv6 规则遵循相同模式,位于 ip6 docker-bridges 表,文档仅展示 IPv4。

差异一:filter-forward-in__bridge1 没有按端口规则,改为整体放行

对比默认 nat 模式,filter-forward-in 链通常会在 ICC 放行之后,为每个发布端口写一条 tcp dport <port> accept,最后以 drop comment "UNPUBLISHED PORT DROP" 收尾(上表中默认桥 docker0 的链就是这个形态)。而 bridge1 的入向链是:

chain filter-forward-in__bridge1 {
	ct state established,related counter accept
	iifname "bridge1" counter accept comment "ICC"
	counter accept comment "UNPROTECTED"
}

没有逐端口的 dport 8080 放行规则,末尾的兜底不是 drop 而是 counter accept comment "UNPROTECTED"——任何端口bridge1 转发进来的包都被接受。文档给出的原因是:若系统层面 filter-FORWARD 链的默认策略为 drop,则只有发布端口的流量才能被转发进容器,未发布端口全部不可达;nat-unprotected 模式恰恰要取消这种限制。

这条规则由 nftabler 的 network.go 生成:

if conf.Unprotected {
    // ...
    Rule: []string{`counter accept comment "UNPROTECTED"`},
}

同目录的 golden 测试数据(如 nftabler/testdata 下大量 gwm=nat-unprotected 命名的 .golden 文件)中均可看到 counter packets 0 bytes 0 accept comment "UNPROTECTED" 这一行,与集成文档相互印证。而对应的 iptabler 实现里,port.goconfig.Unprotected 为真时直接跳过逐端口的过滤规则生成——两个防火墙后端(iptables / nftables)在此行为上保持一致。

差异二:raw-PREROUTING 中没有 "DROP DIRECT ACCESS" 规则

nat 模式下,raw-PREROUTING 链里会有一条针对"外部主机直接访问容器 IP"的 DROP 规则,防止绕过宿主机的 DNAT 直接命中容器地址。而本场景中该链是空的:

chain raw-PREROUTING {
	type filter hook prerouting priority raw; policy accept;
}

文档明确说明:由于不存在 "DROP DIRECT ACCESS" 规则,容器可以从宿主机外部直接访问(例如 192.0.2.2 可以从外部网卡直接被路由到)。在 nftabler 源码中,endpoint.go 的条件正体现了这一点:

if n.config.Internal || conf.Unprotected || conf.Routed || n.fw.config.AllowDirectRouting {
    // 跳过直接访问过滤(DROP DIRECT ACCESS)相关规则的写入
}

internalnat-unprotectedrouted、或守护进程级 AllowDirectRouting 四种情况之一成立时,都会放行对容器 IP 的直接访问——但只有 nat-unprotected 仍保留 NAT,因此它实际是"routed 的可达性 + nat 的地址转换"的组合。

DNAT 与 MASQUERADE 规则原样保留,userland proxy 行为不变

文档最后强调了一点容易被忽略的事实:

"dnat" and "masquerade" rules are still in-place. And, if the userland proxy is enabled, it is still started.

对照上表可以确认两条 NAT 规则都在:

chain nat-prerouting-and-output {
	iifname != "bridge1" tcp dport 8080 counter dnat to 192.0.2.2:80 comment "DNAT"
}
chain nat-postrouting-out__bridge1 {
	oifname != "bridge1" ip saddr 192.0.2.0/24 counter masquerade comment "MASQUERADE"
}
  • DNAT:外部/本机到 :8080 的 TCP 流量被重写到容器 192.0.2.2:80iifname != "bridge1" 排除来自桥内(容器互访)的流量,避免 hairpin 循环。
  • MASQUERADE:源地址属于 192.0.2.0/24 且出接口不是 bridge1 的包做源地址伪装,保证回程报文经宿主机回送。

也就是说,nat-unprotected 放松的只是入站过滤(未发布端口、直接 IP 访问),出站 SNAT 与入站 DNAT 的地址转换链路完全不受影响。文档开头的场景说明"在禁用 userland proxy 的守护进程上运行",只是为了让行为完全由内核规则决定、便于抓规则;若启用了 userland proxy(dockerd --userland-proxy=true 为默认),代理进程仍会照常启动并监听宿主端口。

文档的生成机制:由集成测试从真实守护进程捕获

这份文档不是人工编写的,而是测试 nftablesdoc_linux_test.goTestBridgeNftablesDoc 生成的。其流程在 index.md 与测试文件头注释中都有说明:

  1. 为本文档对应的 section(usernet-portmap-natunprot.md)单独创建一个网络命名空间,在其中启动真实 dockerd;对应配置见 nftablesdoc_linux_test.goindex 表:gwMode: "nat-unprotected"、端口映射 80/tcp → 8080
  2. 按 section 描述创建网络与容器(createBridgeNetworks 会把 gwModebridge.IPv4GatewayMode 选项传给 docker network create,与上文命令行等价)。
  3. nft -s list table ip docker-bridges 捕获规则,拆分成以链/映射名为键的片段(runNftables 函数)。
  4. 套用 templates/usernet-portmap-natunprot.md 这份 markdown 模板(其中的 {{index . "Ruleset4"}}{{index . "chain filter-forward-in__bridge1"}} 等占位符被真实规则替换),生成 generated/usernet-portmap-natunprot.md
  5. 生成结果与仓库中的"金标"文件 diff,不一致则测试失败——这保证每次规则引擎改动都会被显式审查。

模板中两处占位符恰好就是本文重点展开的两个差异链:

{{index . "chain filter-forward-in__bridge1"}}
...
{{index . "chain raw-PREROUTING"}}

因此这份文档可以视为"当前版本 Docker 在该场景下的规则事实";需要留意其开头的警示(见 index.md):这是面向开发者的文档,Docker 的 nftables 规则结构在版本之间会变,不构成稳定接口,不要在生产环境依赖具体链名。

小结

  • nat-unprotected = 保留 DNAT/MASQUERADE 的 NAT 语义 + 取消两类入站保护:入向链兜底从 drop(UNPUBLISHED PORT DROP)变为 accept(UNPROTECTED),raw-PREROUTING 不再 DROP 对容器 IP 的直接访问。
  • 规则生成入口可追溯:模式枚举与解析在 bridge_linux.go,语义定义在 firewaller.go,nftables 落地在 nftabler/network.gonftabler/endpoint.go,单元测试的 golden 数据在 nftabler/testdata
  • 若你只想把容器 IP 变成外部可直达、但不需要发布端口,应比较 usernet-portmap-routed.md(routed 模式);本文场景适合"既要发布端口映射、又不想为未发布端口维持严格过滤"的桥接网络。
登录后查看全文
热门项目推荐
相关项目推荐