深入 eBPF/XDP 丢包雪崩排查:Hash Map 满载引发的 XDP_DROP 风暴与 ksoftirqd 饱和实战

近期排查了一起极其诡异的边缘节点网络雪崩事故。业务表现为随机的 TCP 建连超时,API 网关 P99 延迟阶段性飙升至 3 秒以上(典型的 SYN 丢包重传)。经过链路排查,最终定位于一个新上线的基于 XDP (eXpress Data Path) 的防 DDoS 阻断程序。 核心结论:开发在编写 eBPF 代码时,错误地使用基础的 BPF_MAP_TYPE_HASH 来记录源 IP 访问频率,且未配置任何过期清理逻辑;当 Map 满载后 bpf_map_update_elem 调用失败,异常处理分支居然默认返回了 XDP_DROP。更致命的是,部署脚本未校验网卡驱动兼容性,在部分节点回退到了 Generic XDP (SKB 模式),不仅没起到加速作用,反而直接打爆了 ksoftirqd 软中断。

这不仅仅是代码 Bug,更是对生产环境缺乏敬畏之心的体现。写底层网络逻辑,不带防御性编程思维,等同于在主干道上埋雷。

案发现场:消失的 SYN 包与哀嚎的软中断

监控告警最先在部分 BGP 边缘节点触发,现象非常割裂:

  1. 网络层面:外部监控探测大面积报 TCP Timeout。ss -s 发现节点 SYN-RECV 极少,但外部抓包显示 SYN 已经到达物理机网卡。

  2. 系统层面:出问题的节点 Load Average 飙高,top -1 发现个别 CPU 核心的 si(Soft Interrupt)使用率长时间顶在 100%,进程 ksoftirqd/x 霸榜。

  3. 传统排查失效:内核 dmesg 无任何报错,conntrack -S 表项未满,iptables/nftables 的 drop 计数器毫无波澜。

包进来了,但在进入内核协议栈(Netfilter)之前就凭空消失了。结合软中断被打满的现象,直觉告诉我:有人在网卡底层动了手脚,极大概率是 tc 或者 XDP。

现场拆解:用 bpftool 扒掉“黑盒”的底裤

既然怀疑是底层 Hook,直接上 bpftool 查户口。

执行 bpftool net show,果不其然,网卡 eth0 上挂着东西:

# bpftool net show
xdp:
eth0(2) generic id 142 act XDP_DROP

这里立刻暴露了两个致命问题:

  1. 模式不对:显示为 generic 而不是 driver。Native XDP 是在网卡驱动层(Ring Buffer 刚分配完)处理数据,性能极高;而 Generic XDP 是内核为了兼容不支持 XDP 的网卡做的妥协,它是在 sk_buff 已经分配,甚至包已经进入网络协议栈入口后才执行 eBPF 字节码。此时拦截毫无性能优势,反而因为额外的 BPF 执行逻辑增加了 NET_RX 软中断的开销,直接导致 ksoftirqd 饱和。

  2. 阻断风暴:当前挂载的程序 ID 是 142。

接着查看具体挂载了什么程序和它的 Map 状态:

# bpftool prog show id 142
142: xdp  name xdp_ddos_block  tag 3b185187f1855c4c  gpl
        loaded_at 202X-XX-XXT10:00:00+0800  uid 0
        xlated 528B  jited 312B  memlock 4096B
        map_ids 45

提取关联的 Map ID 45,查看 Map 容量与元素数量:

# bpftool map show id 45
45: hash  name ip_stat_map  flags 0x0
        key 4B  value 8B  max_entries 65536  memlock 5242880B
# bpftool map dump id 45 | grep "Found"
Found 65536 elements

破案了。max_entries 是 65536,当前已经存了 65536 个元素。Map 被彻底打满了。

源码处刑:无脑的 XDP_DROP 与缺失的驱逐机制

把开发叫来,翻开 eBPF C 源码,看到如下逻辑片段时,我血压直接上来了:

struct bpf_map_def SEC("maps") ip_stat_map = {
    .type = BPF_MAP_TYPE_HASH, // 致命错误1:普通的 Hash Map
    .key_size = sizeof(__u32),
    .value_size = sizeof(struct ip_stat),
    .max_entries = 65536,
};

SEC("xdp")
int xdp_ddos_block(struct xdp_md *ctx) {
    // ... 解析 IP 头 ...
    __u32 src_ip = iph->saddr;

    struct ip_stat *stat = bpf_map_lookup_elem(&ip_stat_map, &src_ip);
    if (!stat) {
        struct ip_stat new_stat = { .count = 1, .last_time = bpf_ktime_get_ns() };
        // 致命错误2:未判断更新失败的情况,直接放任后续逻辑或采取错误假设
        int ret = bpf_map_update_elem(&ip_stat_map, &src_ip, &new_stat, BPF_ANY);
        if (ret != 0) {
            // 致命错误3:更新 Map 失败(如满了),直接当做异常流量 Drop 掉!
            return XDP_DROP; 
        }
    } else {
        // ... 频率检测逻辑 ...
    }
    return XDP_PASS;
}

灾难逻辑剖析: 开发者的本意是:“如果连记录状态都失败了,说明系统可能在被严重攻击,为了安全起见,宁可杀错不可放过,直接丢弃(XDP_DROP)”。 但他们忽略了网络世界的复杂性:互联网每天有大量的扫描器、僵尸网络发起一次性连接。使用 BPF_MAP_TYPE_HASH,这些单次访问的源 IP 会永远占据坑位。没有用户态进程去定时清理,也没有内核级的 LRU (Least Recently Used) 淘汰机制,不到几个小时,65536 的容量必然耗尽。 Map 满载后,后续所有正常用户的全新 IP 访问,在执行 bpf_map_update_elem 时都会返回 -E2BIG。代码捕获到这个错误,果断执行了 XDP_DROP。 最终结果就是:防 DDoS 的程序自己变成了一个完美的高性能 DDoS 攻击器,对所有新访客实施无差别静默丢包。

技术结论与重构方案

XDP 的极强性能来源于其极其底层的执行位置,但这也意味着它完全脱离了 Linux 协议栈成熟的异常处理、垃圾回收和可观测性体系。能力越大,越需要防御性编程。

针对该故障,我们进行了彻底整改:

  1. 废弃标准 HASH,强制使用 LRU HASH: 将 Map 类型修改为 BPF_MAP_TYPE_LRU_HASH。当 Map 容量达到 max_entries 时,内核会自动淘汰最久未访问的元素,腾出空间给新连接。永远不要在没有外部 GC 守护进程的情况下,在网络数据面使用不具备自动淘汰机制的 Map。

  2. 修正 Fail-Open (容错放行) 逻辑: 监控程序自身的异常不应导致业务中断。如果 Map 更新失败,正确的做法是打印 BPF Trace 日志或增加异常统计 Counter,并返回 XDP_PASS 交给上层内核协议栈处理,而不是傲慢地返回 XDP_DROP

  3. 强制 Native 模式加载,彻底告别 Generic 软中断陷阱: 修改加载程序(或使用 ip link set dev eth0 xdp obj xxx.o sec xdp 时明确指定模式)。我们重写了加载工具,如果在给定的网卡上 XDP_FLAGS_DRV_MODE (Native 模式) 挂载失败,应当直接终止部署并告警,绝对不允许静默回退到 XDP_FLAGS_SKB_MODE

同类问题速查排查清单 (eBPF/XDP 故障急救)

  1. 确认 XDP 是否介入及运行模式bpftool net showip link show。重点看接口后是否带有 xdp 标记,以及是 xdpgeneric 还是 xdpdrv。如果是 xdpgeneric 且系统软中断高,直接拔掉 XDP 恢复业务。

  2. 检查 eBPF Map 的满载情况: 使用 bpftool map show 获取所有 Map 的 max_entries,再用 bpftool map dump id | grep "Found" 检查当前元素量。接近或等于满载的,必定会引发 bpf_map_update_elem 返回 -E2BIG,需立即排查代码异常分支逻辑。

  3. 定位静默丢包(Drop)统计: XDP 丢包不会体现在 iptables 里。除了在代码里自建 BPF Perf Event 或 Ring Buffer 输出丢包日志外,可以通过 ethtool -S | grep xdp_drop (依赖具体网卡驱动支持)来观测底层拦截量。

  4. 内核 BPF 调试日志探测: 如果开发在代码中使用了 bpf_printk,可通过 cat /sys/kernel/debug/tracing/trace_pipe 查看实时内核 eBPF 打印的报错信息,往往能一针见血发现诸如 Map Update 失败的错误。