--- title: "eBPF 不只是可观测性:一些内核追踪和网络加速的实战笔记" description: "跳出教科书式的 eBPF 介绍,聊点真正干活用的东西——从 XDP 到 TC,从 kprobe 到 BPF 链表,以及那些文档不会告诉你的坑。" pubDatetime: 2026-08-10 author: "Qihan" tags: ["Linux", "内核", "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: ```bash 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_STORAGE`、`BPF_MAP_TYPE_CPUMAP` 这类 map 用起来绑手绑脚。6.6 引入了 BPF linked list 和 `bpf_list_node`,让 eBPF 程序能维护更复杂的数据结构。 ```c 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 了。 ```c // 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 的优势: - 性能更好:kprobe 通过断点/跳转指令模拟,fentry 是直接在函数入口插入 trampoline,延迟更低 - 参数直接可读:kprobe 用 `PT_REGS_PARM1` 这些宏间接拿参数,fentry 用签名就能直接取 - 稳定性高:不再依赖内核函数的 prologue 结构 前提是内核开了 `CONFIG_FUNCTION_TRACER` 和 `CONFIG_BPF_KPROBE_OVERRIDE`。LTS 内核基本都支持了,如果你还在用 4.x ——那就安心用 kprobe 吧。 --- ## 实际踩过的坑 ### 1. BPF 栈大小只有 512 字节 这是新手第一道坎。BPF 程序不能声明大的局部变量,超过 512 字节 verifier 直接拒绝。解法很简单——用 BPF map(`BPF_MAP_TYPE_PERCPU_ARRAY` 做临时 buffer)。 ```c 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` 发到用户态。 ```c bpf_printk("pid %u sent %llu bytes to port %u\\n", pid, bytes, port); ``` 多一个参数都不行,verifier 会让你怀疑人生。 ### 3. verifier 的复杂路径限制 BPF 程序有复杂性限制(complexity limit),循环数、分支数、指令数超标就加载失败。 最烦人的是 **verifier 并不知道你的循环真的会终止**。比如: ```c 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 速率限制: ```c #include #include #include #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 阈值根据实际场景调就行。 --- ## 几个值得留意的方向 - **BPF 可移植性**:CO-RE + BTF 基本解决了跨内核版本的问题,别再写判断内核版本号的拙劣 hack 了 - **用户态 BPF 库**:libbpf 比 bcc 轻量,从 libbpf-bootstrap 起步挺舒服 - **sched_ext**(SCX):6.12 合入的 BPF 调度器框架,用 BPF 写 CPU 调度策略——这件事本身够开一长篇 - **BPF 安全审计**:`BPF_MAP_TYPE_RINGBUF` + fentry 做内核审计日志,比传统 auditd 性能好不少 --- 差不多就这些。都是实际项目里摸出来的,有人能少走点弯路就好。