【译】 再次革新 .NET 的构建和发布方式(一)

原文 | Matt Mitchell

翻译 | 郑子铭

在我写完上一篇关于 .NET 如何构建和发布的文章后,我谨慎地乐观地认为,我不需要再写一篇了。或者至少不需要再写一篇关于我们如何构建和发布的文章。这个问题已经彻底解决了。.NET 做到了!我们在分布式代码库开发和快速构建产品发布的能力之间找到了平衡。恭喜大家,现在基础设施团队可以专注于其他事情了。安全、跨公司标准化、支持构建新产品功能等等。所有这些好东西。

一年半后......

我们正在询问每月发布 3-4 个主要版本(每个版本包含十几个 .NET SDK 版本)需要多少成本,还要保持他们的工程系统更新。另外,我们想在下周的版本中加入一个紧急修复,所以我今天可以提交代码,让团队今晚验证吗?应该不难吧?还有,我想对一个新的跨栈功能进行一些原型设计......我该如何实现呢?

答案大多令人沮丧:

"这会花费很多钱,而且随着时间的推移情况会越来越糟。 "

"我觉得我们没有足够的时间来修复这个问题,我只能估计构建需要多长时间,但至少需要36个小时才能交给验证部门。也许更久? "

"我相信我们能够维持如此庞大的基础设施运转,但我们会慢慢被不断更新换代的成本压垮。 "

"拥有完整的技术栈对你来说有多重要?搭建起来需要一些时间。 "

这些并非我们想要给出的答案。因此,我们不得不重新审视问题,寻找解决方案。

这篇博文主要介绍统一构建项目:.NET 通过将产品构建迁移到"虚拟单体"代码库,并将构建过程整合为一系列"垂直构建",同时仍然允许贡献者在单体代码库之外工作,从而解决诸多问题。我将简要回顾 .NET 生命周期中产品构建的历程。我会重点介绍我们在将分布式产品构建模型应用于单个产品时所吸取的经验教训,特别是其在开销和复杂性方面的不足。最后,我将深入探讨统一构建及其基础技术------Linux 发行版源代码构建。我们将审视这种新的产品构建方法以及我们所取得的成果。

我们怎么会走到这一步?这可不是我精心打造的基础设施。

.NET 诞生于 2015-2016 年,源于 .NET Framework 和 Silverlight 的闭源基础架构。随着我们逐步完善其组件以供外部使用,.NET 也逐步开源。当时,我们按照惯例将其拆分为多个代码库。CoreCLR 代表基础运行时,CoreFX 代表库,Core-Setup 代表打包和安装。随后,ASP.NET Core 和 EntityFramework Core 以及带有 CLI 的 SDK 也相继问世。在接下来的几个版本中,产品进行了重大改进,引入了共享框架,WindowsDesktop 也加入了其中。代码库更多,复杂性也随之增加。

需要理解的是,.NET 是一个在相互依赖的独立代码库中开发的产品,但需要在相对较短的时间内将它们组合起来才能发布。从理论上讲,该产品的"依赖关系图"与任何开源生态系统都非常相似。一个代码库生成一些软件组件,将其发布到公共注册表中,下游用户依赖于这个新组件,并发布他们自己的更新。这是一个生产者-消费者模型,其中变更通过一系列拉取->构建->发布操作在"全局"依赖关系图中传播。这种模型高度分布式且高效,但在时间效率方面未必出色。它使软件供应商和代码库所有者能够对其流程和进度安排拥有相当大的自主权。然而,尝试将这种方法应用于像 .NET 这样使用独立但相互依赖的代码库来表示其组件的产品,存在重大缺陷。

我们不妨称之为"分布式产品构建方法"。为了了解为什么这种方法使用起来比较困难,让我们来看看安全版本发布的过程。

例如:安全服务

考虑发布安全补丁。假设在 .NET 运行时库的某个地方发现了一个安全漏洞。由于 .NET 源自 .NET Framework,假设该安全漏洞也存在于 .NET Framework 4.7.2 中。那么,.NET 的安全更新必须与 .NET Framework 的更新同步发布,否则两者之间就会发生零日漏洞冲突,这一点至关重要。.NET 拥有众多由微软管理的发布渠道,例如 Microsoft Update、我们的 CDN、Linux 和容器包注册表、nuget.org、Visual Studio、Azure Marketplace 等等。这给发布时间带来了一定的限制。我们需要确保发布时间的可预测性。

.NET 的开发结构与典型的开源生态系统非常相似。.NET 运行时、.NET SDK、ASP.NET Core 和 Windows Desktop 共享框架由不同的团队开发,但彼此之间有着大量的协作。有时,它们的开发方式就像是独立的产品。.NET 运行时是产品的基础。ASP.NET Core 和 Windows Desktop 都构建于其之上。大量的开发工具(C#、F#、MSBuild)都基于 .NET 运行时及其一些辅助库构建。SDK 负责收集和构建 CLI,以及任务、目标和工具。许多共享框架和工具的内容都以内置形式重新分发。

为了构建和发布这个安全补丁,我们需要参与整个 .NET 产品开发的众多团队进行协调。我们需要 .NET 图的底层(见下文)构建各自的资源,然后将其提供给下游用户。他们需要获取更新、构建并向下游提供资源。这个过程会持续进行,直到产品达到"一致性"为止;此时,不再有新的变更被添加到图中,并且所有用户都对产品中每个组件的单一版本达成一致。一致性确保所有重新分发组件或其相关信息的地方都能接收到已更改的组件。接下来,我们将进行验证,从所有未发布组件的闭包中提取所有可发布的资源,然后一次性将它们全部发布到全球。

这涉及很多环节,需要在短时间内协调运作。

分布式生态系统的优势和劣势

值得注意的是,这种分布式生态系统式的开发方式确实有很多优势:

  • 分层------代码库边界往往鼓励分层,并导致产品之间的关联性降低。在主要版本开发生命周期中,即使变更在代码图中快速且不均匀地流动,堆栈中的各个组件通常也能保持大致兼容。
  • 社区------代码库的边界往往有利于形成良好且目标明确的社区。例如,WPF 和 Winforms 社区通常是各自独立的。小型代码库通常也更容易上手。
  • 增量性------分布式开发通常允许增量式变更。例如,我们可以对 System.CommandLine 接口进行重大更改,然后随着时间的推移将这些更改逐步引入到各个使用者中。这种方法并非总是有效(例如,假设 SDK 试图只提供一个 System.Text.Json 副本供所有工具使用,但并非所有使用者都同意使用该接口。结果会怎样?!),但它相当可靠。
  • 紧凑的内部循环------规模较小、功能集中的代码库往往拥有更好的内部循环体验。即使是像 requiregit clone或 require 这样简单的操作git pull,在小型代码库中也会更快。代码库的边界往往会给人一种(可能是错觉的)错觉,即对于你的更改,你只需要关注那些你能看到的代码和测试。
  • 异步开发------增量式开发有助于实现更异步的开发方式。如果我的组件需要交付给三个位于不同时区的下游用户,这些团队可以按照自己的节奏推进各自组件的开发,而无需协调配合。
  • 低成本分片/增量构建------分布式开发允许"优化"那些不经常更改且位于依赖关系图边缘的组件的构建。例如,构建一些静态测试资源的叶子节点无需在每次 SDK 更改时都重新构建。上次构建的资源就足够了。

然而,如果你仔细分析,就会发现分布式模型的诸多优势恰恰是其在构建和发布软件时存在的显著缺陷,尤其是在需要短时间内完成对整个图结构中大量部分的变更时。大规模图结构的变更往往缓慢且难以预测。但这是为什么呢?难道这种模型本身存在缺陷吗?其实不然。在典型的开源软件生态系统(例如 NuGet 或 NodeJS 包生态系统)中,这些方面通常不是问题。这些生态系统并不追求速度或可预测性,而是重视每个节点的自主性。每个节点只需关注自身需要生产什么、需要消费什么以及满足这些需求所需的变更即可。然而,当我们尝试将分布式模型应用于快速软件发布时,往往会遇到困难,因为它会加剧两个关键概念的影响,我称之为"产品构建复杂性"和"产品构建开销"。这两个概念共同作用,导致速度减慢,可预测性降低。

产品结构复杂性

在产品构建的语境下,"复杂性"指的是将变更从开发人员的机器上执行到最终以所有所需方式交付给客户所需的步骤数量。我意识到这是一个相当抽象的定义。"步骤"的含义会根据你考察的粒度级别而有所不同。现在,让我们专注于概念性的产品构建步骤,如下图所示:

一个简单的多代码库产品构建工作流程。MyLibrary 和 MyApp 由独立的代码库构建。MyApp 部署到两个客户终端。

.NET 最初拥有一个相对简单的产品依赖关系图以及相应的管理工具。随着产品规模的扩大,新的代码库被添加到图中,构建产品也需要额外的依赖流。依赖关系图变得越来越复杂。我们开发了新的工具(Maestro,我们的依赖流系统)来管理它。现在添加新的依赖项比以往任何时候都更加容易。开发人员或团队如果想要为产品添加新功能,通常只需创建一个新的代码库,然后构建并设置输入和输出即可。他们只需要知道该组件如何融入到更大的产品构建图的一小部分中,就可以添加一个新节点。然而,.NET 并非独立发布每个单元。产品必须达到"一致性",即所有依赖项的版本都达成一致,才能发布。依赖项或其元数据会被重新分发。您必须"访问"所有边。注意:虽然我们不需要更新图中的每个组件,但每次发布时,由于修复或依赖流的变化,都会有相当一部分组件发生变化。然后,你把每个节点的输出结果全部组合起来,就可以出门了。

更复杂的图表存在明显的缺点:

  • 边和节点越多,达到一致性所需的时间往往就越长。
  • 团队更容易出错。协调环节更多,工作流程中人为因素影响结果的环节也更多。工具固然有用,但作用有限。
  • 复杂性也会导致构建环境和需求出现差异。由于各个团队的迁移和升级速度各不相同,很难确保所有人都遵循相同的流程。重建整套环境成本高昂,而且随着基础设施的老化,成本往往会随着时间的推移而增加。

这是 .NET 产品构建流程图中一个虽小但至关重要的子部分,大约在 .NET Core 3.1 版本中。Arcade 提供共享的构建基础架构(虚线),而实线则表示组件依赖关系。变更会在最终到达 SDK 和安装程序之前,经过多个代码库的传递。

产品结构开销

我们将开销定义为"未用于主动生产可交付给客户的产品的耗时"。与复杂度类似,开销的评估粒度可以根据需要而定。让我们来看两个简单的例子,以及.NET早期版本中的开销。

一个简单的多仓库产品构建流程可能如下所示:

图示为一个简单的多仓库产品构建工作流程中的开销。虚线框内的节点代表开销。

在上图中,开销节点(虚线节点)并不直接参与 D 语言包的生成。依赖流服务创建 PR 所需的时间属于开销。等待开发人员注意到并审核 PR 属于开销。等待包推送获得批准也属于开销。这并非意味着这些步骤不必要,只是说明在这些环节中,我们并没有主动为客户生成输出。

构建过程如何?如果我们深入分析代码仓库的构建过程,通常会发现很多开销。考虑一下这个非常简单的构建过程:

简单流水线中的开销示意图。虚线框内的节点代表开销。需要注意的是,这里有很多步骤并不实际生产或向客户发货。这些步骤或许是必要的,但它们仍然是开销。

系统中有一些有趣的开销衡量方法。我们可以将其衡量为总时间的百分比。将每个步骤所花费的时间按其分类相加,然后将总开销除以总时间。这可以很好地衡量整体资源效率。然而,从实际运行时间来看,总开销并不能提供太多信息。为了解开销对端到端时间的影响,我们首先在产品构建图中找到耗时最长的路径,然后计算构成该路径的各个步骤的总开销与该路径总耗时之比。

为了了解这种开销在单个 .NET 构建中的具体表现,我们来看一下运行时 8.0 的构建。这些数据是使用一个自定义工具生成的,该工具可以根据一组对每个步骤进行分类的模式来评估 Azure DevOps 构建。

以下是该建筑中最长的三条路径:

开销 + 复杂性 = 时间

开销是不可避免的。任何产品构建流程都不可避免地存在一定程度的开销。然而,当我们增加产品构建流程的复杂性,尤其是图的复杂性时,开销往往会占据主导地位,并且会成倍增长。例如,你可能不是只支付一次机器队列时间成本,而是在通过图的单个路径中支付 10 次。分配完这些机器后,每次都需要克隆代码库。由于每个步骤都存在固定成本,这些步骤的效率扩展性往往也更差。例如,如果扫描 10MB 的数据需要 10 秒,准备扫描、整理和上传结果需要 1 秒,那么连续执行 10 次该步骤所需的时间比一次性扫描全部 100MB 的数据所需的时间更长(110 秒对比 101 秒)。

更棘手的是,这种成本往往会隐藏起来,并且随着时间的推移而增加。它并不总是显而易见的。对于开发人员来说,本地仓库的构建通常很快。开发人员不会感受到整个持续集成 (CI) 系统在该构建过程中产生的任何额外开销。从更宏观的角度来看,在流水线作业中构建仓库可能同样很快,但开始会产生一些额外开销。虽然仓库本身的构建速度很快,但围绕它还有一些额外的开销步骤。尽管如此,效率仍然相当不错。然后,假设你再放大一些,流水线中还有一些额外的作业,执行其他操作。例如,重用构建其他部分的工件、构建容器等等。这些额外开销在长路径时间中所占的比例就会越来越大。现在,再次放大视角,你会看到该流水线及其相关仓库在整个产品构建过程中的位置。你还需要加上开发人员 PR 审批的时间、依赖流系统运行的时间、更多的克隆操作、更多的构建操作、更多的合规性检查等等。

在分布式产品构建系统中,影响复杂性(进而影响开销)的决策可能在无法感知系统整体开销的层面上做出。例如,添加了一个新节点。单独来看,这没什么问题。但从整体来看,它会带来成本。

虽然在 .NET 8 时期从未制作过能够展现每个组件构建在整个产品构建过程中的复杂性的复杂度图,但我们可以看看仅运行时构建的作业图是什么样的。下面的每个气泡代表一台独立的机器。

.NET 8 构建的复杂性。每个节点代表一台独立的机器。边代表依赖关系。

统一构建的根源在于源代码构建

.NET Source Build 是一种让 Linux 发行版能够在隔离环境中,基于单一统一的源代码布局构建 .NET 的方法。微软大约在 .NET Core 1.1 版本前后开始着手开发这项技术。统一构建的雏形源于 .NET Source Build 团队和负责微软发行版团队之间的一些非正式交流。我承认,基础设施团队经常会比较在 Source Build 基础设施中构建 .NET 产品所需的时间,这其中难免有些嫉妒。50分钟!比在官方 CI 构建中从头开始构建运行时库所需的时间还要短。当然,这并非完全公平的比较。毕竟,Source Build:

  • 只构建一个平台。
  • 不构建任何仅限 Windows 使用的资源(例如 Windows Desktop 共享框架)/
  • 不构建 .NET 工作负载。
  • 不进行任何安装程序打包。
  • 默认情况下不构建测试。

这些都是非常合理的注意事项。但这些注意事项加起来足以造成数十小时的构建时间差异吗?不太可能。更有可能的是,源代码构建方法复杂度低、开销小。除了时间优势之外,它还有其他显而易见的优点:统一的工具集、更便捷的跨栈开发,以及或许最为重要的------对构建内容及其构建时依赖关系的明确保证。

回到走廊里的那些对话。Source Build 的显而易见的优势引发了 .NET 团队成员时不时提出的探究性问题。大多数问题都是这样的:那么......为什么微软不以这种方式构建其发行版呢?答案是:这很难。

原文链接

Reinventing how .NET Builds and Ships (Again)

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (MingsonZheng@outlook.com)