eBPF:为什么它能把观测成本降到几乎为零

难度 ★★★★★ · 前置:读内核源码从哪儿下手:别从开头的第一行开始 | 一句话结论:eBPF 的价值不在「能挂到内核函数上」,而在校验器保证了代码不会卡死、不会越界、不会死循环——正因为这个保证,内核才敢在生产系统上运行动态加载的代码。没有校验器,eBPF 就只是另一个 kernel module。

接口慢,strace 一挂就不慢了

一个接口偶发慢,一天几十次,每次几秒钟,监控上就是一根毛刺。我第一次碰到这种问题,本能反应是把 strace 挂上去,结果怪事发生了,挂上之后它就不慢了。后来才明白不是运气,ptrace 会把目标进程停下来检查,一停就改了时序,本来该卡住的地方被调试器拖成了别的形状,这也解释了为什么很多并发问题在调试器下永远复现不出来。(我那时候还不太信这个说法,觉得是巧合,直到连着三天都是挂上就变快,才认了。)

perf record 也试了,火焰图画得挺漂亮,但那几次慢的根本不在图上,它卡在 IO 等待里,CPU 是闲着的,采样器看见的就是一段空转,你要找的那几秒钟在图上是一片空白。我要的东西其实很具体,哪个进程、在哪个文件上、等了多久,这三样刚好落在所有现成工具的缝隙里。现在回头看,那一次的教训是我没分清采样和追踪的边界,采样器只回答 CPU 在忙什么,而等待这件事本来就不发生在 CPU 上。

下面这三行是我第一次用 bpftrace 时照着别人的博客抄的,把 $1 换成你要盯的进程号就行,比如 sudo bpftrace -e '...' 4210。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sudo bpftrace -e '
kprobe:vfs_read /pid == $1/
{
@start[tid] = nsecs;
}
kretprobe:vfs_read /@start[tid]/
{
$d = nsecs - @start[tid];
if ($d > 10000000) {
printf("slow read %dms by %s\n", $d / 1000000, comm);
@slow_ms[comm] = sum($d / 1000000);
}
delete(@start[tid]);
}
'

挂上去,业务没受影响,延迟数据直接出来了。

这就是 eBPF 的日常用法,在不改变被观测对象行为的前提下,把耗时和上下文一起捞出来,而这件事在 ptrace 那一代工具里是做不到的,因为它必须让被观测的东西停下来,才能看见它,停下来这件事本身就是一种改变,你把一个正在等锁的线程暂停两毫秒,它等的那把锁的释放时机也跟着挪了两毫秒,等你放开它,原来的竞争关系已经不成立了。

以前那几种办法差在哪

方法 代价
strace / ptrace 会让目标进程暂停,改变时序,高并发下可能放大问题
perf 只看 CPU 采样,看不到「等待」和「阻塞」
systemtap 要在目标机器上编译内核模块,很多环境不允许
内核模块 同上,而且一行崩溃能整机 panic
打日志 + 重启 要改代码、要发布、可能复现不了

eBPF 的突破点是把用户写的代码编译成字节码,由内核里的校验器证明它安全,然后在内核里直接跑。重点在那句证明它安全,这是二十年来所有动态追踪方案都没解决的核心问题,不是没人想到往内核里插代码,是没人敢在生产机上插一段自己都没把握的代码,因为内核模块里的一个空指针会让整台机器直接 panic,而一段 eBPF 字节码最多是被校验器拒绝,然后你在日志里读到一段看不懂的报错。

内核凭什么敢跑别人写的代码

eBPF 是内核里的一个虚拟机,加载的字节码必须经过校验器证明「不会越界、不会死循环、不会调用未授权的函数」,通过后挂到指定的执行点上运行。

三个部件缺一不可。

校验器就卡在中间那一步,没有它,eBPF 只是一条加载流程更麻烦一点的 kernel module,多了个编译器,多了几个系统调用,仅此而已,而它凭什么能让人放心,答案全在那个证明上。所谓证明,是把一段代码在所有可能输入下的行为都推一遍,推到每一条路径的结论都落在允许的范围内,这件事听起来比试跑麻烦得多,但它有一个试跑永远给不了的好处,就是不依赖运气,试跑只能覆盖它碰巧走到的那些分支,而证明覆盖的是所有分支,包括那些你根本没想过的输入组合。

校验器挑刺到什么程度

我刚开始写 eBPF 程序时最想不通的一点是,它凭什么在代码跑起来之前就知道这段东西不会炸,这个问题憋了我挺久,直到有一天认真去翻了一圈资料,才发现自己一直想错了方向。

我当初以为校验器是在后台偷偷试跑一遍,跑崩了就回滚,后来翻了源码才明白它一条指令都不执行,它把每条指令、每条分支都走一遍,走到哪一步就记下当时每个寄存器里是什么类型的值,走完全部路径没有一条能越界、能无限转、能调到不该调的 helper,才算通过,所以它给的是证明,不是抽查。这个误解让我很久没想通它为什么连一个明显的死循环都发现不了,因为试跑的话应该当场就卡住了。

下列三类条件,缺一条都过不去。

内存访问必须在边界内

1
2
3
4
5
6
7
8
9
// 校验器拒绝
void *p = bpf_map_lookup_elem(&m, &key);
return *(u64 *)(p + 8); // p 可能是 NULL

// 校验器接受
void *p = bpf_map_lookup_elem(&m, &key);
if (!p) return 0; // 显式检查
u32 *v = p;
return v[0];

这就是为什么 eBPF 程序里到处是 if (!p) return 0;,不是风格,是不写校验器就不让过。

它不相信你,也不相信任何没有写出来的东西,你脑子里那个 p 一定不为空的事实,在它的推导过程里根本不存在,所以每一条解引用之前都得有一行看得见的检查,这也是 eBPF 代码看起来啰嗦而实际不能删的原因。刚写的时候我很烦这些 if (!p) return 0;,觉得是给编译器看的废话,后来自己写了个把 map 查出来的值直接下标的程序,被拒了三次才明白,那行检查不是在哄校验器,是在替未来读代码的人把你当时的假设写下来。

循环的上界必须能被证明

4.14 之前完全不允许循环,只能靠 #pragma unroll 展开。bounded loop 要到 5.3 才放开,在那之前校验器遇到回边(back-edge)直接返回 -EINVAL。放开之后,循环变量仍然必须能被证明有界:

1
2
3
4
5
// 拒绝:i 的上界不可知
for (int i = 0; i < n; i++) { ... }

// 接受:上界是常量
for (int i = 0; i < 100; i++) { ... }

while 是同一版放开的,校验器不区分 for 和 while,只看能不能证明循环会在 1,000,000 条指令内结束。这个数不是给人看的,是校验器算出来的,它是所有路径上指令数的上界,某一条路径一旦可能越过它,加载当场就失败,而且报错信息通常只说指令数超了,不会告诉你是哪一圈转多了,这一点让早期写 eBPF 的人非常难受,因为你得自己顺着所有分支数一遍。

只能调白名单里的 helper

eBPF 程序不能随便调内核函数,只能调白名单里的 helper。

helper 作用
bpf_map_lookup_elem 查 map
bpf_ktime_get_ns 拿纳秒时间
bpf_get_current_pid_tgid 当前进程
bpf_probe_read_user 安全读用户内存
bpf_trace_printk 调试打印(慢,别在生产用)
bpf_perf_event_output 写 perf ring buffer

每个 helper 的可用性还取决于程序类型。一个 XDP 程序不能调 bpf_get_current_pid_tgid,因为 XDP 在网卡驱动里跑,没有当前进程这个概念。这类限制写的时候不报错,加载的时候才炸,而且炸的位置跟你写错的地方通常隔着好几屏。

bpftool feature probe 能列出当前内核支持哪些 helper,想知道自己手里这台机器能干什么,一条命令的事。

名字里的 BPF 是另一个东西

都叫 BPF,cBPF 和 eBPF 是两码事,它们的区别不在新旧,而在设计目标,一个是为包过滤设计的极简指令集,一个是被硬塞进追踪和网络两个领域的通用虚拟机。

cBPF eBPF
寄存器 2 个(A、X) 11 个(r0-r10)
位宽 32 位 64 位
用途 tcpdump 的过滤器(-d 能看到那几条指令) 追踪、网络、安全
内核位置 net/core/filter.c kernel/bpf/

tcpdump -d 'tcp port 80' 显示的就是 cBPF:

1
tcpdump -d 'tcp port 80' 2>/dev/null | head -8
1
2
3
4
(000) ldh      [12]
(001) jeq #0x86dd jt 2 jf 7
(002) ldb [20]
...

这段和 eBPF 字节码长得很像,但指令集不同。现代 tcpdump 在 Linux 上其实已经会先翻译成 eBPF,-d 显示 cBPF 只是为了可读性,你看到的是一份面向人眼的历史遗留格式。

挂载点决定了它能调什么

eBPF 能挂的地方很多,按用途分主要这几类。

类型 挂在哪 典型用途
kprobe / kretprobe 任意内核函数入口/返回 内核追踪
tracepoint 内核预埋的静态探针 稳定追踪(首选)
uprobe / uretprobe 用户态函数 应用追踪
perf_event 硬件/软件性能事件 采样分析
XDP 网卡驱动收包最早期 DDoS 防御、负载均衡
tc / cls_bpf 流量控制层 网络策略
cgroup_skb cgroup 级别的包过滤 容器网络策略
lsm 安全钩子 访问控制

tracepoint 比 kprobe 稳定得多。tracepoint 是内核 ABI 的一部分,名字和参数格式都有承诺,kprobe 挂的那些函数名,可能下个版本就改名、被内联,或者整个函数消失,而这些变化在发行版的 changelog 里往往只有一行,还是写在几万行的变更说明里,你要不是天天盯着根本不会看见。(我有一次升级内核之后一个挂了半年的探针直接不加载了,翻了半天才发现那个函数被合并进了另一个文件。)

生产上能用 tracepoint 就别用 kprobe,这句话我一开始当口号听,后来被那次升级教育过才真信。

bpftrace 项目自己有一张图,把这几类挂载点摆在了同一张表上,左边是动态插桩(uprobe/kprobe),中间是静态探针(tracepoint,按子系统分好了组),右边是硬件和软件事件。图有点旧,但分类至今没变:

bpftrace 的探针类型

map 是程序唯一的出口

eBPF 程序本身只能返回一个整数,要带数据出来必须过 map,这也是它和内核模块最大的差别之一,模块可以直接改内存里的任何东西,而 eBPF 只能往 map 里写。

map 类型 用途
BPF_MAP_TYPE_HASH 通用哈希表
BPF_MAP_TYPE_ARRAY 定长数组(最快)
BPF_MAP_TYPE_PERCPU_HASH 每 CPU 一份,无锁
BPF_MAP_TYPE_RINGBUF 环形缓冲(5.8+,取代 perf buffer)
BPF_MAP_TYPE_STACK_TRACE 存调用栈
BPF_MAP_TYPE_LRU_HASH 满了自动淘汰

PERCPU_HASH 是性能关键。普通 hash 多个 CPU 同时写要加锁,per-CPU 版本每个 CPU 写自己的副本,零竞争,代价是你得在用户态把各个 CPU 的副本加起来。

RINGBUF 是现在的推荐做法。perf buffer 每个 CPU 一个 buffer,核心数一多内存开销很可观,已经在被淘汰的路上。

一次加载要走完哪几步

几个容易忽略的细节:

  1. 校验器是静态证明,不是运行时检查。它不运行程序,而是把每条指令、每条可能的路径都模拟一遍。
  2. 指令数上限有两道,非特权 4096 条(BPF_MAXINSNS),特权 1,000,000 条(BPF_COMPLEXITY_LIMIT_INSNS)。这不是以前 4096、后来放宽的关系,是两道并行的门槛,看加载时有没有 CAP_BPF 或 CAP_SYS_ADMIN。
  3. JIT(net.core.bpf_jit_enable)在上游默认是关的,但主流发行版编内核时开了 CONFIG_BPF_JIT_ALWAYS_ON,所以你真去读通常是 1。关掉之后解释执行慢十倍以上。
  4. 必须声明 license。有些 helper 只对 GPL 开放,加载时校验。

校验器怎么知道那个指针能不能用

它会对每条指令记下寄存器里装的是什么类型的值,然后按这个类型往下推。

1
2
3
4
r1: map_ptr (valid)
r6 = *(u64 *)(r1 + 0)
→ r6: scalar (unknown) ← 因为不知道读出来的是什么
→ 后续用 r6 做指针偏移会被拒

这就是 eBPF 里要频繁用 bpf_probe_read_* 的原因,校验器无法证明用户传进来的这个指针有效,那就交给 helper,让内核在运行时代为检查。

bpf_probe_read_user 在运行时做 access_ok,失败返回 -EFAULT,不会崩。用运行时的检查换编译期的可证明性,eBPF 从头到尾都在做这笔交易,理解了这一点,很多看起来别扭的写法就都顺了。

加载完它就一直挂在那里

eBPF 程序加载之后一直挂在钩子上,直到对应的 fd 关闭,或者被显式卸载。

1
2
sudo bpftool prog list
sudo bpftool prog show id 42

bpftool 是 eBPF 的 ps。排查这个 kprobe 到底被谁占着,只能靠它。

内存限制是个老坑。RLIMIT_MEMLOCK 在裸机上还是 64KB 这个量级,systemd 管的机器通常已经被抬到 64MB 甚至不限,很多 eBPF 工具就是在这里第一次失败的:

1
bpf: map create failed: Operation not permitted

改法(ulimit -l 只能放宽到硬限制以内,普通用户直接 ulimit -l unlimited 会报 Operation not permitted,而且只对当前 shell 有效):

1
2
3
4
ulimit -Hl                                  # 先看硬上限是多少
sudo sh -c 'ulimit -l unlimited; ./你的程序' # 干脆用 root 或改 /etc/security/limits.conf
# 或者 systemd unit 里
# LimitMEMLOCK=infinity

5.11 之后改用 memcg 记账,这个限制基本消失了。但在老内核上,它仍然是第一大故障原因,而且报错信息只字不提内存锁,只说一句 Operation not permitted,第一次撞上根本想不到是这里。

那几十纳秒是从哪省下来的

三个原因。

一是 JIT 编译成机器码,没有解释器开销,和内核原生代码一样快,二是强制的代码简化,早期不允许循环,不允许阻塞,不允许睡眠,程序本身就被压得很短,三是没有上下文切换,程序在触发它的那个 CPU 上直接执行,不换线程,不进用户态。这三条里第三条最容易被低估,因为上下文切换的代价从来不在切换那一下,而在切回来之后 cache 已经全凉了,一次切换可能只花两微秒,可你重新把热数据装回 L1 要花几十微秒,追延迟的人算的是后面那笔账,不是前面那笔。

方案 单次开销 数量级依据
kprobe(无 eBPF) ~1~3 微秒 要把指令换成 int3、触发异常、走 do_int3、再补回原指令
eBPF + tracepoint ~100~300 纳秒 静态探针点,走预置的 __bpf_trace_* 调用
eBPF + fentry(5.5+) ~50~100 纳秒 函数入口直接插一条 call,无异常、无上下文切换
eBPF + XDP ~50 纳秒 在 netif_receive_skb 之前,包里一进来就跑

这组数字给的是数量级,不是精确值。它们来自 Brendan Gregg 的 bpftrace/perf 实测与 vmtest 基准,随 CPU、内核版本、探针在不在热路径上变化很大。想在自己机器上量,最直接的办法是拿同一个空函数,分别用三种方式挂上去,看 bpftrace 报的 @ns 直方图:

1
2
sudo bpftrace -e 'kprobe:vfs_read { @start[tid] = nsecs; }
kretprobe:vfs_read /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }'

数字会比你预期的散,但只要三种方式用同一套测法,相对关系是稳定的,这就够支撑能用 fentry 就别用 kprobe 这个判断了。

为什么数量级会差这么多,kprobe 的工作方式是把目标函数第一条指令替换成断点(int3),执行到这里时 CPU 触发异常、内核的异常处理程序接手、保存现场、调你的探针、再单步执行原指令、最后恢复断点,这一整套走下来,微秒级不奇怪。fentry(CONFIG_FUNCTION_TRACER + BTF)用的是编译器预留的位置(-pg 或 -mfentry 生成的 call __fentry__),内核在启动时把那个 call 的跳转目标改掉,直接跳到你的程序,没有异常、没有单步,代价只是一条原本就在函数入口的 call 指令换了个去处,这也是两者开销差出两三个数量级的根本原因。

fentry 是 kprobe 的现代替代品,前提是内核 5.5+ 且开了 BTF(/sys/kernel/btf/vmlinux 存在)。

所以挂 kprobe 有开销这句话是对的,换个 fentry 就没了。

它也会拖慢系统

不是零成本。三个场景会出问题:

  1. 挂在高频函数上,比如 vfs_read。每个调用都要执行一遍你的代码,100 纳秒 × 每秒 100 万次 = 10% CPU。
  2. bpf_trace_printk 写 debugfs,慢到不能用。它要格式化字符串,再写文件。
  3. map 太大或者访问模式不好,cache miss 的开销会盖过计算本身。

第一条最容易忽略。bpftrace 里 kprobe:sys_enter 这种全量挂在生产上是禁忌,我第一次这么干,负载直接上去一截,而且当时完全没往探针上想,查了半天以为是流量涨了。(那次之后我养成一个习惯,挂探针之前先看一眼这个函数每秒被调多少次。)

正确做法是加过滤条件(/pid == $1/),或者用 tracepoint 加采样。

装完之后的第一行

1
2
3
4
# 主流发行版都有
sudo apt install bpftrace bpfcc-tools linux-headers-$(uname -r)
# 或者
sudo dnf install bpftrace bpftool kernel-devel

linux-headers 是必需的,kprobe 需要知道函数的参数类型。

先数一下系统调用的次数:

1
sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'

几秒后按 Ctrl-C,会打印一张表:

1
2
3
@[node]: 18234
@[bash]: 1203
@[sshd]: 87

这就是 eBPF 的入门三行:一条语句,全局系统调用画像。我第一次敲完盯着 node 那个数看了半天,两万多次里到底有多少是必要的,之前完全没概念。

再看哪个文件读得最慢:

1
2
3
4
5
6
7
8
9
10
11
sudo bpftrace -e '
kprobe:vfs_read {
@s[tid] = nsecs;
}
kretprobe:vfs_read /@s[tid]/
{
$d = nsecs - @s[tid];
@ms[comm] = hist($d / 1000000);
delete(@s[tid]);
}
'

hist() 直接给出直方图:

1
2
3
4
5
@ms[node]:
[0] 5123 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1] 1204 |@@@@@@@@@@@ |
[4, 8) 31 |@ |
[8, 16) 6 |@ |

一次就能看出是大部读得快、少数读得慢,还是普遍都慢。

把聚合放进内核

看是谁在 fork:

1
sudo bpftrace -e 'tracepoint:sched:sched_process_fork { printf("%s -> %s\n", comm, str(args->child_comm)); }'

args->child_comm 这种访问是 bpftrace 做的语法糖,底下帮你调了 bpf_probe_read。

抓 99 分位延迟也是同一个套路:

1
2
3
4
5
6
7
8
9
10
sudo bpftrace -e '
kprobe:tcp_sendmsg {
@s[tid] = nsecs;
}
kretprobe:tcp_sendmsg /@s[tid]/
{
@us[comm] = hist((nsecs - @s[tid]) / 1000);
delete(@s[tid]);
}
'

这是内核里做统计、用户态只看结果这种分工的典型形态。每次调用都传到用户态再聚合,传输本身的开销就和你要观测的东西分不开了,hist() 的聚合在内核里完成,用户态拿回来的只是一张已经算好的表。

从 bpftrace 换成 C

bpftrace 是脚本,逻辑一复杂就得写 C。需要 clang 和 libbpf。

1
sudo apt install clang llvm libbpf-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// hello.bpf.c
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} rb SEC(".maps");

SEC("tracepoint/sched/sched_switch")
int on_switch(struct trace_event_raw_sched_switch *ctx) {
__u32 pid = bpf_get_current_pid_tgid() >> 32;
if (pid == 0) return 0;
bpf_printk("switched to %d", pid); // 简化写法
return 0;
}

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

SEC() 宏决定挂载点。tracepoint/sched/sched_switch 这一段要和 ls /sys/kernel/debug/tracing/events/ 底下的路径对得上,写错就是加载失败,而失败信息里通常只有一句 No such file or directory。

编译(还需要 linux-headers-$(uname -r) 才有 linux/bpf.h,Debian 系的 clang 有时不带 BPF target,先 clang --print-targets | grep bpf 确认):

1
2
sudo apt install clang llvm libbpf-dev linux-headers-$(uname -r)
clang -O2 -g -target bpf -c hello.bpf.c -o hello.bpf.o

-target bpf 是关键,它生成 BPF 字节码,不是 x86 机器码。

-g 把 BTF 信息保留下来,现代 libbpf 的 CO-RE(一次编译,多内核版本运行)全靠它。

CO-RE 解决的是哪个问题

写内核探针最烦的事:结构体字段偏移随内核版本变。你在 6.1 上编译的程序,拿 task_struct->pid 的偏移 1208 去读,拿到一台 5.15 的机器上跑,读到的就是别的字段,数据看着有,全是错的,不报错。老办法是在运行时查 vmlinux 的调试符号现算偏移,或者给每个内核版本各编一份。CO-RE(Compile Once, Run Everywhere,5.2 起)换了路子:

  1. 编译时 -g 生成 BTF(BPF Type Format),把源码里用到的结构体布局记下来,但不写死偏移;
  2. 程序里用 BPF_CORE_READ(task, pid)、bpf_core_field_exists()、BPF_CORE_READ_INTO() 这类宏,编译器把它们转成重定位记录;
  3. 加载时 libbpf 拿目标机器的 /sys/kernel/btf/vmlinux(内核自带的 BTF,CONFIG_DEBUG_INFO_BTF=y),对比两份 BTF,把偏移补进指令里。
1
2
pid_t pid = BPF_CORE_READ(task, pid);        /* 而不是 task->pid */
unsigned int tgid = BPF_CORE_READ(task, tgid);

BTF_CORE_READ 这类宏里还带一层 bpf_probe_read_kernel,所以顺手解决了直接解引用内核指针会被校验器拒这件事。

它解决不了的部分,得诚实说清:

  • 函数被删了、改名了 → 加载直接失败(fentry 尤其脆)。
  • tracepoint 的 format 文件变了 → 字段偏移照样错,BTF 管不到。
  • 结构体字段被拆成嵌套结构 → BPF_CORE_READ 能跟、手写的偏移不能跟。

所以 CO-RE 之后一次编译处处运行这话只对结构体字段成立。真要跨大版本内核,还是得准备降级路径(bpf_core_field_exists() 判断字段在不在),而我第一次用这个宏的时候忘了写 else 分支,程序在一台老机器上静默丢了一半数据。

加载失败该看哪里

1
2
3
sudo bpftool prog list
sudo bpftool map list
sudo bpftool link list

link 是 5.7 之后的概念。一次加载可以创建多个 link,bpftool link 才是谁挂在哪儿这个问题的准确视图。

再看内核支持什么:

1
sudo bpftool feature probe | head -40
1
2
3
4
5
6
7
8
9
Scanning system configuration...
unprivileged_bpf_disabled is set to 2
bpf_jit_enable is set to 1
...

eBPF helpers supported for program type tracing:
- bpf_map_lookup_elem
- bpf_map_update_elem
...

这个输出能立刻回答为什么我的 eBPF 程序加载失败。三个最常见的原因:

  1. unprivileged_bpf_disabled = 1 且当前不是 root;
  2. 内核版本太老,没有这个 helper;
  3. RLIMIT_MEMLOCK 不够。

校验器拒绝长什么样

1
sudo bpftrace -e 'kprobe:vfs_read { $p = ((int *)1); print(*$p); }'

报的是:

1
stdin:1:1-33: ERROR: Probe not found

或者更典型的一种:

1
invalid read from stack...

想看完整的校验器日志(debugfs 没挂的话先 sudo mount -t debugfs none /sys/kernel/debug,另外得有程序正在往 trace_pipe 写,否则这条命令会一直挂在那里):

1
sudo cat /sys/kernel/debug/tracing/trace_pipe

uprobe 能看见明文这件事

1
2
3
4
5
6
sudo bpftrace -e '
uprobe:/usr/lib/x86_64-linux-gnu/libssl.so:SSL_write {
printf("%s wrote %d bytes\n", comm, arg2);
printf("%s\n", str(arg1, arg2));
}
'

这是 eBPF 最有名的用途之一:uprobe 挂在用户态函数上,进程内部的明文直接打出来,几行脚本就能抓到进程往 TLS 里塞的东西。

它同时也是安全的噩梦。能挂 uprobe,就能看到进程里的所有密钥,所以 unprivileged_bpf_disabled 在大多数发行版里是 2,完全禁止非特权用户。(我刚知道这件事的时候第一反应是这也太危险,第二反应是查了一下自己机器上的值,发现早就是 2 了。)

再往上一层是 Cilium 的 hubble、Pixie 这类东西。它们都是 eBPF 的上层应用,把原始的追踪能力包装成服务调用图和 HTTP 指标,看起来像另一类产品,底下跑的还是上面这些机制。读懂它们的前提,是先知道 ringbuf 里那些数据是怎么来的,而这件事我在第一次装 Cilium 的时候完全没概念,只当它是个更花哨的 tcpdump,直到有次丢包查不下去,才回头把前面那些东西补上,补完才发现最难的不是学会 eBPF,是先知道自己缺的是哪一块。

那些流传很广的话,错在哪一层

eBPF 是内核模块的替代品,这个说法定位就不对。内核模块能改内核状态、能做任意事,eBPF 只能读(除非用 bpf_probe_write_user,而且它被严格限制),eBPF 更安全,能力也窄得多,它不是替代品,是一把更小但更安全的刀。这两者最本质的差别在于内核模块是内核的一部分,它出问题就是内核出问题,而 eBPF 是一段被证明过的客人,它出问题只会被拒绝加载,这个差别不是程度上的,是性质上的。(我一开始也把它当成模块的轻量版,直到看见一个模块把整台测试机搞重启。)

eBPF 零开销,这话得看用在哪。单次几十纳秒,在高频函数上累计起来不小,vfs_read 这种每秒百万次的函数,挂一个 100 纳秒的程序就是 10% CPU,这个数我实测过,跟算出来的一致,而 10% 在一个已经很饱和的服务上就是压垮骆驼的那根草。

校验器通过就绝对安全,这话过了。校验器是软件,内核历史上出过多次校验器绕过漏洞(CVE-2021-3490、CVE-2021-31440 等),它是很高的门槛,不是绝对保证。必须编译内核才能用这句也不对,内核 4.x 之后都内置了 eBPF,只要开了 CONFIG_BPF_SYSCALL=y,而主流发行版基本都开着。

要 5.x 才能用,这句得看具体功能。bpftrace 需要 4.9+,fentry 需要 5.5+,ringbuf 需要 5.8+,CO-RE 需要 5.2+ 和 BTF 支持,混在一起说会把人吓退,好像非得上一台新机器才能开始玩,实际上大多数排查用到的只是最早那几个功能。

CO-RE 之后一次编译到处运行完全成立,这句只对结构体字段成立,它适配不了函数被删掉,也适配不了 tracepoint 格式变了这一类变化。bpf_printk 可以用来调试也只算半句真话,它写的是 debugfs,慢到会把你要测的东西本身拖变形,能用几次,不能用一晚上。

挂 uprobe 是安全的,恰恰相反。它能读到进程里的任何数据,包括密码、token、TLS 会话密钥,这是严重的安全边界问题,不是普通调试手段,这也是为什么给它装上限制之后,很多原来靠它实现的方案被迫换成了在应用层插桩。

归结成几条

  • eBPF 的核心是校验器:它静态证明「不越界、不死循环、不调未授权函数」,这个保证才让内核敢在生产上运行外部加载的代码。
  • 加载、校验、JIT、挂载、事件触发执行、写 map,六步,没有例外。
  • 能用 tracepoint / fentry 就别用 kprobe,后者既不稳定,也慢一个数量级。
  • eBPF 程序里满地的 if (!p) return 0; 不是啰嗦,是校验器要求。
  • PERCPU_HASH 和 RINGBUF 是性能关键;bpf_trace_printk 在生产上是禁忌。
  • 开销不是零:高频函数上累计可观,必须加过滤条件。
  • bpftool feature probe 能一次回答「为什么加载失败」;RLIMIT_MEMLOCK 是老内核上的经典坑。
  • 能挂 uprobe 就能读到明文和密钥,这才是 unprivileged_bpf_disabled=2 的原因。

写到这里,eBPF 能回答的问题基本都覆盖了:一次调用花了多久、走了哪条路径、在哪个文件上等的。但它回答不了 CPU 把时间烧在了哪条指令上,那还得回到采样器那边。perf top 里那些 [unknown] 的符号,是另一个方向的麻烦。