最近,我们在 Gusto 创下了一个新纪录:6 分 29 秒。
这是我们为 Gusto 最大的一个应用程序,一个 Rails 单体程序运行测试套件所花费的时间。6 分 29 秒是公司的持续集成(CI)流水线启用以来的最快纪录。上一次 CI 套件跑出这样的成绩,公司的规模还很小,而现在我们共有全球数百名工程师在使用这个 Rails 单体应用,为全美 1% 的小型企业提供支持。
对 Gusto 而言,高速 CI 流水线并不只是做做样子,我们把它视为一种竞争优势,代码部署越快,那么客户的业务开展也会越快。随着 CI 速度的提升,工程师的生产率也在提高,CI 时间每缩短一分钟,Gusto 每位工程师每周可增加 2%的拉取请求。
我们的目标很简单,希望让测试套件的速度成为一个参数的函数,这个参数就是:我们愿意花多少钱?将基础架构简化到这个层面后,就更容易做成本效益分析,例如如果想要将构建速度从 7 分钟提升到 5 分钟,那么需要花费 1 美元。
这篇文章介绍了我们是怎样加快测试套件速度的,其中涉及一个 Rails 单体程序和一个主要用 React 编写的 JavaScript 单页应用程序(SPA),这些经验适用于所有速度较慢的测试套件。
我的同事 Kent 说,构建软件有 3 个步骤:
让它跑起来(Make it work)
让它走上正轨(Make it right)
让它跑得更快(Make it fast)
“让它跑起来”指的是做出不会随便崩溃的软件。在这一步代码可能晦涩难懂,但足以为客户提供价值,并且通过了测试,让我们能信任它。没有测试,就很难判断“它能行吗?”
“让它走上正轨”指的是要让代码可维护,且易于更改。代码不仅能在计算机上运行,更要让人容易理解。新来的工程师可以轻松向代码添加功能,代码中的缺陷也应该很容易隔离和纠正。
“让它跑得更快”指的是要提升软件性能。为什么它会是最后一步呢?对于像 Gusto 这样的金融科技公司来说,如果只关注速度却无视质量,那么我们的客户和我们自己就离破产不远了。并非每段代码都需要优异的性能,如果一段代码每天可能只执行一次,那么就算它有"高性能"水平,却难以阅读和理解,那也是一段失败的代码。
我们把这套原则应用在 CI 套件的提速优化过程中。
让它跑起来
消除不可靠测试
首先需要做的事情是消除测试套件中的不可靠测试(test flakes)。不可靠测试(flaky test)指的是结果不确定的测试,它有时会通过,有时会失败。速度飞快但不可靠的测试套件并不能让你确信代码可以正常运行,这只是在抛硬币赌运气而已。
为了让一个规模庞大的工程团队消除不可靠测试,我们采用并执行了以下政策:
在 master 分支上所有失败的测试都将视为不可靠的。这些测试将标记为已跳过(skipped)。负责不可靠测试的团队可以在空闲时修复它们并取消跳过标记。
这个做法不仅能让测试套件一直亮绿灯,同时也让各个团队决定何时编写更多确定性测试。他们可以立刻开始编写,也可以选择等到再次处理这个功能时再行动。这种方法减少了一个团队的不确定测试给其他团队带来的损害。
当然,这种方法也存在质疑,“如果我们跳过了一项重要的测试该怎么办?”是最常见的问题。没错,这个问题很重要,但我们需要搞清楚问题的背景。一个测试之所以会被标记为已跳过,是因为它会随机失败,首先要考虑的是我们对这个测试和功能到底有多大的信心。很多时候,测试会出现不可靠情况是因为生产环境中的确存在错误!
通过这种方式,我们在主分支上的构建绿灯率从约 75%增至 98%!
让它走上正轨
回到默认状态
随着时间的流逝,我们逐渐偏离了运行 RSpec 测试的默认路径。遵守默认值是很难的。下面是 RSpec 测试的一些默认值:
在各个测试用例之间重置状态。这样可以确保测试是可重复的、确定性的,并且不会相互依赖。
测试执行是随机的。这样可以确保测试之间不存在相互依赖,帮助避免测试污染。
测试文件使用 Rails 自动加载器。这意味着我们仅加载应用程序所需的部分,而不是程序整体,可以帮助避免不完整的测试设置。
重新采用这些默认值的过程并不轻松。确保每个测试用例都重置其状态(数据库、Redis 值、缓存等),都会带来新的不可靠测试。根据其性质,我们可以修复更改或将之前正常的测试标记为不可靠。
我们慢慢重新引入了 RSpec 默认值,这为测试提速奠定了基础。
让它跑得更快
引入测试时间上限
我们的测试是不平衡的。有些测试文件只需几毫秒就能执行完毕,还有些则需要花费数十分钟时间。耗时几分钟的测试是集成测试,涉及我们应用程序中最重要的一些流程。我们希望这些测试的速度能更快,但并不想移除它们。
因为测试套件是分布在多个节点上并行执行的,所以很快就遇到了测试提速的瓶颈。
我们的测试套件速度取决于最慢的测试文件,因此实施了一项新政策:
任何测试文件的执行时间都不能超过 2 分钟。
这个门槛是凭空拉出来的,但似乎很实用。我们只有 40 多个耗时超过 2 分钟的文件。
确定界限之后,我们开始处理速度缓慢的测试,试图让它们通过新的门槛,之前 40 个文件的时间都降到了阈值以下。之后,每个团队都有责任确保其测试文件的执行时间不超过 2 分钟,而执行时间超过 2 分钟的测试文件会被标记为已跳过。
根据最坏情况来平衡测试
现在我们有了一个可靠的测试套件,只是速度很慢,它可以按任何顺序执行测试,但是将测试分配给节点的方法是随机的。有些节点只需几秒钟就完成了,而另一些节点则需要数十分钟。我们怎样才能让它们平衡呢?
我们面临的最后一个问题是测试平衡。我们在这一步评估了两种解决方案:
开发一个队列,以在节点准备就绪后为其输入测试用例。虽然这种方案原理上没问题,但 RSpec 需要对框架做大幅更新才能兼容这种方案。此外,它在所有各不相同的并行作业之间引入了共享状态。
在一次 CI 流程开始时在一个数据库中记录测试时间,将测试分为不同的桶,让所有分组都有相同的长度。
我们采用了记录与分桶的方法将测试分配到各个节点上,因为它非常适合 knapsack。测试运行期间,这种方法也不会在许多不同的并行作业之间共享状态。这是很重要的,因为一个共享队列可能有数百个节点,每个节点每秒为一个构建可以请求数千次工作。
我们建立了一个 MySQL 实例来记录所有文件的测试时间。在每次 CI 流程开始时,它会根据每个测试文件的第 99 个百分位时间生成一个 knapsack 文件。在每次 CI 流程结束时,它将上传新的结果。
为什么是第 99 个百分位?由于我们在共享硬件(AWS)上运行 CI,因此无法控制基础架构,各个测试文件的测试时间会大相径庭。我们无法将这些波动与使用的 EC2 实例类型,或者其他任何可以衡量的参数关联起来。
我们没有进一步完善构建基础架构,而是让系统具备了弹性。我们使用第 99 个百分位来组织测试,从而保证了测试的性能表现有一个下限,而不是在获得较好的平均性能时却存在明显偏低的个例。即便底层硬件发生变化或基础架构层出现故障,CI 管道依旧能保障可预期的性能水平。
这套策略实施之后,我们就有了一个自平衡的系统。测试越多,系统也就越平衡。如果某些测试随着时间的推移变慢,则测试桶也会随之调整平衡状态。
提升并行度
现在到了有意思的地方:让测试速度真的变快。
这里的主要做法是增加并行度。项目开始以来,我们已经从 40 个并行作业增加到了 130 个。这稍稍增加了成本,但大幅提升了 CI 的运行速度。在 Gusto,我们使用 Buildkite 作为 CI 基础架构,但这种并行化的理念适用于所有主流 CI 产品。
虽然我们将并行度提高到了 3 倍以上,但 CI 费用却没有随之线性增长。为什么?因为我们更好地利用了已有的 CPU 时间,通过在各个节点之间平衡作业,总 CPU 时间并没有变化,但是实际运行时间大幅缩短了。
总结
在过去几个月中,我们在一点点让 Gusto 主要应用程序的 CI 管道变得更坚实可靠,而且速度更快。
这种改进依旧是一项日常工作。在出现不可靠测试时我们还是会跳过它们,或者寻找新的优化策略来加快构建速度。无论你们现在使用的是什么技术,我们都希望这篇文章可以为你们的团队提供一个路线图参考,帮助改进你们的 CI 管道和软件发布架构。
原文链接:
From 25 Minutes to 7 Minutes: Improving the Performance of a Rails CI Pipeline
评论