深入 Netfilter 排查:海量 iptables 规则引发的 CPU SoftIRQ 暴涨与 nftables 匿名集合 O(1) 重构实战

结论先行:将 5 万条 iptables 顺序拦截规则重构为原生 nftables 的匿名集合(Set/Map)后,宿主机网卡软中断(SoftIRQ)CPU 开销从 100% 暴降至 2%,网络 P99 延迟从 200ms 恢复至 2ms。在海量 IP 拦截与路由过滤场景下,彻底抛弃 iptables 的 O(N) 线性遍历,改用 nftables 基于红黑树与哈希表的 O(1) 匹配,才是现代网关防御的唯一出路。

近期在排查某边缘网关集群性能问题时,发现部分节点在 PPS(每秒包数)仅 10 万左右时,Load Average 飙升至 40+,网络出现严重丢包。该节点主要承担流量清洗清洗和恶意 IP 封禁任务,安全组件会通过脚本动态下发拦截规则。

案发现场:ksoftirqd 满载与 perf 剖析

登录问题机器(Ubuntu 22.04, Kernel 5.15.0, iptables v1.8.7),通过 mpstat -P ALL 1 观察,发现多个 CPU 核心的 %soft 指标长期顶在 100%,而 %usr 和 %sys 极低。这说明 CPU 周期全部消耗在了内核态网络软中断处理上。

直接挂载 perf 抓取热点函数:

perf record -a -g -F 99 -- sleep 10
perf report

调用栈结果异常刺眼:

-   78.42%     ksoftirqd/2  [kernel.kallsyms]   [k] nft_do_chain
   - 77.10% nft_do_chain
      - 68.32% nft_cmp_eval
      - 4.21% nft_counter_eval

排查防火墙规则,发现安全组件通过 iptables -A INPUT -s -j DROP 累积下发了高达 5.2 万条黑名单规则。

为什么 iptables-nft 包装层依然无法拯救 SoftIRQ 雪崩?

很多人有个误区,认为在较新的 Linux 发行版中,iptables 已经默认切换到了 iptables-nft(即底层调用 nftables API),性能应该自动得到了优化。这是极其致命的错觉。

iptables-nft 仅仅是一个语法翻译层。当你执行 5 万次 iptables -A INPUT -s IP -j DROP 时,翻译层会在 nftables 中原样生成 5 万条独立的规则。

在内核 net/netfilter/nf_tables_core.c 的 nft_do_chain() 函数执行时,Netfilter 依然会像老版本一样,对数据包进行 O(N) 的线性遍历匹配。这意味着:

  1. 合法流量的深渊:正常用户的包如果不匹配任何黑名单,必须遍历完这 5.2 万条规则,直到命中链底部的默认 ACCEPT。

  2. SYN 洪水的放大器:虽然通常会在链表顶部配置 iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT,但对于新建连接(SYN)或恶意扫描包,Conntrack 无法命中,依然会陷入 5.2 万次 nft_cmp_eval 的循环对比中。

单包处理时间的微秒级膨胀,在 10 万 PPS 的乘数效应下,瞬间抽干了 ksoftirqd 的处理能力,导致网卡 Ring Buffer 溢出,触发连环丢包。

nftables 重构实战:利用 Set/Map 实现 O(1) 降维打击

要解决这个问题,必须抛弃 iptables 的扁平化规则思维,改用 nftables(推荐版本 >= 1.0.2)原生的集合(Set)数据结构。内核会自动为集合选择合适的数据结构(针对离散 IP 默认使用 rhashtable,针对网段使用 pipapo)。

1. 编写原生 nftables 配置

清空旧的 iptables 规则,并加载优化的 nftables 配置:

# file: /etc/nftables.conf
flush ruleset

table inet filter {
    # 预先定义一个巨大的哈希集合用于存放黑名单 IP
    set blacklist_ipv4 {
        type ipv4_addr
        flags interval
        auto-merge
        size 100000 # 预分配空间,避免频繁扩容引起的自旋锁竞争
    }

    chain input {
        type filter hook input priority filter; policy accept;

        # 1. 优先放行已建立的连接,跳过后续检查 (Conntrack 加速)
        ct state established,related accept

        # 2. 无效包直接丢弃
        ct state invalid drop

        # 3. O(1) 极速匹配黑名单,命中即丢弃
        ip saddr @blacklist_ipv4 drop

        # 4. 本地回环与 SSH 放行等常规规则...
        iifname "lo" accept
        tcp dport 22 accept
    }
}

2. 动态管理海量 IP 且不重载规则

传统 iptables 增删规则时会对整个链表加锁(xtables_lock),并发极差。而在 nftables 中,向 set 添加元素是轻量级的原子操作,完全不会影响其他规则的执行:

# 批量导入 5 万个黑名单 IP(执行耗时仅需几十毫秒)
nft add element inet filter blacklist_ipv4 { 1.1.1.1, 2.2.2.0/24, 3.3.3.3 }

# 删除特定 IP
nft delete element inet filter blacklist_ipv4 { 1.1.1.1 }

3. 性能验证

重构上线后,再次压测 30 万 PPS 的新建连接风暴:

  • CPU %soft 消耗直接降至 2% ~ 3%。

  • perf top 中 nft_do_chain 的开销跌出前十。

  • 通过 nft list set inet filter blacklist_ipv4 查看,五万个 IP 被压缩在一次底层的哈希查找中,耗时几近于零。

底层原理:Pipapo 与 rhashtable 的威力

在 nftables 的源码实现中(net/netfilter/nft_set_hash.c),当你定义了一个不带 interval(网段)标志的独立 IP set 时,内核默认初始化 rhashtable(可动态扩容的哈希表)。查找复杂度为绝对的 O(1)。

如果带有 interval 标志(例如混合了 CIDR 网段拦截),内核会使用 pipapo (PIle PAcket POlicies) 算法(net/netfilter/nft_set_pipapo.c)。这是一种针对多维范围匹配高度优化的算法,通过预构建位图(Bitmaps),将多级网段的匹配转换为内存连续的位运算操作,且对 CPU Cache 极其友好。这解释了为何无论黑名单是 5 千还是 5 万,单包处理时延的抖动曲线始终保持平滑。

常见问题 (FAQ)

Q1: 在向 nftables 的 Set 中大批量添加 IP 时,会阻塞现有的网络连接吗? 不会。nftables 底层通过 RCU (Read-Copy Update) 机制保障数据的并发读写。对于数据包匹配链路(读侧),RCU 提供了无锁访问能力;而在控制平面批量更新 Set 元素时(写侧),只会发生极短的锁竞争。这与传统 iptables 重载整个规则链引发的剧烈抖动有本质区别。

Q2: 既然设置了 ct state established accept,为什么之前用 iptables 时,正常流量还是会感觉到高延迟? 因为网络环境并非绝对纯净。即使正常长连接被 Conntrack 放行,但在公网环境中,机器每秒都会遭受大量的垃圾扫描包、伪造包或恶意 SYN 包。这些无状态包无法命中 established,必定会落入后续的 5 万条 iptables 规则中进行线性遍历,进而抢占大量 CPU 资源,导致正常连接的软中断处理被系统调度延后,最终体现为 P99 延迟飙升。

Q3: 如何将现有的业务脚本从 iptables 平滑迁移到 nftables? 千万不要依赖 iptables-nft 来容忍旧脚本。正确的做法是:

  1. 重写网关的基线规则,用 nft -f 加载带有 Set 的配置结构。

  2. 将现有的封禁/解封脚本,从 iptables -I INPUT -s $IP -j DROP 替换为 nft add element inet filter blacklist_ipv4 { $IP }。

  3. 如果依赖 fail2ban 等第三方工具,需将 action 配置修改为 nftables-multiport 或直接修改其默认的 backend 为 nftables。