抛弃线性遍历:高并发网关从 iptables 迁移至 nftables 的核心逻辑与底层重构

上午刚做完边缘网关集群的灰度替换,看着监控面板上节点 CPU 的 si(软中断)使用率从常态的 40% 直接掉到了 5% 以下,整个系统的吞吐量算是彻底释放出来了。 长久以来,我们在处理海量 IP 黑名单封禁、复杂的 SNAT/DNAT 转发时,习惯性地依赖 iptables。但随着单机连接数突破百万,原本那些跑得好好的 iptables 规则逐渐成了吃掉 CPU 性能的隐形黑洞。今天借着上午这次重构落地的契机,聊聊从 iptables 迁移到 nftables 背后的底层逻辑,以及如果只用 iptables-translate 为什么根本达不到优化效果。

兼容层的虚假繁荣:iptables-nft 的陷阱

现在很多较新的 Linux 发行版(如 Debian 11/12, RHEL 8+),你在终端敲下 iptables 时,底层其实已经是指向 iptables-nft 的软链接了。 很多人有一个误区,认为系统既然底层用了 nftables 的引擎,性能自然就上去了。这是一个错觉。iptables-nft 仅仅是一个兼容层,它的作用是把 iptables 的命令语法,在内核层面翻译成 nftables 的字节码(Bytecode),但它完全保留了 iptables 的线性遍历逻辑。 在传统的 iptables 架构中,规则在内核里是作为一个连续的内存块(Blob)存储的。这意味着:

  1. 匹配极慢:如果有一万条 IP 封禁规则,数据包进入 PREROUTINGINPUT 链后,内核必须从第一条规则开始逐条进行匹配(O(N) 复杂度)。

  2. 更新代价高昂:每次新增或删除一条规则,内核都需要加锁,并将整个规则集的 Blob 重新拷贝一遍。这在频繁更新规则的 K8s 节点或动态防火墙上,会导致严重的自旋锁竞争。 虽然 iptables-nft 解决了全局锁更新的问题(nftables 支持增量更新),但只要你还是按照以前一条条添加规则的思路去写逻辑,O(N) 的线性匹配性能瓶颈就依然存在。

核心重构:从线性匹配走向集合(Sets)与映射(Maps)

nftables 的真正杀手锏在于原生支持了高级数据结构:Sets(集合)、Dictionaries(字典/映射)和 Concatenations(级联匹配)。这也是我们在重构时改变最大的地方。

1. 使用 Sets 终结海量 IP 匹配

以前我们要封禁 1000 个恶意 IP,iptables 要写 1000 条规则:

iptables -A INPUT -s 1.1.1.1 -j DROP
iptables -A INPUT -s 2.2.2.2 -j DROP
...

在原生的 nftables 中,我们通过定义一个底层的 Hash 表或红黑树(取决于具体的配置和元素数量)来实现 O(1) 或 O(log N) 的查找:

# 定义一个名为 blackhole 的表
table inet blackhole {
    # 定义一个集合,底层用 hash 实现,支持动态更新
    set bad_ips {
        type ipv4_addr
        flags dynamic, timeout
        timeout 24h
    }
    chain input {
        type filter hook input priority 0; policy accept;
        # 一条规则搞定所有匹配
        ip saddr @bad_ips counter drop
    }
}

运行时如果需要动态加黑名单,只需执行:nft add element inet blackhole bad_ips { 3.3.3.3 }。内核层面只需在现有的 Hash 表里插入一个节点,数据包匹配时直接进行键值查找,CPU 开销微乎其微。

2. 使用 Maps 重构复杂的 NAT 转发

在之前的架构中,边缘网关承担了大量不同端口到不同内网机器的 DNAT 转发。几十条 NAT 规则在 PREROUTING 链中依次匹配。 重构为 nftables 后,我们利用 Map 结构,将端口号映射为后端 IP 和端口的组合,实现了只需一条规则就能处理所有转发逻辑:

table ip nat {
    map port_to_backend {
        type inet_service : ipv4_addr . inet_service
        elements = { 
            8080 : 10.0.1.10 . 80,
            8443 : 10.0.1.11 . 443,
            9090 : 10.0.1.12 . 9090 
        }
    }
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        # dnat to 后面接 map 查找,如果匹配直接完成 NAT 重写
        # tcp dport 取出目标端口,去 port_to_backend 字典里查
        dnat to tcp dport map @port_to_backend
    }
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        masquerade
    }
}

这里的 ipv4_addr . inet_service 是 nftables 的 Concatenations(级联)特性。它允许我们将多个字段拼接成一个元组(Tuple)作为 value。这不仅让配置极其清晰,更从底层消除了 Netfilter 链表的遍历损耗。

终极性能释放:Conntrack 与 Flowtable 硬件/软件卸载

只优化规则匹配时间是不够的。任何熟悉 Linux 网络栈的人都知道,Netfilter 的性能大头其实耗在了 conntrack(连接跟踪)上。只要使用了 NAT 或者状态防火墙(如 ct state established,related accept),每一个数据包都要过一遍状态机。 虽然我们无法完全绕过 conntrack,但 nftables 引入了 flowtable 机制(Fastpath),这是 iptables 时代难以实现的降维打击。 它的底层逻辑是:对于一条已经建立(ESTABLISHED)的连接,既然它的双向路由和 NAT 状态已经确定,为什么还要让后续的海量数据包去走完整的 Netfilter Hooks(PREROUTING -> FORWARD -> POSTROUTING)? 上午我们在部分流量极大的节点上开启了软件层面的 flowtable 卸载:

table inet filter {
    # 定义 flowtable,挂载在 ingress 钩子上
    flowtable f_fastpath {
        hook ingress priority 0
        devices = { eth0, eth1 }
    }
    chain forward {
        type filter hook forward priority 0; policy accept;
        # 对于新建连接,走正常的规则过滤,如果允许,则加入 flowtable
        ip protocol { tcp, udp } flow add @f_fastpath
        # 兜底的传统 established 处理
        ct state established,related accept
    }
}

这段配置在内核态发生了什么? 当握手包(SYN, SYN-ACK, ACK)走完传统的 forward 链后,flow add @f_fastpath 会将这条五元组连接插入到 flowtable 中。 当这条连接后续的数据包到达网卡的 ingress hook(比 PREROUTING 更早)时,直接命中 flowtable。内核会在这里直接执行目的 MAC 替换、TTL 减 1 以及 NAT 重写,然后直接塞给网卡发包出去。整个过程完全短路了标准的 IP 路由栈和 Netfilter 钩子链。 这就是为什么上午灰度一开,CPU 软中断直接从 40% 砸到 5% 的根本原因。

迁移过程中的避坑指南

真正执行这种底层迁移时,不能光看着爽,几个坑必须注意:

  1. 禁用内核遗留模块:既然切了 nftables,就彻底一点。通过 rmmod 或者内核黑名单机制,把 ip_tables, iptable_filter, iptable_nat 统统屏蔽。否则内核里同时跑着两套 Hook 注册,性能反而会倒退,还会出现玄学的报文丢失问题。

  2. Conntrack INVALID 状态丢包:迁移后注意监控系统指标 netstat -s | grep "invalid" 或者 conntrack -S。有些非标准客户端发出的乱序包,或者非对称路由产生的单向流,会被 nftables 的严格状态机判定为 INVALID 并 drop。必要时需在 raw 表(nftables 中对应 priority 为 -300 的 hook)里做 notrack(在 nft 里叫 ct state untracked)放行。

  3. 不要依赖 iptables-translate:很多人偷懒,直接用这个工具把以前的 iptables 规则批量翻译成 nft 脚本导入。前面说了,这样只是把垃圾代码换了一种语言重写了一遍。一定要根据业务逻辑,重新抽象成 Maps 和 Sets。 技术演进不是简单的命令替换,弄懂 Netfilter 数据包流转在内核源码层的路径变化,才是做这种底层架构调整的底气所在。今天白天这波重构算是稳住了,后续再观察晚高峰的并发表现。