引言
在之前的文章中,我们详细介绍了 ChaosBlade 中 CPU 故障的实现原理。本文将重点介绍模拟磁盘故障的实际操作与具体实现,包括 Linux 命令(如 dd 和 fallocate)的使用与说明,以及当前 ChaosBlade 在磁盘故障模拟方面的不足之处和优化改进。
目前 ChaosBlade 已支持的基础资源类故障场景如下:
介绍
磁盘对于服务的稳定性有着至关重要的作用,当磁盘故障时可能会导致服务响应时长增加、任务处理速度变慢甚至系统假死等问题。例如
- Web 服务器:
- 静态内容受影响:如果磁盘上存储了网站的静态文件(如 HTML、CSS、JavaScript 文件),磁盘故障可能导致这些文件无法访问,使网站的外观和功能受损。
- 业务服务:
- 日志记录失败:磁盘故障可能导致业务无法正常记录日志,同步日志打印情况下可能会导致业务进程hang住,异步日志打印可能导致线程池打满
- 数据库服务:
-
数据不一致:磁盘故障可能导致数据库中的数据写入不完整,造成数据不一致,影响数据的完整性和准确性。
-
读写延迟增加:磁盘故障可能导致数据库读写操作的延迟增加,降低系统的响应速度。
- 文件存储服务:
-
文件丢失:磁盘故障可能导致文件丢失,如果没有备份,这些文件可能无法恢复,影响用户数据和体验。
-
文件上传受阻:用户无法成功将文件上传到服务上,影响服务的核心功能。
- 消息队列服务:
-
消息丢失:磁盘故障可能导致消息队列中的消息丢失,影响异步任务的处理和系统解耦。
-
消息处理失败增加:由于消息队列中消息丢失,消息处理失败的情况可能增加,导致系统无法正常工作。
为了更好地了解系统性能,增强系统的稳定性,以及提高应对故障的能力,开发人员和系统管理员需要一种有效的方式来模拟磁盘故障来验证磁盘故障后的预案止损手段/程序的自愈能力。而在这里我们引入 ChaosBlade,用于模拟故障并帮助用户实现磁盘故障。
下面是一些磁盘故障注入的验证场景:
下面将正式介绍 ChaosBlade 项目中模拟磁盘故障的使用方式和底层实现,项目地址: https://github.com/chaosblade-io/chaosblade-exec-os
ChaosBlade 磁盘故障模拟
功能介绍
目前 ChaosBlade 支持的磁盘故障场景,包括
安装与使用
首先,您可以从 ChaosBlade GitHub 仓库 下载 ChaosBlade Tool 工具包,并将其解压到目标机器上,然后执行相应的命令来模拟磁盘故障。
磁盘 IO 负载故障
命令格式:
1./blade create disk burn [flags]
2
3
4参数
5--path string 指定提升磁盘 io 的目录,会作用于其所在的磁盘上,默认值是 /
6--read 触发提升磁盘读 IO 负载,会创建 600M 的文件用于读,销毁实验会自动删除
7--size string 块大小, 单位是 M, 默认值是 10,一般不需要修改,除非想更大的提高 io 负载
8--write 触发提升磁盘写 IO 负载,会根据块大小的值来写入一个文件,比如块大小是 10,则固定的块的数量是 100,则会创建 1000M 的文件,销毁实验会自动删除
使用案例:
1# 在执行实验之前可先观察磁盘 io 读写负载
2iostat -x -t 2
3
4
5# 上述命令会 2 秒刷新一次读写负载数据,截取结果如下
6Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
7vda 0.00 2.50 0.00 2.00 0.00 18.00 18.00 0.00 1.25 0.00 1.25 1.25 0.25
8
9
10# 主要观察 rkB/s、wkB/s、%util 数据。执行磁盘读 IO 负载高场景
11blade create disk burn --read --path /home
12
13
14# 执行 iostat 命令可以看到读负载增大,使用率达 99.9%。执行 blade destroy UID(上述执行实验返回的 result 值)可销毁实验。
15
16
17Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
18vda 0.00 3.00 223.00 2.00 108512.00 20.00 964.73 11.45 50.82 51.19 10.00 4.44 99.90
19
20
21# 销毁上述实验后,执行磁盘写 IO 负载高场景
22blade create disk burn --write --path /home
23
24
25# 执行 iostat 命令可以看到写负载增大,使用率达 90.10%。
26Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
27vda 0.00 43.00 0.00 260.00 0.00 111572.00 858.25 15.36 59.71 0.00 59.71 3.47 90.10
28
29
30# 可同时执行读写 IO 负载场景,不指定 path,默认值是 /
31blade create disk burn --read --write
32
33
34# 通过 iostat 命令可以看到,整个磁盘的 io 使用率达到了 100%
35Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
36vda 0.00 36.00 229.50 252.50 108512.00 107750.00 897.35 30.09 62.70 53.49 71.07 2.07 100.00
磁盘填充故障
命令格式:
1./blade create disk fill [flags]
2s
3
4参数
5--path string 需要填充的目录,默认值是 /
6--size string 需要填充的文件大小,单位是 M,取值是整数,例如 --size 1024
7--reserve string 保留磁盘大小,单位是 MB。取值是不包含单位的正整数,例如 --reserve 1024。如果 size、percent、reserve 参数都存在,优先级是 percent > reserve > size
8--percent string 指定磁盘使用率,取值是不带%号的正整数,例如 --percent 80
9--retain-handle 是否保留填充
使用案例:
1# 执行实验之前,先看下 /home 所在磁盘的大小
2df -h /home
3
4
5Filesystem Size Used Avail Use% Mounted on
6/dev/vda1 40G 4.0G 34G 11% /
7
8
9# 执行磁盘填充,填充 40G,即达到磁盘满的效果(可用 34G)
10blade create disk fill --path /home --size 40000
11
12
13# 返回结果
14{"code":200,"success":true,"result":"7a3d53b0e91680d9"}
15
16
17# 查看磁盘大小
18df -h /home
19
20
21Filesystem Size Used Avail Use% Mounted on
22/dev/vda1 40G 40G 0 100% /
23
24
25# 销毁实验
26blade destroy 7a3d53b0e91680d9
27
28
29{"code":200,"success":true,"result":"command: disk fill --debug false --help false --path /home --size 40000"}
30
31
32# 查看磁盘大小
33df -h /home
34
35
36Filesystem Size Used Avail Use% Mounted on
37/dev/vda1 40G 4.0G 34G 11% /
核心源码解析
磁盘 IO 负载故障
磁盘的 IO 负载主要分为读 IO 和写 IO 两种,如果让你来实现有什么好的方案吗?
例如读 IO 负载,我们可以写一个程序,在程序中不断的读取某一个文件来达到提升 IO 读负载的效果。不过这里面的细节还是挺多的,例如要考虑每次读取的缓冲区大小,太大可能会导致程序内存溢出,太小可能 IO 负载还提升不上去。如果开启多个线程读取,可能还会导致 CPU 使用率升高等等问题。
接下来,让我们来看一下在chaosblade中是如何实现磁盘IO负载的。在此之前,我们先来了解一下Linux的dd命令,因为在chaosblade的磁盘IO负载故障模拟中,dd命令至关重要。
linux dd 命令
dd 是一个常用的 Linux 命令,用于在不同设备、文件和数据流之间进行数据复制和转换。它的名称 “dd” 代表 “数据定义”(data definition),尽管现在更多地被解释为 “复制并转换”(copy and convert)。dd 命令强大而灵活,但同时也需要谨慎使用,因为它可以直接操作底层的数据
以下是 dd 命令的基本用法和一些常见的选项:
1dd if=input_file of=output_file bs=block_size count=num_blocks [options]
-
if
(input file):指定输入文件或数据源。 -
of
(output file):指定输出文件或数据目标。 -
bs
(block size):指定每次复制的数据块大小。默认单位是字节。 -
count
:指定要复制的数据块数量。
常见的选项包括:
-
iflag
:用于指定输入选项 -
direct
:执行直接 IO,绕过文件系统缓存。 -
dsync
:在每次读取后进行fsync
,确保数据写入磁盘。 -
fullblock
:读取操作将始终读取完整的块,而不会读取部分块。 -
nonblock
:使用非阻塞 IO。 -
noatime
:不更新文件的访问时间。 -
oflag
:用于指定输出选项 -
direct
:执行直接 IO,绕过文件系统缓存。 -
dsync
:在每次写入后进行fsync
,确保数据写入磁盘。 -
fullblock
:写入操作将始终写入完整的块,而不会写入部分块。 -
nonblock
:使用非阻塞 IO。 -
nocreat
:如果输出文件不存在,则不创建该文件。 -
excl
:当创建输出文件时,如果文件已存在,则返回错误。 -
status
:控制进度信息的显示频率。 -
seek
:指定输出文件的起始偏移量。 -
skip
:跳过输入文件的起始偏移量。
以下是一些 dd
命令的示例:
从一个文件复制到另一个文件:
1dd if=input.txt of=output.txt bs=1024
创建一个固定大小的空文件:
1dd if=/dev/zero of=emptyfile bs=1M count=100
将一个硬盘分区备份到一个文件:
1dd if=/dev/sdb1 of=backup.img bs=4M
从一个设备读取数据并写入另一个设备:
1dd if=/dev/sda of=/dev/sdb bs=4096
生成随机数据并写入文件:
1dd if=/dev/urandom of=randomdata.bin bs=1M count=10
IO 读负载源码解析
下面是模拟 IO 读负载核心代码,通过这段代码可以看到如何利用 dd 命令提升 IO 读负载:
1// read burn
2func burnRead(ctx context.Context, directory, size string, cl spec.Channel) {
3 // create a 600M file under the directory
4 tmpFileForRead := "/chaos_burnio.read"
5 // 利用 dd 创建文件的命令,
6 ddCreateArg := "if=/dev/zero of=%s bs=%dM count=%d oflag=dsync"
7 // 利用 dd 读取文件的命令
8 ddRunningReadArg := "if=%s of=/dev/null bs=%sM count=%d iflag=dsync,direct,fullblock"
9 // 创建文件 chaos_burnio.read 大小=600M
10 createArgs := fmt.Sprintf(ddCreateArg, tmpFileForRead, 6, count)
11 response := localChannel.Run(ctx, "dd", createArgs)
12 if !response.Success {
13 log.Errorf(ctx, "disk burn read, run dd err: %s", response.Err)
14 }
15 for {
16 // 不断利用 dd 读取刚刚创建的文件,达到提升读 IO 负载的效果
17 args := fmt.Sprintf(ddRunningReadArg, tmpFileForRead, size, count)
18 //run with local channel
19 response := localChannel.Run(ctx, "dd", args)
20 if !response.Success {
21 log.Errorf(ctx, "disk burn read, run dd err: %s", response.Err)
22 break
23 }
24 }
25}
首先根据参数传入的路径,创建一个 readFile 文件,文件名是固定的 chaos_burnio.read,然后利用 DD 命令创建 这个 600M 的 readFile 文件
1dd if=/dev/zero of=chaos_burnio.read bs=6M count=100 oflag=dsync
-
dd:用指定大小的块拷贝一个文件,并在拷贝的同时进行指定的转换
-
if: 文件输入源, /dev/zero 则是 linux 提供的虚拟设备,可以无限输出空字符 0x00
-
of: 文件输出源 意思就是将/dev/zero 的空字符 0x00 写入到 of 的 chaos_burnio.read 文件中
-
bs:同时输入/输出的大小 为 6M
-
count: 拷贝 100 个块,每个块的大小为 6M
-
oflag: dsync 代表每一次写入磁盘成功后,才进行下一次写
当创建文件完成后,则开始读取,读取的方式则是开启 for 循环 不断的执行 dd 命令
1dd if=%s of=/dev/null bs=%sM count=%d iflag=dsync,direct,fullblock
从 if 指定的文件中读取(其实就是 chaos_burnio.read 上一步创建的 600M 文件), 输出到/dev/null 中。/dev/null 也是 liunx 一个虚拟设备,类似于无底洞,输入到/dev/null 的内容将直接被丢弃,没有任何痕迹。iflag:指定读的方式 为 dsync,direct,fullblock。dsync 代表读写采用同步 io, direct 读写数据使用直接 io(不使用缓冲 buffer), fullblock 代表读取完整块,通过 dd 不断的读取来提升磁盘的读负载
IO 写负载源码解析
写负载相对读负载更简单一些,写负载就是利用 dd 不断的写入即可
1// write burn
2func burnWrite(ctx context.Context, directory, size string, cl spec.Channel) {
3 tmpFileForWrite := "/chaos_burnio.write"
4 ddRunningWriteArg := "if=/dev/zero of=%s bs=%sM count=%d oflag=dsync"
5 for {
6 args := fmt.Sprintf(ddRunningWriteArg, tmpFileForWrite, size, count)
7 response := localChannel.Run(ctx, "dd", args)
8 if !response.Success {
9 log.Errorf(ctx, "disk burn write, run dd err: %s", response.Err)
10 break
11 }
12 }
13}
磁盘写负载就是利用 dd 不断的从/dev/zero 中读取空字符 0x00,写入到文件中。由此来提升磁盘的写负载。
停止读写负载
停止相对简单,kill 掉故障注入的进程,这里和模拟 CPU 故障停止是一样的,但是额外需要将对应的文件 chaos_burnio.write/chaos_burnio.read 删除,避免占用磁盘空间。
1func (be *BurnIOExecutor) stop(ctx context.Context, read, write bool, directory string) *spec.Response {
2 if read {
3 localChannel.Run(ctx, "rm", fmt.Sprintf("-rf %s*", path.Join(directory, readFile)))
4 }
5 if write {
6 localChannel.Run(ctx, "rm", fmt.Sprintf("-rf %s*", path.Join(directory, writeFile))
7 }
8 return exec.Destroy(ctx, be.channel, "disk burn")
9}
磁盘填充故障
上面介绍了磁盘 IO 负载的实现,主要是通过 dd 命令进行读取和写入,那么磁盘填充故障是不是也可以直接利用 dd 命令实现呢?
答案:不可以,原因是因为 dd 命令的写入是会真实的将数据写入到文件中,这就决定了 dd 的写入速度不够快,在磁盘填充故障场景中可能会有打满磁盘的需求,假设一个磁盘空间比较大,那么利用 dd 执行写入故障的耗时就会比较长,用户体验不好。
在 chaosblade 中是利用 fallocate 命令解决这个问题的,下面先了解下 fallocate 命令
linux fallocate 命令
fallocate
命令是一个用于在 Linux 操作系统中预分配文件空间的实用工具。它允许您在文件系统中为文件预先分配一定大小的空间,而无需实际写入数据。这种预分配空间的操作对于创建大文件、测试文件系统性能以及管理磁盘空间非常有用。
以下是 fallocate
命令的基本语法:
1fallocate [OPTIONS] FILENAME
常用的选项包括:
-
-l, --length
: 指定要预分配的文件空间的大小。可以使用后缀(如 K、M、G、T)来指定单位,例如-l 1G
表示分配 1GB 的空间。 -
-o, --offset
: 指定在文件中的偏移量,从该位置开始进行预分配。 -
--dig-holes
: 尝试创建稀疏文件,仅分配文件空间的元数据而不分配数据块。在支持稀疏文件的文件系统上有效。 -
--punch-hole
: 在文件中"打孔",释放指定范围内的数据块,从而释放空间。在支持 Punch Hole 操作的文件系统上有效。
示例用法:
创建一个 1GB 的空文件:
1fallocate -l 1G filename
创建一个 500MB 的空文件,从文件的第 1GB 处开始:
1fallocate -o 1G -l 500M filename
创建一个稀疏文件,只分配元数据而不实际分配数据块:
1fallocate --dig-holes filename
在文件中"打孔",释放指定范围内的数据块:
1fallocate --punch-hole -o 1G -l 500M filename
填充源码解析
在 chaosblade 中就是利用 fallocate 实现的磁盘填充,因为 fallocate 是在文件系统中预分配空间,而不涉及实际的数据传输。它可以直接在文件系统中分配元数据和数据块,速度比 dd 命令创建文件更快。
1func fillDiskByFallocate(ctx context.Context, size string, dataFile string, cl spec.Channel) *spec.Response {
2 response := cl.Run(ctx, "fallocate", fmt.Sprintf(`-l %sM %s`, size, dataFile))
3}
不过 chaosblade 为了防止 fallocate 执行失败(不支持 fallocate 的文件系统等原因导致的异常),会在 fallocate 执行失败后利用 dd 尝试填充
1// Some normal filesystems (ext4, xfs, btrfs and ocfs2) tack quick works
2if cl.IsCommandAvailable(ctx, "fallocate") {
3 response = fillDiskByFallocate(ctx, size, dataFile, cl)
4}
5if response == nil || !response.Success {
6 // If execute fallocate command failed, use dd command to retry.
7 response = fillDiskByDD(ctx, dataFile, directory, size, cl)
8}
9
10
11// fillDiskByDD 利用 dd 重试
12func fillDiskByDD(ctx context.Context, dataFile string, directory string, size string, cl spec.Channel) *spec.Response {
13 return cl.Run(ctx, "nohup",
14 fmt.Sprintf(`dd if=/dev/zero of=%s bs=1M count=%s iflag=fullblock >/dev/null 2>&1 &`, dataFile, size))
15}
填充大小计算
上面介绍的是利用 fallocate 填充磁盘,这里还有一个关键点当用户指定比例填充时,填充磁盘创建的文件大小是如何计算?
这里需要利用系统调用 syscall.Statfs 获取要填充的目录所属的文件系统的统计信息,从而计算出当前文件系统已使用的比例与要填充的比例进行对比,如果大于则直接返回,如果小于则通过比例差值*总空间字节大小,最终获取到要填充的磁盘大小。
1// 系统调用,获取到文件系统的统计信息
2var stat *syscall.Statfs_t
3err := syscall.Statfs(directory, stat)
4// 磁盘总空间
5allBytes := stat.Blocks * uint64(stat.Bsize)
6// 可用字节
7availableBytes := stat.Bavail * uint64(stat.Bsize)
8// 已用字节
9usedBytes := allBytes - availableBytes
10if percent != "" {
11 p, _ := strconv.Atoi(percent)
12 // 已用的比例
13 usedPercentage, _ := strconv.ParseFloat(fmt.Sprintf("%.2f", float64(usedBytes)/float64(allBytes)), 64)
14 // 预期填充的比例
15 expectedPercentage, _ := strconv.ParseFloat(fmt.Sprintf("%.2f", float64(p)/100.0), 64)
16 if usedPercentage >= expectedPercentage {
17 return "", fmt.Errorf("the disk has been used %.2f, large than expected", usedPercentage)
18 }
19 // 待填充的比例
20 remainderPercentage := expectedPercentage - usedPercentage
21 log.Debugf(ctx, "remainderPercentage: %f", remainderPercentage)
22 // 待填充大小,单位 M
23 expectSize := math.Floor(remainderPercentage * float64(allBytes) / (1024.0 * 1024.0))
24 return fmt.Sprintf("%.f", expectSize), nil
回滚
回滚和磁盘 IO 负载基本一样,也是 kill 填充的进程,并删除填充的对应文件。
实践遇到的问题
在磁盘填充故障场景中注入故障指定填充比例为 100%时,注入成功后到目标机器上查看磁盘使用情况,偶尔会发现磁盘并没有被打满,总是有一小部分空间未被填充。
这个问题的出现可能是因为文件系统碎片化导致的,如果文件系统中有过多的碎片,就会导致 fallocate 创建数据块时失败,出现磁盘没有填充满的情况(默认情况下在 ext4 和 XFS 文件系统中,块大小通常是 4KB)
解决思路:可以利用 fallocate 与 dd 进行互补,当使用 fallocate 注入后定时检查磁盘空间的占用情况是否达到预期值,如果没有的话利用 dd 继续填充,为了加快 dd 的填充速度以及避免填充失败,开启多线程并根据距离预期值的大小按照不同的粒度填充,例如距离预期值还差 10M,那么每次填充 1M。如果距离预期值还差 10K,那么每次只填充 1K。
总结
磁盘故障常见且影响大,掌握模拟方法助于验证系统稳定性和应急处理。通过 ChaosBlade 项目深入学习磁盘故障模拟的实现,我们可以掌握如何使用 dd 和 fallocate 等工具,进一步优化系统的故障恢复能力。此外,通过源码的解析和理解,还可以为开发人员提供编写高效故障模拟工具的指导,并且为其他类似工具的开发奠定基础。
作者介绍
Github 账号:binbin0325,公众号:柠檬汁Code,Sentinel-Golang Committer 、ChaosBlade Committer 、 Nacos PMC 、Apache Dubbo-Go Committer。目前主要关注于混沌工程、中间件以及云原生方向。