深入 nftables 迁移网络黑洞排查:多 Base Chain 语义陷阱与 Docker 流量阻断实战

近期在接手一批新上线的 Debian 12 宿主机时,遇到了一个极其隐蔽的网络黑洞问题。业务侧反馈,将服务从 CentOS 7 迁移到新环境后,宿主机自身网络一切正常,但 Docker 容器内的所有 Outbound 流量(包括 DNS 解析、外部 API 调用)全部超时。

简单看一下背景和结论:为了对齐基础安全基线,系统组在新系统上摒弃了老旧的 iptables,转而使用原生的 nftables 编写了主机防火墙策略,并将 forward 链的默认策略设置为 drop故障的根本原因在于对 nftables 的 Base Chain(基础链)和 accept 动作语义理解不到位。 在配合 iptables-nft(Docker 默认的底层网络驱动)工作时,nftables 中不同表里的 Base Chain 会发生叠加。Docker 规则里的 ACCEPT 仅仅中断了当前链的匹配,报文随后又掉进了原生安全策略的 drop 陷阱中。

把两种时代的产物混用,又不仔细看 Netfilter 底层 Hook 的运转机制,就像在同一个路口安排了两个互不理睬的交警,一个挥手让你走,另一个直接把你的车按死。

现场还原与报错表现

排查过程从最基本的抓包开始。在容器内执行 curl 8.8.8.8,同时在宿主机的几个关键网卡上抓包:

# 容器内 veth 接口看到 SYN 发出,但没有 SYN-ACK
tcpdump -i veth_xxx -nn host 8.8.8.8

# docker0 网桥上能看到报文进入
tcpdump -i docker0 -nn host 8.8.8.8

# 物理网卡 eth0 上毫无动静
tcpdump -i eth0 -nn host 8.8.8.8

报文在路由判决后,准备进行转发(Forward)时凭空消失了。习惯性地敲下 iptables -nL FORWARD,看到 Docker 生成的规则依然健在:

Chain FORWARD (policy DROP)
target     prot opt source               destination         
DOCKER-USER  all  --  0.0.0.0/0            0.0.0.0/0           
DOCKER-ISOLATION-STAGE-1  all  --  0.0.0.0/0            0.0.0.0/0           
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
DOCKER     all  --  0.0.0.0/0            0.0.0.0/0           
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0           
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0           

表面上看,Docker 已经放行了跨网桥的流量。负责实施的同事一口咬定:“Docker 自己管理的 iptables 规则没有任何问题,肯定是内核路由参数 ip_forward 没开!” 然而 sysctl net.ipv4.ip_forward 明晃晃地显示着 1

抽丝剥茧:nftables 里的“平行宇宙”

问题出在哪里?在较新的发行版中,iptables 命令实际上只是 iptables-nft 的一个软链接。Docker 以为自己在操作传统的 iptables,实际上底层被翻译成了 nftables 的规则存入内核。

此时我们看一眼主机上真正生效的全量规则表:nft list ruleset。 精简后的输出如下:

# 这是 Docker 经由 iptables-nft 生成的表
table ip filter {
    chain FORWARD {
        type filter hook forward priority filter; policy drop;
        jump DOCKER-USER
        jump DOCKER-ISOLATION-STAGE-1
        oifname "docker0" ct state related,established counter accept
        oifname "docker0" jump DOCKER
        iifname "docker0" oifname != "docker0" counter accept  # <-- 注意这里,Docker 决定 ACCEPT
        iifname "docker0" oifname "docker0" counter accept
    }
}

# 这是系统组手写的原生 nftables 主机防火墙
table inet my_sec_firewall {
    chain base_forward {
        type filter hook forward priority filter; policy drop;  # <-- 这里是罪魁祸首
        ct state established,related accept
        # 这里仅仅放行了部分特定网段的内网互访,没有提及 docker0
    }
}

这里隐藏着一个巨大的语义陷阱:在旧的 iptables 架构中,一个包在一个 Table/Hook 中如果匹配到了 ACCEPT 规则,它的遍历就彻底结束了,直接进入下一阶段。但在 nftables 架构中,你可以定义无数个挂载在同一 Hook 点的 Base Chain(基础链)。

上述配置中,ip filterinet my_sec_firewall 都注册了针对 forward hook 的 Base Chain,且优先级都是 filter(数值为 0)。

当容器的流量进入 Netfilter 的 forward hook 时,发生了什么?

  1. 报文进入 Docker 的 FORWARD 链。

  2. 匹配到 iifname "docker0" oifname != "docker0" counter accept

  3. 关键点来了:在 nftables 中,Base Chain 里的 accept 叫做 Verdict: accept。它的完整语义是“停止遍历当前 Base Chain,允许该包继续走向下一个处于同等或更低优先级的 Base Chain”。

  4. 于是,报文带着 Docker 赐予的“通行证”,继续走进了系统组手写的 base_forward 链。

  5. base_forward 链左看右看,发现这条流量不符合任何放行规则,直接走默认策略 policy drop,报文被无情丢弃。

这就是典型的“知其然而不知其所以然”。抄袭旧时代的防火墙规范,用新语法包装了一下,结果搞出了网络黑洞。

现场 Debug 铁证:nftrace 的降维打击

为了让同事彻底死心并理解这个过程,直接上 nftables 的杀手锏工具 nftrace 进行数据包流向跟踪。

在我们的防火墙表里加一条 trace 规则:

nft add rule inet my_sec_firewall base_forward meta nftrace set 1

然后在另一个终端启动监听,并再次在容器内触发 curl 8.8.8.8

nft monitor trace

日志无情地揭露了报文的死亡现场:

trace id 75b42d1f ip filter FORWARD packet: iif "docker0" oif "eth0" src 172.17.0.2 dst 8.8.8.8 ...
trace id 75b42d1f ip filter FORWARD rule iifname "docker0" oifname != "docker0" counter packets 12 bytes 720 accept (verdict accept)
...
trace id 75b42d1f inet my_sec_firewall base_forward packet: iif "docker0" oif "eth0" src 172.17.0.2 dst 8.8.8.8 ...
trace id 75b42d1f inet my_sec_firewall base_forward rule meta nftrace set 1 (verdict continue)
trace id 75b42d1f inet my_sec_firewall base_forward verdict drop  # <-- 在这里被默认策略处决!

日志清楚地表明,报文先被 Docker 的链 accept,紧接着落入 my_sec_firewall 的链,并命中 drop 策略。

解决代码与重构建议

想要修复这个问题非常简单,既然它要过两道关,那就在原生安全策略里把 Docker 的网桥放行即可:

nft add rule inet my_sec_firewall base_forward iifname "docker0" accept

但是,作为架构师,这种补丁式的做法是不合格的。 因为 Docker 的网络隔离策略(比如容器间不可见、端口映射暴露限制)本身就非常复杂,如果强行用另一套独立表的策略去叠加,极易造成后续排查的灾难。

最终的整改落地方案:

  1. 停止混用策略:如果系统中存在需要深度接管底层网络的组件(如 Docker、K8S kube-proxy),主机级的防火墙防护应尽量下放给外部设施(如云厂商的安全组、物理防火墙)。

  2. Hook 优先级规避:必须写本机策略时,确保你的防火墙 Base Chain 优先级数值不要和 Docker 的产生竞争。Docker 默认的 priority 是 0。如果你只做简单的黑名单前置拦截,可以建一个 priority -100 的链;如果你想要兜底,可以建一个 priority 100 的链。

  3. 放弃纯净洁癖:不要在运行了遗留 iptables/Docker 逻辑的机器上,强制推行所谓的“纯原生 nftables 架构”。要么让 Docker 禁用 iptables ("iptables": false in daemon.json) 完全靠你自己手写路由转发,要么老老实实顺从 iptables-nft 的兼容模式,把你的安全规则也用 iptables 语法追加进去。

总结与排查清单

在系统底层的迭代中,“兼容”往往是最危险的词汇。iptables-nft 给了一个完美的语法兼容幻觉,却暗改了多链并行的核心逻辑。

同类问题速查清单:

  1. 辨别真伪 iptables:执行 update-alternatives --display iptables 确认系统当前底层是 iptables-legacy 还是 iptables-nft

  2. 全局视角查规则:抛弃 iptables -nL,排查网络不通时必须看全景:nft list ruleset,重点寻找包含 policy drop 的自定义 Base Chain。

  3. 理解 priority 与 accept 的关系:同一 Hook 点存在多个 Base Chain 时,accept 只是“出当前链”,不是“出整个 Hook”。只有 drop 才是真正的一票否决。

  4. 抓包查死因:如果 tcpdump 看到包进了某网卡但出不来,直接开启 nftables trace (meta nftrace set 1) 跟踪,看包死在哪个 Table 的哪条 Rule,比瞎猜高效百倍。