eBPF和systemtap这两个非常强大的工具,它们弥补了静态调试工具的不足,可以完成很多静态调试工具无法完成的事情。例如很多跟时序相关的问题、概率性偶发的问题、压力测试下才能出现的问题、以及像云上这种不方便调试的环境,可能只能通过动态调试的手段来定位分析问题。
它们将进程本身当作是一个数据库,从中获取所关心的信息进行活体分析。当然前提是你得对这个数据库本身有足够的了解,知道去哪里获取信息,以及获取什么信息。对于内核态就是对内核本身的理解,在用户态则是对用户程序的理解。
本文只是浅浅地试用评估了一下两者,并做了简单对比。要想熟练使用,功夫在诗外!
篇幅原因这里省去了安装步骤的介绍。
Systemtap
环境依赖
依赖如下条件
- linux kernel
- kernel module build environment (kernel-devel rpm) and/or dyninst
- optionally, debugging information for kernel/user-space being instrumented
- C compiler (same as what kernel was compiled with), to build kernel modules
- C++11 compiler such as gcc 4.8+, to build systemtap itself
- elfutils 0.151+ with libdwfl for debugging information parsing
- root privileges
用户进程动态追踪在Linux3.5引入,所以内核至少在该版本以上。
动态追踪工具原理
动态追踪工具在逻辑上非常简单。你用类C语言创建一个脚本,通过编译器翻译成探测代码。探测代码通过一个内核地址模块加载到内核地址空间,然后pateh到当前内核的二进制代码中。探针将收集的数据写到中间缓冲(intermediate buffers),这些buffers往往是lock-free的。所以他们对内核性能有较少影响,且不需要切换上下文到追踪程序。另一个独立的实体消费者读取这些buffers,然后输出数据。
systemtap工作原理
SystemTap不是内核的一部分,所以必须适配内核的改动。有时runtime和代码生成器需要适配新的kernel releases。而且,大部分发布版本都stripped了,所以在DWARF格式或者符号表中的调试信息被移除了。很多发行版的调试信息有单独的包,rpm系列是-debuginfo
后缀,deb系列是-dbg
后缀。stap-prep
工具可以自动安装对应版本的内核调试信息。
普通内核,需要配置CONFIG_DEBUG_INFO
选项,将调试信息链接到内核。设置CONFIG_KPROBES
以允许Systemtap打补丁到内核代码,CONFIG_RELAY
和CONFIG_DEBUG_FS
允许buffers和consumer之间传输信息,CONFIG_MODULES
和CONFIG_MODULE_UNLOAD
提供模块设施。还需要解压vmlinuz文件和位于/lib/modules/$(uname -r)/build/
内核源码。
SystemTap没有在内核中的VM(不像DTrace和KTap),它生成C语言写的内核模块源码然后编译,所以需要编译工具链(make, gcc, ld)
编译步骤
编译过程的5个阶段:解析、精加工(tapsets和调试信息链接到脚本)、翻译(生成C语言)、编译、运行
SystemTap在编译时使用两个集合的库来提供内核版本独立的API。Tapsets
是用SystemTap语言写的助手(部分用C写的),在elaborate
阶段嵌入。Runtime
是用C写的,在编译阶段使用。因为准备源码和编译的高复杂性,SystemTap比DTrace慢。为了减轻这个问题,它可以缓存已编译的模块,或者使用编译服务器。
接口稳定性
为了实现最高脚本可移植性,应该尽可能选用最高稳定性的选项。除非它提供的信息不够,再下降到稳定性较低的方法。避免直接使用内核中接口,而是隐藏在抽象中。
使用方式
前端工具stap/staprun
SystemTap有几个前端工具,具有不同功能。
- stapio 是一个消费者,运行模块,从它的buffer打印信息到文件或stdout。它永远不会被独立使用,而是由stap或staprun调用。
- stap 包含所有5个阶段,可以在任意阶段停止。-k和-p 4选项可以创建一个预编译的.ko内核模块。注意Systemtap对内核版本非常严格。
- staprun 允许重用预编译的模块。
一些stap选项
1 | -l PROBESPEC 使用同通配符,打印所有匹配的探针名字。-L还打印探针参数和类型。示例:# stap -l 'scsi.*' |
语法
probe的用法如下:
1 | probe probepoint [, probepoint] { statement } |
1 | kernel.function(PATTERN) |
其中PATTERN的语法如下:
1 | func[@file] |
可以使用通配符,例如:
1 | kernel.function("*init*") |
inline函数不能探测return。
脚本示例及关键语法
下面通过两个具体脚本,来学习下systemtap语法:
1 | global EPOLLIN = 0x001 |
global
关键字定义探针处理函数的全局变量。
probe begin/end
在探测开始前和结束后执行。
probe process().statement()
探测函数中的某个位置。
pid()
是内置函数,表示当前进程pid
next
关键字跳过本probe执行
printf
语法类似C
$events
表示应用程序中的局部变量
@cast(@var("event_list"), "struct epoll_event")
将全局变量转换成具体结构体,注意systemtap中不区分结构体和结构体指针,都用->
进行访问。
@var("event_list")
获取应用程序中全局变量
c
和fd
这些都是探针处理函数的局部变量
字符串拼接可以直接用.
操作符
usymname()
将用户层地址转换成符号
上面的脚本没有涉及关联数组和聚集等关键概念,再来看一个示例
1 | #! /usr/bin/env stap |
关联数组类似其他语言中的hash表、map等,可以设置一个或多个key。聚集用于数据的统计
1 | probe syscall.write` 探测系统调用`write |
wstat[pid(), execname(), $fd]
就是一个关联数组,设置了3个key,分别是进程pid,程序名和系统调用写操作的fd。
wstat[pid(), execname(), $fd] <<< $count
将$count
即写的字节数记录到关联数组中。
probe timer.sec(1)
类似定时器,每隔1s执行
foreach([pid+, name, fd] in wstat)
遍历关联数组,pid
,name
,fd
分别表示该项的各key,pid+
中的+
表示根据pid进行升序排列
@count(wstat[pid, name, fd])
统计关联数组中这个key组合的值的个数,即该进程调用write系统调用的次数
@sum(wstat[pid, name, fd])
统计关联数组中这个key组合的值的和,即该进程调用write系统调用的写的总字节数
delete wstat;
表示清空该关联数组
上面的脚本执行效果如下:
1 | root@NASG:~# stap ./wstat.stp |
bpftrace
环境依赖
年份 | 技术 |
---|---|
2004 | kprobes/kretprobes |
2008 | ftrace |
2005 | systemtap |
2009 | perf_events |
2009 | tracepoints |
2012 | uprobes |
2015 ~ 至今 | eBPF (Linux 4.1+) |
bpftrace
适合强大的单行命令和自定义的短脚本,BCC
适合使用了其他库的复杂脚本、守护进程。普通用户使用较多的是bpftrace
。
推荐使用Linux 4.9以上内核。BCC/bpftrace工具使用的大部分内核BPF组件在4.1到4.9之间增加,后续版本有改进,所以内核越新越好。
- 主要BPF特性开始支持的内核版本
内核版本 | 支持特性 |
---|---|
4.1 | kprobes |
4.3 | uprobes |
4.6 | stack traces, count and hist builtins (use PERCPU maps for accuracy and efficiency) |
4.7 | tracepoints |
4.9 | timers/profiling |
需要打开以下内核配置,CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_EVENTS=y, CONFIG_BPF_JIT=y, CONFIG_HAVE_EBPF_JIT=y。很多发行版都是默认打开的,无需做调整。
实用示例
详细使用方法见 bpftrace docs
单行命令示例
- 执行新程序
1 | bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%s -> %s\n", comm, str(args->filename)); }' |
- 新进程及其参数
1 | bpftrace -e 'tracepoint:syscalls:sys_enter_execve { join(args->argv); }' |
- 使用openat打开的文件
1 | bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }' |
- 统计程序系统调用次数
1 | bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }' |
- 统计不同系统调用的次数
1 | bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }' |
- 基于pid统计系统调用次数
1 | bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[pid, comm] = count(); }' |
- 统计进程读取字节数
1 | bpftrace -e 'tracepoint:syscalls:sys_exit_read /args->ret/ { @[comm] = sum(args->ret); }' |
- 进程读取字节数的分布
1 | bpftrace -e 'tracepoint:syscalls:sys_exit_read { @[comm] = hist(args->ret); }' |
- 追踪进程磁盘IO大小
1 | bpftrace -e 'tracepoint:block:block_rq_issue { printf("%d %s %d\n", pid, comm, args->bytes); }' |
- 统计进程major-fault的次数
1 | bpftrace -e 'software:major-faults:1 { @[comm] = count(); }' |
- 统计进程页错误
1 | bpftrace -e 'software:faults:1 { @[comm] = count(); }' |
- 以49Hz采样用户层栈
1 | bpftrace -e 'profile:hz:49 /pid == 189/ { @[ustack] = count(); }' |
统计耗时的示例
1 | #!/usr/local/bin/bpftrace |
用户层动态探测示例
bpftrace语言特性上没有systemtap丰富,不太能进行复杂的探测操作,具体见下一小节比较
当前版本已经支持将探针附在函数一定偏移处,但是不是很方便。如下所示,需要先找到函数的起始地址
1 | root@NASG:~/stap-script# objdump -tT /opt/openresty/nginx/nginx/sbin/nginx |grep ngx_epoll_process_events |
然后查看函数的汇编指令,找到目标探测点的地址偏移值。
我们这里选择44b0a3: 41 83 fc ff cmp $0xffffffff,%r12
,计算偏移值0x44b0a3 - 0x44b060 = 0x43 = 67
。
然后在探测的时候指定偏移值如下: uprobe:/opt/openresty/nginx/nginx/sbin/nginx:ngx_epoll_process_events+67
1 | root@NASG:~/stap-script# objdump -d /opt/openresty/nginx/nginx/sbin/nginx | grep -A 30 000000000044b060 |
应用程序中的局部变量没有方便的获取方法,可以查看汇编代码,通过寄存器获取,如下所示:
1 | $events = (uint64)reg("r12"); |
完整示例:
1 | #!/usr/bin/env bpftrace |
性能分析
影响性能消耗的主要有3个因素,即探测事件的频率、探针执行的动作、CPU数。可以用如下公式来表示:
1 | Overhead = (frequency × action performed) / CPUs |
典型事件的频率
- Negligible: <0.1%
- Measurable: ~1%
- Significant: >5%
- Expensive: >30%
- Extreme: >300%
uprobes事件不同频率下性能损耗
根据事件频率进行定量测试,可以看到测试结果跟上表基本吻合。bpftrace和systemtap性能损耗基本一样。
事件频率 | bpftrace探测 | bpftrace性能损耗 | systemtap探测 | systemtap性能损耗 |
---|---|---|---|---|
100.36 (102) | 100.25 | 0.11% | 100.28 | 0.08% |
1003.66 (103) | 996.71 | 0.69% | 997.13 | 0.65% |
10035.60 (104) | 9433.30 | 6.00% | 9464.07 | 5.70% |
100216.86 (105) | 61629.70 | 38.50% | 62897.49 | 37.24% |
1004615.82 (106) | 138057.96 | 86.26% | 143209.28 | 85.74% |
10149658.72 (107) | 157846.80 | 98.44% | 166574.78 | 98.36% |
测试环境为Linux 4.15.0-64-generic, Intel(R) Celeron(R) CPU J1900 @ 1.99GHz 单CPU测试。
工作负载程序和测试脚本见后面附录
不同探针操作的性能评估
测试工作负载为 dd if=/dev/zero of=/dev/null bs=1 count=10000k
修改bpftrace单行命令进行测试 bpftrace -e 'kprobe:vfs_read { @ = count(); }'
从下表测试结果可以看到,kretprobes比kprobes慢,uprobes/uretprobes比kprobes/kretprobes要慢得多。
测试环境:Linux 4.15,i7-8650U CPU @ 1.90GHz,使用taskset绑到一个CPU上。
优缺点比较
bpftrace优缺点:
BPF限制
- 不能调用任意的内核函数:只能调用BPF helper。
- BPF程序不能做循环,因为必须在规定时间内结束。后续(Linux 5.3)会支持受限的循环。(随着即将添加的循环,您可能会开始怀疑BPF是否将成为图灵完整的。 BPF指令集本身允许创建图灵完备的自动机,但是由于验证器引入的安全性限制,BPF程序不是图灵完整的。)
- BPF栈大小限制为
MAX_BPF_STACK
,设置为512,多用几个字符串就用完了。解决方法是使用BPF映射存储替代 - 指令数限制为4096。Linux5.2大幅增加了这个限制。
bpftrace其他缺点
- 探针附到函数一定偏移处不是很方法
- 无法直接获取被探测应用程序中函数局部变量值
- 结构体的访问非常麻烦,需要将所有依赖的头文件及其路径都包含进来
- 内核版本要求较高,且在较旧的发行版上安装体验不佳
bpftrace优点
- bpftrace基于内置Linux技术,不用追赶内核版本改动,稳定性更高
- 脚本执行速度比systemtap快(使用llvm编译成BPF)
systemtap优缺点
缺点
- 基于内核模块技术,在RHEL以外的系统上都不可靠
- 不是内置于内核,需要追赶内核版本改动,稳定性不如bpftrace
- 脚本执行速度比systemtap慢,且在生产环境需要gcc编译工具链、内核头文件
- 依赖dwarf,需要安装内核符号,用户层程序则在编译时需要-g
优点
- 脚本语言支持的特性更全,如函数偏移、循环、函数局部变量、结构体引用
- 具有成熟的用户态符号自动加载,可以写比较复杂的探针处理程序
- systemtap中有大量帮助程序(tapset),可用于检测不同的目标
- systemtap普通模式只读,guru模式可以写,如修改程序中某个变量值
- systemtap内核版本要求较低,在4.x以前内核上也能使用。
附录
- uprobes性能测试-应用程序代码
1 |
|
- uprobes性能测试-bpftrace脚本
1 | #!/usr/bin/env bpftrace |
- uprobes性能测试-stap脚本
1 | #!/usr/bin/env stap |
- uprobes性能测试-辅助命令
1 |
|
1 |
|
1 |
|
1 |
|
参考资料
- systemtap新手指南
- systemtap语言参考手册
- systemtap tapset参考手册
- dtrace-stap-book
- bcc repo
- bpftrace repo
- bpftrace docs
- BPF-Performance-Tools-by-Brendan-Gregg