Compendium Adding eBPF for Kernel-Level Visibility

新分类,其实是读各路文章的笔记

原文网址Compendium: Adding eBPF for Kernel-Level Visibility,发表在This Week in Rust上,作者介绍了自己的学习项目Compendium

Introduction

ptrace 可以拦截syscall,跟踪文件描述符,监控内存以及产出交互式的报告,但是无法获知syscall之后系统内核里发生了什么。比如对于write系统调用,ptrace可以获知写了多少字节,但是无法得知块设备为了完成这次I/O操作实际花了多长时间

eBPF通过附加程序在内核跟踪点,使得测量调度延迟和块设备延迟成为可能

Why eBPF? What Ptrace Can’t See

Ptrace在syscall的入口和出口进行拦截,获知参数和返回值以及之间的时间,因此得以构建出I/O、内存、网络活动的图景,但是还有些内核层面的东西是不可见的:

调度延迟:当你的进程被唤醒时,比如一次I/O结束、锁被释放或者传来信号,它不会立刻开始运行,而是进入一个运行队列等待调度器给它分到一个CPU core上,在一个繁忙的系统上,这个延迟可能会很显著

块I/O延迟:SSD等以块为单位读写的设备,对于buffered I/O,内核可能在数据写入设备之前就返回,对于direct I/O,syscall会阻塞,但是ptrace识别不到是阻塞在磁盘还是阻塞在别的地方

eBPF可以让我们在内核跟踪点上附加程序,这些是内核暴露出的稳定的“instrumentation points”,正是用于此类可观测性

我们的程序几乎没有开销地运行在内核上下文,可以访问内核数据结构,并且通过一个ring缓冲区向用户态空间发送事件,同时eBPF 验证器又确保了我们的程序对内核是安全的,不会使内核崩溃、死循环或者访问到不该访问的内存

Scheduler Latency Tracking

测量调度延迟的方法很直观,在进程被唤醒的时候记录一个时间戳,然后在被调度到一个CPU上时计算过了多久

作者把程序附加到了两个跟踪点上:sched/sched_wakeup 和 sched/sched_switch,前者在内核把一个任务标记为可运行时触发,后者在调度器上下文切换到另一个任务时触发

唤醒处理器检查是否PID是我们正在跟踪的,如果是就把时间戳存在一个以PID为键的BPF哈希表里

切换处理器则在表里查询即将执行的PID,计算时间间隔,如果超过10微秒就在ring缓冲区里保存并提交一个事件,如果低于10微秒则是正常调度不值得报告

Block I/O Latency Tracking

块I/O跟踪依循类似的策略,附加到block/block_rq_issue(内核提交一个请求到块设备驱动)和 block/block_rq_complete(设备发出完成信号)

一个关键的设计是只跟踪直接/同步I/O,事件处理器会检查当前的TID并去跟踪的进程表里比对,用bpf_get_current_pid_tgid()这个函数拿到当前线程id。对于buffered I/O,由内核写回线程kworker提交块请求,所以不会满足PID过滤,这是符合预期的:缓冲写回是异步的并与应用解耦,把延迟归因于正在跟踪得到程序是不对的

在非详细模式下,连续的块I/O事件会被合并到一起

The C-Rust FFI Strategy

eBPF程序必须用C写并编译到BPF字节码,问题是如何架起这两者的桥梁而不用引入复杂的运行时依赖

有像aya这样的crate让你用rust写BPF程序,但是此项目用一种更直接的方法,用C写BPF程序,编译到.o文件提交到仓库里并用include_bytes!宏嵌入到rust二进制里。

在运行时,libbpf-rs这个库会加载这个object文件,重定位并发送到BPF验证,这个流程和C一样,验证通过后就把程序附件上去了

对于C BPF程序和rust程序的共享的数据结构,用#[repr(C)]宏和清晰的填充区域去匹配,即用pad来保证4字节对齐

把所有包在一个结构体里,包含BPF object, links, ring buffer, 和PID filter map,然后用Bio::leak把它的生命周期变成static

Fitting Into the Event Loop

原先的compendium事件循环用poll()去推动所有任务,现在eBPF的加入意味着poll要多适配一种fd

更多实现细节跳过

Ptrace + eBPF: Complementary tools!

eBPF理论上也可以跟踪系统调用,用raw_syscalls/sys_enter和raw_syscalls/sys_exit 跟踪点,但是ptrace提供了eBPF无法轻易复制的东西:从被跟踪的进程中读任意内存的能力

ptrace拦截一个open()系统调用时,我们使用process_vm_readv从被跟踪者内存中读取路径字符串,同理对于connect()可以看见IP地址和端口号,write()可以看见缓冲区内容。BPF可以通过工具阅读内核内存,但是阅读用户空间内存从BPF上下文更加的首先且脆弱,尤其对于变长字符串

Ptrace也提供了准确的控制流:我们停在每次系统调用的入口和出口,使得我们可以保持有状态的跟踪。BPF程序每次调用是无状态的,你可以用map存状态,但是编程模型是从根本上不同的

所以分工很明确,ptrace负责系统调用的参数、返回值、文件描述符、内存跟踪,即程序要求内核做什么;eBPF负责调度延迟,块I/O延迟,即内核在幕后做了什么

其它

此项目位于这里,我以为能被我了解到的项目都是非常出名的,1k star起步的那种,看来并非

看了一眼这个项目的依赖:

[package]
name = "compendium"
version = "0.2.0"
edition = "2024"
description = "A user-friendly strace for x86 Linux with HTML reports"
license = "MIT"
repository = "https://github.com/louisboilard/compendium"

[dependencies]
nix = { version = "0.31", features = ["ptrace", "signal", "process"] }
libc = "0.2"
anyhow = "1.0"
clap = { version = "4", features = ["derive"] }
perf-event2 = "0.7.4"
libbpf-rs = "0.26"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

很符合我对“小而美”的认识,培养审美ing