下载本文的 PDF 版本 PDF

使用 Bazel 的远程缓存服务

通过共享和重用构建和测试输出节省时间

Alpha Lam

如今的软件项目变得越来越复杂。随着组织增长增加每日提交量,代码逐年积累。过去只需几分钟即可完成完整构建的项目,现在从仓库获取开始,可能需要一小时或更长时间才能构建。

维护基础设施的开发人员必须不断增加更多机器,以支持不断增长的构建和测试工作负载,同时面临来自用户的不满,他们对提交时间过长感到不满。

运行更多并行作业有所帮助,但这受限于机器上的核心数量以及构建的可并行化程度。增量构建肯定有帮助,但如果生产发布需要全新构建,则可能不适用。拥有许多构建机器也会增加维护成本。

Bazel (https://bazel.build/) 提供了远程和大规模并行运行构建任务的能力。然而,并非每个组织都能负担得起内部远程执行集群。对于大多数项目来说,远程缓存是通过在构建 worker 和工作站之间共享构建输出和测试输出来提升构建和测试性能的绝佳方式。本文详细介绍了 Bazel 中的远程缓存功能 (https://docs.bazel.build/versions/master/remote-caching.html),并探讨了构建您自己的远程缓存服务的选项。在实践中,这可以将构建时间缩短近一个数量级。

 

它是如何工作的?

用户通过指定要构建或测试的目标来运行 Bazel (https://docs.bazel.build/versions/master/user-manual.html)。Bazel 在分析构建规则后,确定实现目标的动作依赖关系图。此过程是增量的,因为 Bazel 将跳过工作区目录中上次调用中已完成的动作。之后,它进入执行阶段,并根据依赖关系图执行动作。这时,远程缓存和执行系统就发挥作用了。

Bazel 中的一个动作由命令、命令的参数和环境变量以及输入文件列表和输出文件列表组成。它还包含远程执行平台的描述,这不在本文的讨论范围之内。有关动作的信息可以编码为协议缓冲区 (https://developers.google.com/protocol-buffers/),它充当动作的指纹。它包含命令、参数和环境变量的摘要,以及来自输入文件的 Merkle 树摘要。Merkle 树的生成方式如下:文件是叶节点,并使用其相应内容进行摘要;目录是树节点,并使用来自其子目录和子文件的摘要进行摘要。Bazel 使用 SHA-256 作为默认哈希函数来计算摘要。

在执行动作之前,Bazel 使用上述过程构造协议缓冲区。然后对缓冲区进行摘要,以查找远程动作缓存,称为动作摘要或动作键。如果命中,结果包含输出文件或输出目录列表及其相应的摘要。Bazel 使用来自 CAS(内容可寻址存储)的文件摘要下载文件内容。从 CAS 中查找输出目录的摘要会得到整个目录树的内容,包括子目录、文件及其相应的摘要。一旦所有输出文件目录都下载完毕,动作就完成了,无需在本地执行。

完成此缓存动作的成本来自输入文件摘要的计算以及查找和传输输出文件的网络往返。此成本通常远低于在本地执行动作。

如果未命中,则在本地执行该动作,并且每个输出文件都上传到 CAS 并按内容摘要索引。标准输出和错误也以类似于文件的方式上传。然后更新动作缓存,以记录输出文件、目录及其摘要的列表。

由于 Bazel 对待构建动作和测试动作的方式相同,因此此机制也适用于运行测试。在这种情况下,测试动作的输入将是测试可执行文件、运行时依赖项和数据文件。

该方案不依赖于增量状态,因为动作由从其直接输入计算出的摘要索引。这意味着一旦缓存被填充,在不同的机器上运行构建或测试将重用所有已计算的输出,只要源文件相同即可。开发人员可以迭代源代码;然后,每次迭代的构建输出都将被缓存并可以重用。

另一个关键的设计要素是,动作缓存和 CAS 中的缓存对象可以独立地被驱逐,因为在缓存未命中或读取其中任何一个时发生错误的情况下,Bazel 将回退到本地执行。缓存对象的数量会随着时间的推移而增长,因为 Bazel 不会主动删除。远程缓存服务负责执行驱逐。

 

远程缓存使用

远程缓存系统涉及两个存储桶:一个存储文件和目录的 CAS,以及一个存储输出文件和目录列表的动作缓存。Bazel 使用 HTTP/1.1 协议 (https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html) 访问这两个存储桶。存储服务需要为每个存储桶支持两种 HTTP 方法:PUT 方法,用于上传二进制 blob 的内容;以及 GET 方法,用于下载二进制 blob 的内容。

使用 Bazel 启用此功能的最直接方法是在 ~/.bazelrc 文件中添加以下示例中的标志

 

build --remote_http_cache=http://build/cache
build --experimental_remote_spawn_cache

 

这启用了远程缓存和本地沙盒执行。

第一个标志 --remote_http_cache 指定了远程缓存服务的 URL。在本示例中,Bazel 使用路径 /ac/(即 http://build/cache/ac)访问动作缓存桶,并使用路径 /cas/(即 http://build/cache/cas)访问 CAS 的存储桶。

第二个标志 --experimental_remote_spawn_cache 启用了对符合条件的动作使用远程缓存,并在缓存未命中时使用沙盒执行。当从存储桶下载或上传到存储桶时,路径的最后一段(也称为 slug)是摘要。

下一个示例显示了 Bazel 可能用于访问缓存服务的两个可能的 URL

 

http://build/cache/cas/cf80cd8aed482d5d1527d7dc72fceff84e6326592848447d2dc0b0e87dfc9a90 http://build/cache/ac/cf80cd8aed482d5d1527d7dc72fceff84e6326592848447d2dc0b0e87dfc9a90

 

为了更精细地控制哪些类型的动作将使用远程缓存而无需本地沙盒执行,您可以使用以下示例中显示的标志。可以通过使用标志 --strategy=<action_name>=remote 来选择单个动作以使用远程缓存服务。

 

build --remote_http_cache=http://build/cache
build --spawn_strategy=remote
build --genrule_strategy=remote
build --strategy=Javac=remote

 

Bazel 的默认行为是从远程缓存读取和写入远程缓存,这允许远程缓存服务的所有用户共享构建和测试输出。此功能已在实践中用于配置相同的机器上的 Bazel 构建,以保证相同且可重用的构建输出。

Bazel 还具有使用 gRPC (gRPC 远程过程调用) 服务访问远程缓存服务的实验性支持。此功能可能会提供更好的性能,但可能没有稳定的 API。Bazel Buildfarm 项目 (https://github.com/bazelbuild/bazel-buildfarm) 实现了此 API。

 

实现缓存服务

HTTP 服务,其支持带有 URL 的 PUT 和 GET 方法,URL 的形式类似于上一节中的第二个示例,可以被 Bazel 用作远程缓存服务。已报告了一些成功的实现。

如果您已经是 Google Cloud Storage (https://cloud.google.com/storage/) 的用户,则它最容易设置。它是完全托管的,并且您根据存储需求和网络流量付费。如果您的开发环境和构建基础设施已经托管在 Google Cloud 中,则此选项提供良好的网络延迟和带宽。如果您的网络受限或构建基础设施不在同一区域,则它可能不是一个好的选择。同样,也可以使用 Amazon S3(简单存储服务;https://aws.amazon.com/s3/)。

对于现场安装,nginx (https://nginx.ac.cn/en/) 和 WebDAV(Web 分布式创作和版本控制)模块 (https://nginx.ac.cn/en/docs/http/ngx_http_dav_module.html) 将是最简单的设置,但如果安装在单台机器上,则缺乏数据复制和其他可靠性属性。

图 1 显示了在 Kubernetes (https://kubernetes.ac.cn/) 中运行的分布式 Hazelcast (https://hazelcast.com/) 缓存服务 (https://hazelcast.com/use-cases/caching/cache-as-a-service/) 的示例系统架构实现。Hazelcast 是在 JVM(Java 虚拟机)中运行的分布式内存缓存。它被用作 CaaS(缓存即服务),并支持 HTTP/1.1 接口。在图中,使用 Kubernetes 部署了 Hazelcast 节点的两个实例,并在集群内配置了异步数据复制。配置了 Kubernetes 服务 (https://kubernetes.ac.cn/docs/concepts/services-networking/service/) 以公开 HTTP 服务的端口,该端口在 Hazelcast 集群内进行负载均衡。通过 JMX(Java 管理扩展)收集有关 JVM 健康状况的访问指标和数据。此示例架构比单机安装更可靠,并且在 QPS(每秒查询数)和存储容量方面易于扩展。

Using Remote Cache Service for Bazel

您还可以实现自己的 HTTP 缓存服务以满足您的需求。为远程缓存服务器实现 gRPC 接口是另一种可能的选择,但 API 仍在开发中。

在缓存服务的所有实现中,考虑缓存驱逐非常重要。动作缓存和 CAS 将无限增长,因为 Bazel 不执行任何删除操作。控制存储占用空间始终是一个好主意。图 1 中的示例 Hazelcast 实现可以配置为使用最近最少使用驱逐策略,并限制缓存对象的数量以及过期策略。用户还报告了随机驱逐和每天清空缓存的成功案例。在任何情况下,记录有关缓存大小和缓存命中率的指标对于微调都很有用。

 

最佳实践

遵循此处概述的最佳实践将避免不正确的结果并最大化缓存命中率。第一个最佳实践是在编写构建规则时不要产生任何副作用。Bazel 非常努力地通过要求用户显式声明任何构建规则的输入文件来确保 hermeticity。当构建规则转换为动作时,输入文件是已知的,并且在执行期间必须存在。默认情况下,动作在沙箱中执行,然后 Bazel 检查是否创建了所有声明的输出文件。但是,您仍然可以使用 genrule 或用 Skylark 语言 (https://docs.bazel.build/versions/master/skylark/language.html) 编写的自定义动作来编写具有副作用的构建规则,Skylark 语言用于扩展。一个示例是写入临时目录并在后续动作中使用临时文件。未声明的副作用将不会被缓存,并且可能会导致不稳定的构建失败,无论是否使用远程缓存。

某些内置规则(例如 cc_librarycc_binary)对系统上安装的工具链和系统库具有隐式依赖性。由于它们未显式声明为动作的输入,因此它们未包含在用于查找动作缓存的动作摘要的计算中。这可能会导致重用使用不同编译器或来自不同 CPU 架构编译的目标文件。生成的构建输出可能不正确。

Docker 容器 (https://docker.net.cn/what-container) 可用于确保所有构建 worker 都具有完全相同的系统文件,包括工具链和系统库。或者,您可以将自定义工具链签入到您的代码仓库中,并教导 Bazel 使用它,确保所有用户都拥有相同的文件。然而,后一种方法会带来一定的代价。自定义工具链通常包含数千个文件,例如编译器、链接器、库和许多头文件。所有这些都将被声明为每个 C 和 C++ 动作的输入。为每个编译动作摘要数千个文件在计算上将是昂贵的。即使 Bazel 缓存文件摘要,它也还不够智能到缓存一组文件的 Merkle 树摘要。结果是,Bazel 将为每个编译动作组合数千个摘要,这会增加相当大的延迟。

不可重现的构建动作应相应地标记,以避免被缓存。例如,这对于在二进制文件中放置时间戳很有用,这是一个不应缓存的动作。以下 genrule 示例显示了如何使用 tags 属性来控制缓存行为。它也可以用于控制沙盒和禁用远程执行。

 

genrule(
  name = "timestamp",
  srcs = [],
  outs = ["date.txt"],
  cmd = "date > date.txt",
  tags = ["no-cache"],
)

 

有时,单个用户可能会将错误的数据写入远程缓存,并导致所有人的构建错误。您可以使用下一个示例中显示的标志将 Bazel 限制为对远程缓存的只读访问。远程缓存应仅由托管机器(例如来自持续集成系统的构建 worker)写入。

 

build --remote_upload_local_results=false

 

缓存未命中的常见原因是环境变量,例如 TMPDIR。Bazel 提供了一项功能来标准化用于运行动作的环境变量,例如 PATH。下一个示例显示了 .bazelrc 如何启用此功能

 

build --experimental_strict_action_env

 

未来改进

只需进行少量更改,Bazel 中的远程缓存功能将变得更加擅长提升性能并减少完成构建所需的时间。

 

优化远程缓存

当使用为动作计算的摘要查找远程缓存后发生缓存命中时,Bazel 始终会下载所有输出文件。对于完全缓存构建中的所有中间输出,情况都是如此。对于具有许多中间动作的构建,这会导致大量时间和带宽花费在下载上。

未来的改进将是跳过下载不必要的动作输出。成功查找动作缓存的结果将包含输出文件列表及其相应的内容摘要。此内容摘要列表可用于计算摘要以查找依赖动作。只有当文件是最终构建工件或本地执行动作需要时,才会下载文件。此更改应有助于减少带宽并提高网络连接较弱的客户端的性能。

即使进行了此优化,该方案仍然需要许多网络往返来查找每个动作的动作缓存。对于大型构建图,网络延迟将成为关键路径的主要因素。

Buck 开发了一种克服此问题的技术 (https://buckbuild.com/concept/what_makes_buck_so_fast.html)。它不是使用输入文件的内容摘要来计算每个动作的摘要,而是使用来自相应依赖动作的动作摘要。如果依赖动作输出多个文件,则可以通过组合来自其生成动作的动作摘要和输出文件的路径来唯一标识每个文件。此机制仅需要源文件的内容摘要和动作依赖关系图即可计算整个图中的每个动作摘要。可以批量查询远程缓存服务,从而节省网络往返。

此方案的缺点是,单个源文件中的更改(即使是微不足道的更改,例如更改代码注释)也会使所有依赖项的缓存失效。潜在的解决方案是使用使用两种方法计算的动作摘要来索引动作缓存。

Bazel 中远程缓存实现的另一个缺点是重复计算输入文件的 Merkle 树摘要。所有源文件和中间动作输出的内容摘要已缓存在内存中,但一组输入文件的 Merkle 树摘要未缓存。当每个动作消耗大量输入文件时,此成本变得很明显,这在使用自定义工具链进行 Java 或 C 和 C++ 编译时很常见。此类构建动作的大部分输入文件来自工具链,如果 Merkle 树的某些部分被缓存和重用,将从中受益。

 

本地磁盘缓存

正在进行使用文件系统存储动作缓存和 CAS 对象的开发工作。虽然 Bazel 已经使用磁盘缓存进行增量构建,但此附加缓存存储了曾经生成的所有构建输出,并允许在工作区之间共享。

 

结论

Bazel 是一个积极开发的开源构建和测试系统,旨在提高软件开发中的生产力。它有越来越多的优化措施来提高日常开发任务的性能。

远程缓存服务是一项新的发展,可以显着节省运行构建和测试的时间。它对于大型代码库和任何规模的开发团队特别有用。

 

相关文章

Borg、Omega 和 Kubernetes
从十年来的三个容器管理系统中学到的经验教训
Brendan Burns、Brian Grant、David Oppenheimer、Eric Brewer 和 John Wilkes
https://queue.org.cn/detail.cfm?id=2898444

非阻塞算法和可扩展多核编程
探索基于锁的同步的一些替代方案
Samy Al Bahra
https://queue.org.cn/detail.cfm?id=2492433

解锁并发
使用事务内存的多核编程
Ali-Reza Adl-Tabatabai、Christos Kozyrakis 和 Bratin Saha
https://queue.org.cn/detail.cfm?id=1189288

Alpha Lam 是一位软件工程师。他感兴趣的领域是视频技术和构建系统。最近,他曾在 Two Sigma Investments 工作。他目前在 Google 工作。

版权所有 © 2018,所有者/作者持有。出版权已授权给 。

acmqueue

最初发表于 Queue 第 16 卷,第 4 期
数字图书馆 中评论本文








© 保留所有权利。

© . All rights reserved.