传统 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)。
当网卡接收到物理信号并转化为数据帧后,整个处理流水线如下:
-
DMA 拷贝:网卡将数据帧写入内存的 Ring Buffer。
-
硬中断(Hard IRQ):网卡通知 CPU 数据已到达。
-
软中断(Soft IRQ):内核唤醒
ksoftirqd,通过 NAPI 机制轮询拉取数据。 -
内存分配(核心痛点):内核为每个数据包调用
build_skb()/alloc_skb()分配核心数据结构struct sk_buff,并进行元数据初始化。 -
协议栈与 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. 编译与挂载
确保系统安装了 clang、llvm 以及内核开发包(当前环境 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 机制),但这会引入额外的上下文切换开销,建议通过开关或概率采样来控制。