近期排查了一起极其诡异的边缘节点网络雪崩事故。业务表现为随机的 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 边缘节点触发,现象非常割裂:
-
网络层面:外部监控探测大面积报 TCP Timeout。
ss -s发现节点SYN-RECV极少,但外部抓包显示 SYN 已经到达物理机网卡。 -
系统层面:出问题的节点 Load Average 飙高,
top -1发现个别 CPU 核心的si(Soft Interrupt)使用率长时间顶在 100%,进程ksoftirqd/x霸榜。 -
传统排查失效:内核
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
这里立刻暴露了两个致命问题:
-
模式不对:显示为
generic而不是driver。Native XDP 是在网卡驱动层(Ring Buffer 刚分配完)处理数据,性能极高;而 Generic XDP 是内核为了兼容不支持 XDP 的网卡做的妥协,它是在sk_buff已经分配,甚至包已经进入网络协议栈入口后才执行 eBPF 字节码。此时拦截毫无性能优势,反而因为额外的 BPF 执行逻辑增加了NET_RX软中断的开销,直接导致ksoftirqd饱和。 -
阻断风暴:当前挂载的程序 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 协议栈成熟的异常处理、垃圾回收和可观测性体系。能力越大,越需要防御性编程。
针对该故障,我们进行了彻底整改:
-
废弃标准 HASH,强制使用 LRU HASH: 将 Map 类型修改为
BPF_MAP_TYPE_LRU_HASH。当 Map 容量达到max_entries时,内核会自动淘汰最久未访问的元素,腾出空间给新连接。永远不要在没有外部 GC 守护进程的情况下,在网络数据面使用不具备自动淘汰机制的 Map。 -
修正 Fail-Open (容错放行) 逻辑: 监控程序自身的异常不应导致业务中断。如果 Map 更新失败,正确的做法是打印 BPF Trace 日志或增加异常统计 Counter,并返回
XDP_PASS交给上层内核协议栈处理,而不是傲慢地返回XDP_DROP。 -
强制 Native 模式加载,彻底告别 Generic 软中断陷阱: 修改加载程序(或使用
ip link set dev eth0 xdp obj xxx.o sec xdp时明确指定模式)。我们重写了加载工具,如果在给定的网卡上XDP_FLAGS_DRV_MODE(Native 模式) 挂载失败,应当直接终止部署并告警,绝对不允许静默回退到XDP_FLAGS_SKB_MODE。
同类问题速查排查清单 (eBPF/XDP 故障急救)
-
确认 XDP 是否介入及运行模式:
bpftool net show或ip link show。重点看接口后是否带有xdp标记,以及是xdpgeneric还是xdpdrv。如果是xdpgeneric且系统软中断高,直接拔掉 XDP 恢复业务。 -
检查 eBPF Map 的满载情况: 使用
bpftool map show获取所有 Map 的max_entries,再用bpftool map dump id检查当前元素量。接近或等于满载的,必定会引发| grep "Found" bpf_map_update_elem返回-E2BIG,需立即排查代码异常分支逻辑。 -
定位静默丢包(Drop)统计: XDP 丢包不会体现在 iptables 里。除了在代码里自建 BPF Perf Event 或 Ring Buffer 输出丢包日志外,可以通过
ethtool -S(依赖具体网卡驱动支持)来观测底层拦截量。| grep xdp_drop -
内核 BPF 调试日志探测: 如果开发在代码中使用了
bpf_printk,可通过cat /sys/kernel/debug/tracing/trace_pipe查看实时内核 eBPF 打印的报错信息,往往能一针见血发现诸如 Map Update 失败的错误。