Moby Docker Engine:禁用 userland-proxy 时用户定义桥接网络端口映射的 nftables 规则解析
本文基于 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-bridges与ip6 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.go 的 runTestNet)。测试有明确的前提条件: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"
}
}
整体结构可以按三层理解:
- 四个
ifname : verdict跳转映射 +filter-FORWARD/nat-POSTROUTING钩子链:filter-FORWARD用oifname vmap @filter-forward-in-jumps和iifname vmap @filter-forward-out-jumps按出/入接口把包分派到每个网桥专属的链;nat-POSTROUTING同理按iifname(进入网桥方向)与oifname(离开网桥方向)分派到nat-postrouting-in__<bridge>与nat-postrouting-out__<bridge>。 - 每个网桥一对 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"丢弃其余入向流量。 - 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-FORWARD;filter-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.go 的 setPerPortDNAT 直接生成:
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)
}
...
}
只有当 Hairpin 为 false(即用户态 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.go 的setPerPortHairpinMasq生成,其注释明确写着“allows containers to access their own published ports on the host when hairpin is enabled (no docker-proxy)”,且仅在n.fw.config.Hairpin为真时添加——与文档场景严格对应。 - MASQUERADE FROM HOST:
fib 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.go 的setPerPortForwarding为每个端口绑定逐条生成(注释说明了多宿主端口映射到同一容器端口时的去重策略)。 - 外部直达拦截:
raw-PREROUTING中ip daddr 192.0.2.2 iifname != "bridge1" counter drop comment "DROP DIRECT ACCESS"——因为网络采用默认网关模式nat,容器 IP 不应从宿主机外部直接可达。 - 出站 masquerade:
nat-postrouting-out__bridge1中oifname != "bridge1" ip saddr 192.0.2.0/24 counter masquerade,使容器访问外部时源地址改写为网桥网关。 - 默认桥 docker0 的 ICC 与端口丢弃:
filter-forward-in__docker0仅放行 established/related 与桥内 ICC,其余丢弃(UNPUBLISHED PORT DROP)。
文档生成机制与验证方式
该文档不是手写的,理解其生成机制有助于判断可信边界:
- nftablesdoc_linux_test.go 的
TestBridgeNftablesDoc在每个独立网络命名空间中启动一个 dockerd(noproxy 小节附加--userland-proxy=false),按section定义创建bridge1网络(192.0.2.0/24,网关192.0.2.1)并运行映射80/tcp -> 8080的容器; - 然后执行
nft -s list table ip docker-bridges抓取带计数器的规则输出,runNftables函数把输出按 map/chain 拆块,存入模板可引用的键(如{{index . "chain nat-OUTPUT"}}、{{index . "Ruleset4"}}); - 用 templates/ 下对应的
text/template渲染出完整 markdown,与generated/下的 golden 文件做 diff,不一致则测试失败。文件头部也标注了<!-- This is a generated file; DO NOT EDIT. -->; - 测试注释说明更新流程:规则变化时先检查 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 PORTmasquerade)。- 同时,
nat-postrouting-in__<bridge>链新增MASQUERADE FROM HOST规则,保证宿主本地地址发起的连接在容器侧有可用的回程路径。 - 其余 ICC、发布端口放行、外部直达拦截与出站 masquerade 规则与启用 proxy 场景一致。
- 该文档集为开发用途、非稳定接口;引用规则时应以当前仓库的 generated/usernet-portmap-noproxy.md 与
nftabler包测试 golden 数据为准。
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