凌晨两点,告警响了。
P99 延迟从 8ms 飙到了 500ms。但 P50 纹丝不动,Grafana 上的平均延迟图看起来一切正常。你打开日志——什么都没有,因为你的日志只记了错误,没记延迟。你加了个 timing middleware 重新部署——但问题消失了,因为它只在高负载下出现。
这就是经典的”观测者效应”困境:你越想看清它,它越不出现。
你需要的不是更多日志,而是一种不修改代码、不重启进程、不影响性能就能观测内核和应用行为的能力。这就是 bpftrace。
如果你还不熟悉 eBPF 的基础概念——验证器、Map、hook 点,建议先读 eBPF:Linux 内核的隐藏武器。本文假设你知道 eBPF 是什么,重点放在怎么用 bpftrace 解决真实问题。
一、bpftrace 是什么:和 BCC/perf 的关系
bpftrace 是一个高级追踪语言,灵感来自 awk 和 DTrace。它把 eBPF 的复杂性藏在了一行命令背后:
# 统计每个进程的 syscall 次数——就这么简单
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'但”简单”不意味着”玩具”。bpftrace 背后是完整的 eBPF 编译管线:你的脚本会经过词法分析、语法解析、语义检查,最终通过 LLVM 编译为 eBPF 字节码,再由内核验证器审核后 JIT 执行。
那它和其他工具是什么关系?
| 工具 | 定位 | 适合场景 | 上手难度 |
|---|---|---|---|
| bpftrace | 高级追踪语言 | 一次性调查、快速诊断、延迟分析 | ★★☆☆☆ |
| BCC (Python/C) | eBPF 工具库 | 编写可重复使用的追踪工具 | ★★★☆☆ |
| libbpf (C) | 底层加载库 | 编写高性能、可部署的 eBPF 程序 | ★★★★☆ |
| perf | 内核自带性能工具 | CPU profiling、PMC 计数、火焰图采样 | ★★★☆☆ |
| ftrace | 内核内置追踪框架 | 函数追踪、事件过滤、不需要额外安装 | ★★★☆☆ |
简单说:bpftrace
是你的第一反应工具。就像你用 top 看
CPU、用 ss 看连接一样,bpftrace
是你看”内核里到底发生了什么”的第一选择。如果 bpftrace
搞不定(比如你需要长驻 daemon 或者复杂的数据结构),再换 BCC
或 libbpf。
安装非常简单:
# Ubuntu/Debian
sudo apt-get install -y bpftrace
# CentOS/RHEL 8+
sudo dnf install -y bpftrace
# 验证安装
bpftrace --version
# bpftrace v0.21.0
# 查看你的内核支持哪些探针
sudo bpftrace -l 'tracepoint:syscalls:*' | head -20二、bpftrace one-liner 速查:进程、I/O、网络、调度
one-liner 是 bpftrace 的灵魂。以下是我在生产环境中反复使用的命令,每一条都能在 30 秒内给你有价值的信息。
进程与系统调用
# 1. 统计每个进程的 syscall 次数(找出谁在疯狂做系统调用)
sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
@syscalls[comm] = count();
}'
# 输出示例:
# @syscalls[nginx]: 284721
# @syscalls[postgres]: 89432
# @syscalls[node]: 312897 ← 这家伙在干什么?# 2. 追踪某个进程的所有 open() 调用(看它在打开什么文件)
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat /comm == "nginx"/ {
printf("%s opened: %s\n", comm, str(args.filename));
}'# 3. 统计新进程创建(谁在 fork 炸弹?)
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_execve {
printf("%-6d %-16s %s\n", pid, comm, str(args.filename));
}'磁盘 I/O
# 4. 按进程统计磁盘 I/O 大小(谁在疯狂写磁盘)
sudo bpftrace -e '
tracepoint:block:block_rq_issue {
@bytes[comm] = sum(args.bytes);
}'
# 5. 磁盘 I/O 延迟分布(直方图,单位微秒)
sudo bpftrace -e '
tracepoint:block:block_rq_issue { @start[args.dev, args.sector] = nsecs; }
tracepoint:block:block_rq_complete /@start[args.dev, args.sector]/ {
@usecs = hist((nsecs - @start[args.dev, args.sector]) / 1000);
delete(@start[args.dev, args.sector]);
}'网络
# 6. 追踪 TCP 连接建立(谁连了你的服务器)
sudo bpftrace -e '
kprobe:tcp_v4_connect {
printf("%-6d %-16s → connecting...\n", pid, comm);
}
kretprobe:tcp_v4_connect /retval == 0/ {
printf("%-6d %-16s → connected\n", pid, comm);
}'
# 7. 统计每个远端 IP 的 TCP 接收字节数
sudo bpftrace -e '
kprobe:tcp_recvmsg {
@recv_bytes[ntop(((struct sock *)arg0)->__sk_common.skc_daddr)] = sum(arg2);
}'CPU 调度
# 8. 追踪进程被调度到哪个 CPU(NUMA 问题排查)
sudo bpftrace -e '
tracepoint:sched:sched_switch {
printf("cpu%-2d: %-16s → %-16s\n",
cpu, args.prev_comm, args.next_comm);
}' | head -50
# 9. 统计每个进程的运行队列等待时间(调度延迟)
sudo bpftrace -e '
tracepoint:sched:sched_wakeup { @qtime[args.pid] = nsecs; }
tracepoint:sched:sched_switch /@qtime[args.next_pid]/ {
@usecs[args.next_comm] = hist((nsecs - @qtime[args.next_pid]) / 1000);
delete(@qtime[args.next_pid]);
}'
# 10. 找出哪些进程在用 futex(锁竞争的信号)
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_futex {
@futex[comm, args.op & 0xf] = count();
}'技巧:运行 bpftrace 后按 Ctrl+C 退出,它会自动打印所有聚合的 map(
@变量)。这个设计非常优雅——你不需要写任何输出逻辑。
三、延迟直方图:用 hist() 和 lhist() 找到延迟分布
平均值是骗人的。如果你的请求延迟是”99% 在 1ms 以内,1% 在 500ms”,平均值会告诉你”6ms,看起来还行”。你需要的是分布。
bpftrace 的 hist() 函数生成 power-of-2
直方图,lhist()
生成线性直方图。这是追踪延迟最有力的武器。
系统调用延迟分布
# 测量 read() 系统调用的延迟分布
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read {
@start[tid] = nsecs;
}
tracepoint:syscalls:sys_exit_read /@start[tid]/ {
@read_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'运行几秒后按 Ctrl+C,你会看到类似这样的输出:
@read_us:
[0] 412 |@@@@@@@@@@@@ |
[1] 1728 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2, 4) 893 |@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[4, 8) 241 |@@@@@@@ |
[8, 16) 87 |@@ |
[16, 32) 34 |@ |
[32, 64) 12 | |
[64, 128) 3 | |
[128, 256) 0 | |
[256, 512) 1 | |
[512, 1K) 0 | |
[1K, 2K) 2 | |
看到了吗?大部分 read 在 1-4 微秒内完成,但有 2 个请求花了超过 1 毫秒。在高并发场景下,这 2 个请求可能就是你的 P99 延迟杀手。
线性直方图:更精确的区间
# 用 lhist() 看 0-1000us 范围内的精确分布
# lhist(value, min, max, step) - 线性分桶
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_read /@start[tid]/ {
@read_detail = lhist((nsecs - @start[tid]) / 1000, 0, 1000, 50);
delete(@start[tid]);
}'
# 输出:每 50us 一个桶,看得更清楚
# [0, 50) 3241 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
# [50, 100) 187 |@@ |
# [100, 150) 43 | |
# ...按进程名分组的延迟
# 哪个进程的 write() 延迟最高?
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_write { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_write /@start[tid]/ {
@write_us[comm] = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
# 输出会按进程名分组显示直方图:
# @write_us[nginx]:
# [0] 1241 |@@@@@@@@@@@@@@@@@@@@@@@@|
# [1] 2891 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
# ...
#
# @write_us[postgres]:
# [1] 342 |@@@@@@@@@@ |
# [2, 4) 1879 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
# [4, 8) 2341 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
# ... ← postgres 的 write 明显更慢,可能是 fsync关键洞察:直方图能告诉你平均值隐藏的真相。如果你看到双峰分布(bimodal),说明有两种不同的代码路径在影响同一个系统调用——一种快,一种慢。这是排查问题的黄金线索。
四、自定义 kprobe/uprobe:追踪你自己的函数
one-liner 很好,但有时你需要更精确的追踪。kprobe 让你在任意内核函数上设置断点,uprobe 让你追踪用户态程序的函数。这是 bpftrace 真正强大的地方。
kprobe:追踪 TCP 发送行为
# 追踪 tcp_sendmsg:谁在发数据,发了多少字节
sudo bpftrace -e '
kprobe:tcp_sendmsg {
// arg0 = struct sock *, arg2 = size
@send_bytes[comm] = sum(arg2);
@send_sizes[comm] = hist(arg2);
}
interval:s:5 {
print(@send_bytes);
clear(@send_bytes);
}'
# 每 5 秒打印一次统计,持续监控kprobe + kretprobe:测量内核函数执行时间
#!/usr/bin/env bpftrace
// 文件:tcp_sendmsg_latency.bt
// 用途:测量 tcp_sendmsg 的执行时间,找出慢的 TCP 发送
kprobe:tcp_sendmsg
{
@start[tid] = nsecs;
@size[tid] = arg2;
}
kretprobe:tcp_sendmsg
/@start[tid]/
{
$delta_us = (nsecs - @start[tid]) / 1000;
$sz = @size[tid];
// 只关注超过 100us 的慢调用
if ($delta_us > 100) {
printf("[SLOW] %-16s pid=%-6d size=%-6d latency=%dus\n",
comm, pid, $sz, $delta_us);
}
@latency = hist($delta_us);
@by_proc[comm] = hist($delta_us);
delete(@start[tid]);
delete(@size[tid]);
}
END
{
// Ctrl+C 时清理临时 map,只保留直方图
clear(@start);
clear(@size);
}运行方式:
sudo bpftrace tcp_sendmsg_latency.bt
# 输出:
# [SLOW] nginx pid=1234 size=4096 latency=2341us
# [SLOW] nginx pid=1234 size=8192 latency=5123us
# ^C ← 按 Ctrl+C 查看汇总直方图uprobe:追踪用户态函数
uprobe 可以追踪任何用户态程序的函数,只要二进制文件里有符号表。这意味着你可以追踪 libc 的 malloc、你的 Go/Rust/C++ 应用的自定义函数,甚至 OpenSSL 的加解密调用。
# 追踪 libc malloc 的分配大小分布
sudo bpftrace -e '
uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc {
@alloc_size = hist(arg0);
}
uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc /arg0 > 1048576/ {
printf("[BIG ALLOC] %-16s pid=%-6d size=%d bytes\n",
comm, pid, arg0);
}'
# 找出谁在做大内存分配——这往往是 GC 压力或内存碎片的根因# 追踪 OpenSSL 的 SSL_read 延迟(排查 TLS 握手和解密开销)
sudo bpftrace -e '
uprobe:/usr/lib/x86_64-linux-gnu/libssl.so.3:SSL_read {
@start[tid] = nsecs;
}
uretprobe:/usr/lib/x86_64-linux-gnu/libssl.so.3:SSL_read /@start[tid]/ {
@ssl_read_us[comm] = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'追踪自定义应用函数
如果你的应用是用 C/C++
编写并保留了符号表(-g
编译),你可以直接追踪你的函数:
# 假设你有一个 HTTP server,想追踪 handle_request 函数
sudo bpftrace -e '
uprobe:/opt/myapp/server:handle_request {
@start[tid] = nsecs;
}
uretprobe:/opt/myapp/server:handle_request /@start[tid]/ {
$us = (nsecs - @start[tid]) / 1000;
if ($us > 10000) {
// 超过 10ms 的请求,打印调用栈
printf("[SLOW REQUEST] %dus\n", $us);
print(ustack);
}
@req_latency = hist($us);
delete(@start[tid]);
}'注意:Go 程序的 uprobe 支持比较特殊。Go 的 goroutine 栈可以动态增长和迁移,而且 Go 的 ABI(参数传递方式)和 C 不同。Go 1.17+ 开始用寄存器传参,之前用栈传参。如果你要追踪 Go 应用,建议用 USDT(User-level Statically Defined Tracing)或者直接用 tracepoint。
五、Off-CPU 分析:那些不在 CPU 上的时间去哪了
这是我见过最多人忽略的分析维度。
你用 perf 做 CPU profiling,发现 CPU
利用率只有
30%,但请求延迟很高。为什么?因为你的程序大部分时间不在
CPU 上运行——它在等锁、等
I/O、等网络、等页面调入。这些时间不会出现在 on-CPU
火焰图里。
On-CPU vs Off-CPU
| 分析类型 | 看到的是 | 看不到的是 | 典型工具 |
|---|---|---|---|
| On-CPU | 函数执行时间(CPU 在忙什么) | 等待时间(CPU 在等什么) | perf, profile |
| Off-CPU | 线程阻塞时间(为什么不运行) | CPU 计算时间 | bpftrace, offcputime |
如果你的应用是 CPU-bound(如编解码、加密运算),on-CPU profiling 就够了。但大部分 Web 服务是 I/O-bound——off-CPU 分析才是关键。
关于为什么延迟分析如此反直觉,这和 CPU 的流水线、缓存一致性协议有关。如果你对多核环境下的时间测量感兴趣,可以看 Memory Barriers:在多核战场上排兵布阵,里面有 TSC(Time Stamp Counter)在多核下漂移的实战案例。
用 bpftrace 做 Off-CPU 分析
#!/usr/bin/env bpftrace
// 文件:offcpu.bt
// 用途:记录线程离开 CPU 到重新上 CPU 的时间 + 内核栈
tracepoint:sched:sched_switch
{
// 记录被切走的线程的时间戳
if (args.prev_state != 0) {
// prev_state != 0 表示非自愿切换(被阻塞了)
@off[args.prev_pid] = nsecs;
}
}
tracepoint:sched:sched_switch
/@off[args.next_pid]/
{
// 被切回来了,计算 off-cpu 时间
$delta_us = (nsecs - @off[args.next_pid]) / 1000;
// 只关注超过 1ms 的 off-cpu 事件
if ($delta_us > 1000) {
@stacks[kstack, comm] = sum($delta_us);
}
@off_cpu_us[comm] = hist($delta_us);
delete(@off[args.next_pid]);
}
END
{
clear(@off);
}sudo bpftrace offcpu.bt
# 运行 30 秒后 Ctrl+C,查看结果
# @stacks 会显示哪些内核调用栈导致了长时间 off-CPU
# 常见的罪魁祸首:
# 1. 锁竞争
# schedule() → __schedule() → schedule_preempt_disabled()
# → __mutex_lock_slowpath() → mutex_lock() ← 等互斥锁
# → your_function()
# 2. I/O 等待
# schedule() → io_schedule() → blk_mq_get_tag()
# → __blkdev_direct_IO() → ext4_file_write_iter() ← 等磁盘
# 3. 网络等待
# schedule() → sk_wait_data() → tcp_recvmsg() ← 等对端数据
# 4. 页面错误
# schedule() → wait_on_page_locked()
# → __do_page_fault() ← 等页面从磁盘调入生成 Off-CPU 火焰图
off-cpu 数据最直观的展示方式是火焰图。你可以用 bpftrace 导出栈数据,然后用 Brendan Gregg 的 FlameGraph 工具生成:
# 第一步:用 bpftrace 收集 off-cpu 栈(折叠格式)
sudo bpftrace -e '
tracepoint:sched:sched_switch {
if (args.prev_state != 0) { @off[args.prev_pid] = nsecs; }
}
tracepoint:sched:sched_switch /@off[args.next_pid]/ {
$us = (nsecs - @off[args.next_pid]) / 1000;
if ($us > 100 && args.next_comm == "myapp") {
@[kstack] = sum($us);
}
delete(@off[args.next_pid]);
}' -d 2>/dev/null | grep -E "^@" > offcpu_stacks.txt
# 第二步:转换为火焰图(需要 FlameGraph 工具)
# git clone https://github.com/brendangregg/FlameGraph.git
cat offcpu_stacks.txt | \
FlameGraph/stackcollapse-bpftrace.pl | \
FlameGraph/flamegraph.pl \
--title "Off-CPU Flame Graph" \
--colors io \
--countname "microseconds" \
> offcpu_flamegraph.svgOff-CPU 常见元凶速查
| 症状 | 内核栈关键函数 | 可能原因 | 排查方向 |
|---|---|---|---|
| 等互斥锁 | __mutex_lock_slowpath |
锁竞争激烈 | 减小临界区、换读写锁 |
| 等自旋锁 | native_queued_spin_lock_slowpath |
自旋锁持有时间过长 | 检查中断上下文持锁 |
| 等磁盘 I/O | io_schedule,
blk_mq_get_tag |
磁盘 IOPS 饱和 | iostat 确认、考虑异步 I/O |
| 等网络数据 | sk_wait_data,
inet_csk_wait_for_connect |
对端慢或网络延迟 | tcpdump / ss 确认 |
| 等 futex | futex_wait_queue |
用户态锁/条件变量竞争 | 检查应用层锁设计 |
| 等 epoll | ep_poll |
正常等待事件(通常无害) | 确认 timeout 设置合理 |
| 等页面 | __do_page_fault,
wait_on_page_locked |
内存压力、swap 使用 | free -h / vmstat 确认 |
真实案例:某次排查 Go 服务的 P99 延迟抖动,on-CPU profiling 显示 runtime.mcall 占比很高但看不出原因。换用 off-CPU 分析后发现:大量 goroutine 在
futex_wait_queue上阻塞,根因是 sync.Mutex 在高并发下的竞争。最终方案:将全局锁拆分为分片锁(sharded lock),P99 从 200ms 降到 8ms。
六、生产环境注意事项
bpftrace 很强大,但在生产环境使用任何追踪工具都需要谨慎。以下是我踩过的坑。
性能开销
bpftrace 的开销取决于探针触发频率,而不是脚本复杂度。
# 低开销:tracepoint 触发频率低
sudo bpftrace -e 'tracepoint:block:block_rq_complete { @bytes = hist(args.bytes); }'
# 大约每秒触发几千次,开销 < 1%
# 中等开销:追踪高频系统调用
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_read { @[comm] = count(); }'
# 高负载下每秒触发几十万次,开销 1-5%
# 高开销(危险!):追踪调度器事件
sudo bpftrace -e 'tracepoint:sched:sched_switch { @[kstack] = count(); }'
# 每秒触发几百万次 + 每次采栈,开销 10-30%
# 生产环境慎用!黄金法则:
- 先用
-l估算探针数量:bpftrace -l 'kprobe:tcp_*' | wc -l - 用
interval:s:1 { print(@); clear(@); }观察触发频率 - 加过滤条件减少触发:
/comm == "myapp"/或/pid == 12345/ - 设置超时:
timeout 30 bpftrace -e '...',避免忘记关闭 - 避免在高频路径上采内核栈(kstack 每次调用约 5-10us)
内核版本要求
| 功能 | 最低内核版本 | 说明 |
|---|---|---|
| bpftrace 基础功能 | 4.9 | kprobe, tracepoint, 基本 map |
| uprobe | 4.17 | 用户态函数追踪 |
| BTF (CO-RE) | 5.2 | 跨内核版本兼容,不需要内核头文件 |
| bpf_ringbuf | 5.8 | 更高效的数据传输通道 |
| kfunc | 5.18 | 类型安全的内核函数追踪 |
| bpf_loop | 5.17 | 支持有界循环 |
| usdt | 需要 bpftrace 0.14+ | User-level SDT 探针 |
# 检查你的内核版本
uname -r
# 5.15.0-92-generic ← 这个版本支持 BTF 但不支持 kfunc
# 检查 BTF 支持
ls /sys/kernel/btf/vmlinux && echo "BTF supported" || echo "No BTF"
# 检查当前内核支持的 eBPF 特性
sudo bpftrace --info 2>/dev/null | head -30安全性考量
eBPF 的验证器提供了强大的安全保证(详见 eBPF:Linux 内核的隐藏武器 中的验证器章节),但仍有一些需要注意的点:
# bpftrace 默认需要 root 或 CAP_BPF + CAP_PERFMON
# 不要把 bpftrace 的 suid 位设给普通用户!
# 推荐做法:用 capabilities 控制权限
sudo setcap cap_bpf,cap_perfmon=ep /usr/bin/bpftrace
# 或者用 unprivileged_bpf_disabled 限制非特权用户
cat /proc/sys/kernel/unprivileged_bpf_disabled
# 1 = 只有 root 可以加载 BPF 程序(推荐生产环境设置)
# 2 = 完全禁止非特权 BPF安全 vs 风险对照:
| 安全保证 | 潜在风险 |
|---|---|
| 验证器保证不会 crash 内核 | kprobe 挂载到被 inline 的函数会失败 |
| 内存访问边界检查 | 高频探针可能导致性能下降 |
| 程序有指令数量上限 | BPF 辅助函数可能引入微小延迟 |
| 自动清理:bpftrace 退出时探针自动卸载 | 如果 bpftrace 进程被 OOM kill,探针可能残留 |
实用调试技巧
# 调试 bpftrace 脚本本身——打印 AST 和 LLVM IR
sudo bpftrace -d -e 'kprobe:tcp_sendmsg { @[comm] = count(); }' 2>&1 | head -50
# 列出所有可用的 tracepoint
sudo bpftrace -l 'tracepoint:*' | wc -l
# 通常有 1800+ 个
# 查看某个 tracepoint 的参数结构
sudo bpftrace -lv 'tracepoint:syscalls:sys_enter_read'
# tracepoint:syscalls:sys_enter_read
# int __syscall_nr;
# unsigned int fd;
# char * buf;
# size_t count;
# 查看内核函数的参数(需要 BTF)
sudo bpftrace -lv 'kprobe:tcp_sendmsg'生产环境部署注意事项
上面的”性能开销”只是冰山一角。真正在生产跑长时间 bpftrace 脚本时,还有几个容易被忽略的坑:
CPU 开销会随时间累积。
一个”看起来没什么”的脚本,如果探针触发频率高,跑几个小时后你会在
perf top 里看到 bpf_prog_*
占了好几个百分点。经验法则:长驻脚本的探针触发频率不要超过
10K/s,否则 CPU
开销会从”可忽略”滑向”不可接受”。
# 监控你的 bpftrace 脚本对 CPU 的实际影响
# 在另一个终端运行:
pidstat -p $(pgrep -f bpftrace) 1
# 关注 %usr + %system,超过 5% 就要警惕采样频率权衡。 用
profile:hz:99 做 CPU profiling 时,99 Hz
是经典选择(避开与 100 Hz
时钟对齐)。但如果你只是想看趋势,profile:hz:49
甚至 profile:hz:19
就够了,开销降一半以上。别默认拉到 999
Hz——那是给短期诊断用的,不是给长驻监控用的。
# 低采样频率的长期 CPU profiling(适合跑几小时)
sudo bpftrace -e 'profile:hz:49 /comm == "myapp"/ { @[kstack] = count(); }'
# 高采样频率的短期精确诊断(最多跑 30 秒)
sudo timeout 30 bpftrace -e 'profile:hz:999 /pid == 12345/ { @[ustack] = count(); }'Map 大小会增长。 每个 @
变量背后是一个 eBPF Map,默认 max_entries 通常是 4096
或更多。如果你的 key 是高基数的(比如
@[pid, tid, kstack]),Map
会持续增长直到触及上限,然后新 key
静默丢失。更糟的是,大 Map 占内存,bpftrace
退出时打印 Map 内容也会卡很久。
# 用 interval{} 定期清理 Map,防止无限增长
sudo bpftrace -e '
kprobe:tcp_sendmsg {
@by_proc[comm] = sum(arg2);
}
interval:s:30 {
print(@by_proc);
clear(@by_proc); // 每 30 秒清零重来
}'-c 和 --unsafe
的使用纪律:
-c CMD:让 bpftrace 启动一个子进程并自动追踪它。好处:脚本随子进程退出而自动结束,不会忘记关闭。推荐用于任何非交互式的一次性诊断。--unsafe:允许使用system()等危险 helper。在生产环境中永远不要加这个标志——它让 eBPF 程序能执行任意 shell 命令,等于把 root shell 交给了脚本。
# 推荐:用 -c 自动限定追踪范围和生命周期
sudo bpftrace -c '/opt/myapp/server --config prod.yaml' -e '
uprobe:/opt/myapp/server:handle_request {
@start[tid] = nsecs;
}
uretprobe:/opt/myapp/server:handle_request /@start[tid]/ {
@latency = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
# 危险:永远不要在生产这么干
# sudo bpftrace --unsafe -e '... system("rm -rf /something") ...'常见反模式
在帮团队排查过几十次”bpftrace 把机器搞慢了”之后,我总结了这些反模式。每一条都是真实踩过的坑。
反模式 1:滥用 bpf_printk / printf
bpf_printk() 写的是内核的 trace buffer(perf
ring buffer),printf() 在 bpftrace
里最终也走类似通道。高频触发时,ring buffer
会饱和,导致事件丢失甚至影响其他 tracing 工具。
# ❌ 错误:每个包都 printf,高负载下每秒几十万次输出
sudo bpftrace -e '
kprobe:tcp_sendmsg {
printf("pid=%d comm=%s size=%d\n", pid, comm, arg2); // 刷爆 ring buffer
}'
# ✅ 正确:用 Map 聚合,只在需要时打印
sudo bpftrace -e '
kprobe:tcp_sendmsg {
@send[comm] = sum(arg2);
@sizes[comm] = hist(arg2);
}
interval:s:5 {
print(@send);
clear(@send);
}'判断标准:如果你的 printf 触发频率可能超过 1000 次/秒,换成 Map 聚合。
反模式 2:高基数 Map Key 导致内存膨胀
# ❌ 错误:用 (pid, tid, kstack) 做 key,基数爆炸
sudo bpftrace -e '
tracepoint:sched:sched_switch {
@[args.prev_pid, args.next_pid, kstack] = count();
// 每一种 (prev_pid, next_pid, 内核栈) 组合都是一个 Map 条目
// 跑几分钟后 Map 可能有几十万条目 → 内存暴涨 + 退出时打印卡死
}'
# ✅ 正确:降低 key 基数,只关注你需要的维度
sudo bpftrace -e '
tracepoint:sched:sched_switch /args.prev_comm == "myapp"/ {
@[kstack] = count(); // 只按栈聚合,过滤特定进程
}'反模式 3:缺少 interval{} 清理导致 Map 无限增长
长驻脚本如果不定期清理 Map,条目数会持续增长直到 max_entries 上限。更隐蔽的是,即使 key 不再活跃(比如已退出的进程 PID),它的条目还在 Map 里占着空间。
# ❌ 错误:跑了 3 天的脚本,Map 里全是已经不存在的进程
sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
@[comm, pid] = count();
// pid 会不断新增(新进程),老的永远不删
}'
# ✅ 正确:定期快照并清理
sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
@[comm, pid] = count();
}
interval:s:60 {
print(@);
clear(@); // 每分钟重置,只看最近一分钟的活跃情况
}'反模式 4:在热路径 kprobe 上不加过滤
内核里有些函数每秒被调用几百万次(比如
__schedule、_raw_spin_lock、page_fault)。无条件地挂
kprobe
上去,等于给每次调用都加了一个函数调用的开销——几百万次/秒 ×
几百纳秒 = 显著的 CPU 占用。
# ❌ 危险:__schedule 是内核最热的路径之一
sudo bpftrace -e 'kprobe:__schedule { @[kstack] = count(); }'
# 每次调度切换都采栈 → CPU 开销可达 20-30%
# ✅ 正确:加过滤条件,只追踪你关心的进程/场景
sudo bpftrace -e '
kprobe:__schedule /comm == "myapp"/ {
@[kstack] = count();
}
interval:s:10 {
print(@);
clear(@);
}'
# 只追踪 myapp 的调度事件 → 开销从 20% 降到 < 1%经验法则:挂 kprobe 之前,先用
sudo bpftrace -e 'kprobe:你的函数 { @cnt = count(); } interval:s:1 { print(@cnt); clear(@cnt); }'看一下触发频率。超过 100K/s 的,必须加过滤条件。
总结
bpftrace 是我工具箱里使用频率最高的诊断工具,没有之一。它让你在不修改一行代码的情况下,看到内核和应用内部的一切。总结一下核心要点:
| 场景 | 推荐方法 | 关键函数/探针 |
|---|---|---|
| 系统调用延迟 | hist() + syscall
tracepoint |
sys_enter_*/sys_exit_* |
| TCP 网络问题 | kprobe 追踪内核 TCP 函数 | tcp_sendmsg,
tcp_recvmsg |
| 内存分配分析 | uprobe 追踪 libc malloc | uprobe:libc:malloc |
| P99 延迟分析 | 延迟直方图 + 条件过滤 | hist(),
lhist() |
| 进程被阻塞 | Off-CPU 分析 + 内核栈 | sched_switch +
kstack |
| 锁竞争 | Off-CPU 火焰图 | futex_wait,
mutex_lock |
| 磁盘 I/O 慢 | block tracepoint | block_rq_issue/complete |
记住这个排查流程:
- 先用 one-liner 快速定位方向(是 CPU 的问题?I/O?网络?锁?)
- 用 hist() 看延迟分布(是均匀慢还是长尾?)
- 如果是长尾,加条件过滤只抓慢请求(
if ($us > 10000) { print(kstack); }) - 如果 on-CPU 看不到问题,换 off-CPU 分析
- 用火焰图可视化内核栈
最后一个忠告:不要在生产环境上跑没测过的 bpftrace 脚本。先在 staging 跑一遍,确认开销可控,再上生产。
bpftrace 不是银弹,但它是我见过最接近”x-ray vision”的东西——它让你透视内核,而内核甚至不知道自己被观察了。
参考资料: