systemtap与bpftrace使用评估及对比

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引入,所以内核至少在该版本以上。

动态追踪工具原理

dynamic-tracing-tool-principle

动态追踪工具在逻辑上非常简单。你用类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_RELAYCONFIG_DEBUG_FS允许buffers和consumer之间传输信息,CONFIG_MODULESCONFIG_MODULE_UNLOAD提供模块设施。还需要解压vmlinuz文件和位于/lib/modules/$(uname -r)/build/内核源码。

SystemTap没有在内核中的VM(不像DTrace和KTap),它生成C语言写的内核模块源码然后编译,所以需要编译工具链(make, gcc, ld)

编译步骤

编译过程的5个阶段:解析、精加工(tapsets和调试信息链接到脚本)、翻译(生成C语言)、编译、运行

systemtap-compile-flow

SystemTap在编译时使用两个集合的库来提供内核版本独立的API。Tapsets是用SystemTap语言写的助手(部分用C写的),在elaborate阶段嵌入。Runtime是用C写的,在编译阶段使用。因为准备源码和编译的高复杂性,SystemTap比DTrace慢。为了减轻这个问题,它可以缓存已编译的模块,或者使用编译服务器。

接口稳定性

为了实现最高脚本可移植性,应该尽可能选用最高稳定性的选项。除非它提供的信息不够,再下降到稳定性较低的方法。避免直接使用内核中接口,而是隐藏在抽象中。

dtrace-systemtap-stability-comparison-data-access

dtrace-systemtap-stability-comparison-tracepoints

使用方式

前端工具stap/staprun

SystemTap有几个前端工具,具有不同功能。

  • stapio 是一个消费者,运行模块,从它的buffer打印信息到文件或stdout。它永远不会被独立使用,而是由stap或staprun调用。
  • stap 包含所有5个阶段,可以在任意阶段停止。-k和-p 4选项可以创建一个预编译的.ko内核模块。注意Systemtap对内核版本非常严格。
  • staprun 允许重用预编译的模块。

一些stap选项

1
2
3
4
5
6
7
8
9
10
11
12
13
-l PROBESPEC 使用同通配符,打印所有匹配的探针名字。-L还打印探针参数和类型。示例:# stap -l 'scsi.*'
-v 打印详细信息,v越多越多信息
-p STAGE 在处理STAGE之后结束
-k 不删除编译过程生成的临时文件(源码和内核模块在/tmp/stapXXXX目录)
-g 允许Guru模式,允许在脚本中绑定到黑名单中探针,允许在脚本中使用嵌入的C写内核存储。简单来说,就是允许危险操作。
-c COMMAND and -x PID 绑定SystemTap到特定进程
-o FILE 重定向输出到文件,已经存在会重写
-m NAME 编译模块的时候,给它一个有意义的名字,而不是stap_
当SystemTap需要把地址解析成符号的时候,它不会查找库或内核模块。下面是一些有用的选项来启用他们的符号解析

-d MODULEPATH 为一些指定的库或内核模块打开符号解析。
--ldd 追踪进程的时候,用ldd来添加所有链接的库用来解析
--all-modules 打开所有内核模块的解析

语法

probe的用法如下:

1
probe probepoint [, probepoint] { statement }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kernel.function(PATTERN)
kernel.function(PATTERN).call
kernel.function(PATTERN).return
kernel.function(PATTERN).return.maxactive(VALUE)
kernel.function(PATTERN).inline
kernel.function(PATTERN).label(LPATTERN)
module(MPATTERN).function(PATTERN)
module(MPATTERN).function(PATTERN).call
module(MPATTERN).function(PATTERN).return.maxactive(VALUE)
module(MPATTERN).function(PATTERN).inline
kernel.statement(PATTERN)
kernel.statement(ADDRESS).absolute
module(MPATTERN).statement(PATTERN)
process(PROCESSPATH).function(PATTERN)
process(PROCESSPATH).function(PATTERN).call
process(PROCESSPATH).function(PATTERN).return
process(PROCESSPATH).function(PATTERN).inline
process(PROCESSPATH).statement(PATTERN)

其中PATTERN的语法如下:

1
2
func[@file]
func@file:linenumber

可以使用通配符,例如:

1
2
3
4
kernel.function("*init*")
module("ext3").function("*")
kernel.statement("*@kernel/time.c:296")
process("/opt/openresty/nginx/nginx/sbin/nginx").function("ngx_epoll_process_events")

inline函数不能探测return。

脚本示例及关键语法

下面通过两个具体脚本,来学习下systemtap语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
global EPOLLIN        = 0x001
global active

probe begin {
printf("Tracing start...\n\n");
}

probe process("/opt/openresty/nginx/nginx/sbin/nginx").statement("ngx_epoll_process_events@src/event/modules/ngx_epoll_module.c:804") {
if (pid() != 28987) next;
printf("----------------------------------------------------------------------------------------------------------------------------------\n");
printf("[%d] epoll_wait return %d\n", pid(), $events);
for(i = 0; i < $events; i++) {
c = @cast(@var("event_list"), "struct epoll_event")[i]->data->ptr;
c = c & (~1);
fd = @cast(c, "ngx_connection_t")->fd;
revents = @cast(@var("event_list"), "struct epoll_event")[i]->events;
events = "";
handlers = "";
if(revents & EPOLLIN) {
events .= "EPOLLIN,";
handlers .= " rev->hander: " . usymname(@cast(c, "ngx_connection_t")->read->handler);
}
printf("fd: %d(%s) %s\n", fd, events, handlers);
}
}

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")获取应用程序中全局变量

cfd这些都是探针处理函数的局部变量

字符串拼接可以直接用.操作符

usymname()将用户层地址转换成符号

上面的脚本没有涉及关联数组聚集等关键概念,再来看一个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
#! /usr/bin/env stap

global wstat;
probe syscall.write {
wstat[pid(), execname(), $fd] <<< $count
}
probe timer.sec(1) {
printf("%5s\t%12s\t%5s\t%5s\t%8s\n", "PID", "EXECNAME", "FD", "OPS", "KB");
foreach([pid+, name, fd] in wstat) {
printf("%5d\t%12s\t%5d\t%5d\t%8d\n", pid, name, fd, @count(wstat[pid, name, fd]), @sum(wstat[pid, name, fd]) / 1024);
}
delete wstat;
}

关联数组类似其他语言中的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
2
3
4
5
6
7
8
root@NASG:~# stap ./wstat.stp
PID EXECNAME FD OPS KB
13910 stapio 1 4 1
15896 sshd 3 4 1
27514 postgres 6 12 1
27514 postgres 3 8 64
27514 postgres 4 4 32
27516 postgres 9 4 0

bpftrace

环境依赖

年份 技术
2004 kprobes/kretprobes
2008 ftrace
2005 systemtap
2009 perf_events
2009 tracepoints
2012 uprobes
2015 ~ 至今 eBPF (Linux 4.1+)

eBPF常用的前端工具有两个,BCCbpftrace

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
2
3
4
5
6
7
8
9
10
11
12
#!/usr/local/bin/bpftrace 

// this program times vfs_read()
kprobe:vfs_read {
@start[tid] = nsecs;
}
kretprobe:vfs_read /@start[tid]/
{
$duration_us = (nsecs - @start[tid]) / 1000;
@us = hist($duration_us);
delete(@start[tid]);
}

用户层动态探测示例

bpftrace语言特性上没有systemtap丰富,不太能进行复杂的探测操作,具体见下一小节比较

当前版本已经支持将探针附在函数一定偏移处,但是不是很方便。如下所示,需要先找到函数的起始地址

1
2
root@NASG:~/stap-script# objdump -tT /opt/openresty/nginx/nginx/sbin/nginx |grep ngx_epoll_process_events
000000000044b060 l F .text 0000000000000417 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
root@NASG:~/stap-script# objdump -d /opt/openresty/nginx/nginx/sbin/nginx | grep -A 30 000000000044b060
000000000044b060 <ngx_epoll_process_events>:
44b060: 41 57 push %r15
44b062: 41 56 push %r14
44b064: 49 89 d6 mov %rdx,%r14
44b067: 41 55 push %r13
44b069: 41 54 push %r12
44b06b: 49 89 fd mov %rdi,%r13
44b06e: 55 push %rbp
44b06f: 53 push %rbx
44b070: 48 89 f3 mov %rsi,%rbx
44b073: 48 83 ec 18 sub $0x18,%rsp
44b077: 48 8b 77 10 mov 0x10(%rdi),%rsi
44b07b: f6 06 80 testb $0x80,(%rsi)
44b07e: 0f 85 fc 02 00 00 jne 44b380 <ngx_epoll_process_events+0x320>
44b084: 8b 15 16 5e 38 00 mov 0x385e16(%rip),%edx # 7d0ea0 <nevents>
44b08a: 48 8b 35 17 5e 38 00 mov 0x385e17(%rip),%rsi # 7d0ea8 <event_list>
44b091: 89 d9 mov %ebx,%ecx
44b093: 8b 3d 2b 87 35 00 mov 0x35872b(%rip),%edi # 7a37c4 <ep>
44b099: 31 ed xor %ebp,%ebp
44b09b: e8 20 1e fd ff callq 41cec0 <epoll_wait@plt>
44b0a0: 4c 63 e0 movslq %eax,%r12
44b0a3: 41 83 fc ff cmp $0xffffffff,%r12d
44b0a7: 0f 84 f3 02 00 00 je 44b3a0 <ngx_epoll_process_events+0x340>
44b0ad: 41 f6 c6 01 test $0x1,%r14b
44b0b1: 75 45 jne 44b0f8 <ngx_epoll_process_events+0x98>
44b0b3: 8b 35 5f a5 38 00 mov 0x38a55f(%rip),%esi # 7d5618 <ngx_event_timer_alarm>
44b0b9: 85 f6 test %esi,%esi
44b0bb: 75 3b jne 44b0f8 <ngx_epoll_process_events+0x98>
44b0bd: 85 ed test %ebp,%ebp
44b0bf: 74 40 je 44b101 <ngx_epoll_process_events+0xa1>
44b0c1: 83 fd 04 cmp $0x4,%ebp

应用程序中的局部变量没有方便的获取方法,可以查看汇编代码,通过寄存器获取,如下所示:

1
$events = (uint64)reg("r12");

完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/usr/bin/env bpftrace
//#include <ngx_connection.h>

// from openresty-1.13.6.2/bundle/nginx-1.13.6/src/event/modules/ngx_epoll_module.c
union epoll_data_t {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
};

struct epoll_event {
uint32_t events;
union epoll_data_t data;
};


BEGIN {
printf("Tracing start...\n\n");
}

uprobe:/opt/openresty/nginx/nginx/sbin/nginx:ngx_epoll_process_events+67 {
if (pid != 28987) {
return;
}

$EPOLLIN = 1;
$EPOLLOUT = 4;
$EPOLLERR = 8;
$EPOLLHUP = 16;
$EPOLLRDHUP = 8192;

printf("--------\n");
$events = (uint64)reg("r12");
printf("[%d] epoll_wait return %d\n", pid, $events);
$i = (uint64)0;
// 不支持循环
if($i < $events) {
$c = ((struct epoll_event *)(uaddr("event_list") + $i * 8))->data.ptr;
$c = $c & (~1);
// 下一行我已经没法继续下去了,涉及的头文件太多了。。。
// $fd = ((struct ngx_connection_s *)$c)->fd;
}
}

性能分析

影响性能消耗的主要有3个因素,即探测事件的频率、探针执行的动作、CPU数。可以用如下公式来表示:

1
Overhead = (frequency × action performed) / CPUs

典型事件的频率

typical-events-frequency

  • 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要慢得多。

bpftrace-per-event-cost

测试环境: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性能测试-应用程序代码
点击展开overhead.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

size_t count = 0;
size_t last_count = 0;
size_t diff = 0;
size_t times = 0;
size_t period = 1;
size_t loop = 1000000;

void handle_signal(int sigNum)
{
if (sigNum == SIGALRM)
{
diff = count - last_count;
last_count = count;
++times;
printf("TPS in the last %3zu seconds: %lf\n", period, (double)diff / period);
printf("TPS in the past %3zu seconds: %lf\n", times * period, (double)last_count / times / period);
alarm(period); // 重置定时时间
}
}

int foo(size_t n) {
size_t i = 0;
while(i < n) {
++i;
}
++count;
}

int main(int argc, char* argv[])
{
if(argc > 1) {
loop = strtoull(argv[1], NULL, 0);
}
if(argc > 2) {
period = strtoull(argv[2], NULL, 0);
}
signal(SIGALRM, handle_signal);
alarm(period);
while(1) {
foo(loop);
}

return 0;
}
  • uprobes性能测试-bpftrace脚本
点击展开test.bt
1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env bpftrace


uprobe:/root/stap-script/perf-test/overhead:foo
{
@n++;
}

END {
printf("n: %d\n", @n);

}
  • uprobes性能测试-stap脚本
点击展开test.stp
1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env stap

global n = 0;

probe process("/root/stap-script/perf-test/overhead").function("foo")
{
n++;
}

probe end {
printf("n: %d\n", n);

}
  • uprobes性能测试-辅助命令
点击展开
1
2
#!/bin/bash
gcc -g -o overhead overhead.c
1
2
#!/bin/bash
./overhead $1 $2
1
2
#!/bin/bash
bpftrace test.bt -c "./overhead $1 $2"
1
2
#!/bin/bash
stap -v test.stp -c "./overhead $1 $2"

参考资料

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道