构建系统

MuYusen 于 2023-08-21 发布 本文总阅读量

参考:

构建系统是工程组织最重要的部分之一,因为每个开发者每天可能与其互动数十次或数百次。随着组织的规模不断扩大,构建功能完备的构建系统对于实现开发者工作效率来说必不可少。对于个人开发者来说,直接编译代码非常简单,因此构建系统似乎过于频繁。但在更大的范围内,构建构建系统有助于管理共享依赖项,例如,依赖于代码库的其他部分,或外部资源(如库)。构建系统有助于确保您在开始构建代码之前具备构建代码所需的一切。构建系统在设置后可以加快速度,帮助工程师共享资源和结果。

为什么选择构建系统

什么是构建系统、它们的用途,为什么您应该使用构建系统,以及为什么编译器和构建脚本不是您的组织开始扩展时的最佳选择。

什么是构建系统

从本质上讲,所有构建系统都具有直接目的:它们将工程师编写的源代码转换为可由机器读取的可执行二进制文件。构建系统不仅仅适用于人工编写的代码,还允许机器自动创建 build(无论是用于测试还是用于发布正式版)。在拥有数千名工程师的组织中,通常大部分构建是自动触发的,而不是直接由工程师触发的。

不能直接使用编译器吗

对构建系统的需求可能不那么显而易见。大多数工程师在学习编码时不会使用构建系统:大多数工程师会先直接从命令行调用 gccjavac 等工具,或在集成开发环境 (IDE) 中调用同类工具。只要所有源代码都位于同一目录中,就可以使用以下命令:

javac *.java

此命令指示 Java 编译器获取当前目录中的每个 Java 源文件并将其转换为二进制类文件。最简单的方式就是使用它。

不过,一旦代码扩展,复杂功能就会开始。javac 非常智能,可以在当前目录的子目录中查找要导入的代码。但是,它找不到存储在文件系统的其他部分(可能是由多个项目共享的库)的代码。也只知道如何构建 Java 代码。大型系统通常涉及使用各种编程语言编写的不同部分,并且这些部分之间具有网络依赖项,这意味着,单一语言的编译器可能无法构建整个系统。

在处理来自多种语言或多个编译单元的代码后,构建代码就不再是一步式流程了。现在,您必须评估代码所依赖的内容,并以正确的顺序构建这些部分,可能针对每段代码使用不同的工具集。如果有任何依赖项发生更改,您必须重复此过程,以免依赖于过时的二进制文件。对于具有中等规模的代码库,此过程很快就会变得繁琐而容易出错。

编译器也不知道如何处理外部依赖项,例如 Java 中的第三方 JAR 文件。如果没有构建系统,您可以通过从互联网下载依赖项并将其粘贴在硬盘上的 lib 文件夹中,然后将编译器配置为从该目录中读取库来管理。随着时间的推移,这些外部依赖项的更新、版本和源代码很难维护。

那 Shell 脚本呢

假设您的爱好项目最初足够简单,您只需使用编译器就可以构建它,但您开始遇到前面所述的一些问题。也许您仍然认为自己不需要构建系统,并且可以使用一些简单的 shell 脚本自动处理繁琐的部分,这些脚本负责按照正确的顺序构建内容。这种做法有一段时间会解决问题,但很快您就会遇到更多问题:

您遇到了常见的扩容问题。如果某个开发者至多花费了一两周时间来编写多达几百行代码(到目前为止可能是刚从大学毕业的初级开发者)。而脚本可能会让您更进一步。但是,一旦您需要协调多个开发者及其机器,就算再有完美的构建脚本也是远远不够的,因为它很难区分这些机器中的细微差别。此时,这种简单的方法已经失效,是时候投资部署真正的构建系统了。

基于任务的构建系统

继 shell 脚本之后,基于任务的构建系统是构建的新逻辑演变。

了解基于任务的构建系统

在基于任务的构建系统中,基本的工作单元是任务。每个任务都是可以执行任何类型的逻辑的脚本,而任务将其他任务指定为必须在其之前运行的依赖项。目前使用的主要大多数构建系统(例如 Ant、Maven、Gradle、Grunt 和 Rake)都基于任务。大多数现代构建系统都要求工程师创建描述如何执行构建的 build 文件,而不是 shell 脚本。

参考 Ant 手册中的以下示例:

<project name="MyProject" default="dist" basedir=".">
   <description>
     simple example build file
   </description>
   <!-- set global properties for this build -->
   <property name="src" location="src"/>
   <property name="build" location="build"/>
   <property name="dist" location="dist"/>

   <target name="init">
     <!-- Create the time stamp -->
     <tstamp/>
     <!-- Create the build directory structure used by compile -->
     <mkdir dir="${build}"/>
   </target>
   <target name="compile" depends="init"
       description="compile the source">
     <!-- Compile the Java code from ${src} into ${build} -->
     <javac srcdir="${src}" destdir="${build}"/>
   </target>
   <target name="dist" depends="compile"
       description="generate the distribution">
     <!-- Create the distribution directory -->
     <mkdir dir="${dist}/lib"/>
     <!-- Put everything in ${build} into the MyProject-${DSTAMP}.jar file -->
     <jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
   </target>
   <target name="clean"
       description="clean up">
     <!-- Delete the ${build} and ${dist} directory trees -->
     <delete dir="${build}"/>
     <delete dir="${dist}"/>
   </target>
</project>

buildfile 采用 XML 格式编写,用于定义一些关于 build 的简单元数据以及任务列表(XML 中的 <target>标记)。(Ant 使用词 target 来表示任务,它使用词 task 来指命令。)每个任务都会执行由 Ant 定义的一系列可能命令,包括创建和删除目录、运行 javac 以及创建 JAR 文件。用户提供的插件可以扩展这组命令,使其涵盖任何类型的逻辑。每个任务也可以通过依赖项属性定义其所依赖的任务。这些依赖关系形成一个循环图,如图 1 所示。

图 1. 显示依赖关系的非循环图

用户通过向 Ant 命令行工具提供任务来执行构建。例如,当用户输入 ant dist 时,Ant 会执行以下步骤:

最后,Ant 在运行 dist 任务时执行的代码等同于以下 shell 脚本:

./createTimestamp.sh
mkdir build/
javac src/* -d build/
mkdir -p dist/lib/
jar cf dist/lib/MyProject-$(date --iso-8601).jar build/*

当语法被去掉后,buildfile 和 build 脚本实际上并没有太大区别。但我们已经通过这样做收获了很多。我们可以在其他目录中创建新的 buildfile,并将其链接到一起。我们可以轻松地以复杂的任意方式添加依赖于现有任务的新任务。我们只需将单个任务的名称传递给 ant 命令行工具,该工具即可确定需要运行的所有内容。

蚂蚁是老软件,最初发布于 2000 年。数年来,Maven 和 Gradle 等其他工具在 Ant 上得到了改进,基本上通过添加外部依赖项自动管理和不含任何 XML 的更简洁语法等功能取代。但这些新系统的性质保持不变:它们允许工程师以有原则的模块化方式编写构建脚本作为任务,并提供用于执行这些任务和管理这些依赖项之间的工具。

基于任务的构建系统的黑暗面

由于这些工具基本上允许工程师将任何脚本定义为一项任务,因此它们功能非常强大,让您能够使用它们执行几乎任何您能想到的操作。但是,这种功能也存在缺点,随着构建脚本变得越来越复杂,基于任务的构建系统可能会变得难以使用此类系统的问题在于,它们最终的实际结果是给工程师带来了太多的电力,使系统没有足够的电(The problem with such systems is that they actually end up giving too much power to engineers and not enough power to the system.)。由于系统不知道脚本在做什么,因此性能会受到影响,因为它在调度和执行构建步骤的方式方面必须非常保守。系统无法确认每个脚本是否按预期运行,因此脚本往往会越来越复杂,最终是需要调试的另一个方面。

并行执行构建步骤有难度

现代开发工作站具有非常强大的功能,有多个核心能够并行执行多个构建步骤。但是,基于任务的系统通常无法并行执行任务,即使看起来应该能够并行执行。假设任务 A 依赖于任务 B 和 C。由于任务 B 和 C 互不依赖,因此是否可以安全地同时运行这些任务,以便系统能够更快地完成任务 A?也许他们没有 接触到任何相同的资源但也许不会 - 可能两者都使用同一文件来跟踪其状态,而同时运行它们会导致冲突。系统一般无法获知这些冲突,因此要么面临这些冲突的风险(导致罕见但难以调试的构建问题),要么必须限制整个构建过程在单个进程中的单个线程上运行。 这可能会对强大的开发者机器产生巨大浪费,而且完全排除了将 build 分发到多台机器的可能性。

难以执行增量构建

良好的构建系统允许工程师执行可靠的增量构建,使细微更改不需要从头开始重新构建整个代码库。如果构建系统速度慢并且无法出于上述原因并行执行构建步骤,这一点尤为重要。但遗憾的是,基于任务的构建系统也存在问题。由于任务可以执行任何操作,因此通常无法检查是否已完成。许多任务只需获取一组源文件并运行编译器以创建一组二进制文件;因此,如果底层源文件未更改,则不需要重新运行这些二进制文件。但是,如果没有其他信息,系统就无法确定 - 可能是任务下载了一个本可能发生改变的文件,也可能是它每次写入的时间戳都可能不同。为了确保正确性,系统通常必须在每次构建期间重新运行每个任务。一些构建系统会尝试让工程师指定重新运行任务的条件,从而实现增量构建。有时这是可行的,但问题往往比实际要难得多。例如,在 C++ 等允许其他文件直接包含文件的语言中,如果不解析输入来源,就不可能确定必须监控整个文件集的变更。工程师通常最终会采用快捷方式,而这些快捷方式可能会导致出现罕见且令人沮丧的问题,即使在不应使用任务结果的情况下也是如此。如果这种情况经常发生,工程师们就养成了在每个 build 之前运行干净整洁以获得新状态的习惯,完全违背了最初使用增量构建的目的。弄清楚何时需要重新运行任务出人意料地巧妙操作,由机器来负责比人工干预的工作要好。

维护和调试脚本时遇到问题

最后,基于任务的构建系统施加的构建脚本通常很难使用。虽然 build 脚本通常不那么严格,但它们就像构建系统一样,并且很容易被 bug 隐藏。以下是使用基于任务的构建系统时经常会遇到的一些 bug 示例:

此处列出的基于任务的框架中没有通用方法来解决这些性能、正确性或可维护性问题。只要工程师可以编写在构建期间运行的任意代码,系统就无法获得足够的信息来始终快速正确运行构建。为了解决这个问题,我们需要从工程师手中夺取一些权力,并将控制权移交给系统,并将系统角色重新理解为运行工件而不是生成工件。

这种方法促使我们创建了基于工件的构建系统,例如 Blaze 和 Bazel。

基于工件的构建系统

Bazel 是一个基于工件的构建系统。

虽然基于任务的构建系统是构建脚本之上的一大步,但它们让各个工程师能够定义自己的任务,从而为其赋予过多的权力。

基于工件的构建系统具有少量由系统定义的任务,工程师可以通过有限方式配置这些任务。工程师仍会告知系统要构建的内容,但构建系统会决定如何构建。与基于任务的构建系统一样,基于工件的构建系统(例如 Bazel)仍具有构建文件,但这些构建文件的内容大不相同。Bazel 中的 buildfile 不是包含 Turing 完整脚本语言的一组命令式命令文件,而是用来描述一组要构建的工件及其依赖项,以及一组影响其构建方式的有限选项。工程师在命令行上运行 bazel 时,需要指定一组要构建的目标(目标),Bazel 负责配置、运行和安排编译步骤(方式)。由于构建系统现在对何时运行哪些工具具有完全控制权,因此它可以做出更强的保证,可以显著提高效率,同时保证正确性。

功能视角

您可以轻松地基于工件的构建系统与功能编程进行类比。传统的命令式编程语言(例如 Java、C 和 Python)会逐个指定要执行的语句列表,其方式与基于任务的构建系统允许程序员定义一系列要执行的步骤相同。相比之下,函数编程语言(例如 Haskell 和 ML)的结构更像是一系列数学方程式。在功能语言中,程序员描述要执行的计算,但会将计算的执行时间和方式的详细信息留给编译器。

这对应于在基于工件的构建系统中声明清单并让系统确定如何执行构建这一想法。使用功能编程无法轻松表达许多问题,但能够从中受益很大的问题:语言通常能够平行地对此类程序进行并行处理,并强行保证它们在命令式语言中无法实现。使用函数式编程表达的最简单的问题是,使用一系列规则或函数简单地将一段数据转换为另一块数据的问题。这正好是构建系统:整个系统实际上是一个数学函数,它将源文件(和编译器等工具)作为输入,并生成二进制文件作为输出。因此,基于功能编程的原则构建构建系统的效果也就不足为奇了。

了解基于工件的构建系统

Google 的构建系统 Blaze 是第一个基于工件的构建系统。Bazel 是 Blaze 的开源版本

build 文件(通常名为 BUILD)在 Bazel 中的如下所示:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

在 Bazel 中,BUILD 文件定义了目标,这里的两种目标为 java_binary 和 java_library。每个目标都对应于一个系统可以创建的工件:二进制目标会生成可直接执行的二进制文件,而库目标会生成可由二进制文件或其他库使用的库。每个目标都有:

依赖项可以位于同一软件包中(例如 MyBinary:mylib 的依赖项),也可以位于同一源代码层次结构中的其他软件包(例如 mylib//java/com/example/common 的依赖项)。

与基于任务的构建系统一样,您使用 Bazel 的命令行工具执行构建。如需构建 MyBinary 目标,请运行 bazel build :MyBinary。在干净的代码库中首次输入该命令后,Bazel:

从根本上说,此处发生的事件似乎与使用基于任务的构建系统时发生的情况有很大不同。事实上,最终结果是相同的二进制文件,生成它的过程涉及分析一系列步骤以找到它们之间的依赖关系,然后按顺序运行这些步骤。但两者之间存在严重差异。第一个步骤出现在第 3 步中:由于 Bazel 知道每个目标只生成一个 Java 库,因此它知道它只需运行 Java 编译器而不是任意用户定义的脚本,因此知道可以并行运行这些步骤。与在多核机器上一次构建一个目标相比,这样可提高一个数量级的性能,而这只能因为基于工件的方法让构建系统负责自己的执行策略,从而可以更好地保证并行性。

不过,优势不仅仅是并行性。当开发者第二次输入 bazel build :MyBinary 而未进行任何更改时,这种方法会让我们显得很明显:Bazel 将在不到一秒内退出,并显示一条消息,说明目标已是最新状态。这是可能的,因为我们之前讨论过函数编程范例 - Bazel 知道每个目标只是运行 Java 编译器的结果,也知道 Java 编译器的输出只依赖于其输入,因此只要输入未更改,就可以重复使用该输出。此分析适用于每个级别;如果 MyBinary.java 发生变化,Bazel 知道要重新构建 MyBinary 但会重复使用 mylib。如果 //java/com/example/common 的源文件发生更改,Bazel 知道要重新构建该库、mylibMyBinary,但会重复使用 //java/com/example/myproduct/otherlib。由于 Bazel 了解其每一步运行的工具的属性,因此它每次都只能重新构建一组最低的工件,同时又保证它不会生成过时的 build。

从工件而不是任务重新构建构建过程既微妙又强大。通过降低向程序员提供的灵活性,构建系统可以详细了解构建过程中每个步骤执行的操作。它可以运用这些信息,通过并行构建流程和重复使用其输出来提高构建效率。但这只是第一步,这些并行处理和重用构建构成了分布式且高度可扩缩的构建系统的基础。

其他巧妙的 Bazel 技巧

基于工件的构建系统从根本上解决了基于任务的构建系统固有的并行处理和重用问题。但是,我们之前仍存在一些我们尚未解决的问题。Bazel 可以巧妙地解决这些问题,在进行后续操作之前,我们应该先讨论这些问题。

将工具作为依赖项

我们之前遇到的一个问题是,构建依赖于我们机器上安装的工具,由于不同的工具版本或位置,跨系统重现 build 可能很困难。如果您的项目使用的语言需要基于构建或编译的目标平台(例如,Windows 与 Linux)而需要不同的工具,并且其中每个平台需要一组略有不同的工具来执行相同的任务,那么问题变得更加困难。

Bazel 通过将工具视为对每个目标的依赖项来解决这个问题的第一部分。工作区中的每个 java_library 都隐式依赖于一个 Java 编译器,该编译器默认采用知名的编译器。每当 Bazel 构建 java_library 时,它都会检查以确保指定的编译器在已知位置可用。与任何其他依赖项一样,如果 Java 编译器发生更改,则依赖于它的所有工件都会重新构建。

Bazel 通过设置构建配置解决了此问题的第二部分,即平台独立性。他们不会直接依赖于工具的目标,而是依赖于配置的类型:

扩展构建系统

Bazel 默认提供针对多种常用编程语言的目标,但工程师始终希望实现更多目标 - 基于任务的系统的优势之一是它们支持任何类型的构建流程,最好不要在基于工件的构建系统中放弃该系统。幸运的是,Bazel 允许通过添加自定义规则来扩展其支持的目标类型。

为了在 Bazel 中定义规则,规则作者声明了规则需要的输入(以 BUILD 文件中传递的属性的形式)和规则生成的固定输出集。作者还定义了该规则将生成的操作。每个操作会声明其输入和输出、运行特定可执行文件或将特定字符串写入文件,并且可以通过其输入和输出连接到其他操作。这意味着,操作是构建系统中最低级别的可组合项单元 - 操作可以随意执行,只要它只使用所声明的输入和输出,Bazel 会负责安排操作并视需要缓存结果。

系统无法万无一失,因为无法阻止操作开发者执行在操作过程中引入非确定性进程等操作。但在实践中,这种情况并不经常发生,将滥用的可能性一直降低至操作级别会大大降低出错的可能性。支持许多常见语言和工具的规则在网上广泛可用,并且大多数项目从不需要定义自己的规则。即使是这样,规则定义也只需要在存储库中的一个集中位置进行定义,这意味着大多数工程师都可以使用这些规则,而无需担心实现问题。

隔离环境

操作听起来可能与其他系统中的任务相同,难道不是依然可以编写同时写入同一文件并最终相互冲突的操作吗?实际上,Bazel 使用沙盒可以避免这些冲突。在受支持的系统上,每个操作都通过文件系统沙盒与其他所有操作隔离开来。实际上,每项操作只能看到文件系统的受限视图,其中包括它声明的输入及其生成的所有输出。这一机制由 Linux 中的 LXC 等系统强制执行,这也是 Docker 背后所采用的技术。这意味着操作不可能相互冲突,因为它们无法读取它们未声明的任何文件,并且它们已写入但未声明的任何文件都将在操作完成时被舍弃。Bazel 还使用沙盒来限制操作通过网络进行通信。

使外部依赖项具有确定性

仍然有一个问题:构建系统经常需要从外部来源下载依赖项(无论是工具还是库),而不是直接构建它们。在本例中,这可以通过 @com_google_common_guava_guava//jar 依赖项查看,该依赖项会从 Maven 下载 JAR 文件。

根据当前工作区以外的文件存在风险。这些文件可以随时更改,可能需要构建系统持续检查文件是否最新。如果某个远程文件在工作区源代码中没有相应的更改的情况下发生更改,则也可能会导致无法重现的 build - build 可能某天能正常运行,但由于没有明确原因所引起的依赖项变更而失败。最后,当外部拥有归第三方所有时,外部依赖项可能会带来巨大的安全风险:如果攻击者能够入侵该第三方服务器,他们可以用自己的设计替换依赖项文件,从而可能完全控制您的构建环境及其输出。

根本问题是,我们希望构建系统了解这些文件,而不将这些文件签入源代码控制系统。更新依赖项应该是一个明智的选择,但该选择应该在中央位置进行一次,而不是由各个工程师管理或由系统自动管理。这是因为即使采用“实时模式”模型,我们仍然希望构建具有确定性,这意味着,如果您查看上周的提交,应该会看到依赖项的状态,而不是它们现在的状态。

Bazel 和一些其他构建系统通过以下方法解决这个问题:需要一个工作区清单文件,其中要列出工作区中的每个外部依赖项的加密哈希。哈希是一种简洁的方式来唯一标识文件,而无需将整个文件签入源代码控件。每当从工作区引用新的外部依赖项时,可通过手动或自动方式将该依赖项的哈希添加到清单中。当 Bazel 运行构建时,它会对照清单中定义的预期哈希值检查其缓存依赖项的实际哈希值,并仅在哈希值不同时重新下载文件。

如果我们下载的工件中的哈希值与清单中声明的哈希值不同,除非更新哈希值,否则构建将会失败。此操作可以自动完成,但这项更改必须获得批准并签入源代码控制系统,然后构建才会接受新的依赖项。这意味着,系统始终都会记录依赖项的更新时间,并且如果没有工作区来源的相应更改,外部依赖项便无法更改。这也意味着,在签出旧版本的源代码时,build 一定会使用与其签入到该版本时所使用的相同依赖项(否则,如果这些依赖项不再可用,build 就会失败)。

当然,如果远程服务器不可用或开始提供损坏的数据,则可能仍然是一个问题 - 如果您没有可用的该依赖项的其他副本,则可能会导致所有构建开始失败。为避免出现此问题,对于任何重要项目,您都需要将它的所有依赖项镜像到您信任和控制的服务器或服务。否则,即使签入的哈希保证了构建的安全性,对于第三方的可用性来说,您还是始终受第三方的制约。

分布式构建

如果您的代码库很大,依赖项链会变得非常深层。即使是简单的二进制文件也往往可以依赖数万个构建目标。在这种规模下,不可能在一台计算机上以合理的时间完成一项构建:任何构建系统都无法避免对机器硬件施加的基本物理法则。实现此目的的唯一方法是使用支持分布式构建的构建系统,在这种模式下,系统的工作单元分布在任意数量和可伸缩的机器上。假设我们已将系统的工作拆分为足够小的单元(稍后会详细介绍),这将使我们能够在我们愿意付费的情况下,尽快完成任何规模的任何构建。这种可伸缩性是我们通过定义基于工件的构建系统而逐渐实现的目标。

远程缓存

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

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

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

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

当然,为了获得远程缓存带来的任何好处,下载工件的速度必须快于构建工件的速度。这并不总是如此,尤其是在缓存服务器远离执行构建的机器时。我们精心调整了 Google 的网络和构建系统,以便能够快速分享构建结果。

远程执行

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

图 3. 远程执行系统

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

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

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

Google 的分布式 build

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

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

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

Google 的远程执行系统名为“Forge”,Blaze 中名为 Distributor 的 Forge 客户端(Bazel 内部等效项)将每项操作的请求发送到我们的数据中心内运行的作业程序(名为“调度器”)。调度程序会维护操作结果的缓存,以允许在系统的任何其他用户创建该操作后立即返回响应。否则,会将操作放入队列。大量执行器作业会持续读取此队列中的操作,执行这些操作,并将结果直接存储在 ObjFS Bigtable 中。这些结果可供执行程序在未来操作中使用,也可供最终用户通过 objfsd 下载。

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

依赖项管理

管理您自己的代码相当简单,但管理其依赖项要困难得多。依赖项有各种类型:有时依赖于任务(例如“在将版本标记为完成之前推送文档”),有时依赖于工件(例如“我需要有最新版本的计算机视觉库才能构建我的代码”)。有时,您的代码库具有其他部分(或某个代码位于您的外部组织中,或拥有某个组织内部的第三方代码)。但无论如何,“我需要先了解这项功能”这一想法在构建系统的设计中反复出现,而管理依赖项可能是构建系统最基本的工作。

处理模块和依赖项

使用基于工件的构建系统(例如 Bazel)的项目已拆分为一组模块,模块通过 BUILD 文件相互表示依赖项。正确组织这些模块和依赖项可能会对构建系统的性能和维持工作量产生巨大影响。

使用精细模块和 1:1:1 规则

在设计基于工件的 build 时,第一个问题是决定单个模块应包含多少功能。在 Bazel 中,模块由目标来指定可构建单元(例如 java_library 或 go_binary)。一种极端方法是将整个项目包含在一个模块中,方法是在根目录中放置一个 BUILD 文件,然后以递归方式将该项目的所有源文件一起连接起来。反之,几乎每个源文件都可以构建到自己的模块中,这实际上要求每个文件都列在 BUILD 文件中所依赖的所有其他文件中。

大多数项目都处于这些极端之间,选择需要在性能和可维护性之间进行权衡。为整个项目使用单个模块可能意味着您永远无需更改 BUILD 文件(添加外部依赖项时除外),但这意味着构建系统必须始终一次性构建整个项目。这意味着,它将无法并行化或分发 build 的某些部分,也无法缓存已构建的相应部分。反过来,每个文件一个模块:构建系统在缓存和调度构建步骤方面具有极大的灵活性,但工程师在每次更改哪个文件引用哪些依赖项时都需要花费更多精力来维护依赖项列表。

虽然确切的粒度因语言(通常甚至在语言内)而异,但 Google 倾向于倾向于比基于任务的构建系统中通常编写的模块小得多的模块。Google 的典型生产二进制文件通常依赖于数万个目标,即使是中等规模的团队也可以在其代码库中拥有数百个目标。对于像 Java 这样的具有强烈内置打包概念的语言,每个目录通常只包含一个软件包、目标和 BUILD 文件(Pants,一个基于 Bazel 的构建系统,我们称之为 1:1:1 规则)。打包惯例较弱的语言经常为每个 BUILD 文件定义多个目标。

较小 build 目标的优势确实开始显现,因为它们可以带来更快的分布式 build,且重新构建目标的频率较低。 在测试进入测试阶段后,优势会更具吸引力,因为更精细的目标意味着构建系统可以更智能地运行可能仅受任何给定更改影响的一小部分测试。由于 Google 相信使用较小的目标可以实现系统性优势,因此我们已在降低负面影响方面取得了一些进展,具体是投资于相关工具来自动管理 BUILD 文件,以免给开发者带来负担。

其中一些工具(例如 buildifier 和 buildozer)在 buildtools 目录中可用于 Bazel。

最大限度地降低模块可见性

Bazel 和其他构建系统允许每个目标指定一个可见性,此属性确定哪些其他目标可能依赖于可见性。专用目标只能在自己的 BUILD 文件中引用。目标可以更明确地显示明确定义的 BUILD 文件列表的目标,在公开公开的情况下,还可以向工作区中的每个目标公开目标。

与大多数编程语言一样,通常最好尽可能减少可见性。通常,只有当这些目标代表可供 Google 的任何团队使用的广泛库时,Google 的团队才会公开这些目标。如果团队需要其他人协调才能使用代码,那么该团队将维护一个将目标作为目标对象的许可名单。每个团队的内部实现目标将仅限于该团队拥有的目录,并且大多数 BUILD 文件都只有一个非公开的目标。

管理依赖项

各个模块需要相互引用。将代码库拆分为精细控制的缺点是,您需要管理这些模块之间的依赖关系(不过,工具有助于自动执行此过程)。表达这些依赖项通常只是 BUILD 文件中的大部分内容。

内部依赖项

在被分解为精细模块的大型项目中,大多数依赖项可能都是内部依赖项;也就是说,对同一源代码库中定义和构建的另一个目标而言是依赖项。内部依赖项与外部依赖项的区别在于,它们是基于源代码构建的,而不是在运行 build 时作为预构建工件下载的。这也意味着,内部依赖项没有“版本”的概念 - 目标及其所有内部依赖项始终在代码库中的同一提交/修订版本上构建。就内部依赖项而言,应谨慎处理一个问题,即如何处理传递依赖项(图 1)。假设目标 A 依赖于目标 B,而后者又依赖于通用库目标 C。目标 A 是否应该使用目标 C 中定义的类?

传递依赖项

就底层工具而言,这没有问题;B 和 C 在构建时将链接到目标 A,因此 C 中定义的任何符号都由 A 知晓。多年来,Bazel 一直允许这种做法,但随着 Google 不断发展,我们开始发现问题。假设 B 已经过重构,不再需要依赖于 C。如果之后 B 对 C 的依赖关系被移除,A 和通过 C 的依赖项使用 C 的任何其他目标都会发生中断。实际上,目标的依赖项已成为其公共协定的一部分,并且永远无法安全更改。这意味着,随着时间的推移,依赖项的积累和 Google 的构建速度开始变慢。

Google 最终通过在 Bazel 中引入“严格传递依赖项模式”解决了这个问题。在此模式下,Bazel 会检测目标是否尝试在不直接依赖于符号的情况下引用该符号,如果是,则失败,并显示可用于自动插入依赖项的 shell 命令。在 Google 的整个代码库中发布这项变更,并对数百万个构建目标进行重构,以明确列出其依赖项是一项需要多年努力的工作,但非常值得。鉴于目标中不必要的依赖项更少,我们的构建速度现在大大提高,并且工程师能够移除不需要的依赖项,而不必担心会破坏依赖于它们的目标。

和以往一样,强制执行严格的传递依赖项时需要做出权衡。这会使构建文件更加详细,因为常用库现在需要在许多位置明确列出,而不是意外拉取,并且工程师需要花费更多精力来向 BUILD 文件添加依赖项。此后,我们开发了一些工具,可以自动检测许多缺失的依赖项并将其添加到 BUILD 文件中,而无需任何开发者干预,从而减少此类重复劳动。但即使没有此类工具,我们发现这种权衡是值得的,因为代码库会扩缩:将依赖项明确添加到 BUILD 文件中是一次性成本,但只要存在构建目标,处理隐式传递依赖项就可能导致持续出现问题。默认情况下,Bazel 对 Java 代码强制执行严格的传递依赖项。

外部依赖项

如果依赖项不是内部的,则必须是外部的。外部依赖项是指在构建系统之外构建和存储的工件。依赖项直接从工件代码库导入(通常通过互联网访问),并按原样使用,而不是从源代码构建。外部依赖项与内部依赖项之间的一个最大区别在于外部依赖项具有版本,并且这些版本独立于项目的源代码而存在。

自动依赖项管理与手动依赖项管理

构建系统可以允许手动或自动管理外部依赖项的版本。手动管理时,buildfile 会明确列出要从工件代码库下载的版本(通常使用 1.1.4 等语义版本字符串)。自动管理时,源文件会指定一系列可接受的版本,并且构建系统始终会下载最新版本。例如,Gradle 允许将依赖项版本声明为“1.+”,以指定只要主要版本为 1,依赖项的任何次要版本或补丁程序版本都可以接受。

自动管理的依赖项对小型项目来说很方便,但对于大型项目或由多个工程师处理的项目而言,它们通常是灾难的配方。自动管理的依赖项存在一个问题,那就是您无法控制版本何时更新。无法保证外部方不会进行重大更新(即使它们声称使用语义版本控制),因此一天运行的 build 可能会在第二天发生故障,而无法轻松检测更改的内容或将其回滚到正常运行状态。即使构建不中断,也可能出现无法跟踪的细微行为或性能变化。

相反,由于手动管理的依赖项需要更改源代码控制,因此可以轻松发现和回滚这些依赖项,并且可以签出旧版代码库以使用旧版依赖项进行构建。Bazel 要求手动指定所有依赖项的版本。即使规模适中,手动版本管理的开销也足以实现其稳定性。

单一版本规则

库的不同版本通常由不同的工件表示,因此理论上,不能出于不同的原因在构建系统中以不同的名称声明同一个外部依赖项的不同版本。这样,每个目标都可以选择要使用的依赖项版本。这会在实践中导致许多问题,因此 Google 会对代码库中的所有第三方依赖项强制执行严格的单版本规则。

允许多个版本的最大问题是菱形依赖项问题。假设目标 A 依赖于目标 B 和外部库的 v1。如果目标 B 之后被重构以添加对同一外部库 v2 的依赖项,则目标 A 将发生破坏,因为它现在隐式依赖于同一库的两个不同版本。实际上,将任何目标中的新依赖项添加到任何具有多个版本的第三方库并不安全,因为此目标的任何用户可能已依赖于其他版本。遵循 One-Version 规则可防止出现此冲突 - 如果目标添加对第三方库的依赖项,则任何现有依赖项都将已在同一版本上,因此可以愉快地共存。

传递外部依赖项

处理外部依赖项的传递依赖项可能特别困难。许多工件代码库(如 Maven Central)允许工件指定该代码库中其他工件的特定版本的依赖项。默认情况下,Maven 或 Gradle 等构建工具通常以递归方式下载每个传递依赖项,这意味着在项目中添加一个依赖项可能会导致总共下载几十个工件。

这样非常方便:在添加新库的依赖项时,必须跟踪该库的每个传递依赖项并手动添加所有依赖项,非常麻烦。但也有一个很大的缺点:由于不同的库可以依赖于同一第三方库的不同版本,因此此策略必然违反了单版本规则并会导致钻石依赖项问题。如果您的目标依赖于使用相同依赖项的不同版本的两个外部库,则不会知道您会获得哪一个。这也意味着,如果新版本开始提取其某些依赖项的冲突版本,更新外部依赖项可能会导致整个代码库中似乎不相关的故障。

因此,Bazel 不会自动下载传递依赖项。遗憾的是,没有什么灵丹妙药,Bazel 的替代方案是,需要一个全局文件,在其中列出存储库的每个外部依赖项,以及整个代码库中用于该依赖项的显式版本。幸运的是,Bazel 提供了一些工具,能够自动生成此类文件,其中包含一组 Maven 工件的传递依赖项。您可以运行该工具一次,为项目生成初始 WORKSPACE 文件,然后可以手动更新该文件以调整每个依赖项的版本。

同样,这里的选择是在便利性和可伸缩性之间进行选择。小项目可能不希望自己自行管理传递依赖项,或许可以使用自动传递依赖项来解决问题。随着组织和代码库的发展,这种策略会变得越来越有吸引力,冲突和意外结果也越来越频繁。在更大的范围内,手动管理依赖项的费用远低于处理由自动依赖项管理导致的问题的费用。

使用外部依赖项缓存构建结果

外部依赖项通常由发布稳定版本库的第三方提供,或许不提供源代码。一些组织可能也会选择将自己的部分代码作为工件提供,从而允许其他代码依赖它们作为第三方而不是内部依赖项。从理论上讲,如果工件的构建速度较慢,但下载速度较快,则可以加快构建速度。

但是,这也会带来很大的开销和复杂性:有人需要负责构建每个工件并将其上传到工件代码库,而客户端需要确保这些工件是最新版本。调试也会变得非常困难,因为系统将从代码库中的不同点构建系统的不同部分,并且源代码树不再一致。

如需解决工件需要很长时间进行构建的问题,更好的方法是使用支持远程缓存的构建系统,如前所述。此类构建系统会将每次构建生成的工件保存到在工程师之间共享的位置,因此,如果开发者依赖于最近他人构建的工件,构建系统会自动下载该工件,而不是进行构建。这样可以提供直接依赖于工件的所有性能优势,同时仍可确保 build 与始终从同一源代码构建时一样一致。这是 Google 在内部使用的策略,您可以将 Bazel 配置为使用远程缓存。

外部依赖项的安全性和可靠性

依赖于来自第三方来源的工件本身就存在风险。如果第三方来源(例如工件代码库)出现服务中断情况,则会面临可用性风险,因为如果无法下载外部依赖项,您的整个 build 可能会停止运行。这也存在安全风险:如果第三方系统遭到攻击者入侵,攻击者可以将引用的工件替换为自己的设计,从而将任意代码注入到您的 build。通过将您依赖的所有工件镜像到您控制的服务器,并阻止您的构建系统访问第三方工件代码库(如 Maven Central),可以缓解这两个问题。需要权衡的是,这些镜像需要花费精力和资源来维护,因此是否使用它们通常取决于项目的规模。通过要求在源代码库中指定每个第三方工件的哈希,导致构建失败,如果该工件遭到篡改,也可以完全避免安全问题。另一种完全规避问题的替代方法是提供项目的依赖项。当项目提供其依赖项时,它会将项目与源代码一起签入源代码控制系统(作为源代码或二进制文件)。这实际上意味着项目的所有外部依赖项都会转换为内部依赖项。Google 在内部使用此方法,将整个 Google 中引用的每个第三方库检查到 Google 源代码树的根目录下的 third_party 目录中。但是,这仅适用于 Google,因为 Google 的源代码控制系统是专为处理超大单声道代码库而定制的,因此并非所有组织都可以选择供应商。

封闭性

概览

如果给出相同的输入源代码和产品配置,封闭构建系统始终都会隔离 build 与主机系统的更改,从而返回相同的输出。

为了隔离构建,封闭构建对安装在本地或远程主机上的库和其他软件不敏感。它们依赖于构建工具的特定版本(例如编译器)和依赖项(例如库)。这会使构建流程变得独立,因为它不依赖于构建环境之外的服务。

封闭性的两个重要方面是:

优势

封闭构建的主要优势包括:

识别非封闭性

如果您准备改用 Bazel,请提前改善现有构建的封闭性,从而更轻松地进行迁移。build 中的非封闭性的一些常见来源包括:

排查非封闭式构建的问题

从本地执行开始,影响本地缓存命中的问题会显示非封闭的操作。