存储层的代码必须能够应对各种棘手故障,但在测试时,想让一块健康的磁盘按需产生故障几乎不可能。虽然 Linux 内核提供了一些在块设备层注入 I/O 错误的方法,但这些方法都无法同时做到:指定要失败的操作类型、选择返回的状态码,以及在不使用堆叠设备(stacked device)的前提下直接针对目标磁盘。而使用堆叠设备意味着测试是在映射器设备(mapper device)上运行,而不是原本需要测试的磁盘。Christoph Hellwig 提出的一系列补丁新增了一个可配置的错误注入接口,完美补齐了这三点不足,且只需通过每个磁盘专属的 debugfs 文件即可进行控制。
现有方法的局限
自 2006 年 Akinobu Mita 引入错误注入基础设施以来,内核就已具备某种形式的块设备层故障注入能力。其块设备层部分(称为 fail_make_request)通过 debugfs 控制项来调整故障注入的概率、间隔与次数。它的缺点在于对所有请求一视同仁 :它无法区分读取、写入或 discard 操作,无法将故障限制在特定扇区范围内,且只能返回 BLK_STS_IOERR 错误。故障发生在 submit_bio_noacct() 阶段,即请求到达驱动程序之前。提交代表该请求之 bio 结构的文系统或其他内核代码会收到 -EIO。如果向上传递的过程中没有任何机制重试该请求,用户空间就会从系统调用中收到 EIO。对于一个介质错误、传输错误和超时各自走不同恢复路径的子系统来说,单一的状态码限制极大。
第二种故障注入机制 should_fail_bio() 由 Howard McLauchlan 于 2018 年引入,作为 BPF 程序的挂钩(hook)。该函数标注了 ALLOW_ERROR_INJECTION(),因此内核的错误注入框架允许 BPF 程序覆盖其返回值。在此变更之前,bio 提交路径会调用 should_fail_request() 来决定是否注入错误,且仅传递磁盘设备与字节计数。现在它改为调用 should_fail_bio(),后者接收 bio 并调用 should_fail_request(),因此在 BPF 程序覆盖该调用之前,旧有特性的行为保持不变。BPF 程序可以读取 bio,并根据操作类型或涉及的扇区来决定是否使每个请求失败。这给予了 should_fail_bio() 过往 fail_make_request 所缺乏的选择性。
然而,这种方法仍有局限:BPF 程序只能替换返回值 。程序可以选择要使哪个 bio 失败,但无法决定如何失败:submit_bio_noacct() 会忽略返回的值,无论如何都以 BLK_STS_IOERR 完成该 bio。
Device-Mapper 子系统提供了另一种常见方法,即专门用来使 I/O 请求失败的目标(targets)。最简单的 dm-error 会映射一个区域,对接收到的所有请求都返回错误。与 fail_make_request 类似,它只能产生 BLK_STS_IOERR,并且无论操作或扇区为何,都会使路由到该目标的所有命令失败。若要向现有设备的 I/O 请求注入故障,必须将 dm-error 目标堆叠在该设备之上。如此一来,测试针对的是生成的映射器设备,而非真正需要测试的设备。
dm-flakey 和 dm-dust 目标具有更高的可配置性,例如可以间歇性地使 I/O 请求失败,或是模拟单个坏块扇区。但它们仍具备关键的局限性:无法真正选择错误状态码,且对读写以外的命令(如分区操作或 discard)处理不佳 。作为 Device-Mapper 目标,它们还受到 Device-Mapper 对目标覆盖扇区范围所施加的对齐规则限制。dm-dust 完全不支持分区设备(zoned devices);而 dm-flakey 与任何支持分区的目标一样,只能将故障限制在与整个分区对齐的区域内,因为目标的范围必须以分区边界为起始与结束。此外,与 dm-error 一样,它们需要在被测设备上方堆叠一个块设备。
新提议的机制
Hellwig 的提案通过全新的 CONFIG_BLK_ERROR_INJECTION Kconfig 选项启用,采用了更直接的路径。当启用该选项时,块设备层会在 /sys/kernel/debug/block/ 下为每个已注册的 gendisk 创建一个 error_injection debugfs 文件。读取该文件会列出该磁盘目前生效的注入规则;向其写入内容则可以新增规则,或通过 removeall 命令清除该磁盘的所有规则。该系列补丁在 Documentation/block/error-injection.rst 中记录了格式细节。
一个规则条目是简短的逗号分隔字符串。其中两个字段是必填的:
-
op:要使之失败的操作,取自块设备层的操作名称(READ、WRITE、DISCARD、分区操作等)。 -
status:要返回的块设备层状态码(IOERR、TIMEOUT、TRANSPORT等)。
另外三个字段则是选填的:
-
start(起始扇区)与nr_sectors(扇区数量):将故障限制在特定范围内,不影响发往同磁盘的其他无关 I/O 请求。默认值分别为扇区 0 和设备剩余部分,因此若未指定,该指定操作类型的每个请求都会成为候选对象。 -
chance:使故障具备概率性。值为 N 时,匹配的请求有 1/N 的概率失败。默认值为 1,代表请求必定失败。
例如,若要让发往 nvme0n1 磁盘扇区 1000 到 1499 的读取请求中,每 10 个就有 1 个因传输错误(TRANSPORT)而失败,测试可以执行:
$ cd /sys/kernel/debug/block/nvme0n1
$ echo 'add,op=READ,start=1000,nr_sectors=500,status=TRANSPORT,chance=10' > error_injection
重新读取同一个文件会按扇区范围、操作、状态和概率列出每个生效中的规则;上述条目会显示为:
1000:1499 op=READ,status=TRANSPORT,chance=10
实现细节
几乎所有新代码都集中在单一文件中:block/error-injection.c。插入 I/O 路径的挂钩位于 submit_bio_noacct_nocheck(),它会在每个 bio 被提交前调用 blk_error_inject()。这个内联函数(inline function)在静态键(static key)启用前不做任何事;在任何磁盘上新增的第一条规则都会开启该静态键。每个磁盘的 GD_ERROR_INJECT 位则进一步将工作缩减至带有规则的磁盘上。只有在满足这些条件时,代码才会进入非内联的 __blk_error_inject()。
静态键是 Jens Axboe 提出的建议。如果没有它,每个 bio 都需要解引用来检查一个几乎总是清空的每磁盘状态位;使用静态键后,当未使用错误注入时,该分支会被完全跳过(patched out)。
__blk_error_inject() 在 RCU 保护下遍历目标磁盘的规则列表,比较每条规则的操作与 bio,检查扇区范围是否重叠,若设有 chance 值则掷一次虚拟的 N 面骰子。第一条匹配的规则获胜:bi_status 会被设置为该规则的状态,bio_endio() 完成该 bio,且 submit_bio_noacct_nocheck() 直接返回,不向设备提交任何内容。因为新规则会放在列表头部且遍历在首次匹配时即停止,所以覆盖相同 I/O 请求的新规则会优先于旧规则;系统故意允许重叠甚至重复的规则存在。
有一个匹配器永远不会触发 的情况是"零长度 bio":范围测试无法匹配不带任何扇区的 bio,因此纯缓存刷新(cache flushes)与 ZONE_RESET_ALL 操作完全无法被注入失败。要为它们加入特例很容易,但纯刷新在 bio 层级是以带有 REQ_PREFLUSH 的空写入传递,而非作为独立的操作,因此每条 WRITE 规则也会开始对刷新生效。正如 Hellwig 在代码注释中所言,要让此功能正确运作,块设备层首先需要在 bio 层级将纯刷新使用 REQ_OP_FLUSH 表示(正如它在 blk-mq I/O 调度器中所做的那样);届时规则就能直接针对刷新进行设置。
设计哲学与 BPF 之争
Hellwig 的实现保持了精简与自洽,重用了现有的每磁盘 debugfs 基础设施,且不依赖其他内核子系统。这种简单性正是他反对更复杂设计的核心理由。
在审查过程中,BPF 被提出作为该机制本身的潜在基础;Daniel Gomez 提出了 BPF_PROG_TYPE_STRUCT_OPS 挂钩,这会将 bio_endio_status() 调用保留在内核中,并将匹配策略移入可加载的程序中。Hellwig 拒绝了这个想法,认为对于这项工作来说机制过于庞大。决定使哪些请求失败的程序必须从 bio 中读取操作和扇区范围,而通过 BPF 类型格式(BTF,BPF 程序藉此访问内核类型内部细节)暴露这些内容,会将块设备层的命令与状态码变成稳定接口(stable interface),Hellwig 表示他不希望内核内部这些需要持续变更的细节被固定下来。
Gomez 回应称,一次编译到处运行(CO-RE)重定位会根据运行中内核的 BTF 来解析字段偏移量,因此布局的变更不会破坏现有程序。Hellwig 则反驳道,这假设了这些字段还会以那种形式存在;bio 迭代器即将进行重构,"我们必须先在这里建立庞大的抽象层"。
BPF 挂钩还需要在测试虚拟机内部支持 libbpf 和 BTF。扇区匹配也需要移入程序中,因为 Hellwig 没有找到适合范围查询的 BPF map 类型,因此每个需要注入失败的范围都需要专属的代码,他说这会"让东西变得非常难用"。他曾亲自原型化过基于 BPF 的注入机制;正如他所说的:"那简直是一塌糊涂",他更倾向于"大约 300 行可以直接从 shell 脚本使用的简单代码"。
当前进展
添加该机制的补丁已合并至 Linux 7.2 版本中。此后工作仍在继续:
-
Hellwig 修复了由 Le Moal 汇报的静态键不平衡问题。
-
新增了一个 blktests 测试用例:
block/044。该测试在独立的扇区范围上创建读取与写入规则,确认注入的错误能到达调用方,并验证格式错误的规则会被拒绝。该测试还会无规则生效时加载和卸载scsi_debug,以测试静态键修复后的路径。 -
Jackie Liu 提出的另一个补丁(将受影响的操作加入 debugfs 的输出中)已在 7.2-rc4 中合并,产生了前文展示的读回格式。
虽然允许刷新操作被注入失败的 bio 层级刷新变更尚未出现,但用户仍可期待在 Linux 7.2 中获得更丰富的测试选择。
