首页
/ Moby 桥接网络 nftables 规则解析:禁用容器间通信(ICC)并发布端口

Moby 桥接网络 nftables 规则解析:禁用容器间通信(ICC)并发布端口

2026-09-06 14:59:49作者:郦嵘贵Just

当你在 Moby(Docker Engine)中以 enable_icc=false 创建用户自定义桥接网络,同时发布容器端口时,内核防火墙中会出现一组特定的 nftables 规则。本文以仓库中 usernet-portmap-noicc.md 场景文档为主体,完整给出该场景下 ip docker-bridges 表的全部规则、逐条解释其作用,并深入 nftabler 源码 说明「放行 vs 丢弃」这一关键判定的产生位置,最后给出在真实主机上自行验证的方法与前提。

一、文档定位:一个由测试自动生成的规则快照

这篇场景文档属于 integration/network/bridge/nftablesdoc 目录下的一套 nftables 规则文档体系,它记录 Docker Engine 在桥接网络下究竟往内核里写入了什么规则。与手写文档不同,它是测试驱动生成的:

  • nftablesdoc_linux_test.go 中的 TestBridgeNftablesDoc 会在一个独立网络命名空间里启动 dockerd,按 index 中定义的各场景创建网络和容器,随后执行 nft -s list table ip docker-bridges 抓取真实规则(见 runNftables);
  • 抓取到的规则按「map / chain」拆分成块,用 templates/ 下的 text/template 模板(本文档模板即 usernet-portmap-noicc.md)渲染成最终 markdown,输出到 generated/usernet-portmap-noicc.md
  • 新生成的文档会与 generated/ 中的 golden 参考做 diff,不一致则测试失败(golden 断言),需要时用 TESTFLAGS='-update' 刷新参考文件。

这意味着文中规则与当前代码库的实际行为一一对应,而非人工维护的文字描述。

同时要注意 index.md 给出的三条前提,本文规则的解释都建立在这些前提之上:

  1. 该文档仅供开发参考——Docker 的 nftables 规则结构在不同版本间会变化,不是稳定接口;
  2. IPv6 规则遵循与 IPv4 完全相同的模式,只是位于不同的表(ip docker-bridgesip6 docker-bridges),文档只展示 IPv4;
  3. 这些表在每次 Docker 启动时重建filter-INPUT 钩子不被使用,从宿主物理网络或宿主本机到达的包会被路由进桥接网络、命中 filter-FORWARD 链,filter-OUTPUT 同理未被使用。

二、场景与等效命令

本文档描述的精确场景是:用户自定义网络上,关闭容器间通信(ICC),且容器发布了端口。等效的手动操作命令为(与 测试中的场景定义 一致,测试中通过 bridge.EnableICC 选项传入 "false"):

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

要点:

  • 网络名 bridge1,子网 192.0.2.0/24,网关 192.0.2.1;容器 c1 拿到 192.0.2.2(测试里容器 IP 由 IPAM 分配,golden 文档中固定为 .2);
  • -p 8080:80 发布端口;enable_icc=false 是本场景与 默认场景 usernet-portmap.md 的唯一区别。

三、完整规则集(IPv4)

以下是该场景下 ip docker-bridges 表的全量内容(与 generated 文档 完全一致):

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 drop 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.1 顶层结构:vmap 分发到「每桥接一个」的链

  • 四个 ifname : verdict 类型的 map 把「接口名」映射到对应的 jump 链。filter-FORWARD 链本体只有两条 vmap 指令:按出接口oifname)跳入 filter-forward-in__<桥>,按入接口iifname)跳入 filter-forward-out__<桥>。这样每个网桥(默认的 docker0 与用户创建的 bridge1)各有一套独立的进出过滤链,互不干扰。
  • nat-POSTROUTING 同理,按出/入接口分发到 nat-postrouting-{in,out}__<桥> 链。
  • 注意 filter-FORWARDpolicy accept:Docker 依赖显式 drop 规则来封锁流量,而不是收紧默认策略;链内所有未匹配的流量最终都会落到各链末尾的兜底规则(如 UNPUBLISHED PORT DROP)。

3.2 DNAT 与「禁止直连容器 IP」

  • nat-prerouting-and-output 中的 iifname != "bridge1" tcp dport 8080 ... dnat to 192.0.2.2:80 是端口发布的 dstnat 规则:来自桥之外的流量(宿主本机、外部网络)访问 8080 时被改写到容器 192.0.2.2:80。该链同时被 nat-PREROUTING(经 fib daddr type local 判定目标为本机地址后)和 nat-OUTPUT(宿主机自身发起的流量)复用,因此 curl 127.0.0.1:8080 与从外网访问都走同一条 DNAT 规则;
  • raw-PREROUTING 中的 ip daddr 192.0.2.2 iifname != "bridge1" counter drop comment "DROP DIRECT ACCESS" 是关键防护:任何绕过 NAT 直接访问容器 IP192.0.2.2)的包(例如同 LAN 主机直连容器地址)在进入 conntrack 之前就被丢弃。它保证了容器只能经由发布端口被触达;
  • nat-postrouting-out__bridge1 中的 masquerade 规则保证容器发往外部网络(oifname != "bridge1")时,源地址 192.0.2.0/24 被替换为宿主地址,即常见的 SNAT/MASQUERADE 行为;docker0 侧对应 172.17.0.0/16

3.3 每桥接的进出过滤链

bridge1 为例:

  • filter-forward-out__bridge1(容器出站方向):先放行 conntrack 中 established,related 状态的包(保证回包畅通),其余全部 accept 并打 OUTGOING 注释——出站不做限制,出网过滤交给后续路由链;
  • filter-forward-in__bridge1(入站方向)是安全策略的核心,下一节单独剖析。

四、核心差异:filter-forward-in__bridge1 中的 ICC drop 规则

文档的核心结论只有一句话:除 ICC 判定外,本场景的规则与 启用 ICC 的网络 完全相同。差异全部集中在这条链:

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

逐条拆解(规则按顺序匹配):

  1. ct state established,related counter accept:处于已建立/相关状态的连接直接放行。这是 conntrack 的固有语义——即使网络已关闭 ICC,规则变更之前已经建立的连接的回程流量仍能通过;但新连接的初始 SYN 不受此规则影响,会继续向下匹配。
  2. iifname "bridge1" counter drop comment "ICC":入接口是本桥的包,即源与目的都在同一 bridge1 网络上的容器互访,新连接在此被丢弃。这正是 enable_icc=false 的内核级实现。作为对照,filter-forward-in__docker0 中对应位置是 iifname "docker0" counter accept comment "ICC"(放行),两条规则唯一的差别就是 verdict。
  3. ip daddr 192.0.2.2 tcp dport 80 counter accept:放行已 DNAT 的发布端口流量。从外部来的 8080 经 DNAT 改写为 192.0.2.2:80 后被路由进 bridge1,命中这条 accept;由于它排在 ICC drop 之后,外部访问不受 ICC 策略影响,而容器间访问 192.0.2.2:80 会被第 2 条先行丢弃。
  4. counter drop comment "UNPUBLISHED PORT DROP":兜底丢弃——凡未发布端口的入站流量(包括试图直连容器上未发布端口的流量)一律丢弃。

counter 关键字使得每条规则都带计数,运维时可用 nft -s list 直接看到每条规则命中了多少包,这对排查「为什么容器 A ping 不通容器 B」这类问题非常有用。

五、源码纵深:iccVerdict 是如何决定的

文档中「drop(而不是 accept)」的差别,在源码里对应一个非常小的分支。在 nftabler/network.go

iccVerdict := "accept"
if !n.config.ICC {
	iccVerdict = "drop"
}

随后该 verdict 被填入规则(非 internal 网络分支):

// Inter-Container Communication
tm.Create(nftables.Rule{
	Chain: fwdInChain,
	Group: fwdInICCRuleGroup,
	Rule:  []string{"iifname ==", n.config.IfName, "counter", iccVerdict, "comment ICC"},
})

这解释了 golden 文档中为什么 ICC 行永远是 iifname "bridge1" counter <accept|drop> comment "ICC" 的固定形态。UNPUBLISHED PORT DROP 兜底则来自同一文件的最终规则组(非 --internal 网络一律 drop)。

ICC 配置沿这条链路向上游溯源:

  1. 用户输入docker network create -o com.docker.network.bridge.enable_icc=false ...。选项标签常量定义在 bridge 驱动的 labels.go
  2. 选项解析bridge_linux.go 中对 EnableICC 选项用 strconv.ParseBool(value) 解析为布尔值(因此 false/0/no 等均可接受);
  3. 默认值:默认的 docker0 桥接配置中 EnableICC 默认为 truebridge_linux.go 附近的默认 bridgeConfig),即不显式指定时容器间通信是放行的;
  4. 配置传递firewaller.goICC 字段注释明确其含义——ICC is false if containers on the bridge should not be able to communicate,nftables 后端与 iptables 后端共用该配置;
  5. 持久化:网络配置变更(含 EnableICC)会写入网络 store(bridge_store.go),daemon 重启后按原配置重建规则,与「表在每次 Docker 启动时重建」的描述一致。

顺带一提,同一功能在 iptables 后端的对应实现位于 iptabler/network.gosetIcc 相关逻辑,可见 Moby 目前同时维护 nftables 与 iptables 两套防火墙后端,而本系列文档只覆盖 nftables 后端。

六、在真实主机上自行验证

你可以完全复现本节的验证流程。前提(对应 测试的 skip 条件):

  • 以 root 运行(非 rootless);
  • 宿主防火墙后端为 nftables(firewalld 运行时该测试会跳过,因为 firewalld 会接管/重写规则,无法文档化稳定输出);
  • 内核支持 nftables(现代发行版默认支持)。

步骤:

# 1. 创建与文档等效的网络和容器
docker network create \
  -o com.docker.network.bridge.name=bridge1 \
  -o com.docker.network.bridge.enable_icc=false \
  --subnet 192.0.2.0/24 --gateway 192.0.2.1 bridge1
docker run -d --network bridge1 -p 8080:80 --name c1 busybox top

# 2. 查看 Docker 写入的 nftables 表(-s 显示计数器)
sudo nft -s list table ip docker-bridges

预期结果:输出应与 generated/usernet-portmap-noicc.md 中的规则集一致,其中容器 IP、宿主地址等可能因 IPAM 分配而异,但链结构、comment 标记(DNATICCMASQUERADEUNPUBLISHED PORT DROPDROP DIRECT ACCESS)应完全吻合。

行为验证:

# 容器间通信应失败(ICC 已禁用)
docker run --rm --network bridge1 busybox wget -qO- --timeout=3 192.0.2.2 || echo "blocked as expected"

# 经发布端口的访问应成功
curl -s http://127.0.0.1:8080

七、同系列的其他场景

本文档只是整套 nftables 场景文档之一,index.md 列出了全部快照,可按需对比理解规则如何随配置变化:

小结

  • enable_icc=false 在内核层面的全部效果,就是让 per-bridge 入站过滤链中那条 iifname "<桥>" counter drop comment "ICC" 规则的 verdict 从 accept 变为 drop;源码中该判定由 nftabler 的 iccVerdict 分支 一行代码决定;
  • 发布端口的 DNAT/MASQUERADE 链路(nat-prerouting-and-outputraw-PREROUTING 的直连拦截、每桥的 masquerade 链)与 ICC 策略正交,不受其影响;
  • 这套文档由 TestBridgeNftablesDoc 从运行中的 daemon 实时抓取生成并做 golden 比对,因此规则快照与当前代码行为严格一致;但如 index.md 所警告,Docker 的 nftables 结构不是稳定接口,跨版本对比时需留意规则可能变化。
登录后查看全文
热门项目推荐
相关项目推荐