系统的可观测性
软件应用和操作系统变得复杂以后,程序的行为跟踪、调试和性能分析成为难题。我们需要知道系统中此时此刻在进行哪些行为,程序中哪些流程是系统的潜在性能瓶颈,程序是否按照预期正确执行,这对系统的可见性(visibility)或可观测性(observability)提出了要求。为提升复杂系统的可观测性,Linux内核提供了多种内核跟踪(tracing)技术。
我们可以很容易想到一种原始的跟踪方法:打印日志。执行到某段代码时,打印日志,提示程序是否正常运转。然而,这种跟踪方法有许多局限性,例如需要维护源代码。若要在新的地方加入打印信息,要重新编译,不容易维护。打印日志会给程序带来很大的性能开销,想象一下,如果是在一个for
循环中插入了打印语句,这会带来可观的额外负载。
相关概念
跟踪点(tracepoint):跟踪点是一段静态的代码,以“打桩”(instrumentation)形式放在程序源码中,向外界提供钩子(hook)。钩子可用于挂载探针函数(probe)。跟踪点通常能动态使能,实现动态跟踪(dynamic tracing),即能在程序运行时开启或关闭某一跟踪点,不需要重新编译程序或重启程序。
静态打桩(static instrumentation):在编译期确定的跟踪点,跟踪点的位置是用户事先知道的。
动态打桩(dynamic instrumentation):在运行时建立的跟踪点,可以是用户定义的位置。
注:不要混淆动态跟踪与动态打桩。无论是静态还是动态的打桩,都应能在运行时开启或关闭。
探针(probe):探针是挂载在跟踪点上的函数,在程序执行到开启的跟踪点时,就会触发探针函数的执行。用户可以自己编写探针,由操作系统提供的接口挂载到跟踪点上;也有许多第三方实现的探针函数,这些函数被集成到第三方编写的跟踪器(tracer)中,提供用户友好的使用方式。由于系统在执行时会经常触发探针的执行(例如,一个挂载到read
系统调用上的探针),因此要求探针运行时的开销越小越好,以免拖慢系统运行。具体而言,编写eBPF程序就是一种探针的实现方式。
事件(event):事件标志着程序执行到了跟踪点。事件可以有具体的语义,例如系统正在发生上下文切换,或者只是单纯提示程序执行到的位置,如当前进入或退出了某个函数。事件是瞬时发生的,表示是时间点,而非时间段。事件发生时通常带有时间戳(timestamp)。
参数(argument):事件通常带有与之相关的参数,例如上下文切换事件的参数可以是前后切换的两个进程的信息。
上下文(context):所有的事件都有一组共有的上下文信息,如事件发生的时间、所在的进程号(pid)、父进程号(ppid)等。
Tracepoint
Linux提供了1000多种预先设置好静态跟踪点(static tracepoints)1,这些跟踪点由内核维护和调优以达到高性能。这些跟踪点称为Linux Tracepoint,由TRACE_EVENT
框架实现。TRACE_EVENT
向用户提供了编程接口。
Linux Tracepoint在关闭时不会有任何副作用(side effect),会被视作一个简单的条件检查和分支跳转指令(用于检查其是否开启)。为了减少Tracepoint在关闭时的开销,内核会源码提示编译器会把Tracepoint相关的代码放到远离热点代码的地方。CPU在读取cache line以及做分支预测和乱序执行时会先执行常规的代码。因此,Tracepoint是缓存友好的(cache-friendly)。此外,Tracepoint的函数调用是以C的宏实现的,省去了函数调用的开销。
Tracepoint由内核维护,一旦编入内核,以后基本不会变动,因此能提供稳定的ABI,而另一种内核跟踪机制kprobe则可能因为内核函数的更名或修改而在接口上发生变化。内核会尽力确保旧版本内核中的Tracepoint会继续出现在新版中。当然,由于需要人工维护,因此Tracepoint并不能覆盖所有的Linux子系统。我们可以编写基于Tracepoint的数据收集和分析工具,发挥其稳定性优势。
工作原理
和其它静态插桩方式一样,Tracepoint也会和内核源码一起编译。默认情况下,Tracepoint是关闭的,因此在插桩点,Tracepoint的实际指令为nop
,表示什么都不做。
在内核运行时,若用户使能了某一Tracepoint,Tracepoint处的nop
指令会被动态改写为跳转指令jmp
。jmp
指令会跳转到当前函数的末尾,这里存放了一个数组,记录了当前Tracepoint的回调函数。用户开启Tracepoint时,探针函数也会以RCU的形式注册到这个数组中。
当Tracepoint被关闭后,跳转指令再次覆盖为nop
,同时用户的探针函数被移除。
我们可以在Linux源码中查看内置的Tracepoint,它们的定义存放在/include/trace/events
目录下,例如我们可以在其中找到sched:sched_process_exec
的定义(Tracepoint的命名格式为subsystem:eventname
)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
TRACE_EVENT(sched_process_exec,
TP_PROTO(struct task_struct *p, pid_t old_pid,
struct linux_binprm *bprm),
TP_ARGS(p, old_pid, bprm),
TP_STRUCT__entry(
__string( filename, bprm->filename)
__field( pid_t, pid)
__field( pid_t, old_pid)
),
TP_fast_assign(
__assign_str(filename, bprm->filename);
__entry->pid = p->pid;
__entry->old_pid = old_pid;
),
TP_printk("filename=%s pid=%d old_pid=%d", __get_str(filename),
__entry->pid, __entry->old_pid)
);
根据源码,我们可以看到这个Tracepoint里涉及的事件参数。不过,在实践中,我们不需要去特意查看源码。Linux内核提供了tracefs
伪文件系统,作为Tracepoint(和一些其它的内核跟踪工具)与用户交互的界面。/sys/kernel/debug/tracing/events
目录下,我们可以看到系统支持的所有Tracepoints。用eBPF编写Tracepoint探针时,可以直接在tracefs
中查看参数格式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cat /sys/kernel/debug/tracing/events/sched/sched_process_exec/format
name: sched_process_exec
ID: 316
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:__data_loc char[] filename; offset:8; size:4; signed:1;
field:pid_t pid; offset:12; size:4; signed:1;
field:pid_t old_pid; offset:16; size:4; signed:1;
print fmt: "filename=%s pid=%d old_pid=%d", __get_str(filename), REC->pid, REC->old_pid
在sched_process_exec
发生时,代码中也会执行到对应的Tracepoint语句trace_sched_process_exec
(该函数遵循trace_subsystem_eventname
的命名惯例):
1
2
3
4
5
6
7
8
9
static int exec_binprm(struct linux_binprm *bprm)
{
// ...
audit_bprm(bprm);
trace_sched_process_exec(current, old_pid, bprm);
ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
proc_exec_connector(current);
return 0;
}
有关内核Tracepoint的定义和惯例可以在内核文档2中查看。
与Tracepoint交互
我们可以借助tracefs
与Tracepoint交互,例如使能某些事件、增加事件跟踪的过滤规则等等。感兴趣的读者可以查看相关文档34。
Raw Tracepoint
Raw tracepoint是内核中新增的一种Tracepoint,有更好的性能,在传入eBPF程序上下文时,不会像Tracepoint那样事先构造好各个参数字段再传入,因此Raw Tracepoint的性能通常更好:
Tracepoint、Raw Tracepoint和kprobe的性能对比5
不过,Raw Tracepoint的参数格式不像Tracepoint一样具有稳定性。典型的Raw Tracepoint如sys_enter
和sys_exit
,定义在/include/trace/events/syscalls.h
文件下。我们可以用这两个Tracepoint跟踪所有的系统调用,传入的上下文参数为寄存器的值,里面包含了系统调用的参数信息,用户需要根据系统调用号解释这些参数的实际含义。