上午刚做完边缘网关集群的灰度替换,看着监控面板上节点 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)存储的。这意味着:
-
匹配极慢:如果有一万条 IP 封禁规则,数据包进入
PREROUTING或INPUT链后,内核必须从第一条规则开始逐条进行匹配(O(N) 复杂度)。 -
更新代价高昂:每次新增或删除一条规则,内核都需要加锁,并将整个规则集的 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% 的根本原因。
迁移过程中的避坑指南
真正执行这种底层迁移时,不能光看着爽,几个坑必须注意:
-
禁用内核遗留模块:既然切了 nftables,就彻底一点。通过
rmmod或者内核黑名单机制,把ip_tables,iptable_filter,iptable_nat统统屏蔽。否则内核里同时跑着两套 Hook 注册,性能反而会倒退,还会出现玄学的报文丢失问题。 -
Conntrack INVALID 状态丢包:迁移后注意监控系统指标
netstat -s | grep "invalid"或者conntrack -S。有些非标准客户端发出的乱序包,或者非对称路由产生的单向流,会被 nftables 的严格状态机判定为INVALID并 drop。必要时需在 raw 表(nftables 中对应 priority 为 -300 的 hook)里做notrack(在 nft 里叫ct state untracked)放行。 -
不要依赖 iptables-translate:很多人偷懒,直接用这个工具把以前的 iptables 规则批量翻译成 nft 脚本导入。前面说了,这样只是把垃圾代码换了一种语言重写了一遍。一定要根据业务逻辑,重新抽象成 Maps 和 Sets。 技术演进不是简单的命令替换,弄懂 Netfilter 数据包流转在内核源码层的路径变化,才是做这种底层架构调整的底气所在。今天白天这波重构算是稳住了,后续再观察晚高峰的并发表现。