前言
在问题排查过程中, 通常包含: 整体观测, 数据采集, 数据分析这几个阶段. 对于简单问题的排查, 可以跳过前两个步骤, 无需额外收集数据, 直接通过分析日志中的关键信息就可以定位根因; 而对于复杂问题的排查, 为了对应用的行为有更完整的了解, 可以通过以下形式收集更多的行为数据帮助分析:
-
调高日志级别生成数据, 例如将内核的
/proc/sys/kernel/sched_schedstats
配置为 1, 例如将应用的日志级别从INFO
调整为DEBUG
; -
利用静态埋点生成数据, 例如内核的 tracepoint, 例如应用的 USDT;
-
利用动态埋点生成数据, 例如内核的 kprobe, 例如应用的 uprobe.
本篇主角 eBPF 提供 "动态将代码挂载在执行点(用户/内核/动态/静态)上进行数据采集, 通过内置数据结构(数组/哈希/函数映射)保存数据及和用户态程序通信" 的能力. 翻译成人话是:
-
eBPF 通过验证器保证了挂载程序的安全性, 避免恶意程序的挂载;
-
eBPF 虚拟机执行挂载程序, 进行数据采集, 数据过滤;
-
eBPF 仅在用户空间请求时才将内核中的数据拷贝到用户空间, 保证消耗最小化.
eBPF 是如此强力(且流行)的机制, 我们怎么利用它对系统进行观测呢? eBPF 支持多种用户空间语言, 让我们先从简单的脚本语言 bpftrace 开始.
bpftrace
bpftrace 是基于 eBPF 的, 用于进行系统追踪的脚本语言, 语法和 awk 基本一致, 如下:
BEGIN
{// BEGIN 语句块, 一开始会被执行一次
} kprobe:foo // 指定挂载点
/condition/ // 仅当匹配该条件, 以下语句块才会被执行
{// 该脚本运行过程中, 每次匹配条件都会执行的语句块
}END
{// END 语句块, 结束时会被执行一次
}
bpftrace 指定 -e
可以指定需要解析的语句块, 例如以下的语句可以打印一句 "hello world"
:
$ bpftrace -e 'BEGIN{printf("hello world.\n")}'
bpftrace 指定 -l
可以列出可用的挂载点, 可用挂载点的数量一定程度上反映了 eBPF 的能力边界, 例如在 CentOS Linux release 8.4.2105
上存在 8w 多个挂载点可供挂载函数. (如果降低内核编译优化等级, 可以导出更多可供挂载的符号)
$ bpftrace -l
...
kprobe:do_sys_open
...
$ bpftrace -l | wc -l
82589
例如我们想查看系统中 open
系统调用正在打开哪些文件, 只需要在挂载点的位置填上 kprobe:do_sys_open
即可:
$ bpftrace -e 'kprobe:do_sys_open {printf("%-7d %-10s %s\n", pid, comm, str(uptr(arg1)))}'
9016 awk /proc/self/maps
9016 awk /dev/null
1095 ksmtuned /sys/kernel/mm/ksm/run
9017 ksmtuned /dev/null
其中:
-
kprobe:do_sys_open
: 指明挂载点, 即每次执行do_sys_open
函数体执行前先执行一次语句块{printf("%-7d %-10s %s\n", pid, comm, str(uptr(arg1)))}
; -
pid
: bpftrace 的内置变量, 保存了curr
进程的进程号; -
comm
: bpftrace 的内置变量, 保存了curr
进程的进程名; -
str
: bpftrace 的内置函数, 用于标志字符串; -
uptr
: bpftrace 的内置函数, 用于标识指针来自用户空间, 当打印char __user *
类型变量时需要用到; -
arg1
: bpftrace 的内置变量, 下标从 0 开始, 此处指代内核函数do_sys_open(int dfd, const char __user *filename, ...)
第二个参数. bpftrace 提供了很多实用的内置变量, 具体参考 reference_guide.
当 eBPF 程序写得越来越长, 单独写成 bpftrace 脚本是更合适的. 以下脚本实现了每秒输出当前系统每个 CPU 运行队列中等待运行进程的个数:
$ wget -qO - https://raw.githubusercontent.com/lilstaz/perf-tool-examples/main/bpftrace/runqlen.bt
...
profile:hz:99 // 1
{$cfs_rq = curtask->se.cfs_rq; // 2@tmp[cpu] = $cfs_rq->nr_running; // 3
}interval:s:1 // 4
{printf("@[%s]: %s\n", "CPU", "RQ_LEN");print(@tmp);
}
...
$ wget -qO - https://raw.githubusercontent.com/lilstaz/perf-tool-examples/main/bpftrace/runqlen.bt | bpftrace - # 5
@[CPU]: RQ_LEN
@[0]: 0
@[3]: 0
@[1]: 0
@[2]: 1
其中:
-
在 profile 模式中, 可以指定采样的频率, 当配置为 99HZ, 每个 CPU 每秒会产生大约 99 次时钟中断, 通过时钟中断注册的回调函数对所需的信息进行采集;
-
bpftrace 脚本中变量前
$
标志该变量是局部变量,@
标志该变量是全局变量, 这里通过 bpftrace 内置变量curtask
拿到了curr
进程, 数据类型为task_struct*
, 通过curtask->se.cfs_rq
获取到当前进程调度实体被挂载哪个运行队列; -
将当前运行的 CPU 作为哈希 key, 将队列上调度实体的个数作为哈希 value, 保存到全局变量
@tmp
中; -
每隔一秒钟输出一次列名及整个哈希表的内容;
-
运行效果, 当前虚拟机有 4 个核心, 其中 CPU 2 的运行队列中存在一个进程.
以上两个案例展示了: eBPF 程序可以挂载在内核函数上, 例如挂在 do_sys_open
函数上, 此时可以通过解析函数的输入获取所需的信息; 也可以注册为 perf_event
的回调函数, 利用 perf 提供的采样机制, 在中断上下文提取所需信息.
bcc
bcc 是一个开源的 Linux 动态跟踪工具. 无第三方模块依赖, 该工具继承 BPF 这个强大的内核中虚拟机的功能, 可对程序进行高效而且安全的跟踪. 在安装了 bcc 之后我们可以在目录 /usr/share/bcc
中找到它, 其 tools
子目录包含了大量实用的观测工具, 大部分由 python 代码写成:
$ ll /usr/share/bcc/tools/
....
-rwxr-xr-x. 1 root root 9528 Jan 24 2023 runqlat
-rwxr-xr-x. 1 root root 7919 Jan 24 2023 runqlen
-rwxr-xr-x. 1 root root 8929 Jan 24 2023 runqslower
...
以我们熟悉的 runqlen 作为例子, 该脚本几乎提供了上节例 2 一模一样的功能:
bpf_text = """
...
struct cfs_rq_partial { // 1struct load_weight load;RUNNABLE_WEIGHT_FIELDunsigned int nr_running, h_nr_running;
};
BPF_HISTOGRAM(dist, cpu_key_t, MAX_CPUS);int do_perf_event()
{unsigned int len = 0;pid_t pid = 0;struct task_struct *task = NULL;struct cfs_rq_partial *my_q = NULL;task = (struct task_struct *)bpf_get_current_task(); // 2my_q = (struct cfs_rq_partial *)task->se.cfs_rq;len = my_q->nr_running; // 3if (len > 0) // 4len--;STOREreturn 0;
}bpf_text.replace('STORE', ...) // 5
b = BPF(text=bpf_text, ...) // 6
b.attach_perf_event(ev_type=PerfType.SOFTWARE, // 7ev_config=PerfSWConfig.CPU_CLOCK, fn_name="do_perf_event",sample_period=0, sample_freq=99)
该 python 脚本主要包含三个部分: 挂载程序(bpf_text); 对挂载程序的修正(bpf_text.replace 对 bpf_text 做字符串替换); 使用 BPF 加载 eBPF 程序文本. 其中:
-
内核没有导出
cfs_rq
结构体, 需要我们自己定义一个数据结构, 目的是以正确的偏移获取cfs_rq
的成员nr_running
; -
eBPF 程序通过
bpf_get_current_task
获取curr
进程. 此处task
等同于 bpftrace 脚本中的curtask
; -
因为标号 1 处结构体的定义, 此处可以以正确的偏移量拿到
nr_running
数值; -
nr_running
计数包含当前运行的进程, 减去 1 之后才是等待队列的长度. 这里需要对len == 0
的场景做特殊处理. 因为我们通过时钟中断注册的回调函数对所需的信息进行采集, 被中断打断的进程可能是 idle 进程, idle 进程运行是不计入nr_running
的. 这里做了场景特判; -
字符串处理, 此处替换
STORE
字符串. 脚本中有些字符串替换是为了兼容系统版本, 例如高版本cfs_rq
才引入runnable_weight
成员, 所以在高版本内核运行该脚本时, 将标号 1 数据结构中的RUNNABLE_WEIGHT_FIELD
替换为runnable_weight
成员, 而在低版本将RUNNABLE_WEIGHT_FIELD
替换为空; 有些字符串替换是为了根据 bcc 脚本的输入做 bpf 代码的调整, 例如脚本输出的单位默认为纳秒, 可以指定参数-m
将输出的单位替换为微秒; -
验证并加载 BTF 元数据到内核, 创建相关的数据结构例如
dist
; - 这里指定使用 perf 基于时钟驱动的软件事件, 即利用 perf 的采样模式,
frequency
指定为 99 HZ, 入口函数指定为 eBPF 程序中的do_perf_event
. 因此该语句主要涉及以下工作:-
验证及加载 eBPF 程序到内核
-
为系统中每一个 CPU 注册 perf 软件事件
-
将每个 CPU 注册的软件事件的回调函数替换为
do_perf_event
函数
-
至此, 我们已经见识过用户态程序使用 bpftrace 及使用 python 写成的 runqlen, 用户态程序除了用于打印 eBPF 程序中收集得到的值, 还可以用于进行用户态内核态通信, 或者用于火焰图生成等数据后处理; 两份 runqlen 代码中 eBPF 程序的内容也基本一致. 下面让我们忽略这些微的差异, 从系统调用的角度看看 runqlen 和内核进行了哪些交互.
syscall
这一节采用 strace 工具对 bpftrace 写成的 runqlen 进行观测, 相关系统调用梳理如下:
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_PERF_EVENT, insn_cnt=33, insns=0x5579141cf330, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(0, 0, 0), prog_flags=0, prog_name="99",..) = 15 // 1
/* 以下两句, 重复 CPU 个数次 */
perf_event_open({type=PERF_TYPE_SOFTWARE, size=0 /* PERF_ATTR_SIZE_??? */, config=PERF_COUNT_SW_CPU_CLOCK, sample_freq=99, sample_type=0, read_format=0, freq=1, precise_ip=0 /* arbitrary skid */, ...}, -1, 0, -1, PERF_FLAG_FD_CLOEXEC) = 16 // 2
ioctl(16, PERF_EVENT_IOC_SET_BPF, 15) // 3bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_PERF_EVENT, insn_cnt=37, insns=0x5579141e1580, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(0, 0, 0), prog_flags=0, prog_name="1", ...) // 4
perf_event_open({type=PERF_TYPE_SOFTWARE, size=0 /* PERF_ATTR_SIZE_??? */, config=PERF_COUNT_SW_CPU_CLOCK, sample_period=1000000000, sample_type=0, read_format=0, precise_ip=0 /* arbitrary skid */, ...}, -1, 0, -1, PERF_FLAG_FD_CLOEXEC) = 23 // 5
ioctl(23, PERF_EVENT_IOC_SET_BPF, 24) = 0 // 6
因为脚本中有两段 eBPF 程序, 所以可以看到系统调用分为两个部分(line 1-3, line 4-6), 其中:
-
加载采样频率为 99HZ 的 eBPF 程序;
-
为 0 号 CPU (CPU ID 由倒数第三个参数指定) 注册一个以时钟驱动的软件事件; 使用 perf_event 的 sampling 模式, 当频率被设置为 99HZ, 每秒采样的次数由
sample_freq
参数指定为 99; -
将步骤 2 软件事件的回调函数修改为步骤 1 加载的 eBPF 程序, 假设系统中有 4 个核, 步骤 2 和 3 会重复 4 次.
-
加载 1 秒打印 1 次输出的 eBPF 函数;
-
为 0 号 CPU 注册一个 1 秒触发一次的软件事件;
-
将步骤 5 软件事件的回调函数修改为步骤 4 加载的 eBPF 程序. 因为每秒只需要输出一次, 此处让 0 号 CPU 负责周期性执行该段 eBPF 程序.
后记
本篇主要从一个小例子 runqlen 入手, 串联介绍了 bpftrace bcc 工具集和相关的系统调用. 因为 eBPF 挂载点渗透到内核每个子系统, 并且对 eBPF 程序提供了安全性验证, 极大增强了内核的可观测性. 如果将内核状态机看作时序维度上的一叠黑胶片, eBPF 就是你手里那杯显影液.
ref
-
bcc python developer tutorial
-
linux observability with bpf
-
https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md