diff --git a/src/content/posts/ebpf-practical-notes.mdx b/src/content/posts/ebpf-practical-notes.mdx new file mode 100644 index 0000000..efe229c --- /dev/null +++ b/src/content/posts/ebpf-practical-notes.mdx @@ -0,0 +1,241 @@ +--- +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 性能好不少 + +--- + +差不多就这些。都是实际项目里摸出来的,有人能少走点弯路就好。 \ No newline at end of file