mirror of
https://github.com/ChenQihan666/MyBlog-Next.git
synced 2026-08-14 07:33:07 +08:00
add: eBPF 实战笔记文章
This commit is contained in:
@@ -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 <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 阈值根据实际场景调就行。
|
||||
|
||||
---
|
||||
|
||||
## 几个值得留意的方向
|
||||
|
||||
- **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 性能好不少
|
||||
|
||||
---
|
||||
|
||||
差不多就这些。都是实际项目里摸出来的,有人能少走点弯路就好。🌀
|
||||
Reference in New Issue
Block a user