☰
bpftrace 实战指南:基于 eBPF 的 Linux 性能排查与动态追踪
2026/10/21 4:58:39 网站建设 项目流程

去年我们对某个吃CPU的线上服务做了几次排查,最后都是靠 bpftrace 收的尾。这个工具第一次用会觉得参数咋这么多,语法也跟 C 和 awk 夹杂在一起,但一旦跑通几个脚本,后面就会顺手很多。这篇使用笔记整理了我从入门到实际解决资源消耗、函数调用链、IO延迟这些问题的完整路径,包括常用现场、参数怎么取、输出怎么排除干扰,适合正在研究 eBPF 工具链、想解决性能资源分配问题的同学参考,也适合第一次接触动态追踪但不知道从哪下手的读者。

bpftrace 本质上是一个建立在 eBPF 之上的前端脚本解释器,它让你用接近 awk 的表达方式去定义内核和用户态程序里的观测点,再把观测值聚合输出。它不需要修改被观测程序,也不需要重启服务,探针加载后只影响被探针命中的那一条指令路径。这意味着同样的语法,可以用来排查线上进程的毫秒级耗时,也可以用来搞清楚一个新接手的老模块到底在调用什么底层能力。

1. 核心思路:用脚本的方式接近内核观测点

bpftrace 解决的痛点是“你想看某个函数有没有被调到、被调了多少次、一次调用花了多久,但又不能改代码”。最常见的传统做法是加日志、加埋点然后重新发布,这在开发环境可行,线上往往不现实。bpftrace 通过 eBPF 在 Linux 内核里挂载探针,走的是内核的 tracing 与 perf 事件框架,所以它观测的是真实执行路径,不会改变业务逻辑,也不需要在代码里做任何标记。

1.1 bpftrace 与传统工具的分工差异

跟 top、perf、strace 这些工具相比,bpftrace 更适合回答“特定函数级别的调用细节”。top 看的是全局负载,perf 擅长采样和热点统计,strace 能跟踪系统调用参数,但 strace 的 ptrace 机制在部分高频系统调用下会带来明显延迟,生产环境需要谨慎。bpftrace 的探针加载方式走内核原生事件回调,开销小很多,而且可以灵活定义统计条件。

它跟 BCC 的关系通常让初学者困惑。BCC 是更底层的 Python/Lua 工具集,适合做复杂工具链,bpftrace 更像是日常排查时快速写脚本的舞台,语法层面把探针定义、过滤条件、聚合输出都收敛在短小文件里。两者共享内核侧的 eBPF 机制,区别主要在表达层和灵活度。实际维护中我习惯两边混用:需要快速验证就用 bpftrace,需要做成常驻监控项再改写成 BCC。

1.2 适用场景与边界情况

我整理的适用场景主要有几类:

  • 系统调用追踪:确认某个进程高频调用 read、write、connect,以及调用频率和参数分布。
  • 内核函数定位:直接挂载 kprobe 观察内核函数如tcp_sendmsg、sock_alloc,分析网络或内存路径。
  • 用户态函数追踪:通过 uprobe 挂载业务进程里的函数,比如定位某 RPC 框架的序列化耗时。
  • 定时采样统计:借助 profile 探针做 CPU 栈采样,不需要 perf 那样繁琐的事件设置。
  • 自定义延时统计:从函数进入到返回的时间差,直接得到单次调用耗时和直方图分布。
  • 跟踪 TCP 重传、连接队列溢出等网络栈问题。

边界情况我也遇到过。bpftrace 对内核版本有要求,一般建议 4.9 以上,但很多高级特性需要 4.14、5.x 甚至更新的内核特性,比如一些 tracepoint 参数变化、BTF 支持程度。另外它依赖内核开启了 CONFIG_BPF、CONFIG_BPF_EVENTS、CONFIG_KPROBE_EVENTS 等配置,如果遇到加载失败,第一反应应该是查内核配置而不是改脚本。

开发环境不能停机,又没有现成监控指标的时候,bpftrace 的作用就像拿内窥镜直接看进程内部,不打断运行、不埋代码,看到的还是真实执行过程中的瞬时状态。不过也要明确边界,它的核心是观测不是修复,定位之后怎么改业务逻辑,还是得靠代码层面的分析,但这些分析需要的证据基本能从脚本里拿到。

2. 安装准备与探针语法基础

bpftrace 的安装方式比较常规,多数发行版包管理器直接可用。我所在的服务器主要是 Ubuntu 和 CentOS 两条线,Ubuntu 上直接apt install bpftrace,CentOS 上需要确保 EPEL 和内核开发包就绪后yum install bpftrace,或者使用官方仓库。

安装不是重点,但权限往往是第一个坑。bpftrace 需要 root 权限,或者给用户补齐CAP_BPF、CAP_PERFMON等能力。我见过不少人明明工具安装成功,却卡在 “Could not open tracepoint” 这类提示上,最后发现只是权限不够。在容器内使用时还需要宿主内核支持相关配置,有些容器基础镜像缺 BTF 文件会导致符号解析失败,后续排查部分会专门讲。

2.1 探针类型选型

bpftrace 的语法核心是“探针 + 过滤条件 + 动作”,探针决定挂载点,过滤条件决定命中后是否执行统计,动作决定输出结果。写脚本的第一步就是选探针,我整理了常见探针适用场景:

探针类型作用常见用途
kprobe / kretprobe内核函数进入与返回内核路径耗时、参数检查
uprobe / uretprobe用户态函数进入与返回业务函数调用频率、处理耗时
tracepoint内核静态跟踪点系统调用、网络事件、内存事件
profile定时采样CPU 热点栈统计
software软件事件缺页、上下文切换
hardware硬件事件缓存命中、周期数
BEGIN / END脚本起始与结束变量初始化、汇总输出
interval定时输出循环打印当前状态

选探针的第一原则是优先用 tracepoint,其次用 kprobe。同样一个观察目标,tracepoint 的参数结构更稳定,kprobe 因为跟着内核源码函数签名走,内核版本一升级很容易变化。比如追踪系统调用,tracepoint:syscalls:sys_enter_openat的参数比直接挂在do_sys_openat2上要稳定得多。但这也不是绝对的,有些行为只有函数级探针能看到,比如追踪某个内联函数或者观察函数尾部返回值,就必须上 kretprobe。

kprobe 还有一个特点是只能挂载非内联函数的入口。因为编译器可能会把短函数内联进调用点,符号表里找不到独立符号,这时可以尝试挂载它的调用者,或者通过 tracepoint 间接观察。很多人一开始不知道这个限制,在函数名上反复测试都提示无法解析,其实是因为它真的被编译器优化掉了。

2.2 探针语法与执行流程

看一个最简单的例子:

bpftrace -e 'kprobe:vfs_read { @reads = count(); }'

这个脚本的意思是挂载内核函数vfs_read,每次进入都执行@reads = count()。@reads是 map 变量,count()是聚合函数,执行结束后 bpftrace 会自动把 map 里的内容打印出来,得到每个 CPU 上的统计总和。如果我想看进程维度,就加上@reads[comm] = count(),comm是内置变量,表示当前进程名。这个改动把统计维度从全局变成按进程分组。

一个完整的探针块由“探针类型:挂载点”与“花括号动作块”组成。动作块里可以写多个语句,用分号分隔。常见写法:

kprobe:vfs_read { @reads[comm] = count(); @bytes[comm] = sum(arg2); }

这里arg2是内核函数第二个参数,对应vfs_read的count参数。但需要说明的是,参数序号跟函数原型是严格对应的,写之前一定要确认当前内核版本的函数签名。bpftrace 的arg0到argN直接映射到函数参数的寄存器或栈位置,参数顺序看/boot/System.map或者内核头文件都可以确认。

探针块之前还可以写过滤表达式,例如只统计某进程:

kprobe:vfs_read / comm == "nginx" / { @reads = count(); }

斜杠中间的表达式是过滤条件,语法上类似 C 的逻辑运算。过滤条件放在探针命中和动作执行之间,可以极大减少运算开销,因为不满足条件的路径不会进入聚合计算。实测中发现,条件里尽量用整数判断,字符串比较虽然方便但开销相对大,在每秒命中百万次的探针上能感受到差异。

3. 参数获取与常用变量细节

bpftrace 能拿到什么数据,很大程度上取决于内置变量和参数映射。刚开始写脚本时我总觉得参数不够用,后来越用越明白,不是数据没有,而是没搞清楚变量来源。

pid、tid、comm、uid、cpu、nsecs、elapsed这些都是内置变量。nsecs是单调时钟的纳秒时间戳,elapsed是 bpftrace 启动以来的纳秒数。这两个时间基准在计算耗时的时候特别关键,比如函数耗时等于返回探针时的nsecs减去进入探针时的nsecs。时间统计必须使用单调时钟,不能用walltime,因为墙上时间可能因为 NTP 调整导致计算出来的耗时出现负数。

3.1 内核态函数参数如何取

kprobe 探针中arg0、arg1等对应函数原型从左到右的参数。以tcp_sendmsg为例:

int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size);

arg0是sk指针,arg1是msg指针,arg2是size。但注意,arg0给出的是指针地址,想要读取指针内部字段需要解引用。比如读取sk结构里的某个字段,得先确认字段偏移,或者借助内核提供的读函数。bpftrace 里可以通过((struct sock *)arg0)->__sk_common这样的方式强制类型转换,但前提是这个结构体定义在 BTF 里可用。

kretprobe 用来拿返回值,例如:

kretprobe:tcp_sendmsg { @ret = lhist(retval, 0, 1000, 100); }

retval是内置的返回值变量,只在 kretprobe 和 uretprobe 里有效。lhist是线性直方图,把返回值大小分桶显示。直方图是 bpftrace 里特别实用的展示形态,尤其是分析耗时分布时,比一堆精确数值更容易看出长尾。

不过 kretprobe 有个需要注意的地方:如果函数是在 kprobe 处匹配命中的,返回探针处的上下文已经不一样了,比如进入时的pid、comm可能已经发生变化。因此如果要关联入口参数和返回值,需要自己在 map 里存上下文。常见技巧是@start[tid] = nsecs;记住每个线程的进入时间,返回时再@ns[tid] = nsecs - @start[tid];然后删除@start[tid],这样可以避免 map 里堆积脏数据。

3.2 用户态 uprobe 参数传递

uprobe 的原理是在用户态进程的指令流里动态插入断点指令,命中后陷入内核执行探针回调。参数映射方式跟 kprobe 类似,arg0对应函数第一个参数。但用户态函数参数的获取有个大前提:得先确认目标二进制符号表里存在这个函数,而且没有被 static inline 掉。

实际追踪 C++ 类成员函数时还需要处理符号修饰,方法名在符号表里是一长串带类型信息的字符串。这种情况可以先用nm -C查看解析后的函数名,再在 bpftrace 里用加上地址偏移的方式挂载,或者直接用带修饰名。还有一种做法是挂载 PLT 入口来追踪动态库函数调用。追踪libc的malloc可以用:

uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc { @malloc_count[comm] = count(); }

如果要统计分配字节数,arg0是size_t size参数,直接sum(arg0)即可。uprobe 对非 root 用户还有额外的CAP_SYS_ADMIN要求,权限不足时会出现无法 attach 的错误。有个容易忽略的点:如果目标进程启用了地址空间随机化,不同进程加载共享库时基址不同,但 bpftrace 已经处理了这部分差异,我们只需要关心符号名。

3.3 printf 与 map 输出的格式控制

数据输出是排查问题时的最终呈现,格式没选好会直接影响判断。bpftrace 支持printf和print两种方式,printf在每次探针命中时实时打印,适合事件日志型输出;print一般配合 map 变量在结束或定时输出。我常用的组合是脚本最后:

END { print(@reads); print(@ns_avg); }

print之前也可以用printf固定前缀,比如:

END { printf("===== summary =====\n"); print(@reads); }

需要精确控制输出格式时,printf支持%d、%u、%s、%llu等标准格式符。实测发现%llu用来打印nsecs差值和sum()结果最安全,因为这些值是 64 位无符号整数,用%d会被截断。字符串内置变量如comm可以直接放在printf的%s位置。

还有个小技巧:调试脚本阶段可以在探针动作里加printf("%s:%d\n", comm, pid);来确认探针确实命中,确认之后再改成聚合操作。这个习惯能省很多盲目排查时间。

4. 常见排查场景的脚本实战

下面几个脚本是我在真实排查里使用的模板,参数解释和坑点都会写在后面,可以直接套用改参数。

4.1 统计系统调用频率,定位频繁读写进程

场景:线上某个节点的 CPU 使用率忽高忽低,怀疑和某个进程疯狂执行系统调用有关,但又不能在进程里加日志。

bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[comm] = count(); }'

这里使用通配符把所有系统调用入口 tracepoint 都挂上。在实际的内核上,sys_enter_*会展开成几十个探针,启动会稍微慢一点,但执行效率没问题。输出结果能直接看到哪个进程在单位时间内的系统调用次数最高。

进一步想看具体是哪个系统调用:

bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[comm, str(args->syscall)] = count(); }'

不同内核版本的args->syscall字段名可能有细微差异,但 4.14 以后基本稳定。如果挂载时提示找不到字段,可以用bpftrace -l tracepoint:syscalls:sys_enter_openat列出可用探针和参数。

如果要统计某个系统调用传入的关键参数值,比如openat打开的文件名:

bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'

str()是 bpftrace 里专门用来把内核指针转换成字符串的内置函数。需要特别注意的是,如果指针无效,str()可能导致探针出错,所以对可能为空的指针先做空判断更稳妥。还有中文文件名和特殊字符路径可能出现截断,如果完整路径非常重要,可以增大str()的第二个参数,比如str(args->filename, 256)。

4.2 函数耗时分布,揪出内核路径长尾

场景:业务反馈某接口响应偶发变慢,应用层看 P99 波动,需要知道慢请求是否在内核 IO 路径上耗时。

#!/usr/bin/env bpftrace kprobe:blk_start_plug { @time[tid] = nsecs; } kretprobe:blk_start_plug / @time[tid] / { @usecs = hist((nsecs - @time[tid]) / 1000); delete(@time[tid]); }

要注意这里用了hist()对数直方图,适合跨度很大的耗时分布。而lhist()线性直方图适合范围稳定的指标。实际输出里会看到若干个数量级的分桶,一眼能看出是不是有长尾超出常规区间。比如大部分请求在 10 到 100 微秒,但少数桶里出现了 1 到 10 毫秒的记录,那就是明显的长尾。

为什么要在返回探针里用/ @time[tid] /做过滤?因为不是每个 kprobe 都能和 kretprobe 配对成功,比如函数可能在入口之后被阻塞,或者线程被杀死,这时@time[tid]没有值。过滤条件可以防止把没有入口时间戳的返回当作正常样本,同时也在返回时顺手清理 map,避免@time无限增长。

还有一种跨 CPU 调度的场景,线程在 kprobe 入口之后被迁移到另一个 CPU,kretprobe 仍然能匹配到相同tid的 map 值,因为这些值是按线程存储的,不依赖 CPU。但如果用了cpu作为维度就要非常小心,入返不在同一 CPU 会导致计算得到异常值。

4.3 内存分配追踪,发现异常大块分配

场景:进程 RSS 持续增长,怀疑某个线程反复做大量内存分配,但代码里没找到可疑循环。

bpftrace -e ' uprobe:/usr/lib/x86_64-linux-gnu/libc.so.6:malloc { @bytes[comm] = sum(arg0); @calls[comm] = count(); } '

很多环境里 libc 路径可能不是这个,可以先ldd <目标程序> | grep libc找到实际路径。输出里按进程名分组看分配总字节数和调用次数。如果某个进程既不是业务主进程也不是常见中间件,需要留意是否 fork 出来的子进程导致内存账单偏移。

如果要针对特定大小阈值过滤,比如只看大于 1MB 的分配:

/ arg0 > 1024 * 1024 / { @big[comm] = count(); }

这种方式比在业务代码里加日志要轻松得多,因为它连编译都不需要,改一行过滤条件重新跑就行。但也要提到一个准确性问题:malloc只是申请虚拟内存,真正触发物理内存分配还得看缺页异常,所以分配大小不等于 RSS 增长量。想追踪实际物理内存增长,得配合tracepoint:exceptions:page_fault_user或software:major-faults这类探针,数据才能对上。

4.4 TCP 连接事件与重传观察

网络类问题用 tracepoint 通常能拿到比较干净的视图。追踪 TCP 连接建立:

bpftrace -e ' tracepoint:tcp:tcp_set_state / args->newstate == 7 / # TCP_CLOSE_WAIT { printf("close wait: %s %d\n", comm, pid); } '

tcp_set_state里的状态数值对应内核里的枚举定义。具体各个状态的数字不是固定不变的记忆点,建议在脚本里用注释写上内核头文件里查到的枚举值,防止内核版本变化后产生误导。更好的办法是直接对照系统头文件里的TCP_CLOSE_WAIT等定义。

追踪重传事件在tracepoint:tcp:retransmit_skb:

bpftrace -e ' tracepoint:tcp:retransmit_skb { @retrans[comm] = count(); } '

重传排查里要注意一个问题:tracepoint 默认自带一堆网络命名空间和连接信息字段,如果只统计 comm 会损失连接四元组信息。排查具体连接时,可以打印args->saddr、args->daddr、args->sport、args->dport,但字段名在内核版本间存在差异,不能指望所有版本都一致。实践中我一般先在测试环境下跑一次bpftrace -lv tracepoint:tcp:retransmit_skb查看字段,确认版本差异后再写进脚本。

4.5 CPU 采样热点线程与调用栈

当某个线程 CPU 占用异常高时,可以用profile探针做定时采样:

bpftrace -e ' profile:hz:99 / comm == "busy_worker" / { @[kstack] = count(); } '

profile:hz:99表示每秒采样 99 次,kstack是内核态调用栈,count()聚合后能在 END 时看到内核栈热点。如果想看用户态栈,把kstack换成ustack。实际经验是用户态栈需要符号表加载,如果程序是 strip 过或者容器里缺 debug 符号,ustack输出会变成一堆地址,此时可以用ustack(addr)打印地址并后处理解析。

这个脚本对内核栈采样效果很好,但有缓存命中和锁竞争等场景时,热点不一定出现在 CPU 占用上。比如自旋锁等待会表现为高 CPU 占用但内核栈大概率停留在 spinner 上,用 bpftrace 能一眼看到栈顶位置,再结合其他工具锁定锁对象。

4.6 上下文切换与调度延迟初查

进程切换频繁可能导致 CPU 开销虚高,严重时还带来调度延迟。追踪切换事件:

bpftrace -e ' tracepoint:sched:sched_switch { @[comm, args->next_comm] = count(); } '

这个脚本统计哪些进程对之间发生了频繁切换。如果统计结果里出现了大量同一个线程组内部切换,可能需要考虑锁竞争或线程过多导致调度器频繁介入。想进一步量化切换后的唤醒延迟,需要把sched_wakeup和sched_switch时间戳联动,逻辑较为复杂,通常一条命令解决不了。

实践中比一条命令更有用的是观察切换方向变化。比如业务进程变慢,如果发现它跟某个 IO 等待线程频繁互相切换,那瓶颈大概率在锁或资源等待,而不是单纯 CPU 不够。这类结论光看 load average 看不出,但 bpftrace 的切换统计能提供直接证据。

5. 性能开销实测与调优心得

第一次用 bpftrace 的人都会问同一个问题:挂探针到底对业务有多大影响?这个影响没有固定值,取决于挂载点命中频率、动作块运算复杂度、以及输出方式。

5.1 命中频率与聚合开销的关系

如果一个探针每秒命中几十万次,动作块里只是做一次count(),那开销通常在可接受范围。内核 eBPF 执行是 JIT 编译的,count 这类操作本身很轻,实际测试中大概在微秒量级。真正昂贵的是频繁调用printf()实时输出字符串,尤其每次命中都打印一行的场景,开销可能比业务代码本身还大。所以在生产环境用 bpftrace 时,输出策略我建议遵循几个原则:

  • 实时调试场景用printf小流量验证,验证完立即改成聚合统计。
  • 长期观察场景必须用 map + END 打印,或者interval定期打印聚合值。
  • 字符串转换str()尽量少用,因为内核态要把字符串复制到用户态,这个是开销大头,对文件名比较敏感的场景尤其明显。

5.2 map 内存膨胀与清理

map 变量按 key 维度和聚合函数存储数据,key 如果太离散,内存会膨胀。比如用pid做 key,短时间内可能积累大量值。bpftrace 的 map 默认大小有限,超过后旧数据可能被替换,影响统计结果。

我的做法是:

  • 需要按线程追踪时,在探针里显式delete(@start[tid]),保证不用的条目被清理。
  • 用interval周期性clear(map)防止内存持续增长。
  • 记录耗时场景统一在返回探针里做 delete 清理,避免脏数据堆积。
  • 统计类脚本用固定维度,比如comm通常比pid更稳定,进程重启后 pid 变了,但 comm 还是同一个业务名。

5.3 内核 BTF 与开发环境差异

内核 BTF 提供了类型信息,让 bpftrace 能自动生成结构体访问代码。但 BTF 依赖内核编译时开启了CONFIG_DEBUG_INFO_BTF,很多老内核镜像没有这个选项。没有 BTF 时,访问结构体字段的脚本就会失败,比如((struct sock *)arg0)->sk_num这类操作会提示类型信息缺失。

解决思路有几个:

  • 确认内核版本与 BTF 支持情况,用ls /sys/kernel/btf/vmlinux看文件是否存在。
  • 老内核上改用 bpftrace 内置的kaddr、cgroupid等函数,它们不依赖结构体定义也能拿部分信息。
  • 使用 tracepoint 而不是 kprobe,因为 tracepoint 的参数列表由内核事件框架固定提供,不需要自己解析结构体偏移。
  • 访问特定结构体字段时,可以先在内核头文件里手动查偏移,然后用kptr((void*)addr + OFFSET)读取,但这属于高级玩法,对版本变化很敏感。

实际踩坑印象最深的就是一次升级内核后,BPF 相关代码没变,但某结构体因为编译选项变化导致字段偏移整体移动,原来能跑的脚本全部失效。所以生产环境升级内核之后,建议把关键脚本重新跑一遍基线测试。

6. 常见问题与排查技巧

这部分整理的是我在各种环境下遇到的报错和处理方式,按出现频率排序。

6.1 权限不足与容器限制

错误信息常见的是Failed to attach kprobe、Couldn't create BPF map、Operation not permitted。非 root 用户跑 bpftrace 通常缺权限,解决方式是为用户添加CAP_BPF和CAP_PERFMON:

sudo setcap cap_bpf,cap_perfmon,cap_sys_admin=ep /usr/bin/bpftrace

容器场景里即使容器内是 root,宿主机没有开放相应 capability 也依然不行,需要在 docker 启动参数里加上 cap-add。这类问题最容易被忽略,因为报错信息和普通权限不足表现一样。排查顺序建议是先uname -a确认内核版本,再检查/sys/kernel/btf/vmlinux是否存在,最后检查当前用户 capabilities。

6.2 探针找不到符号

Failed to find probe或者类似提示通常有三个原因:

  1. 函数确实不存在,尤其老内核版本没有某些新函数。可以先bpftrace -l搜索可用探针。
  2. 函数被编译优化为内联,符号表里没有。可以尝试挂载调用者函数。
  3. 这是一个普通 C 函数,符号存在,但被世界内联或存在于其他编译单元里,此时考虑用 tracepoint 替代。

bpftrace -l是排查这类问题最有效的命令:

bpftrace -l 'kprobe:sys_*'

它会把所有匹配的内核函数列表打印出来,也可以直接搜特定名字。加上-v参数还能列出探针的详细参数数量和类型,对修正脚本特别有帮助。

6.3 tracepoint 字段变化

tracepoint 字段在内核版本之间变化不算少见,尤其网络相关的 tracepoint。遇到Unknown args这类解析错误,直接用-lv查看字段即可:

bpftrace -lv 'tracepoint:syscalls:sys_enter_openat'

这个输出里会显示每个字段的名字和类型,改脚本时照着字段名写即可,不用靠猜测。不同发行版可能打包了不同版本的内核头文件,所以即使相同内核版本,部署环境不同也可能有字段差异。

6.4 输出乱码与截断

高并发场景下用printf输出字符串,偶尔会出现乱码或截断,通常是字符串长度超过默认限制。str()函数默认最多读取 64 字节,改为str(ptr, 256)即可解决。还有字符串可能包含非 UTF-8 字节,终端会显示为乱码,但这不影响统计逻辑判断。

如果输出结果看起来是被阉割的统计值,要检查是不是 map 过大导致内核对 map 做了回收。尤其大量 key 的 map,可以使用BPFTRACE_MAP_KEYS_MAX环境变量调整上限,但治本的办法还是控制 key 维度。

6.5 异步事件与乱序干扰

当探针在多个 CPU 上并行执行时,输出顺序不能保证和事件发生顺序一致。需要严格事件序列的场景,可以把nsecs作为第一个输出列,后续分析时排序即可。另一个容易被忽略的点是printf本身在批量输出时可能把多个 CPU 的事件交错写入,如果要做逐条日志分析,建议在脚本里手动编入 CPU 编号和线程 ID,别只依赖输出顺序。

6.6 长时间运行脚本的处理

有些追踪需要跑几小时,但 bpftrace 默认在空闲时会退出吗?不会,脚本会一直运行直到手动中断或执行到exit()。长时间跑的时候建议用interval定期打印摘要,避免最后一次性输出大量数据。还有退出时 map 里的内容如果没有被明确print,会按默认规则输出,但输出量可能非常大导致丢失前面的数据。更稳妥的方法是在END块里手动打印,并且把输出重定向到文件:

bpftrace script.bt > /tmp/trace.log 2>&1

跑完以后对日志做二次分析,比直接盯着终端更可控。实测中脚本产生几千行输出时,终端缓冲容易刷得看不到早期内容,文件重定向是最靠谱的保留方式。

7. 写在最后:我习惯的几个小技巧

整理几个只有实际跑过才知道的小技巧,作为这篇笔记的结尾。

第一个是善用变量提前保存现场。kprobe 入口到 kretprobe 返回之间,线程可能已经切换,入口时的comm到返回时可能已经变了。这时候想关联入口进程名和返回结果,不能依赖返回时读comm,必须在入口时把值存进 map,返回时再从 map 取。我常用的一种模式:

kprobe:xxx { @name[tid] = comm; @start[tid] = nsecs; } kretprobe:xxx / @start[tid] / { @cost[comm] = hist(nsecs - @start[tid]); delete(@start[tid]); delete(@name[tid]); }

即使某些指标不区分进程名,保留这个习惯也能避免跨线程上下文造成的错误归因。

第二个是写脚本前先确定探针存在。别直接写长脚本,先用bpftrace -l搜名字,再-lv查参数,确认无误后写正式脚本。这个过程只花几十秒,能避免一大堆莫名其妙的报错。

第三个是在脚本里尽量少做字符串拼接和实时打印,能把数据塞进 map 就塞进 map。后端聚合能显著降低探针开销,也更适合生产环境长时间观察。

bpftrace 就是这样一种工具:功能覆盖广,但真正发挥价值要靠对挂载点的理解和对输出策略的把握。遇到 CPU 飙升、系统调用频繁、IO 延迟抖动这些问题,先用它定位,再配合业务日志或者 profiling 工具做进一步归因,整个链路会快很多。希望这份笔记能帮你少踩几个坑,把排查效率提上来。

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

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

立即咨询