第5章:bpftrace——BPF的瑞士军刀

📑 目录

bpftrace不是让你’编写’BPF程序,而是让你’描述’你想观察什么——它把复杂的BPF字节码编译过程藏进了一个简洁的AWK-like语法背后。

故事场景:周五下午的紧急排查

周五下午4:50,距离下班还有10分钟,小李的PagerDuty炸了。

"支付网关的P50延迟从50ms涨到了800ms,但QPS没变,错误率也没变,就是慢。"小李对着监控面板发呆,"我需要知道每一笔支付请求在内核里卡了多久。"

老张正在收拾背包,闻言叹了口气,把包放回椅子上:"给你三十秒。"

他敲了一行命令:

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

五秒钟后,终端开始输出一个漂亮的对数直方图。老张扫了一眼:"看见这个双峰分布了吗?大部分请求在50微秒内完成,但有一小撮尾巴拖到了2毫秒以上——这是网络层面的延迟尖刺,不是应用层的问题。"

小李眼睛都直了:"这就……完了?"

"一行命令。"老张拎起背包,"下次记住:bpftrace(BPF Trace,BPF追踪器)的价值不是它能做多复杂的分析,而是它让你在问题发生的第一秒就开始观察,而不是等你写完了Python脚本,问题已经自愈了。"

门关上了。小李看着屏幕上还在刷新的直方图,默默把这条命令存进了自己的.bashrc别名里。

一、bpftrace的设计理念:从’编程’到’表达’

bpftrace的设计哲学与BCC截然不同。BCC让你编写程序,bpftrace让你描述意图。这种差异体现在每一个语法细节上:

flowchart LR
    A["用户输入
bpftrace脚本 / 单行命令"] --> B["bpftrace编译器
语法解析 / 语义分析"] B --> C["LLVM IR
中间表示"] C --> D["BPF字节码
内核可执行"] D --> E["Kernel Verifier
安全检查"] E --> F["eBPF VM执行
事件触发"] F --> G["Maps / Ring Buffer
用户态输出"]

bpftrace的语法深受 AWKDTrace 启发。如果你熟悉 awk '{print $1}',那么bpftrace的学习曲线几乎是平坦的。

1.1 核心语法元素

bpftrace脚本由三个核心要素组成:

元素作用示例
Probe(探针)定义"在哪里观察"kprobe:do_sys_open, tracepoint:syscalls:sys_enter_read
Predicate(谓词)定义"什么时候记录"/pid == 1234/, /comm == "nginx"/
Action(动作)定义"记录什么"{ @count++; printf("open %s\n", str(args->filename)); }

一个完整的bpftrace单行命令看起来像这样:

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat /comm == "nginx"/ {
    printf("PID %d opening %s\n", pid, str(args->filename));
}'

这行命令的意思是:

  • 探针:挂载在 openat 系统调用的入口 tracepoint(静态追踪点)
  • 谓词:只有当进程名(comm)是 "nginx" 时才触发
  • 动作:打印进程ID和打开的文件名

1.2 与BCC的关键差异

graph LR
    subgraph "BCC"
        B1["Python脚本"] -- 实时编译 --> B2["C BPF代码"]
        B2 -- LLVM Clang --> B3["BPF字节码"]
        B3 -- 每次运行重编译 --> B4["内核加载"]
    end
    subgraph "bpftrace"
        P1["bpftrace脚本"] -- 内置编译器 --> P2["BPF字节码"]
        P2 -- 预编译优化 --> P3["内核加载"]
    end
    style B1 fill:#f9f,stroke:#333
    style P1 fill:#bbf,stroke:#333

bpftrace把 LLVM/Clang 的编译过程内置进了自己的二进制中,并且做了大量预编译优化。这意味着:

  • 启动速度更快:不需要等待Python启动和C代码编译
  • 部署更简单:单个静态链接的二进制文件,无Python依赖
  • 表达更简洁:语法糖让你用一行代码表达BCC需要三十行才能完成的事情

当然,代价是灵活性:bpftrace不适合需要复杂用户态数据处理的场景——那时你仍然需要BCC或libbpf。

二、单行程序:bpftrace的’杀手锏’

bpftrace最强大的使用模式是单行程序(One-Liners)。这些命令可以直接在终端里敲,不需要写脚本文件,是性能分析中的"急救包"。

2.1 进程级追踪

# 统计所有进程执行的新程序
sudo bpftrace -e 'tracepoint:sched:sched_process_exec { printf("%s launched %s\n", comm, str(args->filename)); }'

# 追踪某个特定PID的所有系统调用
sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter /pid == 1234/ { @[probe] = count(); }'

# 查看所有发送的信号
sudo bpftrace -e 'tracepoint:signal:signal_generate { printf("%s sent %s to PID %d\n", comm, args->sig, args->pid); }'

2.2 文件系统分析

# 统计每个进程打开文件的次数
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm] = count(); }'

# 追踪慢于10ms的文件打开操作
sudo bpftrace -e 'kprobe:do_sys_open { @start[tid] = nsecs; }
kretprobe:do_sys_open /@start[tid] && (nsecs - @start[tid]) > 10000000/ {
    printf("Slow open by %s: %d us\n", comm, (nsecs - @start[tid]) / 1000);
}'

2.3 网络分析

# 统计TCP连接建立的目标端口分布
sudo bpftrace -e 'kprobe:tcp_v4_connect { @[ntohs(((sockaddr_in*)args->uservaddr)->sin_port)] = count(); }'

# 追踪TCP发送的字节数分布
sudo bpftrace -e 'kprobe:tcp_sendmsg { @bytes = hist(args->size); }'

# 追踪TCP重传事件(发现网络不稳定)
sudo bpftrace -e 'kprobe:tcp_retransmit { @[comm, args->sk->__sk_common.skc_daddr] = count(); }'

# 统计DNS查询延迟(通过追踪udp_sendmsg到recvmsg)
sudo bpftrace -e 'kprobe:udp_sendmsg /comm == "systemd-resolve"/ { @dns_start[tid] = nsecs; }
kprobe:udp_recvmsg /comm == "systemd-resolve" && @dns_start[tid]/ {
    @dns_latency = hist((nsecs - @dns_start[tid]) / 1000);
    delete(@dns_start[tid]);
}'

老张特别推崇最后一条DNS追踪命令:"生产环境里至少30%的’诡异延迟’最终都指向DNS解析问题。systemd-resolve 卡一下,整个服务链路的P99就崩了。这条单行命令能帮你五秒钟内确认或排除DNS嫌疑。"

2.4 内存与调度

# 统计每个进程的缺页中断次数
sudo bpftrace -e 'software:page-fault:1 { @[comm, pid] = count(); }'

# 追踪线程上下文切换的原因
sudo bpftrace -e 'tracepoint:sched:sched_switch { @[args->prev_comm -> args->next_comm] = count(); }'

这些单行程序的共同点是:不需要写文件、不需要编译、不需要部署。你发现问题,打开终端,敲命令,五秒钟后开始获得数据。这就是bpftrace的核心价值——将观察延迟降到零

三、脚本编写:当单行不够用时

当分析逻辑变得复杂,单行命令会迅速变得不可读。这时就该把bpftrace脚本写进文件了。

3.1 脚本文件结构

一个完整的bpftrace脚本文件(.bt后缀)包含以下结构:

#!/usr/bin/env bpftrace

// 1. 头文件和预处理器(可选)
#include <linux/sched.h>

// 2. 全局变量和初始化(BEGIN探针)
BEGIN {
    printf("Tracing disk I/O latency. Hit Ctrl-C to stop.\n");
}

// 3. 探针定义
kprobe:blk_account_io_start {
    @start[arg0] = nsecs;
}

kprobe:blk_account_io_done /@start[arg0]/ {
    $delta = (nsecs - @start[arg0]) / 1000;  // 微秒
    @latency = hist($delta);
    delete(@start[arg0]);
}

// 4. 结束处理(END探针)
END {
    printf("\nDisk I/O latency distribution (us):\n");
    print(@latency, 10);
    clear(@latency);
    clear(@start);
}

3.2 探针类型全景

bpftrace支持丰富的探针类型,覆盖了从内到外的所有观察点:

探针类型前缀用途示例
kprobekprobe:内核函数入口kprobe:do_sys_open
kretprobekretprobe:内核函数返回kretprobe:do_sys_open
uprobeuprobe:用户态函数入口uprobe:/bin/bash:readline
uretprobeuretprobe:用户态函数返回uretprobe:/bin/bash:readline
tracepointtracepoint:内核静态追踪点tracepoint:syscalls:sys_enter_read
USDTusdt:用户态静态追踪点usdt:/usr/bin/node:http__server__request
softwaresoftware:内核软件事件software:page-fault:1
hardwarehardware:CPU硬件事件hardware:cache-misses:1000000
intervalinterval:定时触发interval:s:1
profileprofile:定时采样profile:hz:99

其中 USDT(User Statically Defined Tracing,用户态静态定义追踪) 探针是bpftrace的隐藏宝藏。许多现代软件(Node.js、MySQL、PostgreSQL、Python)都内置了USDT探针,你可以在不修改代码的情况下追踪应用内部的逻辑事件。例如,追踪Node.js的HTTP请求处理时间:

usdt:/usr/bin/node:http__server__request {
    @start[args->request] = nsecs;
}

usdt:/usr/bin/node:http__server__response /@start[args->request]/ {
    @latency_ms = hist((nsecs - @start[args->request]]) / 1000000);
    delete(@start[args->request]]);
}

3.3 更多USDT实战案例

USDT探针不仅限于Node.js。许多关键基础设施都内置了USDT,覆盖了你日常排查的大部分场景:

MySQL:追踪查询执行时间(需要MySQL编译时启用DTrace支持):

usdt:/usr/sbin/mysqld:query__start {
    @mysql_start[tid] = nsecs;
    printf("Query started: %s\n", str(args->query, 128));
}

usdt:/usr/sbin/mysqld:query__done /@mysql_start[tid]/ {
    $latency = (nsecs - @mysql_start[tid]) / 1000000;
    if ($latency > 1000) {
        printf("SLOW QUERY: %d ms\n", $latency);
    }
    @mysql_hist = hist($latency);
    delete(@mysql_start[tid]);
}

Python:追踪函数调用(Python 3.6+内置了python provider):

usdt:/usr/bin/python3:function__entry {
    @[str(args->filename, 64), str(args->funcname, 64)] = count();
}

这条命令运行几分钟后,@[...] 的输出就是一份Python热点函数排行榜——不需要修改代码,不需要安装 cProfile,一行bpftrace搞定。

Java:追踪 method__entrymethod__return(需要JVM启动时加上 -XX:+DTraceMethodProbes ):

usdt:/usr/lib/jvm/java-11-openjdk/bin/java:hotspot__method__entry {
    @[str(args->class_name, 64), str(args->method_name, 64)] = count();
}

老张特别提醒:"USDT是生产环境的’后门钥匙’——大部分应用都留了这扇门,只是文档里不说。你的任务是找到那把锁。"

四、变量、映射表与内置函数

bpftrace的表达式能力很大程度上来自于它丰富的内置变量内置函数

4.0 控制流与变量作用域

bpftrace支持完整的控制流语法,让脚本可以处理更复杂的逻辑:

// if-else 条件分支
tracepoint:syscalls:sys_enter_read /pid == 1234/ {
    if (args->count > 1048576) {
        printf("Large read: %d bytes by %s\n", args->count, comm);
    } else if (args->count < 64) {
        @small_reads[comm] = count();
    }
}

// 三元运算符
kprobe:tcp_sendmsg {
    $size = args->size;
    $label = $size > 65536 ? "large" : ($size > 1024 ? "medium" : "small");
    @[$label] = count();
}

// 局部变量(以 $ 开头)与全局变量(以 @ 开头)
kprobe:do_sys_open {
    $pid = pid;           // 局部变量,只在当前探针内有效
    @open_count[comm]++; // 全局变量,跨探针、跨CPU共享
}

关键区别

  • $var(局部变量):仅在当前探针动作内有效,存储在BPF寄存器中,速度快但无法跨探针传递
  • @var(全局Map):存储在BPF Maps中,可以跨探针、跨CPU、跨时间窗口共享——但读写需要内存访问,开销更大
  • @var[key](Hash Map):带键的全局变量,相当于线程安全的字典

小李初学bpftrace时,最大的困惑就是"什么时候用 $ 什么时候用 @ "。老张给了他一条口诀:"临时计算用美元,跨探针记数据用圈A。"

4.1 常用内置变量

变量含义示例
pid进程ID/pid == 1234/
tid线程ID@start[tid] = nsecs;
comm进程名printf("%s\n", comm);
nsecs当前纳秒时间戳nsecs - @start[tid]
cpu当前CPU编号@percpu[cpu] = count();
uid用户ID/uid != 0/
args探针参数结构体args->filename
retval返回值(kretprobe/uretprobe)retval < 0
probe当前探针名称@[probe] = count();

4.2 映射表(Map)类型

映射表是bpftrace中存储和聚合数据的核心机制。bpftrace自动选择合适的Map类型:

// 标量计数器
@count++;

// 线程级Hash Map(自动按线程隔离)
@start[tid] = nsecs;

// 进程级聚合
@[comm] = count();

// 直方图(对数分桶)
@latency = hist(delta);

// 线性直方图
@size = lhist(args->size, 0, 1000000, 10000);

// 统计(count/min/max/avg/sum)
@stats = stats(delta);

4.3 关键内置函数

函数作用示例
count()计数@x = count();
sum(val)求和@total = sum(args->size);
avg(val)平均值@avg = avg(delta);
min(val) / max(val)最小/最大值@max = max(delta);
hist(val)对数直方图@lat = hist(delta);
lhist(val, min, max, step)线性直方图@sz = lhist(size, 0, 1024, 64);
str(addr)读取字符串str(args->filename)
printf(fmt, ...)格式化输出printf("PID %d\n", pid);
time(fmt)打印时间time("%H:%M:%S")
ntohs(val) / ntohl(val)网络字节序转换ntohs(args->sin_port)
kstack() / ustack()采集内核/用户态栈@[kstack()] = count();

kstack()ustack() 是两个极其强大的函数。它们可以在任意探针触发时采集调用栈。例如,统计所有导致内核态内存分配的调用路径:

kprobe:kmem_cache_alloc {
    @[kstack(5), comm] = count();  // 采集最多5层内核栈
}

运行一段时间后,@[...] 的输出会告诉你:哪些代码路径最频繁地触发内核内存分配?是文件系统?网络协议栈?还是某个驱动?

4.4 更多实用单行与脚本示例

下面是一些bpftrace社区中广为流传的"经典配方",每一个都能在特定场景下节省你数小时的排查时间:

追踪程序启动时的库加载顺序(发现启动慢的原因):

sudo bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/libc.so.6:__libc_start_main {
    printf("[%s] PID %d started at %s\n", comm, pid, ustack(3));
}'

统计每个CPU上硬中断的分布(NUMA调度问题排查):

sudo bpftrace -e 'hardware:cpu-cycles:1000000 {
    @[cpu, comm] = count();
}'

追踪所有失败的系统调用及其错误码

sudo bpftrace -e 'tracepoint:raw_syscalls:sys_exit /retval < 0/ {
    @[probe, comm, -retval] = count();
}'

这条命令的输出是一份"系统调用失败排行榜"——如果某个错误码(如 -EAGAIN-ENOMEM-ENOENT )频繁出现,你就知道该往哪个方向排查了。

五、调试技巧与常见陷阱

bpftrace虽然简洁,但也有一批让人头大的"坑"。

5.1 调试方法

当bpftrace脚本不工作时,依次检查:

  1. 语法检查bpftrace -d script.bt 可以打印AST,帮助定位语法错误
  2. 探针存在性bpftrace -l '*open*' 列出所有匹配的探针,确认你要挂接的探针存在
  3. 权限问题:bpftrace需要 CAP_SYS_ADMIN 或 root 权限,且内核需要启用 CONFIG_BPF_SYSCALL
  4. 内核版本:某些探针(如 BTF-enabled tracepoints)需要较新的内核(5.2+)

5.2 常见陷阱

陷阱现象解决
字符串读取越界str(args->path) 崩溃或输出乱码使用 str(args->path, 128) 限制长度
Map键不存在访问 @map[key] 时程序被拒绝先用 /@map[key]/ 谓词检查存在性
返回值类型不匹配在kprobe中访问 retvalretval 只在kretprobe中可用
栈深度过大kstack(100) 导致Verifier拒绝内核栈通常限制在10-20层
高频事件淹没探针触发频率太高,系统变卡使用硬件计数器采样(hardware:)或增加谓词过滤

老张给小李总结了一张"bpftrace速查卡",贴在显示器边框上:

# 进程创建 →  execsnoop.bt
# 文件打开 →  opensnoop.bt
# 网络连接 →  tcpconnect.bt
# 系统调用频率 →  syscount.bt
# 火焰图采样 →  profile.bt
# 内存分配栈 →  memleak.bt

"bpftrace不是取代BCC,"老张说,"它是BPF生态的’入口 drug’——让你先用一行命令尝到甜头,然后自然会想去学BCC和libbpf。"

小李深以为然。他已经把bpftrace的单行命令集整理成了一个内部Wiki页面,标题叫《周五4:50急救手册》。

5.3 实战案例:用bpftrace定位Java服务的神秘卡顿

某天下午,小李接到告警:一个Java微服务的P99突然从200ms涨到了4秒,但CPU、内存、网络都正常。Java的GC日志也没有异常。诡异的是,问题持续约10分钟后自动恢复。

"间歇性问题最难搞,"老张说,"因为它通常在你打开IDE之前就已经结束了。bpftrace的长处就是够快——在它自愈之前,你已经拿到数据了。"

他们用了三条bpftrace命令并行采集:

# 终端1:采集off-CPU火焰图样本
sudo bpftrace -e 'profile:hz:99 { @[offcpu_stack, comm] = count(); }'

# 终端2:追踪Java方法的执行时间(需要JVM启用DTrace探针)
sudo bpftrace -e 'usdt:/usr/lib/jvm/java-11-openjdk/bin/java:hotspot__method__entry {
    @java_start[str(args->method_name)] = nsecs;
}
usdt:/usr/lib/jvm/java-11-openjdk/bin/java:hotspot__method__return {
    $name = str(args->method_name);
    if (@java_start[$name]) {
        $latency = (nsecs - @java_start[$name]) / 1000000;
        if ($latency > 2000) {
            printf("SLOW: %s took %d ms\n", $name, $latency);
        }
        delete(@java_start[$name]);
    }
}'

# 终端3:追踪线程上下文切换,看谁在抢CPU
sudo bpftrace -e 'tracepoint:sched:sched_switch {
    @[args->prev_comm, args->next_comm, args->prev_state] = count();
}'

问题再次出现时,数据已经到手。分析结果:

  1. off-CPU火焰图显示大量Java线程卡在 futex_wait ——某个Java锁被长时间持有
  2. Java方法追踪指向了同一个 culprit:一个第三方库的初始化方法在第一次调用时触发了类加载,而类加载持有了一个全局锁
  3. 上下文切换数据显示JVM的GC线程在高峰期频繁抢占业务线程

根因:应用重启后,JVM的 ClassLoader 锁在第一个请求到达时触发了一次性的类初始化,阻塞了所有其他请求。JVM的JIT编译器随后把这个方法内联优化了,所以问题只出现在重启后的前10分钟。

"这个案例里,"老张总结道,"Java层面的profiler看不到锁竞争(因为它发生在JVM内部),系统层面的 top 也看不到off-CPU等待。只有bpftrace这种能跨层穿透的工具,才能把内核态的 futex_wait 和应用层的 method_entry 关联起来。"

小李把这个案例写进了Wiki首页,标题是《为什么你需要在三秒内启动追踪器》。

总结

mindmap
  root((bpftrace核心))
    语法
      探针 Probe
      谓词 Predicate
      动作 Action
    数据类型
      标量 @scalar
      Hash Map @map[key]
      直方图 hist / lhist
      统计 stats
    探针类型
      kprobe / kretprobe
      uprobe / uretprobe
      tracepoint
      USDT
      software / hardware
      interval / profile
    内置函数
      str printf time
      count sum avg
      hist lhist stats
      kstack ustack
    典型场景
      单行ad-hoc分析
      脚本化监控
      火焰图采样
      USDT应用追踪

核心要点:

  1. bpftrace是’描述式’追踪语言:用探针+谓词+动作的三段式语法,一行命令就能完成复杂的内核级观察
  2. 内置编译器消除了部署负担:单个静态二进制,无需Python、无需LLVM开发库,随用随走
  3. 丰富的探针类型覆盖全栈:从内核函数(kprobe)到用户态静态探针(USDT),从软件事件到硬件计数器
  4. Map和内置函数是数据处理引擎:Hash Map做线程级关联,hist/lhist做分布统计,kstack/ustack做调用栈采集
  5. bpftrace与BCC互补而非替代:bpftrace做快速侦察(ad-hoc),BCC做深度攻坚(复杂聚合),libbpf做生产部署(CO-RE)

"BCC让你成为BPF程序员,"老张在周五的复盘会上说,"bpftrace让你成为BPF用户。先成为用户,再决定要不要成为程序员——这是学习任何技术的最短路径。"

小李把这句话也写进了Wiki。