标签: DDoS防御

  • 凌晨三点的 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 机制),但这会引入额外的上下文切换开销,建议通过开关或概率采样来控制。