首页
/ Moby 中 nftables 下容器端口映射的规则生成机制:详解 `docker-bridges` 表与 `-p 8080:80` 的三条关键规则

Moby 中 nftables 下容器端口映射的规则生成机制:详解 `docker-bridges` 表与 `-p 8080:80` 的三条关键规则

2026-09-06 14:32:01作者:申梦珏Efrain

当 dockerd 使用 nftables 防火墙后端时,每个 -p 端口映射并不是一条孤立的规则,而是 ip docker-bridges 表中一组协同工作的链、映射表与 drop/dnat 规则的产物。本文以仓库中自动生成的文档 usernet-portmap.md 为主体,完整梳理"用户自定义桥接网络上、带已发布端口的容器"这一场景下的全部 nftables 规则,并结合 nftabler 源码说明每条规则是如何被创建的。读完后,你可以独立看懂 nft list table ip docker-bridges 的输出,并能从源码层面解释 DNAT、direct-access 拦截与未发布端口丢弃各自的实现位置。

1. 文档来源与适用边界

usernet-portmap.md 是一个自动生成的文件(文件首行即标注 This is a generated file; DO NOT EDIT),它由集成测试 TestBridgeNftablesDoc 生成:测试启动一个真实的 dockerd 实例,创建网络、运行容器,然后用 nft -s list table ip docker-bridges 抓取规则集,再与 templates/usernet-portmap.md 文本模板合并生成 Markdown,最后与 generated/ 目录下的 golden 文件做 diff,规则不一致则测试失败。

需要明确两点适用边界(来自 nftablesdoc/index.md 与测试代码):

  • 这不是稳定接口:Docker 的 nftables 规则结构在版本之间会变化,该文档仅供开发参考;
  • 只展示 IPv4 规则:IPv6 规则遵循同样的模式,只是位于不同的表(ip docker-bridgesip6 docker-bridges);表会在每次 Docker 启动时重建;
  • 测试在 firewalld 运行中、rootless 模式或非 nftables 后端下会自动跳过(见 nftablesdoc_linux_test.go 中的 skip.If 判断),即本场景要求 iptables 兼容层由 nftables 提供。

2. 复现场景:两条命令

文档描述的场景等价于以下操作:

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

要点:

  • -o com.docker.network.bridge.name=bridge1 指定了网桥设备名(对应 nftables 规则中大量出现的 bridge1 接口名),其常量在源码中为 bridge.BridgeName
  • 测试中通过 network.WithOption(bridge.BridgeName, nw.name)network.WithIPAM(...) 构造同样的选项(见 nftablesdoc_linux_test.go),固定使用 192.0.2.0/24 / 192.0.2.1 等文档专用网段,保证生成的规则可复现、可 golden diff;
  • -p 8080:80 在测试里表示为 networktypes.MustParsePort("80/tcp")HostPort: "8080" 的 PortMap。

执行后,ip 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;
        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 {
    }

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

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

3. 表级结构:基链 + verdict map 的调度模型

Docker 在 nftables 中不采用 iptables 时代"每条规则平铺"的方式,而是把调度逻辑集中在 5 个基链(base chain)中,每个网络只挂一组私有链。链与映射表的名称常量定义在 nftabler.go

名称 类型/钩子 职责
filter-FORWARD filter, forward 前向流量的总入口,通过 vmap 按出/入接口分派到各网络链
nat-POSTROUTING nat, postrouting SNAT/伪装总入口,按接口 vmap 分派
nat-PREROUTING nat, prerouting 目标 IP 为本机时跳入 nat-prerouting-and-output 做 DNAT
nat-OUTPUT nat, output 本机发起的流量同样走 DNAT(非回环目标),支持主机直连映射端口
raw-PREROUTING filter, prerouting, priority raw 在 conntrack 之前丢弃对容器 IP 的"直接访问"
nat-prerouting-and-output 普通链 存放所有端口映射的 DNAT 规则

核心调度规则只有两条,写在 init 中:

chain filter-FORWARD {
    oifname vmap @filter-forward-in-jumps   # 进入某桥接网络 → 该网络的 forward-in 链
    iifname vmap @filter-forward-out-jumps # 离开某桥接网络 → 该网络的 forward-out 链
}

vmap(ifname : verdict 类型的映射表)是性能与隔离的关键:与 Docker 网络无关的流量完全不经过任何 per-network 规则;进入/离开 Docker 网络的流量也只经过对应网络自己的链。新增网络时,NewNetwork 通过 configure() 向四个 vmap 各插入一个 "bridge1" : jump ... 元素(见 network.go),这正是上文表中四个 *-jumps map 出现 "bridge1" 条目的来源。

另一个值得注意的点(index.md 明确指出):Docker 不使用 filter-INPUTfilter-OUTPUT 钩子。因为来自宿主物理网络或宿主自身的包是被"路由进"桥接网络的,走的是 filter-FORWARD;这也解释了为什么 nat-OUTPUT 中有一条与 nat-PREROUTING 对称的 fib daddr type local ... jump nat-prerouting-and-output 规则——主机自己访问 8080 时同样要命中 DNAT。

4. 新网络 bridge1 的链集:与 docker0 的对比

新网络拥有自己的一组链,与 docker0 的链结构相同(docker0 上没有容器,所以没有端口规则),差异全部来自端口映射:

入方向 filter-forward-in__bridge1(对比 docker0 同名链):

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

filter-forward-in__ / filter-forward-out__ / nat-postrouting-in__ / nat-postrouting-out__ 四段链名分别由 chainFilterFwdIn 等函数拼出。filter-forward-in__* 链的默认行为由 configure 统一建立:

  1. ct state established,related counter accept —— 放行已建立/相关的连接(conntrack 规则,入/出两条链各一条);
  2. iifname "bridge1" counter accept comment "ICC" —— 仅允许来自本网络内部的通信(Inter-Container Communication);网络设置了 EnableICC=false 时该规则动作变为 dropiccVerdict 逻辑,network.go);
  3. 最后一条兜底:网络 Unprotected(即 gw_mode=nat-unprotected)时为 counter accept comment "UNPROTECTED",否则为 counter drop comment "UNPUBLISHED PORT DROP" —— 即除已发布端口外,外部到容器 IP 的任何新连接都被丢弃

出方向与 NAT 侧

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

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

出方向默认放行(OUTGOING 规则见 network.go),容器出网的回程报文靠 conntrack 放行;nat-postrouting-out__bridge1 则对离开网桥的流量做 masquerade,把源地址改写成宿主机出口地址(当 gw_mode=routedRouted=true 时不生成该 masquerade 规则,见 network.go)。

5. 已发布端口的三条关键规则

-p 8080:80 在 nftables 中落地为三条职责不同的规则,分别位于三个不同的钩子阶段。

5.1 FORWARD 阶段放行映射端口

chain filter-forward-in__bridge1 {
    ...
    ip daddr 192.0.2.2 tcp dport 80 counter accept
    ...
}

这条规则由 setPerPortForwarding 生成:对每个端口绑定在 fwdInPortsRuleGroup 组内写入 <ip> daddr <容器IP> <proto> dport <容器端口> counter accept。它的作用是让 DNAT 改写后的包(目的变为 192.0.2.2:80)能通过 forward 链的白名单机制——注意该链的语义是"白名单制":conntrack 放行 + ICC 放行 + 显式 accept 的发布端口,其余一律 UNPUBLISHED PORT DROP

源码中还有一个细节:多个宿主端口映射到同一容器端口时会生成完全相同的规则,因此添加时用 IgnoreExist: true 去重,删除时容忍规则缺失(port.go 的注释解释了不做引用计数的原因)。

5.2 raw 阶段拦截直接访问(DROP DIRECT ACCESS)

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

这条规则保证:gw_mode=nat(默认值)下,外部网络无法直接访问容器 IP 192.0.2.2,外部必须经宿主机的映射端口进入。它由 filterDirectAccess 在每个 endpoint 加入网络时添加(容器启动、获得 IP 192.0.2.2 时),删除容器时移除。从源码可以看到它的完整豁免条件(endpoint.go):

  • 网络是 internal 时不加(internal 网络本身就无出网/映射语义);
  • gw_mode=nat-unprotectedUnprotected)或 gw_mode=routedRouted)时不加——routed 模式下外部本就路由直达容器网段;
  • daemon 级启用了 direct routing(AllowDirectRouting)时不加。

规则条件 iifname != "bridge1" 意味着宿主自身(包从 bridge1 接口进入)始终可以直接访问容器 IP——注释明确说"host 永远可以直接访问自己网络的容器"。若配置了 TrustedHostInterfaces,这些接口会并入 iifname != {...} 的例外集合。选择在 raw 表(priority raw)执行是因为 raw 钩子先于 conntrack 生效,可以干净地把这类包丢弃而不建立连接跟踪条目。

5.3 DNAT:宿主端口 8080 → 容器端口 80

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

该链是 nat-PREROUTINGnat-OUTPUT 共同的落点(见第 3 节的 fib daddr type local counter jump nat-prerouting-and-output)。规则由 setPerPortDNAT 生成,各条件的来源是:

  • iifname != "bridge1":仅当 daemon 未启用 hairpin(config.Hairpin 为 false,即存在 userland proxy 的场景)时生成。因为启用 proxy 后,"容器通过宿主映射端口访问自己"的请求由 proxy 处理,内核里排除桥接接口自身的包即可;
  • tcp dport 8080 + dnat to 192.0.2.2:80:HostPort 与容器 IP/端口的直接映射。若 HostPort == 0(NAT 未启用,例如 routed 模式)则直接跳过;若宿主侧与容器侧地址族不同(如 host IPv6 ↔ 容器 IPv4),NAT 无法完成,交由 docker-proxy 处理,同样不生成内核规则;
  • 若映射绑定到特定宿主 IP(HostIP 非 any),规则会额外加上 <fam> daddr <HostIP> 匹配条件(daddrMatch);
  • IPv6 侧还会附加 ip6 saddr != fe80::/10 跳过链路本地源地址(v6LLSkip)。

此外,映射到回环地址的端口(-p 127.0.0.1:8080:80)会由 filterPortMappedOnLoopbackraw-PREROUTING 中追加 iifname != lo ... counter drop comment "DROP REMOTE LOOPBACK" 规则,拒绝远端主机连接回环映射端口——这正是同目录文档 usernet-portmap-lo.md 覆盖的场景。

6. 一次完整的请求路径

把上面的规则串起来,外部客户端访问 宿主机IP:8080 的包会依次经历:

  1. raw-PREROUTING:目的地址是宿主机(经 DNAT 前的视角并非容器 IP),DROP DIRECT ACCESS 规则不命中,放行;(若目的直接是 192.0.2.2,则在这里被 drop。)
  2. nat-PREROUTINGfib daddr type local 命中 → nat-prerouting-and-outputtcp dport 8080 命中,目的被改写成 192.0.2.2:80
  3. 路由阶段把包送入 bridge1,进入 filter-FORWARD,vmap 分派到 filter-forward-in__bridge1:新连接首包不是 established/related,也不是 ICC,最终命中 ip daddr 192.0.2.2 tcp dport 80 counter accept 放行;其余未发布端口的包落入 UNPUBLISHED PORT DROP
  4. 容器应答包回到宿主:filter-forward-out__bridge1ct state established,related counter accept 放行;
  5. nat-POSTROUTING → vmap 分派到 nat-postrouting-out__bridge1:源地址在 192.0.2.0/24 且出接口不是 bridge1 时执行 masquerade,客户端看到的对端地址是宿主机 IP,回包由此被正确路由回 DNAT 后的连接。

这套"DNAT 进、masquerade 出、conntrack 保状态、白名单管准入"的组合,与 iptables 后端行为等价,只是用 verdict map 做了 per-network 隔离。

7. 如何在真实环境验证

在 nftables 后端的 Linux 宿主上(iptables 命令由 iptables-nft 提供,且 firewalld 未托管规则),可以按文档流程复现并核对:

# 创建网络并运行容器(与第 2 节相同)
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

# 查看带计数器的完整表(对应文档抓取方式)
nft -s list table ip docker-bridges

# 验证行为
curl http://localhost:8080        # 经 DNAT 可达(nat-OUTPUT 路径)
curl http://<宿主机IP>:8080       # 可达
curl http://192.0.2.2/            # 直接访问容器 IP:被 DROP DIRECT ACCESS 拒绝

仓库内还有两套测试可以交叉印证规则生成逻辑:

  • 单元测试 TestNftabler 通过模拟 nftables 对象比对各配置组合(icc/internal/gwm/hairpin 等)下的 golden 规则文件,testdata/TestNftabler 目录下数百个 __ip.golden / __ip6.golden 文件可直接查看 DROP DIRECT ACCESSUNPUBLISHED PORT DROPDNAT 等规则在不同 gw_mode(nat / nat-unprotected / routed)下的变化;
  • 集成文档测试 TestBridgeNftablesDoc 用真实 daemon 抓取规则并与 golden 对比,当内核/驱动变更导致规则漂移时失败,维护者按测试包注释的修复流程(检查 diff → 更新对应 templates 描述 → TESTFLAGS='-update' 重跑)更新文档。

8. 小结与注意事项

  • ip docker-bridges 表由 NewNftabler 在 daemon 启动时创建,每次 Docker 启动都会重建;IPv4/IPv6 分别是同构的两张表;
  • -p 映射 = 三条规则的组合:filter-forward-in__<bridge> 中的白名单 accept(setPerPortForwarding)、nat-prerouting-and-output 中的 DNAT(setPerPortDNAT),以及 endpoint 级 raw-PREROUTING 的 direct-access drop(filterDirectAccess);
  • UNPUBLISHED PORT DROP 是默认准入策略,nat-unprotected 网络改为 UNPROTECTED 全放行;routed 网络不做 masquerade,external 直连容器网段;
  • 该规则结构属于开发期接口,不承诺跨版本稳定;排查问题时以当前版本实际 nft list 输出为准,并参考 nftablesdoc 下的其他场景文档(loopback 映射、无 userland proxy、no-ICC、internal、routed、nat-unprotected)来对照差异。
登录后查看全文
热门项目推荐
相关项目推荐