文章介绍几种常用的内核动态追踪技术,对 ftrace、perf 及 eBPF 的使用方法进行案例说明。
动态追踪技术,是通过探针机制来采集内核或者应用程序的运行信息,从而可以不用修改内核或应用程序的代码,就获得调试信息,对问题进行分析、定位。
通常在排查和调试异常问题时,我们首先想到的是使用 GDB 在程序运行路径上设置断点,然后结合命令进行分析定位;或者,在程序源码中增加一系列的日志,从日志输出中寻找线索。不过,断点往往会中断程序的正常运行;而增加新的日志,往往需要重新编译和部署。在面对偶现问题以及对时延要求严格的场景下,GDB 和增加日志的方式就不能满足需求了。
动态追踪为这些问题提供了完美的方案:它既不需要停止服务,也不需要修改程序的代码;程序还按照原来的方式正常运行时,就可以分析出问题的根源。同时,相比以往的进程级跟踪方法(比如 ptrace),动态追踪往往只会带来很小的性能损耗。
动态追踪的工具很多,systemtap、perf、ftrace、sysdig、eBPF 等。动态追踪的事件源根据事件类型不同,主要分为三类:静态探针, 动态探针以及硬件事件。
ftrace 最早用于函数跟踪,后来扩展支持了各种事件跟踪功能。ftrace 通过 debugfs 以普通文件的形式,向用户空间提供访问接口,这样不需要额外的工具,就可以通过挂载点(通常为 /sys/kernel/debug/tracing 目录)的文件读写,来跟 ftrace 交互,跟踪内核或者应用程序的运行事件。
在使用 ftrace 之前,首先要确定当前系统是否已经挂载了 debugfs,可以使用如下方式进行确认。
#方式一:查看挂载信息
mount | grep debugfs
#方式二:查看挂载点
ls /sys/kernel/debug/tracing
如果在上面的查找结果中有输出,说明当前系统已经挂载了 debugfs,如果系统未挂载 debugfs,则使用如下的命令进行挂载。
mount -t debugfs nodev /sys/kernel/debug
在 /sys/kernel/debug/tracing 目录下提供了各种跟踪器(tracer)和事件(event),一些常用的选项如下。
ftrace 提供了多个跟踪器,用于跟踪不同类型的信息,比如函数调用、中断关闭、进程调度等。具体支持的跟踪器取决于系统配置,使用如下的命令来查询当前系统支持的跟踪器。
root@ubuntu:/sys/kernel/debug/tracing# cat available_tracers
hwlat blk mmiotrace function_graph wakeup_dl wakeup_rt wakeup function nop
以上是系统支持的所有跟踪器,function 表示跟踪函数的执行,function_graph 则是跟踪函数的调用关系,也就是生成直观的调用关系图,这是最常用的两种跟踪器。也可以通过配置内核,使系统支持更多类型的跟踪器。
在使用跟踪器前,需要确定好跟踪目标,包括内核函数和事件,函数是指内核中的函数名,事件是指内核中预先定义的跟踪点。可以使用如下方式查看内核支持跟踪的函数和事件。
#查看内核支持追踪的函数
cat available_filter_functions
#查看内核支持追踪的事件
cat available_events
接下来以一个简单的示例说明 ftrace 的基本用法,比如我们需要跟踪 open 的系统调用,open 在内核中的实现接口为 do_sys_open,所以需要将跟踪的函数设置为 do_sys_open,以下是跟踪过程的步骤。
#设置函数跟踪点为 do_sys_open
echo do_sys_open > set_graph_function
#设置当前跟踪器为 function_graph
echo function_graph > current_tracer
#配置 trace 属性:显示当前的进程
echo funcgraph-proc > trace_options
#清除 trace 缓存
echo > trace
#开启跟踪
echo 1 > tracing_on
#产生 do_sys_open 调用
ls
#关闭跟踪
echo 0 > tracing_on
在关闭跟踪后,使用 cat 命令查看跟踪结果,如下所示,在 trace 文件中保存了跟踪到的信息,第一列表示接口执行的 CPU,第二列表示任务名称和进程 PID,第三列是函数执行延迟,最后一列是函数调用关系图。
root@ubuntu:/sys/kernel/debug/tracing# cat trace
# tracer: function_graph
#
# CPU TASK/PID DURATION FUNCTION CALLS
# | | | | | | | | |
2) ls-46073 | | do_sys_open() {
2) ls-46073 | | getname() {
2) ls-46073 | | getname_flags() {
2) ls-46073 | | kmem_cache_alloc() {
2) ls-46073 | | _cond_resched() {
2) ls-46073 | 0.119 us | rcu_all_qs();
2) ls-46073 | 0.416 us | }
2) ls-46073 | 0.095 us | should_failslab();
2) ls-46073 | 0.112 us | memcg_kmem_put_cache();
2) ls-46073 | 1.041 us | }
2) ls-46073 | | __check_object_size() {
2) ls-46073 | 0.097 us | check_stack_object();
2) ls-46073 | 0.100 us | __virt_addr_valid();
2) ls-46073 | 0.097 us | __check_heap_object();
2) ls-46073 | 0.662 us | }
2) ls-46073 | 2.109 us | }
2) ls-46073 | 2.414 us | }
ftrace 的输出通过不同级别的缩进,直观展示了各函数间的调用关系。但是 ftrace 的使用需要好几个步骤,用起来并不方便,不过,trace-cmd 已经把这些步骤给包装了起来,这样,就可以通过一行命令,完成上述所有过程。trace-cmd 的安装方式如下。
# Ubuntu
apt install trace-cmd
# CentOS
yum install trace-cmd
trace-cmd安装好之后,可以通过执行如下的命令输出类似的结果,值得注意的是 trace-cmd 的执行不能在 /sys/kernel/debug/tracing 路径,否则执行出出错,提示信息为“trace-cmd: Permission denied”。
trace-cmd record -p function_graph -g do_sys_open -O funcgraph-proc ls
trace-cmd report
ftrace 的追踪功能不止于此,它不仅能追踪到接口的调用关系,还能抓取接口调用的时间戳,用于性能分析;还可根据需要追踪的接口进行模糊过滤,众多的功能不在这里详细介绍,如果项目中需要用到再进行具体了解和总结。
资料直通车:Linux内核源码技术学习路线+视频教程内核源码
学习直通车:Linux内核源码内存调优文件系统进程管理设备驱动/网络协议栈
perf 的功能强大,可以统计分析出应用程序或者内核中的热点函数,从而用于程序性能分析;也可以用来分析 CPU cache、CPU 迁移、分支预测、指令周期等各种硬件事件;还可以对感兴趣的事件进行动态追踪。下面以 do_sys_open 为例,设置目标函数追踪。执行如下命令,可以查询所有支持的事件。
perf list
在 perf 的各个子命令中添加 --event 选项,设置追踪感兴趣的事件。如果这些预定义的事件不满足实际需要,可以使用 perf probe 来动态添加。而且,除了追踪内核事件外,perf 还可以用来跟踪用户空间的函数。执行如下代码添加 do_sys_open 探针。
#执行指令
perf probe --add do_sys_open
#输出
Added new event:
probe:do_sys_open (on do_sys_open)
You can now use it in all perf tools, such as:
perf record -e probe:do_sys_open -aR sleep 1
探针添加成功后,就可以在所有的 perf 子命令中使用。比如,上述输出就是一个 perf record 的示例,执行它就可以对 1s 内的 do_sys_open 进行采样,如下所示。
#执行命令
perf record -e probe:do_sys_open -aR sleep 1
#输出
[ perf record: Woken up 1 times to write data ][ perf record: Captured and wrote 0.810 MB perf.data (18 samples) ]
执行如下命令显示采样结果,输出结果中列出了调用 do_sys_open 的任务名称、进程 PID 以及运行的 CPU 等信息。
#执行命令
perf script
#输出
perf 3676 [003] 7619.618568: probe:do_sys_open: (ffffffffa92e78e0)
sleep 3677 [000] 7619.621118: probe:do_sys_open: (ffffffffa92e78e0)
sleep 3677 [000] 7619.621130: probe:do_sys_open: (ffffffffa92e78e0)
vminfo 1403 [001] 7619.864117: probe:do_sys_open: (ffffffffa92e78e0)
dbus-daemon 749 [001] 7619.864222: probe:do_sys_open: (ffffffffa92e78e0)
dbus-daemon 749 [001] 7619.864310: probe:do_sys_open: (ffffffffa92e78e0)
irqbalance 743 [000] 7620.013548: probe:do_sys_open: (ffffffffa92e78e0)
irqbalance 743 [000] 7620.013687: probe:do_sys_open: (ffffffffa92e78e0)
在使用结束后,使用如下命令删除探针。
#删除 do_sys_open 探针
perf probe --del probe:do_sys_open
eBPF 相对于 ftrace 和 perf 更加灵活,它可以通过 C 语言自由扩展,这些扩展通过 LLVM (Low Level Virtual Machine) 转换为 BPF 字节码后,加载到内核中执行。
虽然 Linux 内核很早就已经支持了 eBPF,但很多新特性都是在 4.x 版本中逐步增加的。所以,想要稳定运行 eBPF 程序,内核至少需要 4.9 或者更新的版本。而在开发和学习 eBPF 时,为了体验和掌握最新的 eBPF 特性,推荐使用更新的 5.x 内核,接下来的案例是基于 Ubuntu20.04 系统,内核版本为 5.15.0-56-generic,eBPF 开发和运行需要相关的开发工具如下。
可运行如下命令进行相关工具的安装。
apt install -y make clang llvm libelf-dev libbpf-dev bpfcc-tools libbpfcc-dev linux-tools-$(uname -r) linux-headers-$(uname -r)
一般来说, eBPF 程序的开发分为如下 5 个步骤。
eBPF 程序执行过程
以上的每一步,都可以自己动手去完成。但为了方便,推荐从 BCC(BPF Compiler Collection)开始学起。BCC 是一个 BPF 编译器集合,包含了用于构建 BPF 程序的编程框架和库,并提供了大量可以直接使用的工具。使用 BCC 的好处是,它把上述的 eBPF 执行过程通过内置框架抽象了起来,并提供了 Python、C++ 等编程语言接口。这样,就可以直接通过 Python 语言去跟 eBPF 的各种事件和数据进行交互。
接下来,就以跟踪 openat()(即打开文件)这个系统调用为例,说明如何开发并运行第一个 eBPF 程序。使用 BCC 开发 eBPF 程序,可以把上面的五步简化为下面的三步。
新建一个 trace_open.c 文件,输入如下内容。
/* 包含头文件 */
#include
#include
/* 定义数据结构 */
struct data_t {
u32 pid;
u64 ts;
char comm[TASK_COMM_LEN];
char fname[NAME_MAX];
};
/* 定义性能事件映射 */
BPF_PERF_OUTPUT(events);
/* 定义 kprobe 处理函数 */
int trace_open(struct pt_regs *ctx, int dfd, const char __user *filename, struct open_how *how)
{
struct data_t data = {};
/* 获取 PID 和时间 */
data.pid = bpf_get_current_pid_tgid();
data.ts = bpf_ktime_get_ns();
/* 获取进程名 */
if (bpf_get_current_comm(&data.comm, sizeof(data.comm)) == 0)
{
bpf_probe_read(&data.fname, sizeof(data.fname), (void *)filename);
}
/* 提交性能事件 */
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
BPF 程序可以利用 BPF 映射(map)进行数据存储,而用户程序也需要通过 BPF 映射,同运行在内核中的 BPF 程序进行交互。用户层为了获取内核打开文件名称时,就要引入 BPF 映射。为了简化 BPF 映射的交互,BCC 定义了一系列的库函数和辅助宏定义。
如下是对上述代码的说明。
创建一个 trace_open.py 文件,并输入下面的内容。
#!/usr/bin/env python3
# 1) import bcc library
from bcc import BPF
# 2) load BPF program
b = BPF(src_file="trace_open.c")
# 3) attach kprobe
b.attach_kprobe(event="do_sys_openat2", fn_name="trace_open")
# 4) print header
print("%-18s %-16s %-6s %-16s" % ("TIME(s)", "COMM", "PID", "FILE"))
# 5) define the callback for perf event
start = 0
def print_event(cpu, data, size):
global start
event = b["events"].event(data)
if start == 0:
start = event.ts
time_s = (float(event.ts - start)) / 1000000000
print("%-18.9f %-16s %-6d %-16s" % (time_s, event.comm, event.pid, event.fname))
# 6) loop with callback to print_event
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
如下是对上述代码的说明。
用户态程序开发完成之后,最后一步就是执行它了。需要注意的是,eBPF 程序需要以 root 用户来运行,非 root 用户需要加上 sudo 来执行,输入如下命令执行程序。
python3 trace_open.py
命令执行后,可以在另一个终端中输入 cat 或 ls 命令(执行文件打开命令),然后回到运行 trace_open.py 脚本的终端,结果如下。
TIME(s) COMM PID FILE
0.000000000 b'ls' 5171 b'/etc/ld.so.cache'
0.000021937 b'ls' 5171 b'/lib/x86_64-linux-gnu/libselinux.so.1'
......
2.088971702 b'gsd-housekeepin' 1803 b'/etc/fstab'
2.089056747 b'gsd-housekeepin' 1803 b'/proc/self/mountinfo'
......
2.741662512 b'cat' 5172 b'/etc/ld.so.cache'
2.741681539 b'cat' 5172 b'/lib/x86_64-linux-gnu/libc.so.6'
......
5.4 eBPF 开发方式简介
除了 BCC 之外,eBPF 还有可以使用其他的方式进行辅助开发,比如 bpftrace 和 libbpf,每种方法都有自己的优点和适用范围,以下是这三种方式的对比。
在实际应用中,可以根据内核版本、内核配置、eBPF 程序复杂度,以及是否允许安装内核头文件和 LLVM 编译工具等,来选择最合适的方案。