一个 fault injection 测试的三次演化
TLDR
fault injection 测试通过制造故障,并收集系统的运行状态和功能表现,判断系统在故障场景下持续提供服务的功能稳定性。出于某些需要,我做了一个这种有点偏门的测试,测试的场景是各种硬件和环境错误。最开始的测试甚至都不是自动化的,渐渐写了几个脚本,形成了自动化的流程,再增加测试场景。具体的测试场景可能很小众,但是其中却也用到了一些相对大众的方法。都是做测试,总是会有共性的地方吧。
0,程序员的玩具
这年代,大家手里都有点到此一游的照片,蓝光 4K 的电影,无损的绝版音乐之类自己很珍惜的数据,这些东西很快就会塞满一块又一块硬盘。硬盘总是有寿命的,虽然不值钱,但是数据无价。所以在坏了几块硬盘,损失了十几 T 数据,手工 copy 总是搞不清楚数据在哪之后,我终于写了个基于 syncthing 的数据备份。
简单描述一下这个小玩意吧,就是 n 个节点组成集群,节点间数据互相备份,无论哪个节点的硬盘坏了,都可以保证整个集群内至少有一个副本还保留着,并且能在其他节点上补齐副本的数量。数据的复制由 syncthing 完成,我的代码只负责决定什么时候把哪些数据的几个副本复制到哪些节点上。
硬件:
古老的 cubieboard2 开发板,有 sata 接口和有线网,没有 wifi,两个 USB 2.0,一个接 U 盘,一个接无线网卡(如果是双卡版会更好,可以省下一个 USB 口);
u 盘,暂存新数据;
SATA 磁盘,保存副本数据;
有线网,节点间数据复制;
无线网,各种外部连接、登录和 api。
基础软件:
Archlinuxarm,滚动更新;
syncthing,节点间数据复制;
redis + zk,维护文件的副本分布关系表和各节点的基本信息(不得已的选择,一直在想办法换掉);
nginx,暴露 api,提供每个节点的 heartbeat 和状态参数。
我的服务细分有五六个,真正做事的只有两个:
meta,维护自身数据,比如运行状态,磁盘容量,节点间关系,上报到 redis;
ctrl,确定 U 盘里暂存的数据的每个副本要分配到哪些节点。
现在有四个节点在运行,每个节点的软硬件完全一样。我尝试写过前端,但是我的前端实在不灵,也尝试过用一些日志和结果工具,但是没耐性仔细研究。而且出于对命令行的执念,我一直用命令行玩的也挺开心,所以前端就基本上一直没动作,一直就是个 CLI 工具,唯一的 GUI 就是 syncthing 自带的 web 界面。
1,墨菲说,能坏的都会坏
hint:即使是 TDD,也不意味着写出来的代码没有 bug,代码实际运行的条件永远比开发的时候想象的更恶劣,因此在测试中有必要发挥想象力,给代码更苛刻的测试环境。当然,这一切都有个限度,这取决于这段代码所承担的功能到底有多重要。密码学领域有句话,安全的加密就是破解的代价大于密码本身。同样的道理,测试到底要进行到什么程度,就取决于代码的价值和测试的代价。
代码有了,没测试肯定不行,否则怎么知道备份运行的靠不靠谱。我最担心的就是各种故障,所以制造了一些故障的场景,这就有了最原始的 fault injection 测试。其实测试场景都很简单,就是各种故障,目前我只考虑了以下几种:
磁盘故障:U 盘,SATA 硬盘;
网络故障:有线网络,无线网络;
服务故障:syncthing 服务,meta 服务,ctrl 服务,zk,redis;
节点整体故障,比如断电。对于集群来说,这和断网的区别不大,但是对单个节点来说意义就很不一样。
测试通过标准:存储的数据不丢,在其他节点补齐没有完成或者丢失的副本。所以如果出现丢失副本或者副本没有及时补齐,那就是 bug 了。
但是在以下场景,理论上确实无法保证不丢数据,或者补齐副本,因此以下场景不做测试:
数据刚进入暂存路径,没有进入处理流程(但是超时未发现暂存数据是 bug);
数据副本数 <= 硬盘故障节点数,如果数据的几个副本恰好都在故障的 硬盘上,那么丢数据是预料之内的事;
无故障节点数 < 数据副本数,这时候找不到足够的节点存储副本,那么副本无法补齐(但是未发现副本数不足是 bug)。
环境是现成的,用例也有了,就可以开始测试了。我的第一个 case 是硬盘故障,测试方法说出来笑掉大牙,就是直接拔 SATA 线——纯纯的原始社会手工测试。
2,上帝说,要有自动化
hint:做自动化测试,首先要明确有没有可能,是不是必要。测试的场景能否通过代码实现,实现的代价有多大,对应的自动化测试会被使用的频率等。软件研发是工程,没有对不对,只有好不好。所谓好,就是在各方面因素之间找到平衡。自动化测试就要在 ROI 上找平衡,确定自动化的范围。敏捷希望快速响应,强调持续集成,持续构建,持续发布,因此必须有足够的自动化测试来支持,但同时,也是因为敏捷希望快速响应,因此自动化测试的程度必须恰到好处,不能在自动化测试的实现和维护上浪费太多的资源。在这个意义上,项目有多少场景可以方便的实现自动化测试,并且足够频繁的使用,一定程度上也会影响对敏捷方法的选择。如果发现自动化测试的难度大,或者维护成本太高,并且执行的机会较少,甚至自动化测试的 ROI 反而低于手工测试,那要么是代码的可测性太差,要么是这个项目不太适合敏捷的方法。自动化测试只是方法和工具,我们追求的是质量和效率。
这个 fault injection 就很适合自动化测试:
故障场景大部分都可以用各种命令工具来模拟,linux 下有非常丰富的模拟异常的工具,这些是自动化测试可能性的基础;
没有太多的上下文依赖,每个场景都可以单独拿出来测试;
测试的需求足够频繁,完全可以串接进流水线;
测试需要的数据只是各种文件,很容易准备。
基于此,我觉得做个自动化难度不高,而且效果明显。模拟大部分故障的方法都很简单:
磁盘故障:这部分方法非常多,比如把磁盘设备配成 /dev/full 模拟磁盘写满,通过设置 /sys/block/sdc/device/timeout 模拟磁盘超时,配合 dmsetup 设置超长延迟,也就相当于磁盘不可读/写。例如,设置 /dev/sda 读写延迟 500ms:
网络故障:用 tc 命令设置网络延迟和限制带宽等,设置超长的网络延迟还可以模拟断网。例如,eth0 网卡延迟 100ms:
服务故障:直接 kill 或者 systemctl stop / disable 来模拟服务故障,但是正常退出还是太温和,没有达到测试的效果;
节点故障:直接 reboot 模拟节点失效,但是恢复太快,我尝试过在 rc-local 里增加 sleep,但是效果不好,而且正常重启的破坏性不够。
hint:自动化测试往往需要准备大量的数据,尤其长链路的业务,经常是后面环节的 case 所需要的数据依赖前面环节的操作结果,这时候如果要单独测试一个 case,就需要生成很多基础数据作为支持。这时候直接保存数据就显得很笨拙,不如保存生成数据的 sql 等脚本更符合敏捷的精神。在测试的时候生成数据,测试后清理,这样虽然增加了额外的步骤,但是以代码和文件的方式来保存自动化测试的数据,换来的是维护性的提升和管理的便利,不但便于保存,而且可以纳入代码管理,也便于和 CI/CD 集成。但是有些项目的有些测试场景中,关联的数据库元素太多,导致测试前需要构造大量复杂的数据,牵涉到多个数据库的多个表,甚至外部数据库,那就需要提升一下可测性了,比如必要的重构,重新考虑代码的实现方式,是不是真的需要这么复杂的关联关系。
我的测试数据就是各种大小的文件,我在测试开始的时候通过 fio 来生成文件,因此就不需要准备文件,只保留一些 fio 的配置。而且 fio 生成数据也挺快的,就是可能缩短磁盘寿命。例如,在当前路径生成一个 10M 的文件,文件名为 file10m,命令是:
后面的参数都可以写成配置,比如:
512k1024.fio
我的重点在于快速生成数据,而不是磁盘性能测试,因此没有特别指定缓存并发和 ioengine 等。fio 是个测试磁盘性能很有用的工具,还可以直接写裸盘,可以读写混合,可以选择缓存方式,以及各种其他功能,参数非常丰富,值得更深入的研究。
第一次演化:从自(己)动(手)到自动
hint:大部分自动化测试都是来自手动测试,反复执行的手工步骤最终都会被写成脚本执行。最初的自动化测试往往就是很简单的一些命令,但是能带来很大便利,提高效率,这也是自动化最开始的雏形。尤其在团队里,这个现象更明显。很多项目在研发期间,每个工程师都可能为了自己的使用便利而编写一些准备环境或者功能验证的脚本,都很值得关注。这些小工具分散在每个人的手里,其实也是一种浪费,如果进行收集整合,形成流水线或者 toolkit,在组织内共享,能发挥更大的作用。敏捷强调自动化,在有意识的做自动化的同时,也要关注这些分散在工程师手里的自然形成的自动化工具,鼓励工程师把自己的小工具拿出来。
专项测试的分类其实有点乱,有些类型是基于测试的方法,有的类型是基于测试的目的。自动化测试是测试方法层面的分类,而 fault injection 是测试目的层面的分类,所以用自动化的方法模拟故障,既是 fault injection,也是自动化测试。
一开始我确实几乎都是手工在执行命令,很快就烦了,于是把命令串起来,加上结果收集,就有了最早的脚本。测试的流程也很简单,只模拟磁盘故障,所以流程有两种:
稳定状态下出现故障:
确认集群的状态可用;
触发故障;
等待同步结束或超时,检查副本数和其他丢失的副本状态;
记录结果;
恢复节点故障,再检查一次集群的状态。
同步文件期间出现故障,和稳定状态下的故障相比,在 setup 环节增加了触发文件同步的步骤:
确认集群的状态可用;
在暂存路径跑 fio 生成一组文件,等待副本开始复制;
在副本复制期间触发故障;
等待同步结束或超时,检查副本数和其他丢失的副本状态;
记录结果;
恢复节点故障,再检查一次集群的状态。
通过计算路径的 md5 来判断路径文件一致,计算路径 md5 我做的比较简单粗暴,用 md5sum 计算文件的 md5,通过计算出路径下每个文件的 md5,然后和相对路径和文件名排序后写进临时文件,再计算临时文件的 md5 的方式,回避了路径 md5 计算的问题。例如,把 node1 上 /some/data_path 复制到 node2 的 /another/data_path,那就需要计算两个 ?/data_path 的 md5。计算 data_path 路径的 md5 的方法就是,在 node1 和 node2 上(python 有 paramiko 可以很方便的在其他节点执行命令)分别执行:
这里要注意把路径拆清楚,node1 的副本路径是 /some/data_path,node2 的副本路径是 /another/data_path,因此需要 cd 到各自的 test_path,这样写进 /tmp/some_random_file 的排序后的文件名才是一致的,才有比较的意义。
第二次演化:分离和扩展
hint:自动化工具可以大而全,也可以小而美。大而全的工具无所不能,各种功能应有尽有,不应有的没准也有,都集合在一起,一旦拥有别无所求,但是这种工具往往也很难驯服,需要大量时间精力去磨合,让它完美的贴合项目的需要。而小而美的工具,每一件都专注于一件事,考验的是使用者的想象力,只要能恰当的组合,也有无限的可能性,而且因为是组合的用法,先天就便于结合新的功能。敏捷擅长应对项目的变化,尤其在预判项目的发展方向缺乏绝对把握的时候,搭建自动化平台和技术选型中如果选择了大而全的工具,一旦未来项目的走向超出预期,不得不重新选择工具,就很可能导致前期的投入浪费,也许小而美的工具组合更值得考虑,轻量化,便于拆解和重组,在变化中不至于全部被放弃,也更符合敏捷小步快跑的精神。
最初的自动化测试,就是选择若干节点作为被测试的节点,在上面执行故障模拟的脚本,而且其实也只测试了磁盘失效场景。渐渐的,当我想多测一些场景,增加更多脚本的时候,就需要复制粘贴改命令。这么做很快就觉得不爽了,所以我做了如下的改动:
分离控制机和 SUT,用 python 通过 paramiko 库向节点发送要执行的脚本;
分离配置和测试脚本,专门用一个脚本记录每个节点的相关配置,如 ip、登录信息、路径等,为异构节点保留扩展性;
分离共同操作。很显然,这里有些操作是每个 case 都要执行的,比如测试前检查集群状态,fio 生成文件,测试结束后恢复集群状态,清理测试文件等,因此这些就构成了 setup 和 teardown;
分离选择和操作,选择要模拟故障的节点和设置故障场景拆成独立的两步,这样在选择故障节点的时候可以增加更多的筛选逻辑,比如随机选,按条件选节点(比如当前磁盘最满的)等;
增加网络、开关机等故障的脚本,增加磁盘全盘 / 局部读写延迟以及组合的场景;
增加日志,记录测试时间和场景,case 失败时记录故障。
以上这些分别形成独立的脚本,这就可以自由组合了,比如,在 2 个随机节点上分别模拟磁盘延迟和断网(能实现,但是我没测过,因为我只有 4 节点,这个场景理论上一定会失败)。
第三次演化:按需调度,自由执行
hint:把各种错误场景拆分成独立的操作,并且有识别事故、保存现场、恢复环境的功能,只需要再加上随机调度,就离 monkey test 只有一步之遥了。这都得益于拆分的独立性以及组合的便利性,小工具的美就这样体现出来了。其实有的时候,尝试把旧思想和旧方法组成新的组合,也有可能形成新的方法。
既然各种故障都分离成独立的脚本了,那就可以按照测试场景的需要任意组合,只要前后加上 setup 和 teardown,中间的测试可以随便换。而且实际的故障出现也没有规律,可能出现在单个节点上,也可能出现在多个节点上,因此也要制造随机的故障场景来测试,这就接近 monkey test 了。monkey test 是测试方法层面的分类,所以无规律的模拟故障场景,既是 monkey test,也是 fault injection。
得益于各种操作分离成独立的脚本,随机测试其实做的很简单,不再有用例的概念,只有场景:
判断副本数和故障节点数量,需要满足:故障节点数 < 副本数 <= 正常节点数,例如 4 节点 2 副本的环境下,故障节点只能测到单节点故障;
单独的线程在暂存路径不断生成数据,触发副本同步;
随机选择节点,随机选择场景,排列组合,随机故障,并记录故障的节点、场景、起止时间;
收集同步结果,记录失败场景的时间点。
这里有点麻烦的就是,失败后记录的时间,需要放进故障时间线内,通过对比才能找到失败时的故障场景。这里其实也可以自动化,但是还没有自动化。考虑到 debug 也需要我去查同步的记录,因此也没有增加太多的麻烦,只是 debug 的时候像极了刑警审嫌疑犯,不断的追问代码:9 点 57 分 32 秒到 10 点 11 分 17 秒之间你在做什么,有 log 可以证明吗……
同步记录查起来还是很方便的,syncthing 有 event 的概念,每个文件同步成功后都会形成一条记录,就是一个 event,我在调用的时候也保存了 event,同步结束后就会比较一次,文件和 event 一致才算是同步成功。但是这其实也埋了不少同步 / 异步通信中断 / 阻塞的 bug,每隔几天就会爆出个把,好像总也改不完,让人心灰意冷。不过只要 event 的记录没错,经过比对,还算是可以很方便的看到哪些文件同步失败。
3,和其他测试工具对比
其实我一开始根本没想过要做成一套工具,所以也就不存在和其他测试工具对比的问题,一切都是顺其自然走下来的。其实用其他测试工具能不能做呢?肯定也可以。实际上如果当初我就想到了要做个工具化平台化的 fault injection,也许我就找个测试框架来用了。但是翻回头看看,这个工具本身也确实有些特点吧,比如:
是工具集,不是框架,不内置,不修改脚本内容,只是调度各种脚本,故障模拟都还可以独立运行,保留其他组织方式的可能性;
不依赖测试脚本的实现方式,扩展性更强,兼容更广。模拟故障都是 shell,如果换成其他用例,用其他方式实现,只要能用 shell 执行的,都可以在外面包一层 shell 收进来用;
其实也不是不能 GUI 化,但是我没做。别问,问就是不会;
同理,输出太丑,纯文本。
4,未解之谜
受限于我的知识技能包、时间和精力以及实验条件,其实还有很多该测而未测的场景,以及值得做而没做的实验,比如:
arm 上模拟 CPU 受限和故障,我还没跑通;
SATA 如何设置更丰富的磁盘故障,我还没找到更好的方法,比如读写慢速和短时间延迟不改路径的实现方式、SATA 没有 SCSI 那么丰富的工具集,SCSI 还可以直接模拟全盘失效,并且方便的扫描恢复,SATA 怎么做我还没查到;
模拟的所有故障都是稳定故障,如何模拟不稳定的故障,比如网络频繁闪断,脚本断网的间隔还是太长了;
如果磁盘本身是 Raid 盘,不同的 Raid 实现方式下会怎样,我还没试过,如果有机会很想试试,但这已经和实用无关了,纯粹只是好奇心;
在 docker 上模拟测试,会不会简化?效果怎样?没试过;
备份服务还会升级,比如异构节点,节点再发现,随时出入集群,同节点多硬盘等,那么数据备份和恢复的策略也要跟着变,到时候怎么测?还没想好。
5,总结
这是一个测试场景比较小众的自动化测试,我的实现过程也毫无章法,最开始并没有想好要形成怎样的架构设计,只是让几个脚本跑起来,就这么开始了,然后丰富场景,一直到现在我觉得几乎可以用来做 monkey test,其实也只是遵循了一些基本的道理。我对架构和设计的理解是 TAOUP 打下的底子,因此这种半盲目的实践好像也并没有走太多的弯路。
敏捷擅长小步快跑,因此对各种自动化的要求,对工具的选择,都需要灵活,身在其中的工程师需要有想象力,发现各种可能性;
细分,做好每件小事;组合,做好一件大事;
利用已有工具简化资源的维护,便于应对变化;
不要想的太复杂,先动手做起来,不必背负太遥远的未来,没有开始才是最大的问题。没想好也不见得是坏事,有时候可能反而不受限制;
自动化不是想出来的,而是用出来的。手动测试做多了,自然会发现无脑重复的部分,于是就有了自动化;
命令行是好东西,纯文本是好东西,灵活简便,还可以合进流水线。
简化,细分,命令行,纯文本,小工具,大组合,这些 Unix 哲学强调的观念,都是无数工程师反复实践过的经验,怎么强调都不过分。Unix 诞生 50 多年以来不断演化,其设计理念和敏捷暗合,可见优秀的工程实践都遵循着类似的原则,这种长久不变的内核,值得多花点力气去学习。
版权声明: 本文为 InfoQ 作者【QE_LAB】的原创文章。
原文链接:【http://xie.infoq.cn/article/b99f0fb5549342d04b318bdeb】。文章转载请联系作者。
评论