标签: nftables

  • 深入 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。

  • 深入 Netfilter 陷阱排查:高并发 SNAT 引发的 nf_conntrack 满载丢包与 nftables Flowtable 加速实战

    核心结论:高并发网关出现 nf_conntrack: table full, dropping packet 导致 502 和延迟雪崩时,盲目调大 nf_conntrack_max 只会加剧 CPU SoftIRQ 负载。治本之策是针对内网信任流量在 raw 表下发 NOTRACK 规则,并全面向 nftables 迁移,利用 Flowtable 实现 Fast-path 连接绕过,能将网络栈包转发的 CPU 消耗降低 40% 以上。

    案发现场:网关节点的 99 线雪崩

    排查过程中,某 Kubernetes 生产集群的 Ingress/NAT 网关节点(Ubuntu 22.04, Kernel 5.15)遭遇突发性高延迟。业务侧反馈 API 接口 99 线延迟从 20ms 飙升至 3s 以上,并伴随大量 502 错误。

    登录节点,top 显示部分 CPU 核心的 si(SoftIRQ)长期被打满至 100%。敲下 dmesg -T | tail,系统日志里被一行致命报错刷屏:

    [Fri ...] nf_conntrack: nf_conntrack: table full, dropping packet
    [Fri ...] nf_conntrack: nf_conntrack: table full, dropping packet
    

    通过 sysctl net.netfilter.nf_conntrack_count 确认,当前连接跟踪表中的条目数已经逼近配置的 262144 上限。这是一个极其典型的 Netfilter 性能瓶颈。

    抓取 conntrack 现场状态进行分析:

    conntrack -L -o extended | awk '{print $4}' | sort | uniq -c | sort -nr
    

    输出显示,绝大多数记录处于 TIME_WAIT 和 UDP 的 UNREPLIED 状态。由于业务采用短连接模式,并发极高,NAT 模块强依赖 conntrack 表进行 Tuple 映射。一旦表满,内核网络栈别无选择,只能将新到达的 SYN 包直接 Drop,引发应用层的重传与超时风暴。

    为什么单纯调大 nf_conntrack_max 无法根治丢包问题?

    遇到这个报错,很多人的第一反应是无脑修改 /etc/sysctl.conf,将 nf_conntrack_max 调到 100 万甚至 200 万。这是极其危险的做法,等于饮鸩止渴。

    Netfilter 在内核中维护连接状态使用的是哈希表。这个哈希表的大小由 nf_conntrack_buckets 决定。如果你只放大了 max(最大连接数),而不按比例放大 buckets(哈希桶数量),会导致单个哈希桶下的链表变得极长。

    对于每一个经过网络栈的包,内核都需要遍历这条链表来查找匹配的五元组。当链表过长时,查找算法的时间复杂度从 O(1) 退化为 O(N)。更致命的是,修改 conntrack 记录需要加自旋锁(Spinlock)。高并发下,漫长的遍历加上激烈的锁竞争,会直接导致内核消耗大量 CPU 在软中断(ksoftirqd)上,系统 Load Average 飙升,网卡丢包率进一步恶化。

    合理的应急配置比例应当是 nf_conntrack_max = nf_conntrack_buckets * 4。 应急修复命令如下:

    # 1. 调整哈希桶大小 (必须先调桶,再调 Max)
    echo 262144 > /sys/module/nf_conntrack/parameters/hashsize
    
    # 2. 调大最大连接数和降低超时时间
    sysctl -w net.netfilter.nf_conntrack_max=1048576
    sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=300
    sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
    

    但这仅仅是续命,并不能解决 CPU 开销问题。

    防御性降级:Raw 表的 NOTRACK 机制

    网关节点并非所有流量都需要进行 NAT。比如同网段的 Pod 到 Pod 通信,或者内部 Consul/Prometheus 的探测流量,完全可以跳过连接跟踪。

    在 iptables 时代,我们在 raw 表下发 NOTRACK 规则,将这部分信任流量提前短路:

    # 对于源和目的都在内网 10.0.0.0/8 网段的包,不进入 conntrack
    iptables -t raw -I PREROUTING -s 10.0.0.0/8 -d 10.0.0.0/8 -j CT --notrack
    iptables -t raw -I OUTPUT -s 10.0.0.0/8 -d 10.0.0.0/8 -j CT --notrack
    

    避坑指南:一旦给数据包打了 NOTRACK 标签,它在后续的 filter 表中就不属于 ESTABLISHED 或 RELATED 状态,而是 UNTRACKED。如果你的 INPUT 链默认策略是 DROP,且只有 -m state --state ESTABLISHED,RELATED -j ACCEPT,那么这些内网包会被统统丢弃。必须补上一条:

    iptables -I INPUT -m state --state UNTRACKED -j ACCEPT
    

    架构演进:全面向 nftables 与 Flowtable Fast-path 迁移

    iptables 最大的架构缺陷在于其规则的线性遍历机制(Linear evaluation)。哪怕你有一万条匹配规则,数据包也要从第一条匹配到最后一条。而 nftables 原生支持 Set 和 Map(类似字典),能在 O(1) 复杂度内完成匹配。

    更重要的是,从 Linux Kernel 4.16 开始,nftables 引入了革命性的 Flowtable 机制(软件 Fast-path),并在 Kernel 5.3 引入了硬件卸载(Hardware Offload)。

    在传统的 Netfilter 路径中,每一个包都要经历完整的 PREROUTING -> FORWARD -> POSTROUTING 链条。 启用 Flowtable 后,对于建立好的双向连接(ESTABLISHED),nftables 会直接在 Ingress 钩子处截获数据包,将其直接推送到对应的出口网卡(Egress),完全绕过 Netfilter 核心网络栈和复杂的路由查找。

    落地配置:构建基于 nftables 的加速网关

    首先,清空老旧的 iptables 规则,确保模块未被深度占用(此处需极度谨慎,确保断网可恢复)。编写 /etc/nftables.conf:

    #!/usr/sbin/nft -f
    
    flush ruleset
    
    table inet filter {
        # 1. 定义 flowtable,挂载在 ingress 钩子,监听内网 eth0 和外网 eth1
        flowtable fastpath {
            hook ingress priority 0;
            devices = { eth0, eth1 };
        }
    
        chain forward {
            type filter hook forward priority 0; policy accept;
    
            # 2. 只有处于 established 状态的连接,才卸载到 flowtable 加速
            ip protocol { tcp, udp } flow add @fastpath
    
            # 基础的连接跟踪允许
            ct state established,related accept
            ct state invalid drop
        }
    }
    
    table ip nat {
        chain prerouting {
            type nat hook prerouting priority 0; policy accept;
        }
    
        chain postrouting {
            type nat hook postrouting priority 100; policy accept;
    
            # 3. 对出公网的流量执行伪装 (SNAT)
            oifname "eth1" masquerade
        }
    }
    

    应用规则:

    nft -f /etc/nftables.conf
    

    性能验证: 应用 nftables Flowtable 后,再次在晚高峰压测。

    1. 查看 CPU si 开销,从之前的单核满载 100% 断崖式下降至 15% 左右。

    2. 观察 conntrack_count,虽然总条目仍然在 10 万级别,但大量的 established 包走的是 Fast-path,不再反复冲击 Netfilter 锁。

    3. P99 延迟从数百毫秒稳定回落至 5ms 以内,丢包彻底消失。

    常见问题 (FAQ)

    Q1:如何平滑从 iptables 迁移到 nftables,不导致生产环境长连接中断? A: 生产环境切忌直接 apt-get purge iptables。Linux 提供了兼容层 iptables-nft。首先替换底层后端,让 iptables 命令实际生成 nftables 规则。跑通后,使用 iptables-save > rules.txt,再通过 iptables-restore-translate -f rules.txt > rules.nft 转换为原生 nftables 语法。最后,在维护窗口 nft -f rules.nft 覆盖并原子化应用新规则集,连接跟踪状态在内核态是共享的,ESTABLISHED 状态的连接不会因此断开。

    Q2:启用 nftables Flowtable 后,为什么用 tcpdump 抓包,部分数据包神秘消失了? A: 这是正常现象。Flowtable 在 Ingress 阶段(网卡驱动收到包后极早期的阶段)就将包转发出去了,直接绕过了协议栈的大部分拦截点(包括 AF_PACKET)。这意味着处于 Fast-path 的数据包可能无法被 tcpdump 捕获到。如果你必须抓包排障,需要临时在 nft 规则中注释掉 flow add @fastpath,让流量回退到慢速路径(Slow-path)。

    Q3:masquerade 和直接写 snat to 性能上有差别吗? A: 有明显差别。masquerade 是专门针对动态 IP 设计的,每次处理 NAT 映射时,它都会去动态查询出口网卡当前的 IP 地址。如果你的网关是静态 IP,务必在 nftables/iptables 中使用 snat to 192.168.x.x 这样的明确指令。静态 SNAT 免去了查路由表获取本机 IP 的开销,在高并发场景下性能表现更好。

    Q4:为什么 UDP 流量也会引发 conntrack 爆满,UDP 不是无连接的吗? A: UDP 在协议栈确实无连接,但 Netfilter 是状态防火墙,它必须为 UDP 维持一个伪连接状态(包含五元组),否则无法进行返回包的 NAT 映射。DNS 解析(短 UDP 请求)是最常见的导火索。如果业务侧不复用 DNS 连接,海量的源端口会导致 conntrack 表被大量状态为 UNREPLIED (默认超时 30 秒) 的 UDP 条目塞满。解决方案是优化业务代码,或在内核调低 net.netfilter.nf_conntrack_udp_timeout_stream 和 timeout 参数。

  • 深入 nftables 规则陷阱排查:NAT Hook 优先级错位引发的 Conntrack 绕过与流量黑洞实战

    核心结论:在从 iptables 向 nftables 迁移时,若自定义 NAT Chain 的 Hook 优先级配置不当(如 PREROUTING 优先级设为低于 -200),会导致数据包在进入 nf_conntrack 跟踪逻辑前触发 NAT 动作。由于 Netfilter 底层 NAT 强依赖连接跟踪上下文,过早执行将引发状态机丢失,导致 DNAT/SNAT 静默失效。解决方案是严格对齐 Netfilter 规范:PREROUTING 必须使用 -100,POSTROUTING 必须使用 100。

    排查网络问题,最怕的不是报错,而是没有报错但流量神秘消失。习惯了 iptables 的老兵在初次接触 nftables 时,往往会被其极度灵活的框架反噬。近期在某次基础架构系统大版本升级(CentOS 7 迁移至 Rocky Linux 9.2,Kernel 5.14.0,nftables v1.0.4)过程中,遇到了一个经典的 NAT 击穿现场。

    案发现场:诡异的 SNAT 穿透现象

    某次集群割接后,业务反馈部分跨网段的出口流量间歇性超时。在网关节点(充当 SNAT 出口)上抓包,看到了令人脑溢血的一幕:

    # tcpdump -i eth0 -nn host 10.200.1.50
    14:22:31.102311 IP 10.0.5.10.43521 > 10.200.1.50.80: Flags [S], seq 123456789, win 64240, options [mss 1460]
    14:22:32.105432 IP 10.0.5.10.43521 > 10.200.1.50.80: Flags [S], seq 123456789, win 64240, options [mss 1460]
    

    10.0.5.10 是内网 Pod IP,10.200.1.50 是外部系统 IP。按理说,经过网关节点时,流量应该被 SNAT 成网关的物理网卡 IP,但抓包显示内网 IP 直接裸奔到了公网。由于上级路由没有该内网网段的路由表,数据包被丢弃,业务端表现为 TCP 握手超时。

    检查 nftables 规则集,运维同学写的配置看起来“非常完美”:

    table ip custom_nat {
        chain postrouting {
            type nat hook postrouting priority -250; policy accept;
            ip saddr 10.0.0.0/16 oif "eth0" masquerade
        }
    }
    

    触发匹配的网段、出接口、动作都没错,而且没有任何报错信息。

    为什么自定义优先级的 NAT 链会静默失效?

    要搞清楚这个问题,必须剥开 Netfilter 的内核源码。

    在原生的 iptables 时代,nat 表的 Hook 优先级是内核硬编码固定的。你在 POSTROUTING 链写规则,它必然在 priority 100 执行。但 nftables 把定义权完全交给了用户,允许你在创建 Base Chain 时随意指定 priority 整数值。

    上述配置中,SRE 为了让 SNAT “尽可能早地执行以减少延迟”,将 postrouting 链的 priority 设置为了 -250。这就是灾难的根源。

    Netfilter 的核心数据包流转链路有着严格的优先级排序(定义在 include/uapi/linux/netfilter_ipv4.h):

    • NF_IP_PRI_RAW = -300

    • NF_IP_PRI_CONNTRACK = -200 (关键点)

    • NF_IP_PRI_MANGLE = -150

    • NF_IP_PRI_NAT_DST = -100

    • NF_IP_PRI_FILTER = 0

    • NF_IP_PRI_NAT_SRC = 100

    NAT 的本质依赖于 nf_conntrack(连接跟踪模块)。只有在 Conntrack 建立起一条连接(状态为 NEW)时,NAT 引擎才会去计算并绑定转换地址。后续属于同一条连接的数据包,直接复用 Conntrack 表里的 NAT 绑定信息,根本不会再去遍历 NAT 规则链。

    我们来看内核源码 net/netfilter/nf_nat_core.c 中的 nf_nat_inet_fn 函数(处理 NAT Hook 的核心逻辑):

    unsigned int
    nf_nat_inet_fn(void *priv, struct sk_buff *skb,
                   const struct nf_hook_state *state)
    {
        struct nf_conn *ct;
        enum ip_conntrack_info ctinfo;
    
        /* 获取当前数据包的连接跟踪信息 */
        ct = nf_ct_get(skb, &ctinfo);
        if (!ct) {
            /* 如果拿不到 ct 上下文,直接 ACCEPT 放行,跳过后续所有 NAT 处理! */
            return NF_ACCEPT;
        }
    
        // ... 后续计算和应用 NAT 转换的逻辑
    }
    

    当 priority 设置为 -250 时,这个自定义的 NAT Chain 会在 Conntrack Hook (-200) 之前执行。此时数据包刚进入协议栈,nf_ct_get(skb, &ctinfo) 返回 NULL。内核一看没有连接跟踪上下文,直接返回 NF_ACCEPT 放行数据包,完全跳过了你辛辛苦苦写的 masquerade 规则。这就是内网 IP 发生“裸奔逃逸”的根本原因。

    排查实录:nftrace 链路追踪

    为了验证推论并在现场拿到铁证,我们使用 nftables 自带的神器 nftrace 进行包流向追踪。

    首先,在 raw 链层面给测试包打上 trace 标记:

    nft add table ip debug_trace
    nft add chain ip debug_trace preraw { type filter hook prerouting priority -350\; }
    nft add rule ip debug_trace preraw ip daddr 10.200.1.50 meta nftrace set 1
    

    随后,另开一个终端监听 trace 日志:

    nft monitor trace
    

    发起一次请求,观察 trace 输出(精简版):

    trace id e4b2c1 ip debug_trace preraw packet: iif "eth0" src 10.0.5.10 dst 10.200.1.50 ...
    trace id e4b2c1 ip debug_trace preraw rule ip daddr 10.200.1.50 meta nftrace set 1 (verdict continue)
    trace id e4b2c1 ip custom_nat postrouting packet: oif "eth0" src 10.0.5.10 dst 10.200.1.50 ...
    trace id e4b2c1 ip custom_nat postrouting policy accept (verdict accept)
    

    注意看输出,数据包流经了 custom_nat postrouting,但是没有触发任何 rule 匹配,直接命中了 policy accept退出。因为内核底层 nf_nat_inet_fn 直接 bypass 了规则引擎。

    修复方案极其简单,遵守内核规范即可:

    修改 Base Chain 的优先级至 srcnat 的标准位置(100):

    nft add chain ip custom_nat postrouting '{ type nat hook postrouting priority 100; policy accept; }'
    

    重载规则后,抓包瞬间恢复正常,NAT 转换成功。

    扩展防御:iptables-nft 混用下的优先级重叠踩坑

    在 K8S 等容器环境中,还存在另一个隐蔽的黑洞:iptables-legacy 与 nftables (或 iptables-nft) 混用。

    许多 OS 发行版默认采用 iptables-nft(用 iptables 的命令行包装 nftables 底层),Kube-proxy 也因此创建了大量 priority = -100 的 nftables nat 规则。 如果你使用原生 nftables 语法另外创建了一个 table,且优先级也设为 -100,会发生什么?

    根据 Netfilter 设计,相同优先级 Hook 的执行顺序是不确定的(通常取决于内核模块加载顺序或 Table 名称字母序)。如果你的原生 nftables 规则先执行并且返回了 ACCEPT(没有做 NAT),那么 kube-proxy 的 KUBE-SERVICES NAT 规则即便随后执行,也可能因为连接状态的改变而无法生效,导致 NodePort 流量随机超时。

    最佳实践:在任何生产服务器上,绝对不要混用多种防火墙工具。要么全盘接管使用纯正的 nftables,要么彻底清理原生 nft 规则,交由 iptables-nft 代理。使用 lsmod | grep _tables 检查,坚决剔除 ip_tables 和 x_tables 内核模块。

    常见问题 (FAQ)

    Q1: 修复了 nftables 的 NAT 规则并 reload 后,为什么已有的长连接依然是断网状态? 因为 NAT 操作只在连接建立(ct state NEW)的首包触发。对于已经建立的存量连接,它们在 Conntrack 表里的记录依然是未 NAT(或者错误 NAT)的状态,后续包直接复用该错误记录。排查时必须用 conntrack -D -p tcp --dport <端口> 清除脏连接,强制触发重连与重新 NAT 计算。

    Q2: 怎么快速确认当前系统里挂载了哪些 Netfilter Hook 及其对应的真实优先级? 如果是较新版本的 nftables (>= 1.0.0),可以直接使用命令: nft list hooks 如果是老版本或者想看底层全貌,可以通过 BPF 工具或检查 /proc/net/netfilter/nf_log 间接推断,或者通过 nft list ruleset 提取所有 Base Chain 的 priority 声明逐一盘点。

    Q3: 为什么有时候 SNAT 成功了,抓包看到了网卡公网 IP,但回程包依然被内核无情 DROP? 除了 NAT,最容易忽视的是 rp_filter(反向路由校验)。如果数据包的入口网卡和内核路由表中回程目标不一致(非对称路由),哪怕 NAT 配置完美,数据包也会在 IP 层被直接丢弃。排查时重点检查 sysctl -a | grep rp_filter,在复杂网络拓扑下通常需要设为 2 (松散模式) 或 0。

    Q4: 刚从 iptables 迁移到 nftables,发现 CPU softirq/usr 飙升,QPS 下跌怎么查? 极大概率是直接用工具(如 iptables-restore-translate)做了 1:1 的线性转换。iptables 的线性链匹配时间复杂度是 O(N),而 nftables 最大的性能优势是支持匿名集合 (Sets) 和映射字典 (Maps) (O(1) 复杂度)。千万不要在 nftables 里写一万行 ip saddr X.X.X.X accept,应当合并为 ip saddr { 1.1.1.1, 2.2.2.2 } accept。

  • 深入 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 filter 和 inet 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,比瞎猜高效百倍。

  • 深入 nf_conntrack 满载丢包排查:SNAT 端口耗尽引发的 SYN 阻断与 nftables Flowtable 旁路加速实战

    高并发网关常遇 nf_conntrack: table full 导致 SYN 丢包。盲目调大 nf_conntrack_max 只会加剧内核自旋锁争用与内存开销。根本解法是排查 SNAT 端口耗尽,并从 iptables 彻底迁移至 nftables,利用 Flowtable 机制开启流量卸载(Offload),让 ESTABLISHED 状态报文旁路跳过 Netfilter 核心链,实测可降低 40% 的 sys CPU 并彻底消除连接跟踪瓶颈。

    案发现场:诡异的 99 线毛刺与超时

    排查过程中,某承载了上万并发连接的 K8s Egress NAT 网关节点(Kernel 5.15.0)频繁出现请求超时,监控大盘显示 TCP 99线延迟出现规律性毛刺,Load Average 中的 sys CPU 间歇性飙升到 80% 以上。

    直接上机器看内核日志:

    $ dmesg -T | tail -n 20 | grep conntrack
    [Thu Oct 26 14:12:33 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    [Thu Oct 26 14:12:33 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    

    经典的连接跟踪表爆满导致丢包。看一下当前连接数与上限:

    $ sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
    net.netfilter.nf_conntrack_count = 262144
    net.netfilter.nf_conntrack_max = 262144
    

    为什么盲目调大 nf_conntrack_max 是一剂毒药?

    遇到 table full,很多人的第一反应是无脑加大 nf_conntrack_max。在低并发场景下这确实管用,但在高吞吐 NAT 网关上,这是一剂毒药。

    nf_conntrack 是基于哈希表实现的。它的核心数据结构由 Hash buckets(桶)和链表组成。当你只调大 nf_conntrack_max 而不调整 nf_conntrack_buckets 时,每个 Hash bucket 下挂载的链表会变得极长。 内核在进行包过滤或 NAT 时,需要遍历链表来匹配五元组。链表越长,查询的开销越大;加上 Hash bucket 的自旋锁(spinlock)争用,在多核高 PPS(Packet Per Second)场景下,CPU 会被 __nf_conntrack_find_get 等函数吃干抹净(表现为软中断 si 和内核态 sy CPU 极高)。

    正确的临时缓解姿势必须是联动调整(保持桶大小为最大连接数的 1/4):

    # 1. 调大 Hash 桶大小(立即生效,不可通过 sysctl 修改)
    $ echo 262144 > /sys/module/nf_conntrack/parameters/hashsize
    # 2. 调大最大连接数
    $ sysctl -w net.netfilter.nf_conntrack_max=1048576
    # 3. 缩短 TIME_WAIT 和 ESTABLISHED 状态的超时时间,加速条目回收
    $ sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=300
    $ sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
    

    但这只是治标。抓包发现,该节点作为 SNAT 网关,真实存在的活跃连接并没有达到 26 万,导致表满的真凶是 SNAT 端口耗尽引发的僵尸连接积压。由于 iptables 的 MASQUERADE 规则,多个内网 Pod 访问外部同一个目标 IP:Port 时,由于源端口池(默认 1024-65535)被快速消耗殆尽,新的 SYN 包在进行 NAT 转换时无法分配到 free tuple,导致连接状态卡死并在 conntrack 表中滞留。

    iptables 时代的穷途末路与 nftables 破局

    只要你还在用 iptables,每一个数据包都要不可避免地穿透 PREROUTING -> FORWARD -> POSTROUTING 整条链。即使是已经建立连接(ESTABLISHED)的报文,也要每次去走一遍 Rule 解析和 Conntrack 状态机。

    Kernel 4.16+ 引入了 nftables 的杀手锏功能:Flowtable (Fast-path Offload)。 它的底层原理极其优雅:对于已经建立连接的 TCP/UDP 流量,Flowtable 会在网卡的 ingress hook 点(非常靠前的位置)直接进行路由转发和 NAT 替换,完全绕过传统的 Netfilter 过滤链和 Conntrack 查询。

    实战:将 iptables NAT 迁移至 nftables Flowtable

    不要再用 iptables-nft 这种套壳工具了,直接写原生的 nftables 配置。以下是我们在网关节点上的落地配置,实现内网到外网的 SNAT 并开启 Flowtable 硬件/软件卸载。

    清除老旧规则:

    $ iptables -F && iptables -t nat -F
    $ systemctl stop iptables
    

    编写 /etc/nftables.conf:

    flush ruleset
    
    table inet filter {
        # 定义 Flowtable 开启卸载
        flowtable f {
            # 挂载在非常靠前的 ingress 钩子,优先级 0
            hook ingress priority 0;
            # 绑定内外网网卡(根据实际情况修改)
            devices = { eth0, eth1 };
        }
    
        chain forward {
            type filter hook forward priority 0; policy drop;
    
            # 核心逻辑:允许 ESTABLISHED 流量,并将新流量加入 flowtable 'f'
            ip protocol { tcp, udp } flow add @f
    
            # 允许内网 (10.0.0.0/8) 到外网的初始包通过
            iifname "eth0" oifname "eth1" ip saddr 10.0.0.0/8 accept
    
            # 允许已建立连接的回包
            ct state established,related accept
        }
    }
    
    table ip nat {
        chain postrouting {
            type nat hook postrouting priority 100; policy accept;
            # 传统 SNAT/Masquerade,只对首包生效
            oifname "eth1" ip saddr 10.0.0.0/8 masquerade random
        }
    }
    

    应用配置并验证:

    $ nft -f /etc/nftables.conf
    $ nft list ruleset
    

    注意:masquerade random 的加入是为了缓解 SNAT 端口分配的哈希碰撞冲突,配合 Flowtable 能最大程度压榨网关性能。

    性能表现对比

    迁移至 nftables Flowtable 后,使用 perf top 观察内核函数调用:

    • 迁移前:ipt_do_table 和 nf_conntrack_in 长年霸占 Top 3,软中断消耗极大。

    • 迁移后:由于首包建立连接后,后续几十个甚至成百上千个数据包直接从网卡 ingress 进入 nft_flow_offload_eval 后被路由发出,ipt_do_table 直接消失,sys CPU 占用率暴降 40% 以上,dmesg 中再无 table full 报错。

    常见问题 (FAQ)

    Q1:为什么我明明清空了 iptables,用 iptables -L 还能看到一些莫名其妙的规则? 因为较新的 OS(如 Debian 11+, RHEL 8+)默认将 iptables 软链接到了 iptables-nft。这是兼容层,你在 iptables 敲的命令,其实被转换成了 nftables 的内置表。要查看纯正的 iptables 规则,请使用 iptables-legacy -L。在系统层面彻底向 nftables 演进时,强烈建议干掉所有 legacy 和兼容层,统一用 nft 命令行管理。

    Q2:开启 nftables Flowtable 之后,为什么 tcpdump 抓不到部分数据包了? 这是预期行为。Flowtable 提供了 Software Offload 和 Hardware Offload (NIC HW offload)。如果是 Hardware offload(需要网卡驱动支持 tc 卸载),数据包在物理网卡层面就被转发了,根本不会进入内核网络栈,挂在 AF_PACKET 上的 tcpdump 自然抓不到。即使是 Software offload,由于绕过了常规的 Netfilter RX 路径,抓包结果也会呈现“只看到 SYN 包,看不到后续数据流”的现象。排查网络问题时,需要临时禁用 flowtable 规则。

    Q3:在 K8s 中使用 IPVS 模式的 kube-proxy,也会受 nf_conntrack 限制吗? 会。虽然 IPVS 维护了自己的连接管理哈希表,但它仍然深度依赖 Netfilter 框架做底层的包拦截和 NAT 协调(尤其是 nf_conntrack)。K8s 场景下大量短连接(如探针、微服务间 RPC)极易打满 conntrack。除文中提到的调优手段外,建议通过 kube-proxy 启动参数 --conntrack-max-per-core 来合理规划容量,而非手动修改 sysctl,防止被 Kubelet 重置。