深入 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 的严格校验)、未初始化的变量等。请严格遵守“防御性编程”规范,确保包边界检查无懈可击。