以开发人员速度为重点改进 CI/CD
以开发人员速度为重点改进 CI/CD
当您想到软件可扩展性时,会想到什么?也许在多个服务器上分片数据库或负载平衡请求。但是,您多久考虑一次扩展测试和合并代码的过程?
随着 Samsara 的工程师规模不断扩大,我们的产品变得越来越复杂,我们必须弄清楚这个问题:如何扩展我们的持续集成 (CI) 系统。因为我们有一个 单回购 ,我们的 CI 很容易很快变得臃肿复杂——我们正在运行前端测试、Go 测试、Python 测试、服务构建检查、linter 等等。由于这种膨胀,出现了更多的故障模式,工程师经常浪费时间调试或重试失败的 CI 运行。不可靠的 CI 系统会阻止工程师合并和部署代码,如果发生中断,这可能是特别灾难性的。
使用 CI 可以优化很多东西,但是当我们研究如何有效地扩展我们的系统时,指导我们权衡的主要原则是开发人员的速度。高效的产品开发生命周期对 Samsara 的业务至关重要——它使我们能够快速交付产品并根据客户反馈对其进行迭代。
在本文中,我们将讨论我们怀疑其他运行单一存储库的科技公司可能会遇到的 CI 扩展问题。我们将解释已被证明可成功提高 Samsara 开发人员速度的解决方案或缓解措施,以便您能够获得可行的见解。具体来说,我们将介绍:
- 是什么让 CI 不可靠?
- 合并竞争问题和合并队列作为解决方案
- 片状问题及解决方案
- 重新审视合并种族
是什么让 CI 不可靠?
你可能想知道,如果每个 PR 都需要通过 CI 才能合并到“主分支”中,那么 main 怎么会失败 CI?故障模式可以分为两个桶:
- 合并竞争条件发生,两个 PR 可能在 main 中发生冲突,即使它们都独立通过了 CI。
- 不稳定的测试/基础设施导致原本健康的主干看起来不健康。
在两者之间,我们发现合并竞赛更加痛苦。脱落的 CI 运行可以重新运行,但合并竞赛会破坏 main 并需要工程师合并 PR 来修复它。这些破损通常会持续数小时,给开发人员带来痛苦。
让我们揭开什么是合并竞赛的神秘面纱。
合并竞赛问题
当多人提出拉取请求并合并到 main 中时,合并竞赛的可能性会增加。这是一个例子:
- PR1 重命名包
原来的
至最后
. CI通过。 - PR2 添加使用包的逻辑
原来的
. CI也通过了。 - PR1 被合并到 main 中。
- PR2 合并到主目录中。代码编译失败,因为包
原来的
已不存在,已重命名为最后
. - main 被破坏,直到有人提出 PR 来解决问题,或者通过恢复其中一个 PR 或打开一个新的 PR。
就像任何并发问题一样,如果我们将写入(合并)序列化到临界区(主分支),我们将不会受到合并条件的影响。合并队列正是完成了这一点。
合并队列:权衡的解决方案
PR A PR B PR C(你的 PR)
合并队列正是它听起来的样子:而不是直接合并到 main 中,您的 PR 被放置在 PR 队列中,并且队列一次将更改合并到 main 中。
在 Samsara,我们早在 2019 年就推出了一个合并队列,但我们发现它比帮助更痛苦,并且由于对合并时间的负面影响很快将其恢复。
我们意识到合并队列会加剧 CI 不稳定,因为如果队列中的 PR A 失败(由于不稳定或合法问题),它将被踢出队列,并且 PR A 后面的所有 PR 将中止当前运行并开始新的运行CI 运行。如果队列中有很多 PR 排在你前面,而且很多都失败了,那么你的 PR 可能需要运行多次(部分)CI 才能真正合并,当然,你的 PR 冒着自行脱落的风险,导致更大挫折。
请注意,即使碎片为零,合并队列也会固有地增加 CI 时间。在将 PR 添加到合并队列之前,它必须通过 CI,因为我们希望避免损坏的 PR 污染队列。
- 运行1:提出PR,运行CI。如果通过,您可以将其添加到合并队列中。
- 运行 2:PR 在队列上变基并再次运行 CI 至少一次,可能更多次,因为 PR 在它之前失败。
随着我们扩展到 2021 年,随着合并竞赛变得越来越频繁,我们决定重新审视合并队列的实现。然而,经验告诉我们,为了让合并队列对开发人员体验产生积极影响,我们必须首先解决两件事:易碎性和 CI 速度。
CI 片状问题(和缓解措施)
在轮回,我们已经看到了各种形状和大小的薄片。
上游依赖片状
如果我们在 CI 中对某个上游依赖项进行网络调用,那么我们的 CI 可靠性将受到每个上游依赖项可靠性的上限。
这就是为什么最好限制依赖项的数量。如何?以下是我们在轮回所做的一些事情:
- 我们用 纱线离线镜像 从存储在我们的 Git 存储库中的 tarball 安装 node_modules,这样我们就不会每次都从网络下载依赖项。
- 供应我们的 Go 依赖项 进入我们的 Git 存储库。
- 将尽可能多的依赖项烘焙到我们用来运行 CI 作业的 Docker 映像中。
测试片状
如果您的代码库正在运行数千个测试,那么其中一些会很不稳定,这是很自然的。
在轮回,我们通常会看到测试失败,因为:
- 执行线程之间的竞争条件
- 测试中基于时间的灵敏度,例如。不使用模拟时钟
- 缺乏对非确定性数据结构进行排序
有大量关于单元测试易碎性原因的现有文献,例如 来自检查员的帖子 ,所以我们将讨论我们的缓解过程,而不是关注原因。
我们发现,主动缓解和被动缓解的混合对于管理 Samsara 的测试脆弱性是必要的。
CI 测试研磨机(主动式)
每当将测试引入代码库时,我们都会对测试进行 10 次研磨(运行)以尝试在它们合并到 main 之前捕获任何不稳定的测试。理想情况下,我们会在每次 CI 运行时对每个测试进行打磨,以确保不会在现有测试中引入片状问题,但这会使我们的测试慢 10 倍,这是不值得的权衡。
我们还尝试通过检查分支头和主分支合并基础之间的 Git 差异来研究直接在分支中接触到的现有测试。当然,有很多方法可以在不直接接触的情况下使测试变得不稳定,但它仍然被证明是有帮助的。
需要明确的是,试磨只是第一道防线。我们发现有些测试在 <0.1% 的情况下会剥落,因此对于这些情况,即使您将其研磨 100 次,也只有 10% 的机会会抓住它。这就是为什么反应性措施也是必要的。
CI 可靠性报告卡(反应式)
我们有一个每日 cron,它分析过去一天中主分支的所有 CI 构建,找到失败的构建,然后查看日志以解析出失败的特定作业。我们甚至可以对特定的错误日志进行模式匹配以提供更多上下文。
然后,我们将生成的“报告卡”发布到 Slack 频道以获取可见性。 CI 所有者可以查看哪些作业失败,并在必要时深入了解更多细节。
自动片状测试跳过(反应性)
我们运行一个 cron 来查看每个 CI 构建的日志并检查 Go 测试失败(我们上传易于解析的 JUnit 文件)。
如果对于特定的测试,我们发现通过→失败→通过的模式,那么我们会自动生成一个 PR 来跳过它。 JIRA 票证会为每个不稳定的测试生成并分配给拥有的团队。
选择性测试(反应性)
回想一下 Samsara 是一个 monorepo,这意味着我们默认情况下会为每个分支运行过多的 CI 检查,即使开发人员的更改通常仅限于特定域。实际上,如果 PR 只涉及前端代码,则不需要运行 Go 单元测试。
有了这个想法,我们引入了查看 PR 更改文件的逻辑,并将这些文件与一组 glob → CI 检查映射进行匹配,以确定需要运行哪些检查。
此外,我们静态分析代码库的导入图,以检测需要运行哪些测试套件。如果一个 Go 服务 A 发生了变化,而 Go 服务 B 完全不依赖于 A,则不需要运行 B 的测试。
请注意,选择性测试可减少片状的机会。想象一下,有一个测试有 50% 的时间会出现故障,但只有一半的 CI 运行实际上会触发故障测试。现在实际上只有 25% 的时间会剥落!
从哲学上讲,选择性测试为 monorepo CI 提供了 polyrepo 的一些可靠性和隔离性,同时保持了 monorepo 的简单性和凝聚力。
CI 速度问题(和缓解措施)
随着我们代码库的增长,CI 变得越来越慢。这对于迭代 PR 的开发人员来说是非常痛苦的,而对于遇到 CI 碎片的开发人员来说,这让他们感到沮丧。而且,如前所述,缓慢的 CI 是实施合并队列的成本超过收益的原因之一。
CI 减速背后的罪魁祸首是我们庞大的 Go 测试套件。从 2019 年到 2022 年,我们很高兴地看到单元测试的数量增加了 5 倍。选择性测试(在上一节中提到)帮助我们降低 CI 速度。通过只测试 PR 涉及的包,我们可以运行更少的测试。尽管如此,一些 PR 最终还是接触了一个经常导入的包,并导致我们运行整个 Go 测试套件。
为了在这些情况下提高性能,我们在不同的 CI 作业运行分片上并行测试包。我们发现分片会变得不平衡——一个分片会很不幸地被要求测试多个测试时间很长的 Go 包。作为一种缓解措施,我们开始收集每个包测试时间的历史指标,然后使用这些指标在分片之间公平地分配包测试。
同时,我们的一些 Go 包的测试时间开始超过 10 分钟,这意味着无论你并行化得多么好,最长的分片至少需要 10 分钟。为了解决这个问题,我们添加了一些逻辑来将运行时间最长的 Go 包分成多个块,这样一个 Go 包就可以分布在不同的分片中。
并行化在 CI 时代创造了奇迹,但它不是灵丹妙药。
- 并行化意味着花钱买速度(更多 EC2 实例)
- 并行化将在运行时间最长的操作上达到稳定状态(请参阅 阿姆达尔定律 )。即使我们有能力运行 1000 个分片,每个分片也必须执行 CI 作业设置(准备 Git 存储库,设置 Docker 环境),编译它负责测试的包,运行测试,发出测试指标,然后执行CI 作业清理。
尽管如此,通过这些努力,我们能够将 CI 的速度保持在 2019 年的水平,尽管 Go 测试的数量增加了 5 倍。
重温合并竞赛
到 2022 年初,我们的片状度约为 2%,我们的 CI 速度稳定在 15 分钟左右。回到(重新)引入合并队列的想法,15 分钟的 CI 将变成 30 分钟的 CI,这并不可怕,但也不是很好。这让我们停了下来。 Samsara 高度重视开发速度。我们想找回花在修复合并竞赛上的时间,但我们不确定将 CI 时间加倍是否值得。
中间解决方案
虽然合并队列保证了一个健康的主分支,但我们觉得我们没有 需要 保证主要健康,特别是如果这意味着 CI 等待时间的生产力净损失。因此,我们实施了一个中间解决方案:对有风险的 PR 进行自动变基。
为了确定哪些 PR 存在合并竞争风险,我们使用基于基本提交年龄的启发式方法。我们目前的规则是:
- 如果创建或推送到您的 PR,如果其基本提交比 main 的 HEAD 提交晚 12 小时以上,我们将自动为您重新设置您的 PR。这一切都在几秒钟内发生,因此几乎不会被察觉,这就是为什么我们可以在不影响开发人员速度的情况下更积极地变基。
- 合并您的 PR 时,如果其基本提交比 main 的 HEAD 提交晚 72 小时以上,我们将自动为您重新设置您的 PR。
我们的自动变基是由一个基于 webhook 的 worker 实现的,它监听 GitHub PR 相关的 webhook 事件。
回到前面的合并竞争示例,当 PR2 被创建时,GitHub 会向工作人员发送一个 webhook 事件,工作人员将评估 PR 是否存在合并竞争风险。如果是这样,PR2 将自动重新设置基准。对于 PR2 的作者来说,额外的 CI 时间可以忽略不计,但它也大大减少了合并竞赛打破主线的机会。
结论
经过两年时间添加监控、构建工具和测试假设,我们能够将 CI 可靠性提升到健康状态。
CI reliability from 2021 into 2022.
诚实地思考你正在优化的维度是很重要的。如果我们纯粹针对 CI 可靠性进行优化(乍一看似乎是个好主意),我们会实现一个合并队列——但这会以 CI 运行时为代价。我们认为整体开发人员体验/速度是最终目标,我们能够从合并队列转向中间解决方案。
我们希望这篇文章讨论许多成长中的科技公司将面临的相关挑战,并且我们的一些学习可以对您有用。如果你觉得这种工作很有趣,我们就是 招聘 并希望收到您的来信!
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明