分类: 底层架构与性能调优

  • 深入 eBPF/XDP 陷阱排查:Generic 模式退化引发的 ksoftirqd 耗尽与软中断风暴实战

    XDP并非银弹。在开启大接收卸载(LRO)或巨型帧的网卡上挂载XDP程序,会导致其悄无声息地退化为 Generic XDP(SKB模式)。这完全丧失了 XDP_DROP 零拷贝优势,在高PPS下直接导致 ksoftirqd 软中断100%打满,引发严重丢包与P99延迟雪崩。破局之道:关闭网卡LRO/GRO,并在生产环境中强制使用 xdpdrv Native 模式挂载,拒绝静默降级。

    排查过程中,我们遇到了一个极其隐蔽的网络性能故障。某高并发入口网关集群(Kernel 5.15.0,Mellanox ConnectX-4 网卡),为了抵御 UDP 反射攻击和 SYN Flood,我们在网卡上挂载了基于 eBPF/XDP 的 DDoS 过滤程序。

    日常流量下系统风平浪静。但某次压测中,当突发流量仅达到 200万 PPS 时,网关节点的 Load Average 瞬间飙升至 80+,业务 P99 延迟从 3ms 直接雪崩至 2000ms 以上。

    现场勘探:看似“正常”的假象

    登录故障节点,第一感觉是 CPU 遭到了软中断屠杀:

    $ mpstat -P ALL 1
    CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
    ...
     12    0.00    0.00    0.00    0.00    0.00  100.00    0.00    0.00    0.00    0.00
     13    0.00    0.00    0.00    0.00    0.00  100.00    0.00    0.00    0.00    0.00
    

    大量 CPU 核心的 %soft 被打满,进程上下文中大量出现 ksoftirqd/N

    查看网卡底层丢包情况,硬件 Ring Buffer 出现了灾难性的溢出:

    $ ethtool -S eth0 | grep rx_missed_errors
         rx_missed_errors: 18459203
    

    按理说,XDP 程序的执行位置在网卡驱动层(DMA 之后,SKB 分配之前),直接返回 XDP_DROP 丢弃恶意包,单核处理能力应该在 10M PPS 以上。区区 2M PPS 怎么会把软中断彻底打爆?

    致命陷阱:静默退化的 Generic XDP

    习惯性地检查 XDP 程序的挂载状态,一条不显眼的 xdpgeneric 暴露了真凶:

    $ ip -details link show eth0
    2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
        link/ether b8:59:9f:xx:xx:xx brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 9000
        prog/xdp id 42 tag 3b185a8f47700000 jited xdpgeneric
    

    注意最后的 xdpgeneric。这意味着当前 XDP 程序并没有运行在 Native 模式,而是回退到了 Generic 模式。

    为什么 Native XDP 会悄无声息地退化为 Generic 模式?

    要理解这个致命陷阱,必须清楚 XDP 的三种运行模式:

    1. Offloaded XDP (xdpoffload):直接编译到智能网卡硬件中执行,CPU 0 消耗。

    2. Native XDP (xdpdrv):在网卡驱动层的 RX Ring 中执行。此时内存 DMA 刚完成,操作系统还未分配庞大臃肿的 sk_buff 结构体,是软件层面的最高性能模式。

    3. Generic XDP (xdpgeneric):内核网络协议栈的后备方案。挂载点位于 netif_receive_skb_core() 中。

    Generic 模式的灾难性在于:当数据包到达这个 Hook 点时,内核已经完成了 sk_buff 的内存分配、元数据初始化、甚至部分解析工作! 你本想用 XDP 在门口直接拒之门外,结果 Generic 模式是把人请进客厅,倒好茶水(分配 SKB),然后再把人赶出去。在高频 DDoS 攻击下,海量的 SKB 分配和释放直接把内存分配器(SLUB)和软中断处理(net_rx_action)干到崩溃。

    退化是如何发生的? 绝大多数网上的教程,教你挂载 XDP 程序时使用的命令都是:

    ip link set dev eth0 xdp obj ddos_filter.o sec xdp
    

    在这个命令中,iproute2 工具使用了一个极为“贴心”却在生产环境中极其有害的逻辑:它首先尝试 Native 模式;如果网卡驱动拒绝,它会静默地使用 Generic 模式挂载,并返回成功。

    网卡驱动为什么会拒绝 Native XDP?通常因为特性冲突。查看故障节点的网卡配置:

    $ ethtool -k eth0 | grep large-receive-offload
    large-receive-offload: on
    

    当前网卡开启了 LRO(Large Receive Offload)或者使用了超出 XDP 限制的 Jumbo Frame。在 Linux 内核驱动(如 mlx5ixgbe)实现中,如果开启了 LRO,驱动层函数(如 mlx5e_xdp_allowed)会直接返回 falseiproute2 接收到 -EOPNOTSUPP 错误后,立刻降级为 Generic 模式。

    修复与防御性编程实践

    第一步:环境清理与网卡调优 关闭 LRO/GRO 等与 Native XDP 冲突的硬件卸载特性:

    ethtool -K eth0 lro off gro off
    

    注:关闭 GRO 可能会增加正常大包传输时的 CPU 消耗,需根据业务场景权衡,但作为 DDoS 防御节点,这是必选项。

    第二步:强制 Native 模式挂载 永远不要在生产环境脚本中使用模糊的 xdp 参数,必须显式指定 xdpdrv。宁可挂载失败报错,也绝不接受静默降级。

    # 卸载原有的 generic xdp
    ip link set dev eth0 xdp off
    
    # 强制使用 Native 模式挂载
    ip link set dev eth0 xdpdrv obj ddos_filter.o sec xdp
    

    如果底层不支持,iproute2 会直接抛出异常,触发自动化运维流水线的告警,而不是埋下一颗定时炸弹。

    进阶陷阱:全局 BPF Map 带来的 Cache Line 伪共享

    解决了模式退化问题后,我们发现 10M PPS 下 P99 延迟依然存在偶发抖动。排查 eBPF 源码发现,开发同学为了统计恶意 IP 的丢包数,使用了如下数据结构:

    struct {
        __uint(type, BPF_MAP_TYPE_HASH);
        __uint(max_entries, 100000);
        __type(key, __u32);   // Source IP
        __type(value, __u64); // Drop Count
    } drop_stats SEC(".maps");
    
    // XDP 处理逻辑中:
    __u64 *count = bpf_map_lookup_elem(&drop_stats, &src_ip);
    if (count) {
        __sync_fetch_and_add(count, 1); // 致命瓶颈
    }
    

    在网卡多队列(RSS)架构下,多个 CPU 核心的软中断同时处理大量数据包。当这些核心通过 __sync_fetch_and_add 原语高频修改同一个全局 Map 中的同一个元素时,会引发极其严重的 Cache Line Bouncing(缓存行颠簸)。多核之间的 MESI 协议同步开销,足以将 XDP 的性能优势吞噬殆尽。

    正确姿势:使用 Per-CPU MapBPF_MAP_TYPE_HASH 改为 BPF_MAP_TYPE_PERCPU_HASH。每个 CPU 核心只在本地 L1/L2 Cache 中更新自己的统计值,用户态程序读取时再进行跨核汇总。

    struct {
        __uint(type, BPF_MAP_TYPE_PERCPU_HASH);
        __uint(max_entries, 100000);
        __type(key, __u32);
        __type(value, __u64);
    } drop_stats SEC(".maps");
    
    // XDP 逻辑:
    __u64 *count = bpf_map_lookup_elem(&drop_stats, &src_ip);
    if (count) {
        *count += 1; // 无需原子操作,因为是 Per-CPU 私有数据
    }
    

    修改后,10M PPS 攻击流量下,单核软中断 CPU 占用率从 85% 锐减至 15%,系统彻底回归平稳。

    常见问题 (FAQ)

    Q1:如何判断当前内核版本和网卡驱动是否支持 Native XDP? A:除了查阅官方文档,最直接的方式是使用 bpftool 工具查询网卡特性(需要内核 >= 5.14):

    bpftool net -p
    bpftool feature probe dev eth0
    

    或者尝试强制挂载 ip link set dev eth0 xdpdrv obj dummy.o,看内核 dmesg 输出的错误日志。

    Q2:使用了 XDP_DROP 丢弃的数据包,还能用 tcpdump 抓到吗? A:不能。 Native XDP 的执行点位于驱动层,早于 tc (Traffic Control) 子系统,更早于 AF_PACKET 抓包点。因此被 XDP_DROP 掉的包对于 tcpdump 是完全隐形的。如果需要调试丢弃情况,建议通过 eBPF Map 导出统计数据,或利用 bpf_trace_printk 查看(仅限调试环境)。

    Q3:网卡驱动报 XDP_SETUP_PROG failedOperation not supported,除了 LRO 还有什么原因? A:常见原因有三个:

    1. MTU 过大:由于 XDP 通常要求一帧数据必须位于连续的物理内存页中(不支持散页/Scatter-Gather),如果 MTU > 3900(因网卡而异),会导致驱动拒绝 XDP。

    2. RX 队列数量:某些老旧网卡驱动要求 XDP 的 TX 队列数必须等于 RX 队列数(用于 XDP_TX)。可通过 ethtool -L 调整。

    3. 驱动 Bug:部分虚拟化网卡(如早期版本的 virtio-netveth)对 Native XDP 支持有缺陷。

    Q4:为什么加载 eBPF 程序时会报 bpf verifier failed A:eBPF 验证器 (Verifier) 极其严苛,拒绝任何可能导致内核崩溃的代码。常见错误包括:死循环(未限定上限的 for 循环)、越界内存访问(没有在访问 packet data 前做 data + offset > data_end 的严格校验)、未初始化的变量等。请严格遵守“防御性编程”规范,确保包边界检查无懈可击。

  • 深入 eBPF/XDP 网络雪崩排查:Netfilter 软中断打满引发的丢包与 XDP 内核级加速防御实战

    高并发下 Netfilter 必然成为性能瓶颈。排查某次网关节点大面积丢包时,确认系海量小包打满 ksoftirqdnf_conntrack 溢出导致。直接抛弃 iptables 方案,通过 eBPF 挂载 XDP 程序在网卡驱动层(SKB 分配前)进行拦截与转发,CPU 软中断开销骤降 80%,99线延迟从 200ms 恢复至 2ms,系统吞吐量提升三个数量级。

    故障现场:ksoftirqd 榨干 CPU 与 Conntrack 溢出

    某次生产环境的高并发突发流量下,K8S Ingress 节点(OS: Ubuntu 22.04, 内核 Linux 5.15)出现大面积请求超时。前端监控显示 99 线延迟飙升至 200ms 以上,甚至出现 502/504 错误。

    登录宿主机,第一眼看系统负载:

    $ uptime
     10:14:32 up 45 days, 14:20,  2 users,  load average: 84.12, 75.33, 60.10
    

    Load Average 极高,敲击键盘都有迟滞感。直接看 CPU 消耗,top 里的 si(Soft Interrupt)指标在多个核心上死死顶在 100%,相关的进程全是 ksoftirqd/n

    %Cpu(s):  1.5 us,  3.2 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi, 95.3 si,  0.0 st
      PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
       14 root      20   0       0      0      0 R  99.9   0.0  10:23.12 ksoftirqd/1
       20 root      20   0       0      0      0 R  99.9   0.0   9:14.05 ksoftirqd/2
    

    与此同时,dmesg 中正在疯狂刷屏经典报错:

    $ dmesg -T | tail -n 5
    [Thu Oct 12 10:15:01 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    [Thu Oct 12 10:15:01 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    

    典型的网络软中断风暴 + 连接跟踪表打满。虽然通过 sysctl -w net.netfilter.nf_conntrack_max=2097152 临时缓解了丢包,但这只是扬汤止沸,软中断依然居高不下,节点的网络栈已经处于半瘫痪状态。

    为什么传统的 iptables/Netfilter 在高并发下必然雪崩?

    要理解这场雪崩,必须拆解 Linux 传统的网络收包路径。

    当网卡收到一个数据包时,硬中断触发后,真正的重头戏在软中断 NET_RX_SOFTIRQ。此时,内核会为每个数据包调用 __alloc_skb() 分配一个 sk_buff 结构体。这个结构体极其庞大(通常包含数百个字段),高频的内存分配和释放本身就是巨大的开销。

    紧接着,包会进入内核协议栈,穿越 Netfilter 的重重关卡(PREROUTING, INPUT, FORWARD 等)。如果是 K8S 环境,kube-proxy 写入的数万条 iptables 规则会以线性或树状(ipset)进行匹配。最致命的是 Conntrack(连接跟踪) 机制。每次建连,内核都要加锁更新连接状态表。当 PPS(每秒包数)达到数十万级别时,nf_conntrack 的自旋锁竞争会导致 CPU 缓存命中率暴跌,最终表现为 ksoftirqd 吃满 CPU,后续的包连 sk_buff 都分配不到,直接在网卡 Ring Buffer 处被丢弃。

    可观测性介入:用 eBPF/bpftrace 精准定位丢包点

    在实施改造前,我们需要硬核的数据佐证。只看 dmesg 不够,到底包是在协议栈的哪一步被 Drop 的? 利用 bpftrace 编写一行脚本,直接 Hook 内核的 kfree_skb 函数(内核丢弃数据包时通常会调用它),并打印调用栈:

    # 依赖环境: bpftrace 0.14.0+
    $ bpftrace -e 'kprobe:kfree_skb /comm == "ksoftirqd/1"/ { @[kstack] = count(); }'
    

    运行 10 秒后 Ctrl+C 停止,输出的核心堆栈如下:

    @[
        kfree_skb+1
        nf_conntrack_in+1345
        ipv4_conntrack_in+28
        nf_hook_slow+66
        ip_rcv+165
        __netif_receive_skb_core+2180
        net_rx_action+354
        __do_softirq+215
        run_ksoftirqd+42
    ]: 45210
    

    数据确凿:短短 10 秒内,在 nf_conntrack_in 链路下触发了 4.5 万次 kfree_skb。传统的防御方案(如加机器、调大 sysctl 参数)在百万级 PPS 面前毫无招架之力。必须进行降维打击——绕过 sk_buff 和 Netfilter。

    降维打击:XDP (eXpress Data Path) 零拷贝拦截实战

    XDP 是基于 eBPF 的一项技术,它允许我们在网卡驱动层,即数据包刚通过 DMA 拷贝到内存,尚未分配 sk_buff 之前,执行我们自定义的 eBPF 程序。

    排查过程中,我们发现异常流量具有明显的端口和 IP 聚集特征。直接编写 XDP 程序,对恶意流量执行 XDP_DROP,对合法突发流量直接在驱动层打标或放行。

    以下是精简后的 XDP C 代码(xdp_filter.c),实现对特定目标端口(如 8080)的异常小包直接 Drop:

    #include <linux/bpf.h>
    #include <linux/if_ether.h>
    #include <linux/ip.h>
    #include <linux/tcp.h>
    #include <linux/in.h>
    #include <bpf/bpf_helpers.h>
    
    SEC("xdp")
    int xdp_drop_prog(struct xdp_md *ctx) {
        // 获取数据包的起止指针
        void *data_end = (void *)(long)ctx->data_end;
        void *data     = (void *)(long)ctx->data;
    
        // 解析以太网头部
        struct ethhdr *eth = data;
        if (data + sizeof(*eth) > data_end)
            return XDP_PASS;
    
        // 仅处理 IPv4
        if (eth->h_proto != bpf_htons(ETH_P_IP))
            return XDP_PASS;
    
        // 解析 IP 头部
        struct iphdr *iph = data + sizeof(*eth);
        if ((void *)iph + sizeof(*iph) > data_end)
            return XDP_PASS;
    
        // 解析 TCP 头部
        if (iph->protocol == IPPROTO_TCP) {
            struct tcphdr *tcph = (void *)iph + sizeof(*iph);
            if ((void *)tcph + sizeof(*tcph) > data_end)
                return XDP_PASS;
    
            // 如果目标端口是 8080,直接在网卡驱动层丢弃 (模拟黑洞)
            if (tcph->dest == bpf_htons(8080)) {
                // 可在此处加入 eBPF Map 统计丢包数量
                return XDP_DROP;
            }
        }
    
        // 其他数据包正常进入协议栈
        return XDP_PASS;
    }
    
    char _license[] SEC("license") = "GPL";
    

    编译与挂载: 利用 Clang 将 C 代码编译为 BPF 字节码,并通过 iproute2 工具直接挂载到宿主机物理网卡(如 eth0)。

    # 编译 (需安装 clang 12+ 和 linux-headers)
    $ clang -O2 -g -Wall -target bpf -c xdp_filter.c -o xdp_filter.o
    
    # 以 Native 模式挂载到 eth0
    $ ip link set dev eth0 xdp obj xdp_filter.o sec xdp
    
    # 查看挂载状态
    $ ip link show eth0
    2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp/id:45 qdisc mq state UP mode DEFAULT group default qlen 1000
        link/ether 00:16:3e:xx:xx:xx brd ff:ff:ff:ff:ff:ff
        prog/xdp id 45 tag 8fxxxxxx
    

    效果对比: 挂载 XDP 后,恶意流量在网卡驱动层即被截断,根本不会触发 alloc_skb,更不会进入 Netfilter。

    • ksoftirqd 的 CPU 占用率从 100% 瞬间暴降至 15% 左右。

    • dmesgnf_conntrack 报错消失。

    • 合法业务流量的 99 线延迟恢复到健康的 2ms 范围内。

    常见问题 (FAQ)

    Q1:XDP 的 Generic 模式和 Native 模式有什么性能差异? Generic 模式(xdpgeneric)是内核网络栈模拟的 XDP,此时 sk_buff 已经分配,性能提升有限,主要用于测试或不支持 XDP 的网卡驱动。Native 模式(xdp)是在网卡驱动层实现,包刚放入内存就触发,零拷贝,性能是 Generic 模式的 4-5 倍。生产环境必须确保网卡驱动(如 ixgbe, mlx5)支持 Native XDP。

    Q2:eBPF Map 并发读写时如何保证数据一致性? 在多核并发场景下,统计包量等操作直接更新普通的 Array/Hash Map 会有竞态问题。应当使用 BPF_MAP_TYPE_PERCPU_ARRAYBPF_MAP_TYPE_PERCPU_HASH。这种 Map 会为每个 CPU 核心维护独立的数据副本,更新时无锁,用户态读取时再遍历所有 CPU 的值进行汇总。

    Q3:使用 Cilium 替换 kube-proxy 后,NodePort 流量依然有延迟,如何排查? Cilium 默认并不全量开启底层 XDP 加速。如果 NodePort 流量仍有延迟,需检查 Cilium Agent 配置是否启用了 bpf-node-portkube-proxy-replacement=strict。可以通过 cilium status 查看 XDP 加速状态,并使用 cilium bpf nat list 确认底层的 eBPF NAT 表是否正常接管了 iptables 规则。如果网卡不支持 Native XDP,Cilium 会退化到 TC (Traffic Control) 层的 eBPF hook,性能会打折扣。