凌晨三点的 ksoftirqd 飙升:基于 XDP 与 eBPF 映射的无锁网卡级丢包实战

传统 Netfilter/iptables 在应对千万级 PPS 小包洪泛时,会因大量 sk_buff 内存分配耗尽 CPU,引发严重软中断风暴。本文给出终极解法:基于 Kernel 5.15 编写 XDP 程序,在网卡驱动层实现零拷贝的 XDP_DROP,结合 eBPF Per-CPU Map 实现无锁统计,将单核拦截性能从 150 万 PPS 直接拉升至 800 万 PPS 以上。

凌晨三点,监控大盘上的 API 网关 P99 延迟突然飙升到 5 秒以上,随之而来的是节点 Load Average 破百的刺耳警报。登录机器,敲下 top,一个老生常谈的惨烈现场:

# top - 03:15:22 up 124 days,  4:12,  1 user,  load average: 102.14, 88.55, 48.23
%Cpu0  :  0.0 us,  0.0 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi, 100.0 si,  0.0 st
%Cpu1  :  0.0 us,  0.0 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi, 100.0 si,  0.0 st
...
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
    9 root      20   0       0      0      0 R  99.9   0.0   1245:10 ksoftirqd/0
   16 root      20   0       0      0      0 R  99.9   0.0   1244:52 ksoftirqd/1

ksoftirqd 满载,典型的软中断风暴。切到 sar -n DEV 1 看一眼网卡流量,eth0 的 RX PPS(每秒接收包数)冲到了 450 万,但 RX kB/s 却只有区区 200MB/s。很明显,典型的 64 字节 TCP SYN 洪泛或 UDP 盲打。

之前配置的 iptables -t raw -A PREROUTING -p tcp --syn -j DROP 规则明明在生效,为什么 CPU 还是被打死了?

为什么常规 Netfilter 拦不住线速小包攻击?

要理解这个问题,必须剥开 Linux 内核接收网络包的底层链路(RX Data Path)。

当网卡接收到物理信号并转化为数据帧后,整个处理流水线如下:

  1. DMA 拷贝:网卡将数据帧写入内存的 Ring Buffer。

  2. 硬中断(Hard IRQ):网卡通知 CPU 数据已到达。

  3. 软中断(Soft IRQ):内核唤醒 ksoftirqd,通过 NAPI 机制轮询拉取数据。

  4. 内存分配(核心痛点):内核为每个数据包调用 build_skb() / alloc_skb() 分配核心数据结构 struct sk_buff,并进行元数据初始化。

  5. 协议栈与 Netfilter:包进入 GRO 处理,经过 TC 子系统,最终到达 Netfilter 的 PREROUTING 链(即 iptables 规则生效的地方)。

在千万级 PPS 的小包场景下,步骤 4 的 alloc_skb 内存分配与释放开销占据了 CPU 超过 70% 的周期。哪怕你的 iptables 规则再精简,当包到达 Netfilter 时,内核已经把最耗时的脏活全干完了。这也就是为什么“墙内丢包”依然会导致系统雪崩。

我们需要把防线前推,推到 sk_buff 分配之前。这就是 XDP(eXpress Data Path)大显身手的地方。

XDP 实战:在 DMA 缓冲区直接执行裁决

XDP 程序作为一个 eBPF 钩子,直接挂载在网卡驱动层(Native XDP)。包刚进内存,还没来得及包装成 sk_buff,我们的代码就可以直接读取裸数据(Raw Packet),并返回 XDP_DROP 让驱动原地丢弃。

1. 编写防御性 eBPF C 代码

防御性编程在 eBPF 中是强行规定的:BPF Verifier(校验器)会极其严苛地审查指针越界。如果不做边界检查,代码根本无法注入内核。

以下代码实现了一个过滤特定端口(如 80 端口)恶意 SYN 包,并通过 Per-CPU Map 记录丢包数的 XDP 程序:

// xdp_drop.c
#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>

// 定义 Per-CPU Array Map,用于无锁高频计数
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __type(key, __u32);
    __type(value, __u64);
    __uint(max_entries, 1);
} drop_cnt SEC(".maps");

SEC("xdp_syn_drop")
int xdp_prog_main(struct xdp_md *ctx) {
    // 获取包在内存中的起始与结束地址
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;

    // 1. 解析以太网头
    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end) 
        return XDP_PASS; // 校验器要求:必须做边界检查

    if (eth->h_proto != __constant_htons(ETH_P_IP))
        return XDP_PASS;

    // 2. 解析 IP 头
    struct iphdr *ip = (void *)(eth + 1);
    if ((void *)(ip + 1) > data_end)
        return XDP_PASS;

    if (ip->protocol != IPPROTO_TCP)
        return XDP_PASS;

    // 3. 解析 TCP 头
    struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
    if ((void *)(tcp + 1) > data_end)
        return XDP_PASS;

    // 4. 拦截逻辑:如果是目标端口为 80 的 SYN 包
    if (tcp->dest == __constant_htons(80) && tcp->syn && !tcp->ack) {
        __u32 key = 0;
        __u64 *value = bpf_map_lookup_elem(&drop_cnt, &key);
        if (value) {
            *value += 1; // Per-CPU 更新,无需原子操作锁
        }
        return XDP_DROP; // 在网卡驱动层直接丢弃,不分配 sk_buff
    }

    return XDP_PASS;
}

char _license[] SEC("license") = "GPL";

2. 编译与挂载

确保系统安装了 clangllvm 以及内核开发包(当前环境 Kernel 5.15.x)。

# 编译为 BPF 字节码
clang -O2 -g -Wall -target bpf -c xdp_drop.c -o xdp_drop.o

# 将 XDP 程序挂载到 eth0 网卡
# 这里默认使用 native 模式,如果网卡驱动不支持,可加 force 强制使用 generic 模式(但性能退化)
ip link set dev eth0 xdp obj xdp_drop.o sec xdp_syn_drop

挂载瞬间,监控图表上的 ksoftirqd CPU 使用率从 100% 垂直断崖下跌到 5% 以内。Load Average 开始快速回落。

闭环:基于 eBPF Map 的可观测性

没有监控的运维就是在裸奔。包是丢了,丢了多少?我们需要读出 eBPF Map 里的数据。

我们之前在代码里定义了 BPF_MAP_TYPE_PERCPU_ARRAY。为什么不用普通的 ARRAY?因为在多核网卡多队列场景下,多个 CPU 同时执行 XDP_DROP 并累加同一个内存变量,会引发严重的 Cache-line bouncing(缓存行伪共享)和原子锁竞争,反向拖垮性能。Per-CPU Map 为每个 CPU 核心分配独立的内存区域,完全无锁。

使用 bpftool 工具读取数据:

# 1. 找到我们的 map id
bpftool map list
# 输出示例:
# 105: percpu_array  name drop_cnt  flags 0x0
#      key 4B  value 8B  max_entries 1  memlock 4096B

# 2. 导出特定 ID 的 Map 数据
bpftool map dump id 105
# 输出示例:
[{
        "key": 0,
        "values": [{
                "cpu": 0,
                "value": 1548291
            },{
                "cpu": 1,
                "value": 1692831
            },
            ...
        ]
}]

你可以编写一个简单的 Python BCC 脚本或 Go 程序(基于 cilium/ebpf 库),定期拉取这个 Map 数据,聚合后暴露出 Prometheus Metrics,一套极轻量级的防 DDoS 可观测闭环就建立起来了。

卸载 XDP 程序的命令也极其简单:

ip link set dev eth0 xdp off

常见问题 (FAQ)

Q1:挂载时报错 RTNETLINK answers: Operation not supported 是怎么回事? 通常是因为你的网卡驱动不支持 Native XDP(例如某些老旧的虚拟化网卡或特定的老版本驱动)。解决办法是改用 generic 模式:ip link set dev eth0 xdp generic obj xdp_drop.o sec xdp_syn_drop。注意,Generic XDP 运行在 sk_buff 分配之后,性能收益大打折扣,但可用于测试逻辑。

Q2:代码编译没问题,挂载时被 Verifier 拒绝,提示 invalid memory access eBPF 的 Verifier 极度保守。你不仅需要检查 ethhdr 的越界,任何通过偏移量访问内存的操作(比如通过 IP 头长度推导 TCP 头位置 struct tcphdr *tcp = (void *)ip + (ip->ihl * 4))之后,都必须紧跟边界检查 if ((void *)(tcp + 1) > data_end)。少写一行检查,Verifier 就会判定有越界风险而拒绝加载。

Q3:XDP 把包在网卡层丢了,排查问题时我用 tcpdump 还能抓到这些包吗? 抓不到。tcpdump 基于 AF_PACKET 套接字,工作在内核协议栈层面。XDP 的介入时机早于它。如果你必须对这些丢弃的包进行采样分析,需要在 XDP 代码中利用 bpf_perf_event_output 将特定包的 Header 异步推送到用户空间(类似 AF_XDP 机制),但这会引入额外的上下文切换开销,建议通过开关或概率采样来控制。