WEBKT

拒绝平均值欺骗:基于 eBPF 监控 Linux 块设备 I/O 延迟分布实战

3 0 0 0

在评估 Linux 系统存储性能时,绝大多数运维和开发人员的第一反应是运行 iostat -xz 1。然而,iostat 输出的 r_awaitw_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)    |  <-- 观测终点
  +-------------------------------------------+

为了准确衡量驱动程序与硬件设备本身的处理延迟(不包含在内核块层排队等待的时间),我们需要记录以下两个内核事件之间的时间差:

  1. 起点block_rq_issue。块层将请求(struct request)正式分发(Issue)给底层硬件驱动。
  2. 终点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_requestsubmit_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% 存储分位数延迟,还可以在存储介质发生老化或降级时,第一时间发出长尾预警。

内核观测者 eBPFLinuxIO监控

评论点评