分布式 build

当您拥有大型代码库时,依赖项链可能会变得非常深。即使是简单的二进制文件,也通常依赖于数万个构建目标。在这种规模下,不可能在合理的时间内在一台机器上完成构建:任何构建系统都无法绕过对机器硬件施加的物理学基本法则。实现此目的的唯一方法是使用支持分布式 build 的构建系统,其中系统完成的工作单元分布在任意且可扩缩数量的机器上。假设我们将系统的工作分解成了足够小的单元(稍后会进行详细介绍),这样我们就可以在愿意付费的情况下尽快完成任意大小的构建。这种可伸缩性是我们一直致力于通过定义基于工件的构建系统来实现的制胜法宝。

远程缓存

最简单的分布式构建类型是仅利用远程缓存的分布式构建,如图 1 所示。

具有远程缓存的分布式构建

图 1. 显示远程缓存的分布式构建

每个执行构建的系统(包括开发者工作站和持续集成系统)都会共享对通用远程缓存服务的引用。此服务可能是 Redis 等快速本地短期存储系统,也可能是 Google Cloud Storage 等云服务。每当用户需要构建工件时(无论是直接构建还是作为依赖项构建),系统都会首先检查远程缓存,以确定该工件是否已存在。如果是这样,它就可以下载工件,而无需进行构建。否则,系统会构建工件本身并将结果上传回缓存。这意味着,更改不频繁的低级别依赖项只需构建一次并在用户之间共享,而不必由每个用户重新构建。在 Google,许多工件是从缓存提供而非从头开始构建,从而大大降低了运行构建系统的成本。

为了使远程缓存系统正常运行,构建系统必须保证构建完全可重现。也就是说,对于任何构建目标,都必须能够确定该目标的一组输入,以使同一组输入在任何机器上生成完全相同的输出。这是确保下载工件的结果与自行构建工件的结果相同的唯一方法。请注意,这要求缓存中的每个工件都根据其目标及其输入的哈希进行键控,这样,不同的工程师可以同时对同一目标进行不同的修改,并且远程缓存会存储生成的所有工件并相应地传送它们,而不会发生冲突。

当然,要从远程缓存中获得任何好处,下载工件需要比构建工件更快。但实际情况并非始终如此,尤其是当缓存服务器离进行构建的机器很远时。Google 的网络和构建系统经过精心调整,能够快速分享构建结果。

远程执行

远程缓存并不是真正的分布式构建。如果缓存丢失,或者您做出的低级别更改需要重新构建所有内容,那么您仍然需要在机器上本地执行整个构建。真正的目标是支持远程执行,在这种模式下,构建的实际工作可以分布在任意数量的工作器中。图 2 展示了远程执行系统。

远程执行系统

图 2. 远程执行系统

在每位用户的机器(用户是人类工程师或自动构建系统)上运行的构建工具会向中央构建主实例发送请求。构建主服务器将请求拆分为组件操作,并通过可扩缩的工作器池安排这些操作的执行。每个工作器都会使用用户指定的输入执行要求的操作,并写出生成的工件。这些工件会在其他机器上共享,从而执行需要它们的操作,直到可以生成最终输出并将其发送给用户。

实现此类系统最棘手的部分是管理工作器、主实例和用户本地机器之间的通信。工作器可能依赖于其他工作器生成的中间工件,并且最终输出需要发送回用户的本地机器。为此,我们可以在之前介绍的分布式缓存的基础上进行构建,方法是让每个工作器将其结果写入缓存,并从缓存中读取其依赖项。主实例会阻止工作器继续操作,直到其所依赖的所有操作完成为止。在这种情况下,这些工作器将能够从缓存中读取其输入。最终产品还会进行缓存,可供本地机器下载。请注意,我们还需要另外一种方法来导出用户源代码树中的本地更改,以便工作器可以在构建之前应用这些更改。

为此,需要结合前面介绍的基于工件的构建系统的所有部分。构建环境必须完全自描述,以便我们可以在没有人为干预的情况下启动员工。构建流程本身必须完全独立,因为每个步骤可能会在不同的机器上执行。输出必须完全确定性,以便每个工作器都可以信任从其他工作器接收的结果。此类保证对于基于任务的系统来说极其困难,因此几乎不可能基于该系统构建可靠的远程执行系统。

Google 的分布式 build

自 2008 年以来,Google 一直在使用采用远程缓存和远程执行的分布式构建系统,如图 3 所示。

高级构建系统

图 3. Google 的分布式构建系统

Google 的远程缓存称为 ObjFS。它包含一个后端,用于将构建输出存储在 Bigtable 中,该后端分布在我们的生产机器舰队中;此外还有一个前端 FUSE 守护程序,名为 objfsd(在每个开发者的机器上运行)。通过 FUSE 守护程序,工程师可以浏览 build 输出,就像浏览存储在工作站上的普通文件一样,但只有少数文件内容是用户直接请求的,可以按需下载这些文件内容。按需提供文件内容可大大减少网络和磁盘使用量,并且系统构建速度是将所有构建输出存储在开发者本地磁盘上时的两倍。

Google 的远程执行系统叫做 Forge。Blaze 中的 Forge 客户端(Bazel 的内部等效项)称为“Distributor”,将针对每项操作的请求发送到在我们的数据中心内运行的作业(名为 Scheduler)。该调度程序会维护操作结果的缓存,如果系统的任何其他用户已经创建了操作,该调度程序就会立即返回响应。否则,它会将操作置于队列中。大量的执行器作业会不断从此队列中读取操作,执行这些操作,并将结果直接存储在 ObjFS Bigtable 中。这些结果可供执行器用于后续操作,也可以由最终用户通过 objfsd 下载。

最终,我们打造的系统可以灵活扩容,从而高效支持 Google 内部执行的所有构建。Google 的 build 规模非常庞大:Google 每天运行数百万个 build,执行数百万个测试用例,通过数十亿行源代码生成 PB 级的 build 输出。这样的系统不仅可以让我们的工程师快速构建复杂的代码库,还可以让我们实现大量依赖于我们构建的自动化工具和系统。