分布式 build

当您拥有大型代码库时,依赖项链可能会非常深。 即使是简单的二进制文件也通常依赖于数万个 build 目标。在这种规模下,在单台机器上以合理的时间完成 build 根本是不可能的:没有任何 build 系统可以绕过机器硬件所受的基本物理定律。唯一可行的办法是使用支持分布式 build 的 build 系统,其中系统执行的工作单元分布在任意数量的可扩缩机器上。假设我们已将系统的工作分解为足够小的 单元(稍后会详细介绍),这样我们就可以根据愿意支付的费用,以尽可能快的速度完成任何大小的 build。这种可扩缩性是我们通过定义基于工件的 build 系统一直努力实现的目标 。

远程缓存

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

使用远程缓存进行分布式构建

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

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

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

当然,为了从远程缓存中获得任何好处,下载工件的速度需要比构建工件的速度更快。情况并非总是如此, 尤其是在缓存服务器距离执行 build 的机器很远的情况下。Google 的 网络和 build 系统经过精心调整,能够快速共享 build 结果。

远程执行

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

远程执行系统

图 2。远程执行系统

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

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

为了使此方法正常运行,前面介绍的基于工件的 build 系统的所有部分都需要协同工作。build 环境必须完全 自描述,以便我们无需人工干预即可启动工作器。build 进程本身必须完全自包含,因为每个步骤都可能 在不同的机器上执行。输出必须完全确定,以便每个工作器都可以信任从其他工作器收到的结果。对于基于任务的系统来说,提供此类 保证非常困难,这 使得在基于任务的系统之上构建可靠的远程执行系统几乎不可能 。

Google 的分布式 build

自 2008 年以来,Google 一直使用分布式 build 系统,该系统同时采用 远程缓存和远程执行,如图 3 所示。

高级构建系统

图 3. Google 的分布式 build 系统

Google 的远程缓存称为 ObjFS。它由一个后端组成,该后端将 build 输出存储在分布在我们生产 机器群中的 Bigtable 中,以及一个前端 FUSE 守护进程(名为 objfsd),该守护进程在每位开发者的 机器上运行。借助 FUSE 守护进程,工程师可以浏览 build 输出,就像它们是存储在工作站上的普通文件一样,但文件内容仅针对用户直接请求的少数文件按需下载。按需提供文件内容大大减少了网络和磁盘 使用量,与我们将所有 build 输出存储在开发者的本地磁盘上相比,该系统能够以两倍的速度进行构建。

Google 的远程执行系统称为 Forge。Blaze (Bazel 的内部等效项)中的 Forge 客户端(称为 Distributor)向在我们的 数据中心内运行的作业(称为 Scheduler)发送每个操作的请求。Scheduler 维护操作 结果的缓存,如果系统中的任何其他用户已创建该操作,则可以立即返回响应。否则,它会将操作放入 队列中。大量执行器作业不断从此队列中读取操作, 执行这些操作,并将结果直接存储在 ObjFS Bigtable 中。执行器可使用这些 结果来执行未来的操作,或者最终用户可以通过 objfsd 下载 这些结果。

最终结果是一个可扩缩的系统,可高效支持 Google 执行的所有 build 。Google 的 build 规模确实非常庞大:Google 每天运行数百万个 build,执行数百万个测试用例,并从数十亿行源代码中生成 PB 级 的 build 输出。此类系统不仅让我们的工程师能够快速构建复杂的代码库,还让我们能够实现大量依赖于我们的 build 的自动化工具和系统。