拒绝平均值欺骗:基于 eBPF 监控 Linux 块设备 I/O 延迟分布实战
在评估 Linux 系统存储性能时,绝大多数运维和开发人员的第一反应是运行 iostat -xz 1。然而,iostat 输出的 r_await 和 w_await(读写平均响应时间)往往是一个“美丽的谎言”。
假设一个数据库存储系统在 1 秒内处理了 100 个 I/O 请求:其中 99 个请求的延迟是 1 毫秒,而剩下的 1 个请求因为底层垃圾回收或队列阻塞耗时 1 秒。此时 iostat 汇报的平均延迟大约是 11 毫秒。从平均值来看系统运行良好,但那个发生在一秒处的 1 秒延迟,足以触发上层分布式系统的超时重试,甚至导致整个微服务链条发生级联雪崩。
传统的 /proc/diskstats 只能提供累加指标,无法反映真实的延迟分布(Latency Distribution)。为了精准捕获长尾延迟(Tail Latency),我们需要深入 Linux 块设备 I/O 栈。本文将介绍如何利用 eBPF(Extended Berkeley Packet Filter)技术,在内核态以极低开销实时收集块设备 I/O 延迟分布。
1. Linux 块设备 I/O 栈的关键观测点
在编写 eBPF 程序之前,必须理清一个 I/O 请求在 Linux 内核块设备层(Block Layer)生命周期。现代 Linux 内核普遍采用多队列块设备架构(blk-mq),一个标准的 I/O 请求会经历以下关键阶段:
+-------------------------------------------+
| VFS / Page Cache |
+-------------------------------------------+
|
v (submit_bio)
+-------------------------------------------+
| Block Layer |
| 1. Insert queue (block_rq_insert) |
| 2. Merge request (block_rq_merge) |
| 3. Issue to driver (block_rq_issue) | <-- 观测起点
+-------------------------------------------+
|
v (queue_rq)
+-------------------------------------------+
| Device Driver (NVMe/SCSI) |
+-------------------------------------------+
|
v (Hardware Interrupt)
+-------------------------------------------+
| Complete IRQ (block_rq_complete) | <-- 观测终点
+-------------------------------------------+
为了准确衡量驱动程序与硬件设备本身的处理延迟(不包含在内核块层排队等待的时间),我们需要记录以下两个内核事件之间的时间差:
- 起点:
block_rq_issue。块层将请求(struct request)正式分发(Issue)给底层硬件驱动。 - 终点:
block_rq_complete。驱动程序处理完毕,通过中断通知块层请求已完成(Complete)。
这两个节点在内核中都有对应的 Tracepoints(跟踪点),这是 eBPF 最安全且稳定的挂载选择。
2. 方案一:使用 bpftrace 快速进行临时排查
如果你需要在生产环境临时排查磁盘长尾延迟,编写复杂的 C 代码可能来不及。bpftrace 是最适合进行即兴性能分析(Ad-hoc analysis)的工具。
下面这个 bpftrace 脚本,通过跟踪块设备请求的 Issue 和 Complete 事件,在内核态利用 BPF Map 维护请求的启动时间,并在请求结束时计算差值,最终以直方图(2的幂次方桶)形式展示延迟分布:
#!/usr/bin/env bpftrace
/*
* monitor_io_latency.bt - 监控块设备 I/O 延迟分布
*/
BEGIN
{
printf("开始监控块设备 I/O 延迟... 按 Ctrl-C 结束并输出直方图。\n");
}
/* 1. 记录请求分发的时间戳 */
tracepoint:block:block_rq_issue
{
// 使用设备号和起始扇区号组合作为 key,保证在途 I/O 的唯一性
@start[args->dev, args->sector] = nsecs;
}
/* 2. 计算完成时的延迟,并按设备分类记录直方图 */
tracepoint:block:block_rq_complete
{
$start_time = @start[args->dev, args->sector];
if ($start_time > 0) {
$latency_us = (nsecs - $start_time) / 1000;
// 记录该设备(由 dev 标识)的延迟分布直方图(单位:微秒)
@io_latency_us[args->dev] = hist($latency_us);
// 清理 Map 避免内存泄漏
delete(@start[args->dev, args->sector]);
}
}
END
{
printf("\n设备 I/O 延迟分布直方图(单位: 微秒):\n");
}
运行与输出结果解读
将上述脚本保存为 io_dist.bt 并执行:
sudo bpftrace io_dist.bt
当产生磁盘 I/O 时,终端在退出时会打印如下的直方图:
@io_latency_us[8388608]:
[128, 256) 28 |@@@@@@@@@@@@@ |
[256, 512) 104 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[512, 1K) 45 |@@@@@@@@@@@@@@@@@@@@@@ |
[1K, 2K) 12 |@@@@@@ |
[2K, 4K) 3 |@ |
[4K, 8K) 0 | |
[8K, 16K) 1 | |
8388608:表示设备号。其主设备号和次设备号可以通过(8388608 >> 20)和(8388608 & 0xfffff)计算,这里对应(8, 0),即/dev/sda。[256, 512):表示延迟在 256 微秒到 512 微秒之间的请求有 104 次。[8K, 16K):出现了一次大于 8ms(8192微秒)的长尾延迟。这种极端数据就是被iostat平均值抹平的关键隐患。
3. 方案二:使用 BCC 开发生产级监控工具
bpftrace 虽然轻量,但不便于将数据结构化输出给 Prometheus、Graphite 等监控系统,也不利于定制化逻辑。在生产级监控 Agent 中,我们通常使用 BCC(BPF Compiler Collection)框架基于 Python 或 Go 编写控制面,C 语言编写内核态 BPF 代码。
下面展示一个完整的 BCC Python 脚本,它会将延迟转换为对数直方图并周期性刷新。
内核 BPF 代码 (C 语言)
#include <uapi/linux/ptrace.h>
#include <linux/blk-mq.h>
// 定义一个哈希表保存请求开始时间,key 是 struct request 结构体指针
BPF_HASH(start_times, struct request *, u64);
// 定义直方图,按设备号区分
BPF_HISTOGRAM(lat_dist, u32);
// 挂载到 blk_mq_start_request。这是更底层的驱动分发函数,精度高于 tracepoint
int trace_req_start(struct pt_regs *ctx, struct request *req) {
u64 ts = bpf_ktime_get_ns();
start_times.update(&req, &ts);
return 0;
}
// 挂载到 blk_account_io_done。请求处理完成时触发
int trace_req_completion(struct pt_regs *ctx, struct request *req) {
u64 *tsp, delta;
tsp = start_times.lookup(&req);
if (tsp == 0) {
return 0; // 未找到开始时间,可能是在 BPF 启动前就已发起的请求
}
delta = bpf_ktime_get_ns() - *tsp;
u64 latency_us = delta / 1000;
// 获取设备的主次设备号作为直方图 Key
u32 dev = 0;
if (req->rq_disk) {
dev = req->rq_disk->major << 20 | req->rq_disk->first_minor;
}
// 存储到直方图
lat_dist.increment(bpf_log2l(latency_us), dev);
// 删除临时记录防止内存泄露
start_times.delete(&req);
return 0;
}
控制面代码 (Python)
#!/usr/bin/env python3
from bcc import BPF
from time import sleep, strftime
# 读取内核 C 代码
bpf_source = open("io_monitor.c", "r").read() # 假设上述 C 代码保存在 io_monitor.c
# 初始化 BPF
b = BPF(text=bpf_source)
# 挂载 Kprobe 进内核
# 兼容不同内核版本,推荐使用 blk_mq_start_request
b.attach_kprobe(event="blk_mq_start_request", fn_name="trace_req_start")
# 在 I/O 结束记录时,兼容挂载到 blk_account_io_done
if BPF.get_kprobe_functions(b"blk_account_io_done"):
b.attach_kprobe(event="blk_account_io_done", fn_name="trace_req_completion")
else:
# 较老内核版本的备选方案
b.attach_kprobe(event="blk_account_io_completion", fn_name="trace_req_completion")
print("eBPF I/O 监控启动。每 5 秒输出一次直方图...")
# 获取 BPF 中定义的直方图 Map
dist = b.get_table("lat_dist")
try:
while True:
sleep(5)
print("\n[%s] === I/O Latency Distribution (us) ===" % strftime("%H:%M:%S"))
dist.print_log2_hist("Latency (us)")
dist.clear() # 清空数据,以便下一次周期统计
except KeyboardInterrupt:
print("已退出监控。")
4. 生产环境部署的三个核心避坑指南
在生产环境部署基于 eBPF 的 I/O 监控程序时,不能盲目直接运行。必须注意以下技术细节以防引发系统故障:
4.1 BPF 哈希表的内存泄漏问题
在方案二中,我们使用 BPF_HASH(start_times, struct request *, u64) 记录在途请求。如果某个请求由于底层硬件死锁、驱动Bug或设备热拔插导致永远没有触发 Completion 回调,那么该请求在 BPF Map 中的记录将永久驻留,逐渐消耗系统内存。
- 解决策略:在生产级监控中,必须引入超时清理机制。例如,用户态控制程序定期轮询
start_times表,剔除时间戳距离当前时间超过 30 秒的“僵尸请求”。
4.2 避免使用高频事件(如 submit_bio)
不要轻易去 hook generic_make_request 或 submit_bio 等发生在块层顶部的通用函数。如果 I/O 并发极高,高频的 BPF 上下文切换会带来不可忽视的 CPU 开销(尤其在核心数较少的机器上)。
- 解决策略:优先选择
block_rq_issue。因为请求在合并(Merge)成request后,调用频率会远低于 BIO 的提交频率。
4.3 兼容多内核版本的处理
Linux 块设备子系统在 5.x 和 6.x 内核中经历了多次重构,部分函数和结构体字段经常发生变化。例如,struct request 里的 rq_disk 指针位置在不同版本中可能不同。
- 解决策略:现代生产环境建议使用 CO-RE (Compile Once – Run Everywhere) 技术,采用
libbpf搭配带有 BTF 支持的内核,通过BPF_CORE_READ()宏自动适配内核结构体偏移。
5. 总结
借助 eBPF 的强大性能,我们得以在不侵入业务、几乎不引入系统开销的前提下,用“显微镜”去观察每一次 Linux 块设备 I/O。通过将延迟直方图集成进 Prometheus 监控系统,你不仅能获得真实的 99% / 99.9% 存储分位数延迟,还可以在存储介质发生老化或降级时,第一时间发出长尾预警。