差生文具多之(一)eBPF

前言

在问题排查过程中, 通常包含: 整体观测, 数据采集, 数据分析这几个阶段. 对于简单问题的排查, 可以跳过前两个步骤, 无需额外收集数据, 直接通过分析日志中的关键信息就可以定位根因; 而对于复杂问题的排查, 为了对应用的行为有更完整的了解, 可以通过以下形式收集更多的行为数据帮助分析:

  1. 调高日志级别生成数据, 例如将内核的 /proc/sys/kernel/sched_schedstats 配置为 1, 例如将应用的日志级别从 INFO 调整为 DEBUG;

  2. 利用静态埋点生成数据, 例如内核的 tracepoint, 例如应用的 USDT;

  3. 利用动态埋点生成数据, 例如内核的 kprobe, 例如应用的 uprobe.

本篇主角 eBPF 提供 "动态将代码挂载在执行点(用户/内核/动态/静态)上进行数据采集, 通过内置数据结构(数组/哈希/函数映射)保存数据及和用户态程序通信" 的能力. 翻译成人话是:

  1. eBPF 通过验证器保证了挂载程序的安全性, 避免恶意程序的挂载;

  2. eBPF 虚拟机执行挂载程序, 进行数据采集, 数据过滤;

  3. 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

其中:

  1. kprobe:do_sys_open: 指明挂载点, 即每次执行 do_sys_open 函数体执行前先执行一次语句块 {printf("%-7d %-10s %s\n", pid, comm, str(uptr(arg1)))};

  2. pid: bpftrace 的内置变量, 保存了 curr 进程的进程号;

  3. comm: bpftrace 的内置变量, 保存了 curr 进程的进程名;

  4. str: bpftrace 的内置函数, 用于标志字符串;

  5. uptr: bpftrace 的内置函数, 用于标识指针来自用户空间, 当打印 char __user * 类型变量时需要用到;

  6. 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

其中:

  1. 在 profile 模式中, 可以指定采样的频率, 当配置为 99HZ, 每个 CPU 每秒会产生大约 99 次时钟中断, 通过时钟中断注册的回调函数对所需的信息进行采集;

  2. bpftrace 脚本中变量前 $ 标志该变量是局部变量, @ 标志该变量是全局变量, 这里通过 bpftrace 内置变量 curtask 拿到了 curr 进程, 数据类型为 task_struct*, 通过 curtask->se.cfs_rq 获取到当前进程调度实体被挂载哪个运行队列;

  3. 将当前运行的 CPU 作为哈希 key, 将队列上调度实体的个数作为哈希 value, 保存到全局变量 @tmp 中;

  4. 每隔一秒钟输出一次列名及整个哈希表的内容;

  5. 运行效果, 当前虚拟机有 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 { // 1
    struct load_weight load;
    RUNNABLE_WEIGHT_FIELD
    unsigned 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(); // 2
    my_q = (struct cfs_rq_partial *)task->se.cfs_rq;
    len = my_q->nr_running; // 3

    if (len > 0) // 4
        len--;

    STORE
    return 0;
}

bpf_text.replace('STORE', ...) // 5
b = BPF(text=bpf_text, ...) // 6
b.attach_perf_event(ev_type=PerfType.SOFTWARE, // 7
    ev_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 程序文本. 其中:

  1. 内核没有导出 cfs_rq 结构体, 需要我们自己定义一个数据结构, 目的是以正确的偏移获取 cfs_rq 的成员 nr_running;

  2. eBPF 程序通过 bpf_get_current_task 获取 curr 进程. 此处 task 等同于 bpftrace 脚本中的 curtask;

  3. 因为标号 1 处结构体的定义, 此处可以以正确的偏移量拿到 nr_running 数值;

  4. nr_running 计数包含当前运行的进程, 减去 1 之后才是等待队列的长度. 这里需要对 len == 0 的场景做特殊处理. 因为我们通过时钟中断注册的回调函数对所需的信息进行采集, 被中断打断的进程可能是 idle 进程, idle 进程运行是不计入 nr_running 的. 这里做了场景特判;

  5. 字符串处理, 此处替换 STORE 字符串. 脚本中有些字符串替换是为了兼容系统版本, 例如高版本 cfs_rq 才引入 runnable_weight 成员, 所以在高版本内核运行该脚本时, 将标号 1 数据结构中的 RUNNABLE_WEIGHT_FIELD 替换为 runnable_weight 成员, 而在低版本将 RUNNABLE_WEIGHT_FIELD 替换为空; 有些字符串替换是为了根据 bcc 脚本的输入做 bpf 代码的调整, 例如脚本输出的单位默认为纳秒, 可以指定参数 -m 将输出的单位替换为微秒;

  6. 验证并加载 BTF 元数据到内核, 创建相关的数据结构例如 dist;

  7. 这里指定使用 perf 基于时钟驱动的软件事件, 即利用 perf 的采样模式, frequency 指定为 99 HZ, 入口函数指定为 eBPF 程序中的 do_perf_event. 因此该语句主要涉及以下工作:
    1. 验证及加载 eBPF 程序到内核

    2. 为系统中每一个 CPU 注册 perf 软件事件

    3. 将每个 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) // 3

bpf(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), 其中:

  1. 加载采样频率为 99HZ 的 eBPF 程序;

  2. 为 0 号 CPU (CPU ID 由倒数第三个参数指定) 注册一个以时钟驱动的软件事件; 使用 perf_event 的 sampling 模式, 当频率被设置为 99HZ, 每秒采样的次数由 sample_freq 参数指定为 99;

  3. 将步骤 2 软件事件的回调函数修改为步骤 1 加载的 eBPF 程序, 假设系统中有 4 个核, 步骤 2 和 3 会重复 4 次.

  4. 加载 1 秒打印 1 次输出的 eBPF 函数;

  5. 为 0 号 CPU 注册一个 1 秒触发一次的软件事件;

  6. 将步骤 5 软件事件的回调函数修改为步骤 4 加载的 eBPF 程序. 因为每秒只需要输出一次, 此处让 0 号 CPU 负责周期性执行该段 eBPF 程序.

后记

本篇主要从一个小例子 runqlen 入手, 串联介绍了 bpftrace bcc 工具集和相关的系统调用. 因为 eBPF 挂载点渗透到内核每个子系统, 并且对 eBPF 程序提供了安全性验证, 极大增强了内核的可观测性. 如果将内核状态机看作时序维度上的一叠黑胶片, eBPF 就是你手里那杯显影液.

ref

  1. bcc python developer tutorial

  2. linux observability with bpf

  3. https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md

差生文具多之(一)eBPF_第1张图片

你可能感兴趣的:(性能观测,Linux,linux,eBPF,性能观测)