谷歌如何保持低位延续时间?

2021-06-06 18:05:13

Monorepos已经存在了很长时间。它们背后的基本思想是存储公司或产品的完整源代码,包括其所有依赖项,在单个源存储库中,并为整体具有集成的构建和测试过程。

我喜欢将BSD系统视为开源Monorepos的规范示例,但这些从未被认为是许多(C.f.CATB)的良好实践。由于谷歌的工程实践及其日益愿望与公众分享详细信息,因此莫罗尔斯在过去十年中只变得流行。谷歌的Monorepo可能是世界上最大的,如果在他们的规模上工作 - 思考 - 它对其他人都必须有好处,对吧?不是那么快。

Monorepos是一个有趣的野兽。如果正确地修补,它们确实能够实现恰当的均匀性和代码质量,以其他方式难以实现。然而,如果无人看管,它们会成为纠结依赖性,缓慢的建造和令人沮丧的开发人员体验的无法管理怪物。无论您是良好还是不良经验,直接依赖于Monorepo背后的工程支持水平。简单地说,Monorepos需要专门的团队(复数)和工具来运行很好(不仅仅是随机工程师“志愿者”他们的努力),而且这些花费了很多时间和金钱。

由于这一成本,您必须在采用Monorepo模型之前谨慎态度。你必须有一个很好的故事,周边地支持,否则你是为了长期痛苦。一旦你进入MONOREPO,“长期”是保证的,因为在这种环境中产生的依赖性混乱是不可能的。最糟糕的情况是当你没有积极决定植入一个monorepo的时候,但由于有机或意外的快速增长,你最终结束 - 在这种情况下,您的工具和实践几乎肯定没有为此做好准备。

在这篇文章中,我会看看谷歌如何能够在保持建立时间最小的同时成功运行世界上最大的Monorepo。例如,它们可以在几分钟内验证并合并大多数PI上的CI,同时具有几乎绝对的信心,以至于它们不会破坏任何东西 - 然而在几分钟内建立整个存储库是不可能的。我将限制这篇文章来分析构建时间,并将特别避免谈论测试时间。两者都很重要,但两者都有很不同的解决方案。也许后续帖子将覆盖测试😉。

如前所述,Monorepo的一个关键特征是为整体拥有统一的构建过程。在常见的情况下,存储库在顶级处于单个入口点,这意味着必须为每种更改构建整个存储库以确保树的运行状况。各个工程师可能可以手动挑选树的哪些部分以在其开发机上构建(例如,通过在子目录中运行make),但CI环境将盲目地从根本中构建。

这种方法与Monorepos与他们支持的公司或产品无束缚的事实相关联,导致建立时间到气球......直到他们将开发过程拖到停止。如果您添加了这一点,大多数构建系统需要偶尔干净的操作,请保证挫折:

拉出请求几乎无法管理,因为它们需要长期验证时间,并且可能会达到未预测的合并冲突。

质量遭受,因为开发人员不想支付另一个CI的惩罚,只是为了解决代码审查的最终通行证中提出的NITS。

面对这些情况,自然诱惑是将存储库分成较小的碎片并重新获得对构建时的控制。 (顺便说一句,这是我现在参与的具体项目。)并且根据您所处的工具以及您在改变它的自由(或缺乏)的情况下,这可能是唯一可能的/正确答案。但为什么?为什么要移动到较小的存储库修复构建时间?通过拆分拆分时,代码总量不会变小;如果有的话,它可能会增加更多!答案可能是显而易见的:

多个存储库引入同步点。在这样的模型中,交叉存储库依赖关系被表示为具有特定版本号的二进制包依赖关系。从本质上讲,较小的存储库利用建立其他人已经完成并因此绑定了他们的构建时间。

然而......谷歌的MONOREPO是不使用二元工件的别名:它们在头部建立一切,除了最基本的C ++ Toolchain(谷歌Bazel Partrance中称为Crosstool),以便引导被引导原因。他们如何把它赶走?答案很简单,与上面给出的完全相同:他们利用其他人已经完成的建造。以及他们这样做的具体方式是通过对性能问题应用常见解决方案:缓存。

Google构建依赖于跨用户(远程)的大规模工件缓存,该缓存存储所有构建操作的输出。此缓存是介绍多个存储库中受益的相同“同步点”。通过利用此缓存,任何给定构建所需的绝大多数依赖项都将由某人或其他某种构建编制,并且将被重用。

但是......除非您整体获得90%+缓存命中率,否则单独缓存不足以修复构建时间。这就是Monorepo和伟大的建筑时间在Monorepo和伟大的建筑时间之间的差异。在下一节中,我将研究导致这种高速缓存命中率的具体机制。如果谷歌可以拥有它们,你也可以。从成立开始,Bazel一直试图向公众传播这些做法 - 但您也可以使用其他构建工具应用这些相同的概念。

首先,我们必须确保构建动作(例如编译器调用,资源捆绑)是确定性的。给定一组输入文件和工具处理它们,输出不得受到环境差异的影响。换句话说:必须充分指定构建操作,以便它们不依赖于可以更改其输出的隐藏依赖项。

我在上一个“Google如何避免干净的构建?”的深入了解了这一主题。发布我分析了这个简单的想法如何允许增量构建始终工作。我也瞥了一眼它带来的许多其他福利,包括如何导致最佳建设时间,这是我在这里覆盖的。

确定性行动是在Monorepo中减少建筑时间的基础。所以......你必须在利用任何剩余点之前对此进行分类。

一旦构建行动是确定性的,下一件事就是增加为任何给定的构建重复使用先前缓存的动作的机会。这通常只有在构建使用完全相同的配置时才可能,否则它们的输出将不同,并且可能与彼此不兼容。例如:调试版本将无法安全地重用发布构建的输出。这有点明显,但不仅有两种配置。往往会更多。还有很多。

实际上,构建配置的数量随着项目的大小而增长 - 而且矛盾的是,选项数量增长以减少构建时间。你看:当工程师痛苦地缓慢建造时,他们在他们的意图中,他们可以在有条件地编制项目的某些部分来加速旋钮。在单用户/单机构建中很好,但它不会缩放,并且在Monorepo World中是一种反模式,我们必须在用户身上几乎完美的缓存。

因此,对于monorepo建立快速,我们实际上必须均匀化配置,以便大多数工程师和CI运行几乎相同的配置。实际上,这意味着具有用于交互式开发的调试构建,并释放生产使用。那就是关于它的。

作为一个轶事,我们遇到了这个特定的问题,同时在某个iOS团队陷入远程构建时。该团队仅在笔记本电脑上开发,因为它们具有缓慢的构建,工程师添加了许多功能标志来使组件可选。当我们将它们移动到远程构建和远程缓存时,我们看到了很少的好处:这些用户仍然重建一切,原因是因为由于不同的配置,他们无法利用以前缓存的结果。一旦我们删除了大多数构建条件,他们就开始真正看到远程缓存/执行和更快的构建的好处。违反直觉,不是吗?

到目前为止,我们知道我们的行为是确定性的,并且用户构建有机会重用缓存,因为我们制作了构造。但这是问题:我们如何种子缓存?

必须仅从可信源填充构建工件的交叉用户缓存。我们不能拥有在其计算机上构建的开发人员的工作站注入输出工件,因为开发人员可能会篡改输出。这意味着跨用户远程缓存只能从无法篡改的机器填充。 (如果在用户身上未使用缓存,则无需额外的预防措施。)这些可信源有两种形式:

第一个,并且易于在任何情况下易于应用的那个是CI运行。 CI运行在没有(不应该是!)的机器上,通过较大的工程人口直接访问,因此我们可以信任他们生成的输出精确地从送到它们的代码和工具中。

第二个,和更微妙的那个是用户启动的构建。当工程师在工作站上构建时,如果我们使用可信环境生成这些输出,我们可以通过用户缓存其构建输出。这种可信环境以远程执行的形式出现。在远程执行方案下,构建计算机将各个构建操作发送到远程服务。这项服务也是值得信赖的,代表用户运行操作,但用户在将输出保存到缓存之前,用户没有机会干扰输出。

您可能会认为受损编译器是这种情况下的攻击矢量:工程师修改编译器,将其发送到远程CI机器或远程执行工作人员以构建一段代码,并将包含恶意代码的输出注入到其中缓存。但那不是这种情况。请记住:编译器也是一个对动作的输入,就像源文件一样。如果使用编译器篡改,则操作的签名更改,这意味着缓存密钥更改,这意味着除非它们具有相同的受损编译器,否则无人能够解决此类缓存条目。

最终,您想采取两种方法。您希望定期CI运行以填充缓存,因为这些缓存确保缓存经常从头重新填充。并且您希望逐步逐步源自交互式构建和PRS的缓存输出,以跟上漂移,并在其使用的配置中跟上可能的分歧。

只要用户坚持上一节中讨论的符合祝福的配置,只要您拥有这些数据来源来填充缓存,那么您应该开始看到高缓存命中比率并更快地构建时间。

确实。我是一个信徒,他们很好,不仅因为谷歌:很久以前就教我这个原理。也就是说,除非您的工程实践和工具跟上,除非您的工程实践和工具跟上,这可能是较差的选择,这可以非常昂贵。如果您无法负担成本,则可以更好地留在多个较小的存储库中,并让较小的开发人员组以ad-hoc方式处理它们。整体不会那么好,但整体经验可能不那么令人沮丧。

最后,Monorepos是一个“实施细节”,它在一个地方捆绑多个单独的组件。拥有一个monorepo不应暗示每次改变时都必须建立整个东西。拥有多个存储库不应该意味着它们是孤立的岛屿。后者恰恰是我们试图在我当前的团队中证明的东西:我们希望看到我们如何在没有实际作为monorepo的情况下集成较小的存储库。因为,具有次优工具...构建时间不是唯一的问题。源控制是另一个主要的一个:git,如果你碰巧使用它,不是monorepos的最大选择。

如果您喜欢这篇文章,请订阅此博客以跟上两个即将到来的相关主题!一旦我们达到了他们,就会确保建设时间仍然很低;另一个可能是Google如何在CI上保持测试运行。