☰
bpftrace实战:用eBPF动态追踪定位进程与延迟问题
2026/10/21 4:58:37 网站建设 项目流程

如果你需要在一个正在运行、不能随便重启的系统上,搞清楚“哪个进程在疯狂写文件”或者“为什么这台机器某个接口的延迟突然翻倍”,bpftrace 应该是最快给出答案的工具之一。这个基于 eBPF 的动态追踪神器,允许你在不改代码、不重启服务的情况下,用几行类似 awk 的脚本去观测内核和应用的真实行为。我最近半年排查线上问题基本都靠它,这篇笔记就把我从安装、语法到实战脚本、踩坑经验一次性整理出来,适合刚接触 bpftrace 的运维、后端开发和 SRE 同学直接照着用。

1. 第一件事:搞清楚 bpftrace 到底是干嘛的

1.1 和 perf、systemtap 的定位差异

很多人一开始会把 bpftrace、perf、systemtap 混在一起。它们确实都做动态追踪,但思路很不一样。perf 是内核自带的强大采样器,擅长做 CPU 采样和硬件事件统计,像是给系统拍 X 光片,能看出整体哪里热,但很难用几行命令去表达“我想看某个进程打开文件时传进来的路径名”。systemtap 虽然也能做,但它的门槛和风险都更高,早期需要编译内核模块,对生产环境来说负担不小。

bpftrace 走的是另一条路:它把用户写的脚本编译成 eBPF 字节码,加载进内核虚拟机,在指定的探针位置执行。整个过程不用编译内核模块,探针的加载和卸载都很快,脚本语法又特别接近 awk,非常适合做“现场抽查”。比如你想立刻知道当前系统上 openat 系统调用的频率分布,一条命令就能看到实时结果,perf 要做到同样的事得写不少命令组合。

1.2 适合谁,以及哪些场景不建议用

如果你手里有这么几类任务,bpftrace 会非常顺手:第一类是故障定位,比如磁盘 I/O 突然飙高,想知道是哪个进程在写;第二类是热点分析,比如 CPU 占用高的内核路径在哪里;第三类是接口延迟观测,比如某个系统调用的耗时分布是怎样的。这些都是“短时间、高信息量”的现场调查类问题,bpftrace 的"追事件"模型天生适合。

但有些场景我不会用它。如果是需要长期、持续监控并出报表,那更适合用 node_exporter、grafana 那套或者成熟的 APM 系统;如果是需要做复杂的网络包分析,tcpdump 和 eBPF 的 libpcap 生态会更合适;如果你的内核太老,比如还是 4.x 早期版本且没有 BTF(BPF Type Format)支持,跑 bpftrace 会遇到不少兼容性麻烦,倒不如先用 perf 顶一下。工具没有高低之分,选对场景最重要。

1.3 和 BCC 是一对兄弟

很多资料会把 bpftrace 和 BCC 放在一起提,它俩确实都是开源社区里同一批人搞出来的动态追踪工具族。BCC 是一个 Python 库加一堆现成工具,适合写比较复杂的程序,比如做带网络协议解析的追踪任务;bpftrace 则是脚本化的“快速刀”,几十行以内就能完成大多数调查任务。我的习惯是:临时看现象、验证假设,用 bpftrace;要落地成一个长期可维护的工具,再考虑用 BCC 或干脆写原生 eBPF 程序。

2. 上手前的准备工作:内核、内核头文件、安装与权限

2.1 内核版本和 BTF/CO-RE 的关系

bpftrace 能跑起来,内核至少要 4.1 以上,因为从那个版本开始内核对 eBPF 的基础支持才完善。但真正影响体验的是内核有没有开启 BTF,也就是/sys/kernel/btf/vmlinux这个文件是否存在。如果存在,说明内核自带了类型信息,bpftrace 可以做到 CO-RE(一次编译到处运行),脚本换到不同内核版本上也能兼容,这在排查多台异构服务器时非常重要。

我建议直接在一台内核 5.10 以上的现代发行版上开始学 bpftrace,比如较新的 Ubuntu LTS、Debian 或 Fedora 系列。内核太老的话,加载 kprobe 和 tracepoint 大多也能用,但像 kfunc(带 BTF 的内核函数探针)这类新特性就别想了。如果你在生产环境遇到一堆老内核机器,那就做好“脚本写两版”的心理准备:一版用 tracepoint 和 kprobe 兜底,另一版在新核心里用 kfunc 跑得更稳。

2.2 安装 bpftrace 的三种姿势

安装方式其实很省事。第一选择是直接用发行版自带的包管理器,比如apt install bpftrace或dnf install bpftrace,装完直接能用。第二选择是去 bpftrace 官方发布的二进制页面下载静态编译版本,好处是不依赖系统库,适合那些你不想动系统包的内部机器。第三选择是自己从源码编译,如果不是要改源码或者参与开发,我不建议轻易走这条路,因为依赖 llvm、clang 那一套非常耗时。

装完之后,建议先验证基础环境能不能用。跑一下bpftrace --version,再跑bpftrace -l 'tracepoint:syscalls:sys_enter_openat'。如果列表里有输出,说明 tracepoint 探针可用;如果没有任何输出,先确认是不是 root 权限,或看一下内核的 DEBUG_FS 有没有挂载。我遇到过很多次“明明装了但报错”的情况,十有八九是权限和挂载问题,不是工具本身的问题。

2.3 运行权限:为什么非 root 不可

bpftrace 要加载 eBPF 程序,必须得有足够权限,通常是 root,或者具备 CAP_BPF、CAP_SYS_ADMIN 这些 capability 的普通用户。在实际操作中我基本都用sudo bpftrace,图省事也免得纠结 capability。

容器里跑 bpftrace 要额外注意:容器默认的 seccomp 和 capabilities 往往不够,加载 BPF 时的bpf()系统调用会被拦下来。通常的做法是给容器加--privileged或单独加上所需的 capabilities,并且把宿主机的/sys/kernel/btf、/sys/kernel/debug挂载进容器。如果你是在 Kubernetes 环境里想临时定位问题,更推荐直接在宿主机上用nsenter进到目标 pod 的共享命名空间里跑,而不是费劲调容器配置。

3. 语法拆解:probe、filter、action 是三个基本盘

3.1 探针类型:kprobe、tracepoint、uprobe、profile

bpftrace 的脚本骨架特别简单,核心就一句:探针类型:探针名字 { 动作 }。括号前面的是“什么时候触发”,括号里的是“触发后干什么”。唯一需要注意的,是探针类型非常多,选对类型能省掉一半的调试时间。

我日常最常用的有四类。第一类kprobe/kretprobe,用来挂在内核函数入口和返回点,比如kprobe:vfs_read会在vfs_read被调用时触发,kretprobe:vfs_read则在该函数返回时触发,可以拿到返回值和时间戳。优点是灵活、内核函数随便挑,缺点是内核版本一变,函数名可能就没了,而且如果目标函数被编译器内联,kprobe 直接挂不上。

第二类tracepoint,它是内核源码里预先埋好的稳定事件点,比如系统调用、TCP 重传、块设备请求都有对应的 tracepoint。用tracepoint:syscalls:sys_enter_openat的时候就比 kprobe 稳得多,参数结构也在数据结构里明确写好了,所以在生产环境我优先用它。

第三类uprobe/uretprobe,用来追踪用户态程序里的函数,比如你想看某个自研服务里面某个函数的调用频率和参数,现在不用改代码,直接用 uprobe 挂在函数上就行。第四类profile,它不是“事件触发”,而是周期性采样,比如profile:hz:99表示每秒采样 99 次,做 CPU 热点分析全靠它。另外还有BEGIN和END两个特殊探针,分别对应脚本启动和退出时执行一次。

3.2 动作语法:printf、变量、map 与 hist

动作部分看起来像 C 语言,但我更愿意把它理解成 awk 的进阶版。常用的动作就是printf、print、count、sum、hist、time这几个。

首推必会的是printf。它和 C 语言的格式串几乎一样,但支持的格式符有限,最常见的是%s(字符串)、%d(整数)、%x(十六进制)。如果你要在 tracepoint 里打印文件名,直接用str(args->filename),因为内核传过来的是一个字符指针,得通过str()函数把它转换成字符串,直接%s打指针值会看到一串地址。

变量这块,bpftrace 有两类,很容易混淆。一类是$开头的临时变量,它们只在当前探针的一次触发内有效,比如$pid = pid;,处理完这个事件就没了。另一类是@开头的 map 变量,它们才是全局的,可以在不同探针之间共享,跨事件累积。比如@opens[comm] = count();的意思是:以进程名为 key,每次探针触发就把计数加一,最终得到每个进程名对应的打开文件次数,这是 map 变量最经典的用法。

直方图是 bpftrace 最能出效果的功能。hist()函数会把一组数值自动分桶,变成指数分布的直方图;lhist()则是线性直方图,适合范围明确的延迟数据。我追踪一次系统调用的耗时分布,一般写@us = hist((nsecs - @start[tid]) / 1000);,之后一 print 出来就能看到延迟集中在哪里、有没有长尾。

3.3 内置变量和常用 filter 写法

bpftrace 提供了一堆内置变量,不用声明直接用。我最常用的是pid、tid、comm、nsecs、elapsed、cpu、kstack、ustack、curtask、args。它们的含义很好猜:pid是进程号,tid是线程号,comm是进程名,nsecs是系统启动以来的纳秒数,cpu是当前 CPU 编号,kstack是内核调用栈,ustack是用户态调用栈。

filter 就是过滤条件,写在探针和动作中间的一对斜杠里。语法很直观,比如tracepoint:syscalls:sys_enter_openat /comm == "sshd" / { ... },表示只有当进程名是 sshd 时才执行动作。条件可以组合,用&&、||、!=这些运算符。需要注意字符串比较在旧版本里可能不支持,但现在主流新版本都支持了,如果你用了老发行版,遇到类似问题就去查一下版本说明。

4. 七个实战脚本:从系统调用到用户态追踪

4.1 查频繁打开文件的进程

这是我最常丢给 bpftrace 干的活。某天线上通知一台机器文件描述符不够用,但又不知道是谁在疯狂打开文件。直接上 tracepoint 按进程聚合:

sudo bpftrace -e ' tracepoint:syscalls:sys_enter_openat { @opens[comm] = count(); } '

执行后每隔几秒按 Ctrl+C 退出,END探针会打印出@opens这张 map 的内容,马上就能看到哪个进程名排在前面。如果你还想知道它打开的具体是哪个文件,把后面的动作加上printf("%s %s\n", comm, str(args->filename));就能逐条看到实时调用。实战里注意一点:str()读用户态字符串要拷贝内存,在高频调用下会有额外开销,所以先聚合统计、再找具体路径,是更稳妥的做法。

4.2 追踪 TCP 重传:从 tracepoint 看网络问题

排查网络抖动时,怀疑有 TCP 重传但不确定是哪个连接在重传。可以用 tracepoint 里的 TCP 事件直接抓到源头:

sudo bpftrace -e ' tracepoint:tcp:tcp_retransmit_skb { printf("time=%d pid=%d comm=%s saddr=%s:%d daddr=%s:%d\n", elapsed / 1000000, pid, comm, ntop(args->saddr), args->sport, ntop(args->daddr), args->dport); } '

ntop()函数负责把地址结构转成可读的字符串,端口用args->sport和args->dport取。跑一小会儿,就能看到是哪些连接在重传,再结合对端 IP 去查链路问题。这个脚本对生产环境比较友好,因为重传事件本身不像系统调用那么高频,开销可控。

4.3 内核函数调用统计与内核栈聚合

有时候问题出在内核路径上,比如某项内核函数调用次数异常多。kprobe 可以挂到具体内核函数上,再加上kstack输出调用栈,能很快定位是谁调进来的:

sudo bpftrace -e ' kprobe:vfs_read { @[kstack] = count(); } '

这个脚本的输出会按调用栈聚合,每个唯一的内核栈对应一个计数,看到计数最高的那个栈,基本就知道热点路径在哪儿了。kprobe 在vfs_read这种高频函数上开销不小,所以别开太久,一般跑几秒到几十秒就够了。另外,如果报错说挂不上vfs_read,多半是内核里这个函数名变了或者被内联,可以先bpftrace -l 'kprobe:vfs_*'看看实际有哪些带vfs_前缀的函数。

4.4 用户态动态追踪:盯住 libc 的 malloc

内核函数追踪完,想看看用户态程序的动态行为,uprobe 就是干这个的。例如怀疑某个服务的堆内存分配很频繁,可以挂到 libc 的 malloc 上统计每次请求分配的大小:

sudo bpftrace -e ' uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc { @bytes = hist(arg1); } '

arg1对应 malloc 的第一个参数,也就是分配字节数。直方图打出来后,能一眼看出大多数请求分配的是几十字节还是几百字节,这对接入层参数调优很有参考价值。uprobe 挂载时需要给出动态库的绝对路径,路径错了就会报符号找不到;另外如果动态库用的是相对路径或者程序还没启动,uprobe 也挂不上去,需要先确认库文件确实存在。

4.5 用 profile 定时采样定位 CPU 热点

事件追踪在“只看高频路径”时反而不是最优解,这时就该轮到profile定时采样了。比如想看看整机 CPU 到底烧在内核哪个栈上:

sudo bpftrace -e ' profile:hz:99 { @[kstack] = count(); } '

跑几十秒后 Ctrl+C 退出,输出里计数最高的栈就是 CPU 密集的路径。hz:99是每 CPU 每秒采样 99 次,之所以用 99 而不是 100,是为了和某些采样周期错开,避免谐波干扰。实测下来,这个脚本在定位高 CPU 问题上基本能替代一部分 perf 的工作,而且输出更聚焦。

4.6 统计某程序调用的完整系统调用序列

有时候想知道某个具体的进程到底在干什么,与其去翻 strace 日志,不如用 tracepoint 把系统调用名称按进程过滤出来统计:

sudo bpftrace -e ' tracepoint:syscalls:sys_enter_* /pid == 12345/ { @syscalls[probe] = count(); } '

这里的probe内置变量会返回当前触发探针的完整名字,比如tracepoint:syscalls:sys_enter_read。按进程号过滤后,就能统计这个进程最常调用哪些系统调用。如果想看调用顺序,直接去掉count()改成printf("%s\n", probe);就行。这种方式比 strace 性能影响小很多,适合在压测环境持续观测。

4.7 系统级延迟直方图:观察 read 和 write

对 I/O 延迟敏感的业务,我最喜欢用 kretprobe 搭配时间戳做耗时分布,这在解释“为什么变慢了”的时候特别有说服力。下面这个脚本统计 read 系统调用的耗时分布:

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

入口探针记录线程对应的开始时间,返回探针里做差值并转成微秒,直方图就能显示延迟分布。filter 里的/@start[tid]/是为了防止有些 read 调用没能匹配到入口,避免计算出负值。这个模式可以套用到任何内核函数上,换成vfs_write就能看写延迟,换成tcp_sendmsg就能看发送路径延迟。注意 map 里会积累每个线程的开始时间,如果一个线程高频调用,map 持续增长会占内存,所以跑一段时间就该清理或退出。

5. 踩坑速查:探针挂不上、没输出、性能开销大怎么办

5.1 常见报错与排查对照表

现象常见原因处理建议
探针 attach 时报权限错误没有 root 或 CAP_BPF用 sudo 跑,或给容器加特权能力
报错Could not resolve symbol内核函数不存在、被内联或路径写错用bpftrace -l 'kprobe:函数名*'搜一下真实符号名
tracepoint 完全没输出过滤条件写错,或事件频率极低先去掉 filter 试试,用-v看展开结果
输出中大量Lost events事件太密集、缓冲区太小或动作太重避免在动作里频繁str(),尽量用count()聚合;分批追踪
BEGIN没执行老版本问题或权限层级不对检查 bpftrace 版本,确认探针语法正确
uprobe 挂不上库路径不对、库没加载或符号被 strip用readelf确认符号存在,确认路径是绝对路径

排查时有个万能技巧:先用bpftrace -l确认你要挂的探针确实存在。有时候不是脚本问题,而是这台机器上内核编译选项没开某些事件。

5.2 几个从实战中总结的小技巧

第一个技巧是善用-v展开正则。比如bpftrace -e 'tracepoint:syscalls:sys_enter_open*'这类带通配符的探针,-v跑一遍会把它匹配到的所有具体探针都列出来,确认弹跳点是否符合预期。

第二个技巧是在脚本里用interval:s:1做定时清理。短时间 tens 秒级的观测还好,但如果要持续跑几分钟,map 里会积累海量 key,内存占用会逐渐上涨。可以用定时探针定期print(@); clear(@);把累积数据打出来再清空,既能持续观测又避免了内存无限涨。

第三个技巧是用-c启动子进程。比如我想追踪某个程序的系统调用,又不想去手动找 pid,可以用bpftrace -c './my_service' -e 'tracepoint:syscalls:sys_enter_openat { printf("%s\n", str(args->filename)); }',bpftrace 会启动这个程序并自动追踪它及其子进程的系统调用。这个用法在做小范围验证时非常省事。

5.3 如何估算和限制 bpftrace 开销

每类探针的开销差别很大,不能一概而论。tracepoint 本身是内核预埋的,开销相对小;kprobe 会在函数入口插入跳板,如果挂的是vfs_read、sched_switch这种百万级每秒的热点函数,即使动作再简单,对吞吐的影响也能感知到。uprobe 同理,挂到用户态热函数上也是不小的成本。

我给自己定了个简单原则:能上 tracepoint 就不上 kprobe,能聚合统计就不逐条打印,能采短时间就不长时间跑。一个 kprobe 追踪线程nsecs差值的动作,在每秒几十万次调用的机器上会引入可观开销,所以我一般会在压测前先做一次基线对比,或者干脆把采样频率降下来。

如果确实需要长时间跑,建议加上频率限制。profile 探针可以把hz:99改成hz:19;事件驱动的探针可以在动作里加一个时间判断,比如同一秒内只处理一次事件。这些做法都会损失一部分细节,但在生产环境保命优先。

最后给新手一条建议:bpftrace 的语法看着简单,真正卡你的往往不是语法,而是对系统调用、内核函数、动态库加载这些基础知识的熟悉程度。先拿bpftrace -l把探针列表当成字典翻,再把perf的tracepoint列表对照着看一遍,很快你就能自己组合出各种排查脚本。遇到奇怪的现象别硬猜,加-v、查/proc/kallsyms、看内核日志,一步步把范围缩小,问题基本都能水落石出。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询