LWN:对比 SystemTap 和 bpftrace!

Linux News搬运工

共 10127字,需浏览 21分钟

 · 2021-04-27

关注了就能看到更多这么棒的文章哦~

Comparing SystemTap and bpftrace

April 13, 2021
This article was contributed by Emanuele Rocca
DeepL assisted translation
https://lwn.net/Articles/852112/

有时候开发者和系统管理员需要分析正在运行中的代码里的问题。被检查的程序可能是用户空间进程,也可能是内核,或者两者都要同时分析。在 Linux 上有两个主流工具可以进行这种分析,就是 SystemTap 和 bpftrace。SystemTap 从 2005 年就出现了,而 bpftrace 则是最近才出现的,但对一些人来说,它似乎已经可以替代 SystemTap 了。然而,SystemTap 仍然是一些实际案例中的首选分析工具。

尽管早在 2004 年,Linux 就以 KProbes 的形式加入了动态注入(dynamic instrumentation)功能,但该功能很难使用,知道的人也不多。在一年后 Sun 发布了 DTrace,很快就成为 Solaris 的亮点之一。Linux 用户自然也开始要求有类似的功能,于是在这种情况下 SystemTap 很快就成为了最靠谱的方案。但是 SystemTap 一直被人们批评,感到很难运行起来,而 Solaris 上的 DTrace 基本上就是开箱即用,非常简单。

虽然 DTrace 的 tracing 功能既支持 kernel 也支持 user-space,但直到 2012 年,Linux 才实现了 Uprobes 来支持对 user-space 的 tracing 功能。2019 年左右,bpftrace 得到了巨大关注,部分原因是人们开始普遍关注 BPF 的各种使用实例。最近,甲骨文公司(Oracle)一直在为 Linux 来重新实现 DTrace,希望能基于内核中最新的 tracing 功能。不过,目前来说,考虑到这个领域已经有了现有的方案,DTrace 可能来得太晚了。

SystemTap 和 bpftrace 所使用的底层内核基础功能基本相同,都是用 KProbes 来对 kernel 函数进行动态 tracing,使用 tracepoints 来进行 static kernel instrumentation(静态内核注入),使用 Uprobes 来对 user-level 的函数进行动态 tracing,使用 user-level statically defined tracing(USDT)来进行 user-space 的静态注入。这两个方案都允许通过更高级语言 "脚本(script)" 来对内核和用户空间程序进行控制,用脚本来指定需要探测(probe)的内容和方式。

两者之间最重要的设计上的区别在于,SystemTap 会将用户提供的脚本翻译成 C 代码,然后作为 module 编译并加载到所运行的 Linux 内核中。bpftrace 则相反,会将脚本转换为 LLVM 中间表示(intermediate representation,类似于伪代码),然后再编译为 BPF 程序。使用 BPF 有几个优点:创建和运行一个 BPF 程序比编译并加载一个内核 module 要快很多。对由键值对(key/value pair)组成的数据结构,可以通过使用 BPF maps 来轻松支持。BPF verifier 确保了 BPF 程序不会导致系统崩溃,而 SystemTap 使用的内核 module 的方法意味着需要在运行时添加各种安全检查(safety check)。另一方面,使用 BPF 会使得某些功能比较难以实现,例如比较难以实现 custom stack walker(自定义的堆栈遍历),我们将在本后后半部分介绍这一点。

下面的例子从用户的角度展示了这两个系统的相似方面。下面是一个简单的 SystemTap 程序,用来注入分析内核里的 icmp_echo() 函数,看起来是这个样子的:

probe kernel.function("icmp_echo") {
println("icmp_echo was called")
}

实现相同功能的 bpftrace 程序是这样的:

kprobe:icmp_echo {
print("icmp_echo was called")
}

接下来我们来看一下 SystemTap 和 bpftrace 在安装过程、程序结构(program structure)和功能上的区别。

Installation

SystemTap 和 bpftrace 在所有主流 Linux 发行版中都已经缺省包含了,可以用各位熟悉的软件包管理器来轻松地完成安装。SystemTap 需要安装 Linux kernel header 头文件才能正常工作,而 bpftrace 则不需要,只要内核启用了 BPF 类型格式(BTF,BPF Type Format)支持就好。根据用户想分析的是用户空间程序还是内核,还会有一些额外要求。在分析用户空间软件时,SystemTap 和 bpftrace 都需要被分析的软件的调试符号(debugging symbol)。如何安装这些 symbol 数据,需要根据各位所用的发行版上的特有方式来安装了。

在使用 elfutils 0.178 版本或更高版本的系统上,SystemTap 可以使用远程的 debuginfod 服务器,从而使得寻找和安装正确的 debugging symbol 的过程完全自动化。例如,在 Debian 系统上可以这样做:

$ export DEBUGINFOD_URLS=https://debuginfod.debian.net
$ export DEBUGINFOD_PROGRESS=1
$ stap -ve 'probe process("/bin/ls").function("format_user_or_group") { println(pp()) }'
Downloading from https://debuginfod.debian.net/
[...]

bpftrace 还没有类似的功能。

要对内核进行注入分析的话,SystemTap 还需要 kernel debugging symbol(内核调试 symbol)文件,这样才可以使用一些高级的调试功能,比如说查找出函数调用时的具体参数或者某个局部变量,针对函数体中的具体某行代码进行注入分析等。这种情况下也可以使用远程的 debuginfod 服务器来自动完成符号表文件的加载。

Program structure

这两个方案都提供了一种类似 AWK 的语言,灵感均来自于 DTrace 的 D 脚本语言,用来要做什么动作。bpftrace 的脚本语言与 D 基本相同,并遵循如下这个通常的结构:

probe-descriptions
/predicate/
{
action-statements
}

也就是说:当这个 probe 被触发时,如果指定的(可选)predicate 信息匹配上了,就执行指定的 action-statements。

SystemTap 程序的结构略有不同:

probe PROBEPOINT [, PROBEPOINT] {
[STMT …]
}

在 SystemTap 中,语言本身并没支持设置特定的 predicate,但可以用条件判断语句来达到同样的目的。

例如,下面的 bpftrace 程序打印了 PID 为 31316 的进程发起的所有 mmap() 函数调用:

uprobe:/lib/x86_64-linux-gnu/libc.so.6:mmap
/pid == 31316/
{
print("mmap by 31316")
}

SystemTap 中的对应实现是:

probe process("/lib/x86_64-linux-gnu/libc.so.6").function("mmap") {
if (pid() == 31316) {
println("mmap by 31316")
}
}

bpftrace 中的数据聚合和报告的方式与 DTrace 中的完全相同。例如,下面的程序对内核中 tcp_sendmsg()函数发送的字节数进行了按 PID 的汇总、聚合:

$ sudo bpftrace -e 'kprobe:tcp_sendmsg { @bytes[pid] = sum(arg2); }'
Attaching 1 probe...
^C

@bytes[58832]: 75
@bytes[58847]: 77
@bytes[58852]: 857

和 DTrace 一样,bpftrace 在程序退出时默认会自动打印汇总结果,因此不需要写代码来按上面的 PID 来分类在进行打印输出。这种缺省行为也有缺点,为了避免自动打印所有的数据结构,用户必须明确显式调用 clear() 来清除掉那些不应该被打印的数据。例如,可以改变上面的脚本,只打印最多的 5 个进程,bytes map 就必须要在程序结束时被 clear 掉:

kprobe:tcp_sendmsg {
@bytes[pid] = sum(arg2);
}

END {
print(@bytes, 5);
clear(@bytes);
}

此外还有其他一些强大功能,比如生成直方图(histogram),从而可以写出非常简洁的脚本,比如下面的例子就对 vfs_read() 调用所读取的字节数进行了展示:

$ sudo bpftrace -e 'kretprobe:vfs_read { @bytes = hist(retval); }'
Attaching 1 probe...
^C

@bytes:
(..., 0) 169 |@@ |
[0] 206 |@@@ |
[1] 1579 |@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[2, 4) 13 | |
[4, 8) 9 | |
[8, 16) 2970 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[16, 32) 45 | |
[32, 64) 91 |@ |
[64, 128) 108 |@ |
[128, 256) 10 | |
[256, 512) 8 | |
[512, 1K) 69 |@ |
[1K, 2K) 97 |@ |
[2K, 4K) 37 | |
[4K, 8K) 64 |@ |
[8K, 16K) 24 | |
[16K, 32K) 29 | |
[32K, 64K) 80 |@ |
[64K, 128K) 18 | |
[128K, 256K) 0 | |
[256K, 512K) 2 | |
[512K, 1M) 1 | |

SystemTap 中也提供了统计数据的汇总。<<< 操作符就是用来向统计数据汇总中添加数据的。SystemTap 在程序退出时不会自动打印汇总结果,所以需要显式地打印:

global bytes
probe kernel.function("vfs_read").return {
bytes <<< $return
}

probe end {
print(@hist_log(bytes))
}

Features

这些 DTrace 的类似系统有一个非常有用的功能,就是能够取得 stack trace,从而可以查看哪个函数调用流程触发了某个具体的 probe 位置。kernel stack trace 在 bpftrace 中可以按如下方式获取:

kprobe:icmp_echo {
print(kstack);
exit()
}

用 SystemTap 的话,相同的功能是这么写的:

probe kernel.function("icmp_echo") {
print_backtrace();
exit()
}

bpftrace 有一个重要局限,就是不能生成用户空间的 stack trace,除非被分析的目标程序在编译时启用了 frame pointer(帧指针)。绝大多数情况下,这都意味着用户必须重新编译目标程序之后才能对其进行注入分析。

而 SystemTap 的用户空间堆栈回溯机制(user-space stack backtrace mechanism),则是利用调试信息来遍历堆栈,从而提供完整的 stack trace。这意味着不需要重新编译了:

probe process("/bin/ls").function("format_user_or_group") {
print_ubacktrace();
exit()
}

上面的脚本就会生成一个完整的 backtrace,下面只节选了几项来展示:

0x55767a467f60 : format_user_or_group+0x0/0xc0 [/bin/ls]
0x55767a46d26a : print_long_format+0x58a/0x9f0 [/bin/ls]
0x55767a46d840 : print_current_files+0x170/0x3e0 [/bin/ls]
0x55767a465d8d : main+0x62d/0x1a00 [/bin/ls]

这不太可能在 bpftrace 中实现,因为需要在内核或 BPF bytecode 字节码中来实现这个功能才行。

Real world uses

下面举个在真实生产环境中调查的例子,由于 backtrace 的限制,bpftrace 无法进行更进一步的分析了,所以需要用 SystemTap 来继续调查。Wikimedia 使用 LuaJIT 的时候遇到了一个有趣的问题,可以看到 Apache Traffic Server 会产生很高的 CPU 使用率,我们可以通过如下命令来确认出这是由于 mmap()被调用得过于频繁了:

$ sudo bpftrace -e 'kprobe:do_mmap /pid == 31316/ { @[arg2]=count(); } interval:s:1 { exit(); }'
Attaching 2 probes...
@[65536]: 64988

如果不能用 SystemTap 生成 user-space backtrace 的话,调查只能到此为止了。请注意,这个例子中的问题牵涉到了 Lua JIT 组件,也就是说就算打开 frame pointer 来重新编译 Apache Traffic Server 从而使得 bpftrace 可以生成 stack trace 也仍然是不够的,我们还必须重新编译 LuaJIT。

与 bpftrace 相比,SystemTap 的另一个重要优势是允许通过变量名来访问函数的参数和局部变量。在 bpftrace 中,只有在调试内核时才能用名字访问到参数,还需要是使用 static kernel tracepoint 或者最近版本 kernel 中的实验性的 kfunc 功能才行。kfunc 功能是基于 BPF trampolines 的,看起来前景很不错。当使用常规的 kprobes 时,或者在对用户空间的软件进行检测时,bpftrace 只能通过位置信息(arg0, arg1, … argN)来访问到参数。

SystemTap 还能按源代码文件来列出可用的 probe 点,在定义 probe 的时候也能按文件名来进行匹配。这个功能可以用来只针对代码库的特定领域来进行分析的情况。例如,下面的命令可以用来列出(-L 参数的效果)Apache Traffic Server 中的 iocore/cache/Cache.cc 中定义的所有函数:

$ stap -L 'process("/usr/bin/traffic_server").function("*@./iocore/cache/Cache.cc")

经常有必要在函数内部的某个地方设置一个 probe 点,而不是每次都只分析函数入口点或返回语句位置的。在 SystemTap 中,这可以使用 statement probe 来实现。下面的代码会列出每个可用的 probe 点,以及每个位置下可用的变量:

$ stap -L 'process("/bin/ls").statement("format_user_or_group@src/ls.c:*")'
process("/bin/ls").statement("format_user_or_group@src/ls.c:4110") \
$name:char const* $id:long unsigned int $width:int
process("/bin/ls").statement("format_user_or_group@src/ls.c:4115") \
$name:char const* $id:long unsigned int $width:int
process("/bin/ls").statement("format_user_or_group@src/ls.c:4116") \
$width_gap:int $name:char const* $id:long unsigned int $width:int
process("/bin/ls").statement("format_user_or_group@src/ls.c:4118") \
$pad:int $name:char const* $id:long unsigned int $width:int
[...]
process("/bin/ls").statement("format_user_or_group@src/ls.c:4131") \
$name:char const* $id:long unsigned int $width:int $len:size_t

从完整的输出内容中可以看到,在函数 format_user_or_group()中,有 10 个不同的位置可以添加 probe,还有其他可以看到的各种变量。通过查看源代码,我们可以看到到底需要对哪一行进行 probe,相应地撰写 SystemTap 程序。

如果想用 bpftrace 来实现同样的效果,那么我们需要对函数进行反汇编,然后根据汇编指令位置来提供正确的偏移量给 Uprobe,这还是很麻烦的。此外,bpftrace 需要在编译时打开 Binary File Descriptor(BFD),这样此功能才可以生效。

虽然所有的软件基本都会有 bug,但那些会影响到调试工具的问题就特别棘手。有一个问题会影响到使用了某些特定 LLVM 版本的系统中的 bpftrace,这个问题值得一提。由于 LLVM 中的一个 bug 导致 IR(intermediate representation)中的 load/store 指令在不应该被 reorder 的时候发生了 reorder,导致一些原本有效的 bpftrace 脚本会产生无法预料的结果。只要添加或删除一些无关代码就可能会导致行为变化,不一定能触发这个错误了。这个底层 LLVM 错误同样导致其他一些的 bpftrace 脚本也失败。这个问题最近在 LLVM 12 中得到了修复,因此 bpftrace 用户应该确保他们运行的是不受这个问题影响的最新版本 LLVM。

Conclusions

SystemTap 和 bpftrace 提供了类似的功能,但在设计方案上有很大区别,一个在底层使用了可加载的内核 module,另一个使用了 BPF。基于内核 module 的方法提供了更大的灵活性,并允许实现使用 BPF 很难甚至不可能实现的功能。另一方面,BPF 显然是一个理想的 tracing 工具,因为它提供了一个快速而安全的环境来来实现对系统的可观察性。

对于许多应用场景来说,bpftrace 开箱即用,而 SystemTap 通常需要安装额外的依赖关系才能充分利用其所有功能。Bpftrace 的速度一般比较快,而且提供了各种快速汇总和报告的功能,可以说比 SystemTap 提供的类似功能更简单易用。另一方面,SystemTap 提供了一些与众不同的功能,比如:生成用户空间 backtrace 而不需要 frame pointer、通过变量名来访问函数参数和局部变量、以及对任意语句进行 probe 的能力。在今天的 Linux 系统诊断问题时,两者似乎都有其价值。

全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~



浏览 65
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报