很多人第一次听到 eBPF 这个名字,是在讨论 Kubernetes 网络方案、云原生安全,或者某个性能排查的帖子里。但真正上手用过的可能没那么多。你把它当成一个可以在 Linux 内核里安全运行用户态代码的沙箱,可能还是觉得抽象。换个说法:它让你在不改内核源码、不加载内核模块、不影响在线服务的前提下,拿到系统内部几乎任何角落的数据——CPU、内存、文件、网络、进程调度,内核里发生的事,只要你想看,都能看到。
这事儿在十年前是不可想象的。那时候一想到内核观测,脑子里蹦出来的要么是 strace 这类性能损耗大、只能看单个进程的工具,要么是改内核、编内核、重启机器、跪半天运气的那套玩法,要么是 SystemTap 这种装起来复杂到怀疑人生的家伙。eBPF 把这条路彻底改变了,而且不是渐进式改良,是换了一种玩法:把一段段小型程序加载进内核的虚拟机里,由内核的安全校验器检查通过后,挂到指定的探测点上,像一面显微镜一样照看内核的运行。它的热度不是炒作,是真的解决了一大批长期无解的问题。
这篇文章我会从原理、实操到排障,把 eBPF 这套东西完整拆给你看。核心围绕 Linux 内核可观测性这条主线,讲清楚它为什么能成、怎么用、踩过哪些坑。适合刚接触 eBPF、搞清概念但不知道从哪下手的开发者,也适合已经写过几个 BPF 程序、想在原理和实战上更进一步的同学。
1. 可观测性困局与 eBPF 的破局思路
1.1 传统观测手段的短板到底在哪
先把老路的痛苦盘一遍,你才能懂新路的含金量。
strace:靠 ptrace 实现,每次系统调用都要发生一次进程停止和恢复,性能损耗能吃满一个核。生产环境基本不敢常开,顶多临时抓一下。/proc、/sys:静态快照多,动态事件少,想看某个进程在某时刻干了啥,不好意思,数据不在这。perf:功能强大,但采样数据偏底层,事件粒度粗,拿来做典型性能剖析还行,想做精细的事件级追踪链路,很难。- 内核模块:想观测哪儿就改哪儿,自由度最高,但代价也最高。内核版本一变就编译报错,调试不当直接内核崩溃,生产环境谁敢随便 insmod。
这些工具的本质问题不是功能不够,而是没有一个统一的、安全的、低开销的机制,让你在“内核运行时”动态地注入观测逻辑。要么侵入太深(内核模块)、要么代价太大(ptrace)、要么能力太弱(procfs)。eBPF 等于在这个空位上填了一个新选项:安全、高效、动态、丰富的数据来源。
1.2 eBPF 的核心设计:受限虚拟机加事件驱动
eBPF 的本质可以概括成一句话:一套运行在 Linux 内核里的微型虚拟机,配合事件驱动模型,让用户定义的程序在内核事件发生时被安全执行。
程序代码(BPF 字节码)由用户态编译,加载进内核后,先经过校验器(Verifier)的严格审查。校验器会模拟执行所有路径,检查循环、越界、空指针、整数溢出等问题,确认它不会导致内核崩溃或者死循环,才会让它附着到 Hook 点上执行。
事件一来,BPF 程序触发,收集需要的数据,塞进 BPF map 或者 perf event ring buffer,回到用户态再读取分析。整个过程在内核态完成,没有来回切换,并且执行时间是微秒级甚至纳秒级的,这就是它能扛住生产流量的原因。
veth 传递、套接字过滤、磁盘 IO 监控、进程执行追踪,这些都是它的应用场景。但底层逻辑都一样:内核里到处都是可观测的事件点,eBPF 让你在这些点上挂上自己的逻辑。
1.3 为什么是现在:云原生与微服务倒逼可观测性进化
为什么 eBPF 这几年突然火到不行?是因为云原生架构把传统监控的短板放大了。
微服务拆得细,服务间调用全靠网络,问题延迟可能藏在某个 Pod 的网卡队列里。容器又是动态调度,上一秒在 node1,这一秒在 node10,传统基于主机名的监控全抓瞎。Kubernetes 还在网络层做了各种 overlay、隧道、代理,传统抓包方式根本看不清虚拟化后的流量走向。
在这些新场景下,你可以用 eBPF 做到很多传统手段做不到的事:按容器维度观测流量(不用关心 IP 漂不漂)、在内核网络协议栈挂程序记录连接分布、追踪每个新进程的启动参数和网络行为。安全公司拿它做运行时入侵检测,网络方案拿它做内核级负载均衡,可观测性平台拿它做无侵入的分布式追踪。本质上都是因为这件事:它让内核变成了一个数据源,一个随时可以按照需求查询或者订阅的数据源。
2. eBPF 核心技术点拆解:从概念到落地
2.1 基本构件:BPF 指令、MAP、程序类型
把 eBPF 拆开看,主要就这几样东西。
BPF 字节码:加载进内核的不是 C 代码,是编译后的字节码。C 代码通过编译器(Clang/LLVM)生成 BPF 指令序列,内核拿到后由虚拟机解释执行,或者通过 JIT(Just-In-Time)编译成本地机器码跑。JIT 模式下的性能接近原生代码,这也是 eBPF 能在生产环境大规模使用的基础。
BPF Map:内核态和用户态之间共享数据的结构,类似一个跨权限边界的键值存储。Map 类型非常多,常用的有哈希表(BPF_MAP_TYPE_HASH)、数组、环形缓冲区(ringbuf)、perf event array、LRU hash 等。内核态的 BPF 程序把统计结果写进 map,用户态的程序读出来分析,两边各干各的,不互相阻塞。
程序类型:eBPF 不是一套通用的“跑在虚拟机里的任何程序”,它是有明确类型限定的,不同类型挂载在不同的 Hook 点,干不同的事。比如BPF_PROG_TYPE_KPROBE挂内核函数、BPF_PROG_TYPE_TRACEPOINT挂内核静态追踪点、BPF_PROG_TYPE_XDP挂在网卡驱动层处理报文。类型不同,可访问的上下文结构也不同,比如网络程序能访问 skb(socket buffer)结构,追踪程序拿到的则是寄存器快照和参数。
三种核心构件配合起来,就形成了 eBPF 的基本模型:用户写逻辑 → 编译成字节码 → 内核校验 → 挂到事件点 → 数据通过 map 回传用户态。
2.2 挂载点选型:Kprobe、Uprobe、Tracepoint,怎么选
做观测的第一步是选对 Hook 点。eBPF 常见的钩子大概分三类:
| 类型 | 作用对象 | 稳定性 | 开销 | 适用场景 |
|---|---|---|---|---|
| kprobe/kretprobe | 内核函数动态插桩 | 内核版本变动就会变 | 较低 | 追踪内核函数内部逻辑、参数、返回值 |
| tracepoint | 内核静态埋点 | 稳定,版本间保证兼容 | 最低 | 追踪系统调用、调度、文件系统、网络等标准事件 |
| uprobe/uretprobe | 用户态函数动态插桩 | 用户态程序变动就会变 | 看场景 | 跟踪用户态应用内部函数、库函数调用 |
用起来的感觉是这样的:
- 想研究某个内核函数被调用时传了什么参数,用 kprobe 准没错,比如追踪
tcp_rcv_established理解 TCP 连接收包路径。 - 想统计每个进程发起了多少次
openat系统调用、操作了哪些文件路径,优先 tracepoint,因为sys_enter_openat这个静态点很稳定,不用追着内核函数签名变化跑。 - 想看看一个 Go 服务内部某个方法的耗时分布,用 uprobe 挂到用户态程序的符号上,不用改代码,不用埋点,直接观测。
选型的时候别图省事,内核版本升级频繁的话,kprobe 上的函数名一变,你的 BPF 程序就不是“重新编译”能解决的了,得改代码。tracepoint 才是稳定抓手。
2.3 加载与生命周期:从 Clang 编译到 Verifier 校验
一个 eBPF 程序的完整旅程大概是这样的:
- C 代码写好后,用 Clang/LLVM 编译,
-target bpf生成 BPF 字节码目标文件。 - 用加载器(BCC 框架、libbpf 库、或者手动
bpf()系统调用)把字节码传进内核。 - 内核执行 verifier 检查,这一步是 eBPF 安全模型的根基。Verifier 会逐条模拟指令执行,遍历所有分支,确认:不会越界访问内存、不会死循环、不会访问未初始化的变量、不会将任意内核指针传给用户态。
- 校验通过,程序被 JIT 编译成本地指令,绑定到对应 Hook 点,进入运行状态。
- 用户态通过 map 读取数据,或者通过
perf_event_open、ring_buffer收取事件流。
校验器经常会成为新手发愁的地方。写得太野就报R1 invalid mem access、invalid indirect read from stack、infinite loop detected这些看着头大的错误。实际上,你只要按规范写(比如固定循环次数、用 bpf helper 访问结构体字段、指针运算加边界检查),大多数报错都是可以让 verifier 信服的。
2.4 Helper 函数:eBPF 程序的“内置工具箱”
eBPF 程序不能随意调用内核函数,只能使用内核提供的一组白名单接口——helper 函数。这类函数就是 BPF 程序的工具箱,非常重要。
常用的 helper 包括:
bpf_probe_read_kernel():安全读取内核内存,因为 BPF 指令不能直接解引用任意内核地址,得用这个封装。bpf_get_current_pid_tgid():获取当前任务 PID 和 TGID。bpf_ktime_get_ns():获取内核时间戳,做延迟计算用。bpf_trace_printk():简单的输出到调试管道(trace_pipe),适合调试,不适合生产。bpf_redirect()/bpf_skb_store_bytes():网络类程序常用的处理报文函数。bpf_map_update_elem()/bpf_map_lookup_elem():读写 map 数据。
Helper 函数的列表在持续扩充,不同内核版本能用的 helper 不同。所以写生产级 BPF 程序时,得注意目标内核版本支持的 helper 范围,这也是 CO-RE(一次编译到处运行)之外另一个兼容性维度。
3. 实操:手写第一个可观测性工具
3.1 工具链选型:BCC、libbpf、bpftrace,各自什么定位
在动手前,先花点时间把生态里的三巨头说清楚,因为很多人搞不懂它们之间的关系。
- BCC(BPF Compiler Collection):Python 封装 + C 内核端代码。好处是写起来方便,字符串、Map 操作、统计功能都帮你封装好了。缺点是对内核版本敏感,尤其依赖编译时的内核头文件,换内核环境可能导致整套工具重新编译。
- libbpf + CO-RE:偏向生产风格的开发方式。BPF 程序编译成 ELF 文件,用户态用 libbpf 加载。配合 BTF(BPF Type Format)信息,实现一次编译、跨内核版本运行。复杂度和门槛比 BCC 高,但可控性和产品化程度更好。
- bpftrace:高级语言式的单行命令工具,用类 awk 语法快速编写观测脚本。适合临时排查、快速验证,比如“统计所有进程打开文件的系统调用”一句话搞定。不适合做复杂的数据处理,它是轻骑兵不是重炮。
选型建议很明确:快速调查用 bpftrace,写脚本工具用 BCC,做产品化的常驻监控程序直接上 libbpf + CO-RE。不要在一开始就纠结,三个都试一遍,感知更强。
3.2 第一个程序:跟踪 openat 系统调用
下面这个示例,目标是用 kprobe 挂载do_sys_openat2函数(现代内核中openat系列最终会调到这儿),捕获每次文件打开调用,把进程 PID、文件名和操作结果输出到用户态。
先给出 bpftrace 版本(最快看到效果):
#!/usr/bin/env bpftrace kprobe:do_sys_openat2 { $filename = str(args[1]); printf("PID %d opened: %s\n", pid, $filename); }运行起来,就能看到每个调用该内核函数的进程打开了什么文件,几行代码,零依赖。这是 bpftrace 那一路。
接下来写一个 BCC 风格的 Python 程序,走完整周期:
from bcc import BPF bpf_text = """ #include <linux/sched.h> #include <uapi/linux/ptrace.h> struct data_t { u32 pid; u64 ts_ns; char comm[TASK_COMM_LEN]; char fname[256]; }; BPF_HASH(opened_files, struct data_t); BPF_PERF_OUTPUT(events); int trace_openat(struct pt_regs *ctx, const char __user *filename) { struct data_t data = {}; data.pid = bpf_get_current_pid_tgid() >> 32; data.ts_ns = bpf_ktime_get_ns(); bpf_get_current_comm(&data.comm, sizeof(data.comm)); bpf_probe_read_user(&data.fname, sizeof(data.fname), (void *)filename); opened_files.update(&data); events.perf_submit(ctx, &data, sizeof(data)); return 0; } """ bpf = BPF(text=bpf_text) bpf.attach_kprobe(event="do_sys_openat2", fn_name="trace_openat") def print_event(cpu, data, size): event = bpf["events"].event(data) print(f"{event.ts_ns} PID={event.pid} comm={event.comm.decode()} file={event.fname.decode()}") bpf["events"].open_perf_buffer(print_event) while True: bpf.perf_buffer_poll()真实跑起来时,你会看到一瞬间终端上刷出一堆进程的打开文件记录——从bash到ls,再到某个服务进程读配置文件。这些数据如果拿去做异常行为检测,就是运行时安全的雏形。
3.3 实际排查:一个文件延迟问题的完整复盘
这里我复盘一个自己经历过的案例,方便你看出整套工具怎么组合使用。
现象是业务反馈某台机器上的文件读写偶发延迟,每秒一次,持续几百毫秒。CPU 不忙、IO 也没有长时间打满,常规top、iostat看不出端倪。我先后用 bpftrace 做了三次观测:
第一步,抓vfs_write和vfs_read的延迟分布:
bpftrace -e ' kprobe:vfs_read { @start[tid] = nsecs; } kretprobe:vfs_read /@start[tid]/ { @ns = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'第一次输出基本都在 10 微秒内,没什么异常。但第二次跑,发现有一个二维峰值出现在 20 毫秒处,于是缩小范围定位到具体文件系统操作函数ext4_file_write_iter/f2fs_write_begin之间。
第三步,同时挂上kprobe:block_rq_insert看块设备层的 IO 排队,结果发现高延迟发生时,块设备队列出现了大量合并等待,再对照kprobe:xfs_buf_find,最终确认是 XFS 的 buffer cache 在某种缓存淘汰过程里抖动。
整个排查过程没有改一行内核代码、没有重编内核、没有对业务进程做任何侵入,只是用 eBPF 挂几个探测点,观测完撤掉,服务无感知。这个形态的生产排查能力,在 eBPF 之前,你需要一台测试机、一个内核模块、已经一堆运气才能做到。
3.4 内核版本兼容:BTF 与 CO-RE 的关键机制
很多人在 eBPF 上翻车,翻的最多的车就是内核兼容性。
传统 BPF 程序在加载时会依赖内核头文件中的结构体布局(毕竟要访问内核数据结构),内核版本一变,布局不同,编译出来的字节码可能越界访问或者解析错误。CO-RE 的核心思路是:把程序对结构体字段的偏移量访问,变成可重定位的表达式。程序在加载时通过 BTF 信息获取目标内核的真实偏移量,重新调整指令,实现一次编译、多处运行。
BTF 是内核提供的一套描述数据结构、函数、变量等类型信息的元数据格式。新内核默认开启,旧内核不满足条件时,BCC 方案就退回到动态编译,libbpf 方案则直接报No BTF found之类的错误。这也是为什么做产品级交付时,除了代码本身,还得评估目标环境的 BTF 支持情况。
实际建议是:能开 BTF 就开,内核版本能统一就统一。等排查到一个“程序在测试机好好的,生产机一加载就报找不到字段”的问题时,你就知道 BTF 这几个字母的分量了。
4. 常见坑与排障实录
4.1 权限问题与容器环境限制
eBPF 对权限有严格要求。加载 BPF 程序通常需要CAP_BPF或CAP_SYS_ADMIN权限,否则会被拒绝。容器里默认通常没这个权限,常见报错是:
Operation not permitted排查思路:
- 确认当前用户或服务的 capabilities,比如
capsh --print看当前 shell 的能力集。 - 容器运行时需要加
--privileged或者更精准地添加 capabilities,比如 Docker 用--cap-add=CAP_BPF、K8s 的securityContext.capabilities.add加上BPF和PERFMON。 - 同时检查是否启用了
kernel.unprivileged_bpf_disabled参数,很多发行版默认把它设为 1,普通用户无法加载 BPF 程序。
这也解释了为啥很多 eBPF 工具跑在宿主机上很顺,一进容器就报错——不是代码有问题,是权限被墙了。
4.2 调试技巧:bpftool 与 trace_pipe
eBPF 程序不像普通用户态程序那样好调试,能打印的地方有限。我用得最多的调试组合是两样:
第一是bpf_trace_printk(),程序里写了这行,输出会进到/sys/kernel/debug/tracing/trace_pipe,用cat就能读。注意这是调试专用,生产环境输出量大时会拖累性能。
第二是bpftool,这个命令行工具是排查 BPF 程序状态的瑞士军刀。常用命令包括:
# 列出系统上所有加载的 BPF 程序和 map bpftool prog list bpftool map list # 查看某个程序的详细信息,包括类型、加载时间、JIT 代码大小 bpftool prog show id 123 # 实时追踪程序是否被调用 bpftool prog tracelog当程序加载成功但没数据时,先用bpftool prog list确认程序在不在,再用bpftool prog show看有没有命中记录。如果 attach 后显示run_cnt一直是 0,多半是挂载点选错了,函数名在那个内核版本里不存在。
4.3 生产环境的几个经典坑
生产环境用 eBPF,最危险的坑不是性能,而是程序把内核搞崩。
先说一个我自己的教训:当时给一个网络程序挂 kprobe,为了拿 skb 里的字段,直接用指针偏移的方式访问。测试内核跑得很欢,上线当晚,某台机器内核直接 panic。后来排查是目标内核结构体多了一个字段,偏移错位,读到了非法地址。从那以后,我所有的 BPF 程序都强制走bpf_probe_read_kernel或者直接依赖 BTF 的 CO-RE 机制解析字段,绝不用硬编码偏移。这是 eBPF 新手最容易犯的致命错误。
第二个坑是程序死循环。早期 BPF 程序不允许循环,后来内核放宽到有界循环。但如果你写了一个循环边界依赖于外部数据的代码,verifier 可能让它通过,运行时却可能卡在某个奇怪状态下。目前可靠的做法:循环边界必须是编译期常量或者在入口时固定下来的值,别用运行时的 map 数据做边界。
第三个坑是 map 容量和事件丢失。把事件塞到 perf buffer 时,如果用户态消费不过来,事件会被丢弃。长时间高 I/O 下,丢事件会直接导致观测数据失真。排查时看bpftool map show的 lost_count 和perf_event相关参数,落地实践时设置合理的 buffer page 数量。
第四个坑是版本漂移:kprobe 事件名在 kernel 5.x 和 6.x 之间经常出现改名或参数变化。依赖内核函数名没意义,能用 tracepoint 的用 tracepoint,能用 CO-RE 的用 CO-RE,尽量做到跟版本解耦。
4.4 性能开销把控:什么时候该用、什么时候别用
eBPF 不是零成本。虽然它比传统方案高效很多,但挂载点的位置、程序复杂度、事件频率都会放大开销。
挂kprobe到热路径函数(每秒调用百万次的那种),你的 BPF 程序再轻量,累积起来也会可观。几个量化感受:
- 单个 BPF 函数如果只是读几个字段、更新 map,在 JIT 模式下大约零点几微秒到一两微秒,热路径上能接受。
- 如果调用了
bpf_probe_read_user来读用户态内存(比如文件名、字符串),成本明显增加,因为要安全地址转换。 - 如果串入用户态用 Python 脚本做数据处理,瓶颈很快从内核转移到用户态,吞吐上不去。
我的判断标准非常简单:先问一句“这个事件每分钟发生多少次”。低频事件如进程创建、容器启动,随便挂;高频事件如每个网络包、每次收发包,就要严格控制程序长度;每秒千万级以上的事件,干脆换 XDP/eBPF 的一体化处理,或者放弃动态追踪改用采样思路。
5. 生态与应用场景:eBPF 能做什么,不能做什么
5.1 典型落地场景一:网络与安全
在 eBPF 的所有应用中,网络和安全是离钱最近的。
XDP(eXpress Data Path)是 eBPF 在网卡驱动层提供的高性能数据通路。它能在协议栈处理之前就决定一个包是丢掉、放行还是转发,转发性能可以达到几百万包每秒。云厂商用它做 DDoS 防护,把恶意流量在最早的入口就干掉,业务进程甚至感知不到攻击来过。Cilium 作为 CNI 插件,用 eBPF 实现 Kubernetes 的 Service Mesh 转发、安全策略和可观测性,替代了 iptables 和 sidecar 代理的一大半工作,吞吐和延迟都比老方案好不少。Falco 则是云原生安全领域最有名的 eBPF 工具,在运行时监控系统调用行为,检测异常进程、容器逃逸尝试和敏感文件访问。
这些项目的共同点,是把原来“在用户态以沉重代理方式实现的功能”下沉到了内核里的 eBPF 虚拟机,利用内核自身对系统状态的全景视野,高效做决策。
5.2 典型落地场景二:性能剖析与可观测性
可观测性工具是 eBPF 最先被验证的领域,也是个人开发者最容易上手的方向。典型代表 Pixie、DeepFlow、SkyWalking 等平台都在用 eBPF 做无侵入式追踪:不用改应用代码、不用加埋点依赖,可以直接看到请求在内核协议栈里的耗时分布、网络连接五元组、进程的 CPU/内存/IO 用量。
这些数据对排障的价值是巨大的。比如你有个微服务调用很慢,日志显示接到了请求但返回等了 800ms。传统思路要一层层排查网关、网络、数据库;eBPF 思路是直接在节点上挂一个追踪程序,看到请求在内核里停留的位置是 socket 读取超时还是 connect 等待,十秒钟就能定位到问题在哪儿。
个人如果只想快速体验,从 bpftrace 开始写几行脚本,看看自己的系统里有哪些进程在打开什么远端地址,就能直观感受到这套观测范式的不同。
5.3 eBPF 的边界:它不是万能药
聊优点聊得嗨,容易忽略边界。eBPF 不擅长的事,我觉得至少有三类:
第一类是复杂业务逻辑。eBPF 程序受 verifier 限制,不能随便循环、不能动态申请内存、不能调用任意内核函数,写复杂逻辑极其痛苦。如果你要做的是重业务计算,老老实实用普通编程语言,别硬塞进 BPF。
第二类是任何需要持久化状态或外部交互的功能。BPF 程序本身无状态,要永久存储得靠 map,要跟外部系统交互得靠用户态协作,本质上不适合做“服务”,更适合做“事件处理器”。
第三类是动态追踪能力覆盖不了的地方。有些内核代码路径没有正在运行的函数符号,或者函数被 inline 了,kprobe 就挂不上。插桩底层实测时还是有局限,不是所有内核地址都能动态替换的。
懂了边界,你才知道什么时候该用 eBPF 什么时候该绕开。把它用在对的地方,它就是一把极其锋利的刀;用错了地方,你会花大量时间对付 verifier 和内核版本。
结尾与经验补充
最后分享一点我这两年和 eBPF 打交道下来最深刻的感受。很多人以为写 BPF 程序的难点在 C 语言、在内核知识,其实真正的难点是调试。BPF 程序一旦加载失败,你看到的错误信息往往是“哪里不行”,而不是“应该怎么改”。我总结了一个很笨但很有效的调试顺序:先用 bpftrace 验证事件点对不对、数据结构能不能访问到,再写 BCC 验证逻辑正确性,最后才换成 libbpf 做正式产品。每次往前推一步,范围更小,问题定位更准。
还有一个小技巧想单独说一下:eBPF 程序的名字。内核要求每个程序有一个名字,最好用有意义的字符串,比如trace_openat、xdp_loadbalancer,别叫bpf_prog_1。因为生产环境里,一个节点上可能挂着几十个 BPF 程序,bpftool prog list输出里命名清楚,你可以两秒钟找到自己的那段代码。命名混乱时,排查效率和心情会同时变差。
工具选型上,如果你问我个人偏好:开发环境用 bpftrace 做快速验证,BCC 处理中等复杂度的脚本,正式输出的监控功能全部用 libbpf + CO-RE 来写。这条路线兼顾效率、稳定性和可维护性,是很多大厂内部的标准姿势。
从 2014 年进入内核主线的几个 patch 开始,eBPF 这十年发展速度惊人。它真正改变了 Linux 内核可观测性的游戏规则,把原本需要专业内核工程师才能做到的系统内观测,变成了普通开发者和运维也能上手的技能。这套东西值得你花时间深入学习,而且现在学,正好。后续你还会在安全、网络、存储、数据库领域看到更多基于它的创新。保持关注,动手写一个属于自己的 BPF 程序,你会发现一个新世界。