开头部分可以这样写:任何一个在 Linux 内核和网络领域泡过几年的人,最近几年一定躲不开 eBPF 这个词。早期它是 BPF(Berkeley Packet Filter)的延伸,用来高效抓网络包,今天它已经变成内核里的"可编程沙盒",可以在不修改内核源码、不加载内核模块的前提下,动态注入并运行用户定义的字节码,完成追踪、观测、安全过滤、性能调优甚至网络转发这些事。这篇文章我想从一个实际操作者的角度,把 eBPF 到底是什么、它能干什么、怎么上手、有哪些坑讲清楚,适合刚接触这个概念的后端工程师、运维和 SRE,也适合那些想评估要不要在系统里引入 eBPF 做可观测性的架构师。
eBPF 这个名字网络上讲得很多,但大多一上来就丢一堆架构图,术语满天飞。它本质上不是什么魔法,就是在内核里预留了一个"安全运行用户代码"的虚拟机。你可以把它理解成给操作系统装了一个安全插件系统,只不过这个插件运行在内核态,并且所有动作都要先通过审核,越界就拒绝执行。
1. 从抓包工具到内核编程框架的演化
1.1 远古BPF做的事情
要理解 eBPF,最好先看看它的爸爸 BPF。我第一次接触 BPF 还是很多年前用 tcpdump 抓包的时候,在命令行写到"tcpdump -i eth0 port 80",这个表达式最终会被编译成一段 BPF 指令,下发到内核的套接字过滤点。它干的事很纯粹:让内核在拷贝整个包到用户态之前,先用一段逻辑判断"这个包我要不要",是端口 80 的就留下,不是的就丢弃。
那个年代的 BPF 只有两个寄存器、寥寥几十条指令,能做的也就是过滤网络包,算力非常有限。但好处是安全工作,内核里限制死了一系列约束,比如不允许循环、不允许任意内存访问,指令格式固定,任何人都可以安全地把这段字节码塞进内核。这套机制从设计原则上,就为后来的 eBPF 铺好了路。
1.2 eBPF到底改了什么
eBPF 口头上叫扩展 BPF,其实已经是重写了一套架构。它不再局限于抓包,而是把 Linux 内核里各种事件点(kprobe、uprobe、tracepoint、perf event、cgroup hook 等)都暴露成可挂载钩子。用户程序在这些钩子上附加 eBPF 程序,就能在内核发生某个事件前或发生后,立刻执行一段你在用户态编写、但在内核态运行的逻辑。
这里有个关键:eBPF 程序不是以源码形式直接跑在内核里的。它先被 clang/LLVM 编译成 BPF 字节码,经过内核的 verifier 验证,确保不会越界、不会死循环、不会破坏内核稳定性,再交给一个内置的解释器(或者 JIT 编译成机器码)执行。所以 eBPF 的能力是"安全地扩展内核",而代价是要遵守它的规则。
1.3 为什么大家现在都在提它
内核社区推动 eBPF 的原因其实很现实:传统的内核模块开发门槛高,接口不稳定,模块写错直接宕机,而且不同内核版本之间兼容性让人头疼。相比之下,eBPF 程序通过 verifier 验证后,运行期间即使逻辑有问题,也只会拒绝执行或者退出,不会把系统搞挂。再加上数据面和观测面都有大量现成框架(比如 XDP、tc、cilium、Falco 这些),生态滚起来之后,大家发现做网络加速、做安全监控、做性能剖析,原来绕不开的底层内核逻辑现在都能用 eBPF 优雅解决,自然就火了。
2. 核心组成拆解:加载、验证与运行
2.1 前端工具链是怎么把代码变成字节码的
我经常在项目里把 eBPF 程序写成一个 C 文件,然后用 clang 编译成目标文件。安装好 llvm 和内核头文件后,一条命令大致长这样:
clang -O2 -g -target bpf -c bpf_prog.c -o bpf_prog.o其中的-target bpf会让编译器产出 BPF 目标架构的 ELF 文件,-O2可以优化掉不必要的访存操作,-g保留调试信息便于后续 map 和 trace 点分析。生成的.o文件里面包含了大头是"程序指令"、小头是"各种 map 定义和许可证信息"的内容。此时这段代码还是静态的字节码,需要靠某个加载器把它放进内核。
实际的加载器可以是 bpf() 系统调用的直接封装、libbpf 库、bpftool 工具,或者像 bcc 这种更上层的 Python 框架。拿 libbpf 来说,它在用户态负责解析 ELF,找到其中的 eBPF 程序段,创建 map,然后调用bpf(BPF_PROG_LOAD)让内核验证并加载。这中间还有一个 ABI 命名的讲究:ELF 文件里的 section 名称(比如SEC("xdp")后面的 xdp)决定了这段程序挂到什么类型上。
2.2 内核里的 verifier 究竟会查什么
verifier 是所有 eBPF 程序必须过的鬼门关。它逐条模拟执行字节码,跟踪每一条指令前后寄存器里值的范围、指针的合法性和可能指向的对象类型。如果发现一处可能访问未初始化内存,或者生命周期错误地使用了指针,就拒绝加载,并打印拒绝原因。
常见被拒情况我帮你总结几类:
- 访问越界:比如 map 查找结果没有判空就直接解引用
- 循环次数不可预测:虽然现在支持有限循环,但循环边界需要能在验证时算出来,否则拒绝
- 栈上局部变量使用超出范围
- 给定的 helper 函数受限制:比如有些 helper 只能在某个程序类型里调用
这些规则很烦,但它也保证了任意用户都不容易写出一段搞瘫内核的代码。一段 eBPF 程序挂了,最多丢掉这次事件的处理,不会导致内核 panic,这是设计上最值钱的特性。
2.3 JIT 编译与运行时内存模型
字节码被验证通过之后,内核会优先尝试 JIT 编译,把 BPF 指令翻译成当前 CPU 架构的原生指令,缓存下来执行。相比解释执行,JIT 的性能通常能提升数倍,尤其在网络热路径上差别非常明显。可以通过sysctl net.core.bpf_jit_enable开启或者确认状态,多数现代发行版默认已经打开,具体可以用:
sysctl net.core.bpf_jit_enableeBPF 程序自己不能直接访问内核任意内存,它必须显式通过 helper 函数来读取数据。比如要获取当前进程的 PID,需要调用bpf_get_current_pid_tgid();要拿到当前 CPU 编号,可以读bpf_get_smp_processor_id()。程序和一个用户态进程之间通过 map 结构来通信,map 可以在 eBPF 程序中写入,然后从用户态读取,实现统计、状态同步和数据导出。
3. 最适合入手的三大应用场景
3.1 内核观测与性能追踪
我最早做 eBPF 项目就是从 profiling 开始的。传统排查高 CPU 问题经常用 perf,但 eBPF 做出来的程序更加灵活,因为你可以在具体的函数入口、返回点挂载 kprobe,实时统计一次调用的耗时分布,或者抓取调用栈并进行聚合。比如做一个简单的函数延迟直方图,可以先取调用开始的 kprobe 拿到时间戳存到 map 里,再在 kretprobe 里拿结束时间,把差值加进直方图 buckets。
这一套逻辑用 bcc 的 Python 接口写起来非常快。你可以在几十行脚本里统计内核函数的耗时分布、进程级别的 I/O 字节数、TCP 重传原因,这些东西以往要反复去解析 trace 文件,现在可以精确到具体事件采样,而且对业务影响极小。反正我一直觉得观测类应用是 eBPF 所有能力里最容易收获效益的方向。
3.2 网络包处理与转发加速
网络方向是 eBPF 最火热的落地场景。XDP(eXpress Data Path)在网络驱动早期位置挂钩,可以在报文进入内核协议栈之前就做丢弃、转发或修改,而 tc 层的 eBPF 也能替代很多 iptables 逻辑。轻量级负载均衡、防火墙过滤、DDoS 防护,这些以前要么靠 DPU 硬件加速,要么靠内核协议栈硬抗,现在用 eBPF 在软件层面就能做到接近硬件线速的转发。
写一个简单 XDP 程序丢弃目标是特定 IP 的包,伪代码看起来就是:
SEC("xdp") int xdp_drop(struct xdp_md *ctx) { void *data = (void *)(long)ctx->data; void *data_end = (void *)(long)ctx->data_end; // 解析以太网头、IP头 if (data + sizeof(struct ethhdr) > data_end) return XDP_PASS; struct ethhdr *eth = data; if (eth->h_proto == htons(ETH_P_IP)) { struct iphdr *ip = data + sizeof(struct ethhdr); if (ip->daddr == target_ip) { return XDP_DROP; } } return XDP_PASS; }真正做产品化的时候往往要配合 map 下发布过滤规则,而不是写死 IP,这也要注意 XDP 程序在回报 XDP_DROP 后网卡直接丢弃报文,对 CPU 的消耗比上层 iptables 小得多。
3.3 安全审计与运行时防护
安全方向也是 eBPF 价值密度很高的地方。通过挂载 tracepoint 或者 kprobe 监听 execve、文件打开、网络连接创建等系统调用,eBPF 程序可以实时记录进程行为,甚至基于规则阻止非法进程启动。很多运行时安全产品,比如一些容器安全软件,内部就是这样工作的。
我在某容器平台的项目里,用 eBPF 收集容器内异常 execve 调用,结合 map 保存白名单哈希,一旦发现未授权执行命令就立刻导出事件到用户态做告警。整套方案不用改动业务代码,也不侵入容器环境,落地成本相对传统 agent 方式低很多,非常适合安全团队快速搭建检测能力。
4. 从零写一个最小 eBPF 观测程序
4.1 环境准备与编译
先说下我习惯的环境搭配:Linux 内核 5.4 及以上,发行版装好clang、llvm、libbpf-dev、linux-tools。很多旧内核虽然也有 eBPF 支持,但像 BPF CO-RE(Compile Once, Run Everywhere)这类能力需要 5.x 内核和新的 libbpf,所以尽量用新一点的内核,省得被低版本的特性限制折腾。
最简单的 hello-world 式 eBPF 观测程序,是统计进程执行 execve 系统调用的次数。这个程序如果不想引入太多依赖,可以直接用 bcc 的 Python 接口写,但完整理解链路我还是建议用 libbpf + C 来写一遍,下面给个可用版本的核心片段。
#include <linux/bpf.h> #include <bpf/bpf_helpers.h> char LICENSE[] SEC("license") = "GPL"; struct event { __u32 pid; char comm[TASK_COMM_LEN]; }; struct { __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); __uint(max_entries, 64); __type(key, int); __type(value, __u32); } events SEC(".maps"); SEC("tracepoint/syscalls/sys_enter_execve") int trace_execve(void *ctx) { struct event e = {}; e.pid = bpf_get_current_pid_tgid() >> 32; bpf_get_current_comm(&e.comm, sizeof(e.comm)); bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &e, sizeof(e)); return 0; }这里SEC("tracepoint/syscalls/sys_enter_execve")表示程序挂载在 execve 系统调用入口的 tracepoint 上,内核执行到该点会调用此段逻辑。结构体里用events这个 PERF_EVENT_ARRAY map 把事件数据发送到用户态,用户态程序通过 perf buffer 收到通知并读取内容。
4.2 用户态加载逻辑
用户态用 libbpf 加载上面的 BPF 对象时,常规步骤是:
struct bpf_object *obj; obj = bpf_object__open_file("execve_trace.o", NULL); bpf_object__load(obj); struct bpf_program *prog = bpf_object__find_program_by_name(obj, "trace_execve"); bpf_program__attach(prog);然后创建 perf buffer 回调。这一步如果只是想快速验证,直接用bpftool就能完成加载和挂载:
bpftool prog load execve_trace.o /sys/fs/bpf/execve_trace type tracepoint bpftool prog attach pinned /sys/fs/bpf/execve_trace tracepoint/syscalls/sys_enter_execve不过实际产品环境里,还是要用 libbpf 的工程化流程,管理程序的加载、附加、注销和 map 的读写。
4.3 运行验证与结果解析
程序跑起来之后,可以在另一个终端执行几条ls或者date命令,然后用bpftool prog show查看附加状态,或者从用户态程序打印事件。每执行一次 execve,你都会看到对应的 pid 和进程名。这个小例子虽然简单,它已经包括了 eBPF 程序编写、编译、加载、挂载、map 通信和用户态解包的全链路,能跑通这个,后续深入就有骨架了。
5. 工具选型解析:bcc、libbpf 还是 bpftrace
5.1 bcc 适合快速原型
bcc(BPF Compiler Collection)通过 Python/Lua 脚本把 C 代码嵌入运行时编译,写起来快,方便调试和验证想法。它的问题是每次运行都要在目标机器上调用 clang 编译,依赖较重,部署稍微麻烦,而且脚本方式和大型项目的工程化风格不太搭。我个人的经验是用它做交互式排查和写一次性脚本特别顺手,比如查某个 PID 的 syscall 调用频率,几行代码就能出结果。
5.2 libbpf 适合生产级落地
libbpf 就是把 eBPF 程序预编译成.o文件,用户态加载器只负责 open/load/attach。它天然配合 CO-RE,可以针对不同内核版本运行,不需要在目标机器装完整的编译器。生产上如果需要分发 agent 到大量机器,建议走这个方向。缺点是前期的构建链路复杂一点,需要管理内核头文件、BTF 信息和编译流程。
5.3 bpftrace 适合瞬时诊断
bpftrace 是一种高级脚本语言,语法像 awk,表达力足够强悍,适合在线上快速定位问题,比如统计所有进程打开文件的次数、追踪某个 PID 后续系统调用序列。我自己很少拿它做长期运行的任务,但它是排查疑难杂症的瑞士军刀。可以简单看一个例子:
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm] = count(); }'一条命令就能统计当前各进程执行 openat 的频次,非常直接。
5.4 选型时值得注意的几个点
选型不是越底层越好。如果你的目标是快速回答"到底谁在写这个文件",bpftrace 几秒钟搞定,别去写 C;如果目标是做一个生产环境的监控组件,不要用 bcc 脚本跪在每台机器上编译,最好用 libbpf 做静态二进制分发;如果只是想熟悉内核机制,建议三种都过一遍,理解他们之间的异同。一般团队没有精力维护完整 libbpf 工程时,采用 bpftrace 做诊断、bcc 做脚本化的思路也可以。
6. 在实践中踩过的坑与排查技巧
6.1 verifier 拒绝加载时怎么定位
我踩得最多的坑就是忽略 verifier 的报错。它虽然提示信息写得比以前友好,但依然让人头大。遇到R0 invalid mem access 'inv'或者invalid indirect read from stack这类报错,建议按下面步骤排查:
- 先看是不是 helper 函数的参数类型不对,比如把 64 位的 pid_tgid 直接当成 int 传给了需要
__u32的 helper - 再看 map 查找结果是否判空,未判空就用指针几乎必挂
- 检查有没有访问超出 data_end 的包内容,XDP/RX 程序里尤其容易犯
- 直接打印简化后的验证日志,用
bpftool prog load加-d参数,能看见更细的跟踪过程
一个技巧:如果程序逻辑复杂、实在找不到,用二分法逐步注释代码,缩小到具体哪一行指针分析出错,再对着报错去改。这比瞎猜有效率得多。
6.2 map 同步和并发更新导致的性能陷阱
eBPF map 是高效的,但并发更新时可能遇到竞争。PER_CPU 类型的 map 可以规避大多数统计计数场景的锁竞争,但如果你需要精确的全局总量,最终聚合放到用户态做。在用 hash map 保存每个连接状态时,最好预估好 map 容量,否则 key 频繁增删会导致哈希扩容,在高吞吐下有明显性能抖动。
还有一点容易被忽略:BPF_MAP_TYPE_LRU_HASH 有淘汰策略,可以用来限制内存,但如果把重要的安全规则放进去,可能在压力下被淘汰掉导致过滤失效。这种坑在安全性要求高的场景是致命的,规则类数据不要放到 LRU map 里。
6.3 Tracepoint 与 kprobe 的选择权衡
kprobe 可以探测任意内核函数,灵活度高,但因为是插桩,可能受内核函数重命名、优化内联影响;tracepoint 是内核官方暴露的稳定事件,兼容性好但可能缺少你想要的细粒度参数。我的建议是能选 tracepoint 优先 tracepoint,只有功能覆盖不了再考虑 kprobe。另外 uprobe 可以跟踪用户态动态库函数,比如调查一个用户态函数调用频率,也很有用,只是需要注意进程地址空间变化带来的偏移兼容问题。
6.4 内核版本差异与 BTF 的救赎
低版本内核上开发好的 eBPF 程序放到高版本跑,可能就挂在 struct layout 变化上。解决这个问题靠 CO-RE 机制:编译时只记录字段偏移的 BTF 信息,运行时根据目标内核的 BTF 自动重定位。这就要求编译环境带有内核的 BTF 文件,并且加载时目标内核/sys/kernel/btf/vmlinux存在。如果目标机器上没有这个文件,会导致加载失败。检查方式:
ls -l /sys/kernel/btf/vmlinux如果文件不存在,先升级内核或者手动开启 CONFIG_DEBUG_INFO_BTF。这个配置是让 eBPF 程序跨版本运行的基石。
7. 性能优化与稳定性设计心得
7.1 尽量让逻辑简单短路
eBPF 程序在内核热路径里执行,多一条指令,延迟就多一点。观测类型的程序,能提前返回就提前返回,尽量减少 helper 调用。拿网络过滤场景来说,其实很多包在解析完 L2/L3 头部后就能决定放行或丢弃,没必要去查更多 map 或者做复杂匹配。把最想匹配的规则放在前面,命中后提前 return,收益显著。
7.2 利用批量事件减少用户态开销
从 eBPF 到用户态的数据传输是常见瓶颈。inflight 事件很多时,PERF_EVENT_ARRAY 可能成为热点。可以用 ring buffer map(BPF_MAP_TYPE_RINGBUF)替代,它批量拷贝数据,丢失可控,性能更好。如果数据量特别大,考虑在 eBPF 侧先聚合(比如先做直方图、计数),只把汇总结果定期上报,而不是每个原始事件都上报。
7.3 保证主路径没有动态分配
eBPF 程序不允许调用普通内核内存分配,所有内存分配必须在 helper 允许的范围内,或者通过 map 预分配。在 XDP 场景里做转发还需要预分配足够的 DMA 内存页,动态分配不现实。这个设计约束本身也让 eBPF 程序的性能可预测。编写时多利用栈上局部变量,将中间数据控制在 512 字节栈范围内,减少不必要的 map 操作。
7.4 高流量下怎么保护用户态分析端
eBPF 可以把事件喂给用户态,但用户态处理不过来时,需要背压或丢事件。可以用 map 的丢弃策略、perf buffer 的 wakeup 策略来平衡实时性和开销。不要试图让用户态程序无限缓冲,内存耗尽比丢几个采样点严重得多。一个实际项目里为了让延迟排行尽量准确,我采用先聚合请求耗时到 map,再每秒导出一次聚合表的方式,用户态压力瞬间降了一个量级。
8. 当前生态和未来延伸
8.1 网络、可观测与安全都在围绕它重做
现在你随便看一个云原生网络项目,几乎都有 eBPF 的影子。容器网络里做数据路径、策略隔离,很多已经用 eBPF 实现;可观测性领域的开源方案,也大量押注在 eBPF 上;安全领域更不用提,Linux 上的运行时安全检测工具基本都是 eBPF 的形态。这说明它已经从一个小众调试手段变成系统软件基础设施的一部分。
8.2 我看到的局限与值得留意的方向
eBPF 也不是万能的。可编程性的边界始终存在,复杂的流状态管理、大包重组这类工作它不一定比内核协议栈更高效;verifier 的严格限制也决定了不是所有逻辑都能搬到内核里;map 容量、内存占用、以及内核版本特性差异都是迫在眉睫的工程问题。大规模使用还依赖完善的 CO-RE 构建链和告警平台,这块各团队的能力参差不齐。
从我个人的角度,后续值得重点关注两个方向:一是 BPF 指令集和验证器的持续演进,让开发者写代码时更少遇到边界限制;二是与硬件卸载结合,把一部分 eBPF 程序卸载到网卡或 DPU 上执行,进一步提升网络性能。这个领域变化很快,每隔几个月就有新 helper 函数和新能力进内核,保持跟进的最好方式就是实际跑一个小程序,亲手验证文档里说的新特性。
9. 追问与实战建议
9.1 新手最容易忽略的定位
很多人一开始就绕到复杂的网络程序里,被指针、数据头解析和 verifier 轮番折磨。我建议新手先做观测类程序,因为数据不涉及修改内核行为,不危险,也不能把包丢了,即便有 bug 也无非错过几个事件。先玩转 map、perf event、tracepoint,再进网络与安全领域,会顺很多。
9.2 从一个小项目开始而不是配置大框架
不要第一天就尝试搭建完整的 eBPF agent 平台。我推荐从"统计某台机器上进程执行 execve 的次数"或者"追踪一个容器的 syscall 序列"这种小任务开始,跑通之后再逐渐堆功能。在某公司的一次内部分享里,有个同学用 bpftrace 一条命令定位了线上日志激增的原因,比以往逐台排查节省了半天时间,这种小而实际的价值才是 eBPF 最打动人的地方。
9.3 常用资料和跟进节奏
官方文档永远第一优先,没事翻一翻内核源码里的samples/bpf和tools/testing/selftests/bpf,那里有大量可运行的例子。其次多看看别人分享的真实生产案例,尤其关注失败经验。理论基础到了之后,实际操作遇到卡点,基本都是因为内核版本特性不熟悉或者 map 操作方式不对,解决方式只能是多跑多试、把报错吃透。
我自己这几年的体会是:eBPF 不只是"一个工具",更是一套理解内核运行逻辑的思维框架。以前我觉得内核是个不可变的黑盒,系统出问题只能靠开 trace、反复刷日志。用它之后,我开始敢在关键路径上"插一脚"去看看到底发生了什么,这种能力对排查复杂系统问题帮助特别大。
如果有条件,拿一台测试机把前面那个 execve 例子运行一遍,再试着加一个 map 统计各进程执行次数。跑通了之后你自然会开始琢磨:是不是还能追踪某个端口的数据,是不是还能过滤恶意域名,是不是还能给内核加一个轻量流量监控。那时候再回头看我前面写的这些内容,应该就只剩"实践"这一件事需要自己去做了。