作者: Brie Bunge 以及 Sharmila Jesupaul
介绍.在 Airbnb,我们最近引入了 Google 的开源构建工具 Bazel,作为我们后端、Web 和 iOS 平台的统一构建系统。本文将讲述我们为 Airbnb 大规模(超过 1100 万行代码)Web 单一代码仓库采用 Bazel 的经验。我们将分享我们如何准备代码库、迁移的指导原则以及迁移选定的 CI 任务的过程。我们的目标是分享在我们开始这段旅程时会对我们有用的信息,并为围绕 Bazel 在 Web 开发中的讨论做出贡献。
我们为什么这么干?在过去,我们为各种CI任务编写了自定义构建脚本和缓存逻辑,这些任务维护困难且随着仓库的增长不断遇到扩展瓶颈。例如,我们的代码检查器ESLint和TypeScript的类型检查不支持原生多线程并发。我们扩展了单元测试工具Jest,使其成为这些工具的运行工具,因为它支持利用多个工人的API。
这样是不可持续的,我们为工具的低效寻找变通,这些工具并不支持并发,而且我们正面临着长期的维护成本。为了应对这些挑战,并更好地支持我们不断增长的代码库,Bazel 的强大而复杂的功能,性能、并行和缓存满足了我们的需求。
此外,Bazel 是一种与语言无关的构建工具。这促进了Airbnb向单一通用构建系统的整合,并让我们能够共享通用的基础设施和专业知识。现在,一个在我们后端单代码库工作的工程师可以切换到网页单代码库,仍然知道如何构建和测试。
这为什么这么难?2021年当我们开始迁移时,行业内没有公开的先例可供参考,尤其是在大型网站中集成Bazel方面。开源工具无法直接使用,需要额外配置,而利用远程构建执行(RBE)则引入了更多的挑战。我们的网站代码库庞大,包含大量零散文件,这在将这些文件传输到远程环境时导致了性能问题。此外,我们还制定了迁移原则,包括提升或保持整体性能,以及在迁移到单代码库的过程中减少对开发人员的影响,特别是在过渡期间。我们成功地实现了这两个目标,即提升或保持整体性能以及减少对贡献代码库的开发人员的影响。请继续阅读以获取更多详细信息。
准备仓库环节我们事先做了一些准备工作,使仓库变得适合Bazel——主要是打破循环依赖以及自动生成BUILD.bazel文件。
跳出循环我们的单代码库结构是顶层有一个frontend/目录,下面包含各个子项目或模块。最初,我们打算在大约1000个顶层的frontend目录中各自添加BUILD.bazel文件。然而,这样操作会在依赖图中产生循环,而Bazel不允许这样的循环存在。因为构建目标需要形成一个有向无环图,打破这些循环常常感觉就像在与九头蛇战斗,每次移除一个循环,其他更多的循环就会出现。为了加速处理过程,我们将问题建模为寻找最小反馈弧集(MFAS),以识别出移除后能留下DAG的最小边集,这个集合带来的干扰最小,工作量较小,并揭示了问题边的存在。
自动生成的BUILD.bazel(构建文件)我们自动生成 BUILD.bazel 文件的原因有以下几点:
- 大部分内容可以通过静态分析的导入/要求语句来了解。
- 自动化让我们能够快速迭代BUILD.bazel的更改,以进一步完善我们的规则定义。
- 迁移需要时间完成,我们不想在用户尚未从中受益时要求他们保持这些文件的更新。
- 手动保持这些文件的更新将构成额外的负担,从而降低开发者的体验。
我们有一个命令行工具叫做 sync-configs,它可以在单代码仓库中生成基于依赖关系的配置文件,例如 tsconfig.json、项目配置文件和 now BUILD.bazel 文件。它使用了 jest-haste-map 和 watchman,并用一个自定义版本的 dependencyExtractor 工具来确定文件级别的依赖图。并利用 Gazelle 的一部分来生成 BUILD.bazel 文件。这个命令行工具类似于 Gazelle,但还会生成一些特定于 Web 的配置文件,例如用于 TypeScript 编译的 tsconfig.json 文件等。
CI (持续集成) 移植项目准备工作完成后,我们开始将CI作业迁移到Bazel。这是一项巨大的任务,因此我们将工作分为增量里程碑。我们审查了CI任务,并选择迁移那些受益最大的任务:类型检查、代码审查和单元测试。为了减轻开发人员的负担,我们指派中央Web平台团队负责将CI任务迁移到Bazel。我们逐一进行任务迁移,以尽早为开发人员提供增量价值,增强信心,集中精力和力量,并建立动力。对于每个任务,我们确保开发体验优质,性能优化,CI失败可以在本地复现,同时被替换的工具也完全弃用并移除。
使用 TypeScript我们从TypeScript (TS) 的CI任务着手。我们尝试了使用开源的ts_project规则。但由于输入数量庞大,它在与RBE配合时表现不佳,因此,我们编写了一个自定义规则来减少输入的数量和减小其大小。
最大的输入来源是[node_modules]中的文件。在此之前,每个npm包的文件都是单独上传的。因为Bazel与Java配合得很好,我们为每个npm包打包了一个完整的tar以及一个特定于TS的tar(仅包含*.ts和package.json),类似于Java的JAR文件(本质上是zip文件)。
另一个输入来源来自传递依赖关系。沙箱中的一些传递下来的 node_modules
和 d.ts
文件被包括进来,因为从技术上讲,它们可能需要用于后续项目的编译。例如,假设项目 foo
依赖于 bar
,并且 bar
的类型在 foo
的编译输出中被暴露。结果,依赖于 foo
的项目 baz
也需要在沙箱中包含 bar
的输出。对于长的依赖链来说,这会导致输入中包含许多实际上并不需要的文件,使得输入变得臃肿。TypeScript 提供了一个 — listFiles 标志,可以告诉我们哪些文件将被编译。我们可以将这些文件打包成一个 tsc.tar.gz
文件,其中包括准备编译的有限文件集和生成的 d.ts
文件。这样,目标项目只需包括直接依赖项,而不是所有的间接依赖项。
下面的图展示了我们如何使用tars和—listFiles标志来精简:types目标类型的输入/输出。
这个自定义规则解除了切换到 Bazel 处理 TypeScript 的限制,这项任务现在完全符合我们的 CI 运行时预算。
条形图展示了从使用我们自定义的genrule后得到的速度提升。
开启 ESLint 检查我们接下来迁移了ESLint任务。Bazel 最适合那些独立且输入范围窄的动作。我们的某些 lint 规则(例如,特殊内部规则、导入/导出、导入/扩展)会检查 lint 文件以外的文件。为了减少输入大小并只对直接影响的文件进行 lint 检查,我们将 lint 规则限制为那些能够独立运行的规则。这意味着移除了一些 lint 规则(例如,因 TypeScript 而冗余的规则)。因此,我们的 CI 时间减少了超过 70%。
时间序列图显示了仅对直接受到影响的目标运行ESLint时,5月初时的运行速度的提升。
使用 Jest我们的下一个任务是启用Jest,这带来了一些特有的挑战,因为我们需要引入更多的一套内部和外部依赖项,并且需要修复更多的Bazel特定问题。
工人和 Docker 缓存区为了减少输入大小,我们打包了依赖项,但提取仍然很慢。为解决这个问题,我们引入了缓存机制。缓存分为两层,一层在远程工作者上,另一层在工作者的 Docker 容器中,并在构建时固化到 Docker 镜像中。Docker 层的存在是为了避免在自动缩放时丢失缓存。我们每周运行一次 cron 任务来更新 Docker 镜像,使其保持新鲜度同时避免频繁更新。更多详情,请参阅 这次 Bazel 社区日演讲。
显示通过符号链接的npm依赖项到Docker缓存和工作缓存的图示
增加缓存这一措施使我们的 Jest 单元测试 CI 任务整体速度提高了约 25%,并将我们依赖项的提取时间从 1 到 3 分钟缩短到每个目标 3 到 7 秒。为此实现,我们还需要启用 NodeJS 的 preserve-symlinks 选项,并修补了一些工具,使其不再跟随符号链接到实际路径。我们还将此缓存策略扩展到了我们的 Babel 转换缓存,这同样是一个性能瓶颈。
隐含的依赖接下来,我们需要修复特定于 Bazel 的测试失败。大多数这些问题都是由于缺少文件。对于任何无法静态分析的输入,例如作为字符串引用而没有导入的文件,或 .babelrc 中引用的 babel 插件字符串,我们增加了对 Bazel 保留注释的支持(例如,// bazelKeep: path/to/file),其作用相当于该文件已被导入。这种方法的优点是,
- 它与使用该依赖的代码一同存放。
2. 不需要手动编辑 BUILD.bazel 文件来添加或移动注释 # 保持注释
3. 不影响运行时间。
一小部分测试不适合 Bazel,因为它们需要对仓库有更大的视图范围,或者依赖关系是动态且隐式的。我们将这些测试从单元测试任务中移出,放入单独的持续集成测试。
防止倒退有超过20000个测试文件,而且有数百人在同一个仓库里积极工作,我们需要确保测试修复不会因产品开发而被取消。
我们这里有三种CI构建队列:
- 必填项,这会阻止任何更改。
2. “可选”,即非阻塞的,
3. “隐藏的”,即非阻塞的,并且不会出现在PR中。
当我们调整测试时,我们将它们从“隐藏”调整为“必需”,通过一个规则属性进行调整。为了确保单一的真相来源,在 Bazel 中标记为“必需”的测试,在即将被替换的 Jest 设置中没有被运行。
# frontend/app/script/__tests__/BUILD.bazel
jest_test(
name = "jest_test",
is_required = True, # 使此目标成为拉取请求中的必检项
deps = [
":source_library",
],
)
示例 jest_test 规则,这表示此目标将会运行于‘required’构建队列上。
我们编写了一个脚本,通过对比使用Bazel之前和之后的测试运行时间、代码覆盖率统计和失败率来确定迁移的准备情况。幸运的是,大部分测试可以在不作额外修改的情况下直接启用,所以我们分批启用了这些测试。我们与中央团队和Web平台团队合作,分阶段解决了剩余的失败案例,修复并更新了Bazel中的测试脚本和数据,以避免给开发人员增加额外的工作负担。经过一段时间的宽限期后,我们完全禁用了并移除了非Bazel的Jest基础设施,并将is_required参数移除。
本地 Bazel 体验在我们进行CI迁移的同时,我们确保开发人员可以本地运行Bazel来重现和迭代CI失败。我们的迁移原则包括提供不低于现有工具的开发体验和性能。JavaScript工具具有友好的命令行界面体验(例如,监听模式、针对特定文件、丰富的交互性),并集成了开发人员喜欢的IDE集成,我们希望保留这些特性。默认情况下,前端开发人员可以继续使用他们熟悉的工具,在某些情况下,如果更有利于开发,他们可以选择使用Bazel。Bazel与非Bazel间的差异很少见到,当这些差异出现时,开发人员可以找到解决这些问题的方法。例如,开发人员可以运行一个脚本,failed-on-pr,该脚本会重新运行任何导致CI失败的目标,以便轻松重现这些问题。
关于构建失败的注释以及用于重现失败的脚本,例如:yak script jest:failed-on-pr。
我们也对特定平台的二进制文件进行一些标准化处理,以实现在 Linux 和 MacOS 构建之间重用缓存。这通过在本地开发人员的 MacBook 和 CI 系统中的 Linux 机器之间共享缓存,加快了本地开发和 CI 任务的速度。对于原生 npm 包(如node-gyp依赖项),我们排除特定平台的文件,并仅在执行机器上构建包。执行机器就是运行测试或构建过程的机器。我们还使用“通用二进制文件”(比如 node 和 zstd),其中包含所有平台的二进制文件作为输入,这样无论从哪个平台执行操作,输入都保持一致,并在运行时选择合适的二进制文件,例如:选择正确的二进制文件。
结尾
使用 Bazel 处理我们核心的 CI 任务带来了显著的性能提升,包括 TypeScript 类型验证(快 34%)、ESLint 代码格式检查(快 35%),以及 Jest 单元测试的增量运行速度提升 42%,总体提升 29%。此外,随着代码库的变大,我们的 CI 现在可以更好地扩展。
接下来,为了进一步提升 Bazel 的性能,我们将专注于在 CI 运行间持久化预热的 Bazel 主机环境,优化构建图,支持那些不使用 Bazel 的 CI 作业,并可能探索 SquashFS 以进一步压缩和优化 Bazel 沙箱。
我们希望分享我们的历程能为考虑将 web 项目迁移到 Bazel 的组织提供一些有价值的见解。
鸣谢感谢 Madison Capps、Meghan Dow、Matt Insler、Janusz Kudelka、Joe Lencioni、Rae Liu、James Robinson、Joel Snyder、Elliott Sprehn 和 Fanying Ye 以及帮助将 Bazel 引入 Airbnb 的所有内部和外部合作伙伴。
我们也非常感激广泛的Bazel社区的友好和支持,他们乐于分享想法。
*****[3]: 你可以在这里找到一些更新的 TS 规则,可能更适用于你 [https://github.com/aspect-build/rules_ts]。
[4]:我们后来改为使用zstd而不是gzip,因为它压缩效果更好且更稳定,确保tarball在不同平台上的兼容性。
所有产品名称、标识和品牌为其各自所有者的财产。本网站使用的所有公司、产品和服务名称仅用于标识目的。使用这些名称、标识和标志并不意味着推荐或认可。