跳转到内容
启涵的小破站
返回

eBPF 不只是可观测性

写这篇东西的起因很简单:最近折腾了一阵 eBPF,网上教程不少,但大多数不是停留在”hello world”就是复制粘贴 kernel 文档。真正上手做点实际的事——比如用 XDP 挡 DDOS、用 TC 做透明代理——中间踩的坑,很少有文章好好讲。

这篇不打算再从头给你讲一遍 eBPF 是什么、verifier 怎么工作,那些官方文档写得够清楚了。我更想聊的是:你在做 eBPF 程序时大概率会遇到但文档没写的东西


XDP 的几件事,和你想的不太一样

XDP 不一定要网卡支持 native

很多文章告诉你 XDP 需要网卡驱动支持 native mode——严格来说没错,但实战中你八成会用 generic mode(XDP_FLAGS_SKB_MODE)做开发调试,最后再切到 native。用 ip link set dev eth0 xdpgeneric obj xdp_prog.o 就行,内核会把 SKB 模式的 BPF 程序挂在入口。

性能差距当然有——generic mode 走了完整协议栈再到 BPF,native 在驱动层就截住了。但开发阶段差那几微秒无所谓,先把逻辑调通。

XDP_PASS 不是”绕过协议栈”

有同学以为 XDP_PASS 过的包直接进 TCP/IP 栈。实际上 XDP_PASS 只是让包回到正常的内核网络路径,该过的 netfilter(iptables/nftables)、路由决策一个都不少。你在 XDP 里做了合法性过滤,PASS 出去的包该被 iptables 拦还是被拦。

XDP_TX 的典型场景:不是回包而是 hairpin

XDP_TX 可以把包从收到它的网卡原路丢回去。很多人以为这是用来构造回包的,但更常见的落地场景是做 hairpin load balancing——比如在 LVS/Direct Routing 场景下,从 eth0 进来的包处理后直接从 eth0 TX 回去,不走路由表。

性能好归好,但前提是网卡驱动支持。ixgbe、mlx5 这些主流没问题,有些虚拟网卡(virtio_net 的老版本)就不行。


TC BPF:比 iptables 更灵活的流量控制

clsact qdisc 是更干净的挂载点

传统用 tc filter add dev eth0 ingress 挂 ingress BPF,但你得先有一个 root qdisc。从 kernel 4.12 开始,推荐用 clsact:

tc qdisc add dev eth0 clsact
tc filter add dev eth0 ingress bpf da obj tc_prog.o sec tc_ingress
tc filter add dev eth0 egress bpf da obj tc_prog.o sec tc_egress

clsact 的好处是不用操心 qdisc 链,直接挂 ingress/egress。注意参数 da(direct-action)——没它 filter 只做分类不执行 action,新手最容易漏掉。

绕过 conntrack 做有状态过滤

Netfilter 的 conntrack 在大量连接场景下性能瓶颈很明显。TC BPF 里可以用 bpf_skb_load_bytes+bpf_map_lookup_elem 自己做状态表。实测 64 字节的 BPF_MAP_TYPE_HASH 存五元组,单核跑满 10G 线速比 iptables conntrack 能省 30-40% CPU。

代价是得手写超时回收逻辑。BPF_MAP_TYPE_HASH 不支持自动过期,可以配合 bpf_timer(5.15+)做定时清理。


BPF 链表(Linked List)是 6.6 才真正能用的

6.6 kernel 之前,BPF_MAP_TYPE_TASK_STORAGEBPF_MAP_TYPE_CPUMAP 这类 map 用起来绑手绑脚。6.6 引入了 BPF linked list 和 bpf_list_node,让 eBPF 程序能维护更复杂的数据结构。

struct node {
    __u64 value;
    struct bpf_list_node list;
};

struct array_map {
    __u64 count;
    struct bpf_list_head head __contains(node, list);
};

但有个限制很烦:链表头只能在 map value 里定义,不能直接在全局变量里用。而且 bpf_list_push_front/back 这些 helper 对指针的有效性检查非常严格——说白了还是 verifier 那套安全机制的锅。


kprobe/fentry 的选择:不要惯性用 kprobe

如果你还在用 SEC("kprobe/xxx"),可以考虑换 fentry/fexit 了。

// kprobe 方式
SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk)
{
    // ...
}

// fentry 方式(5.5+)
SEC("fentry/tcp_v4_connect")
int BPF_PROG(tcp_v4_connect, struct sock *sk)
{
    // ...
}

fentry 的优势:

前提是内核开了 CONFIG_FUNCTION_TRACERCONFIG_BPF_KPROBE_OVERRIDE。LTS 内核基本都支持了,如果你还在用 4.x ——那就安心用 kprobe 吧。


实际踩过的坑

1. BPF 栈大小只有 512 字节

这是新手第一道坎。BPF 程序不能声明大的局部变量,超过 512 字节 verifier 直接拒绝。解法很简单——用 BPF map(BPF_MAP_TYPE_PERCPU_ARRAY 做临时 buffer)。

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, __u32);
    __type(value, struct my_big_struct);
} tmp_buf SEC(".maps");

// 用的时候
__u32 zero = 0;
struct my_big_struct *buf = bpf_map_lookup_elem(&tmp_buf, &zero);
if (!buf) return XDP_ABORTED;

2. bpf_printk 不是 printf

bpf_printk 最多支持 三个 格式化参数,而且只认 %d %u %x %ld %lu %lld %llu %p %s 这几种,不认 %f%zu。想打的变量太多?分多次调用或者用 BPF_MAP_TYPE_PERF_EVENT_ARRAY 发到用户态。

bpf_printk("pid %u sent %llu bytes to port %u\\n", pid, bytes, port);

多一个参数都不行,verifier 会让你怀疑人生。

3. verifier 的复杂路径限制

BPF 程序有复杂性限制(complexity limit),循环数、分支数、指令数超标就加载失败。

最烦人的是 verifier 并不知道你的循环真的会终止。比如:

for (i = 0; i < max; i++) {
    if (arr[i] > threshold) { /* do something */ }
}

如果 max 是一个变量,verifier 会拒绝。得用 #pragma unroll 让编译器展开,或者用 bpf_loop helper(5.17+)。

4. 尾调用有 stack 限制

bpf_tail_call 不增加函数调用栈,但 tail call chain 最多 33 层MAX_TAIL_CALL_CNT)。超过这个数 tail call 静默失败,返回 0。而且 tail call 之后当前 BPF 程序的栈内容全部消失——所以没法”partial execute + tail call”做分段处理。


实战:XDP 源 IP 速率限制

贴一个我在用的简化版 XDP,用 BPF_MAP_TYPE_LRU_HASH 做单 IP 速率限制:

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

#define RATE_LIMIT_PPS 1000

struct ip_rate_key {
    __u32 src_ip;
};

struct ip_rate_val {
    __u64 last_seen;
    __u32 pps;
};

struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 100000);
    __type(key, struct ip_rate_key);
    __type(value, struct ip_rate_val);
} rate_map SEC(".maps");

SEC("xdp")
int xdp_rate_limit(struct xdp_md *ctx)
{
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    struct ethhdr *eth = data;

    if ((void *)(eth + 1) > data_end)
        return XDP_ABORTED;

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

    struct iphdr *ip = data + sizeof(*eth);
    if ((void *)(ip + 1) > data_end)
        return XDP_ABORTED;

    struct ip_rate_key key = { .src_ip = ip->saddr };
    struct ip_rate_val *val, new_val = {};
    __u64 now = bpf_ktime_get_ns();

    val = bpf_map_lookup_elem(&rate_map, &key);
    if (val) {
        __u64 elapsed = now - val->last_seen;
        if (elapsed < 1000000000ULL) {
            val->pps++;
            if (val->pps > RATE_LIMIT_PPS)
                return XDP_DROP;
        } else {
            val->last_seen = now;
            val->pps = 1;
        }
    } else {
        new_val.last_seen = now;
        new_val.pps = 1;
        bpf_map_update_elem(&rate_map, &key, &new_val, BPF_NOEXIST);
    }

    return XDP_PASS;
}

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

BPF_MAP_TYPE_LRU_HASH 自带最近最少使用淘汰,不会让 map 无限膨胀。rate limit 阈值根据实际场景调就行。


几个值得留意的方向


差不多就这些。都是实际项目里摸出来的,有人能少走点弯路就好。


分享本文:

下一篇
Debian 12(Bookworm)安装教程

加载评论区中...