Rush Stack商店博客活动
跳至主要内容

联合构建 (实验性)

Rush 的 "联合构建" 功能 (协作构建) 提供了一种轻量级的解决方案,用于在多台机器上分配工作。这个想法是对您已经在做的事情的简单扩展:只需在不同机器上生成相同 CI 管道的多个实例,就可以通过 Rush 的 构建缓存 来共享工作。

例如,假设您的作业运行 rush install && rush build,我们在两台机器上启动此命令。如果机器 #1 已经构建了一个项目,那么机器 #2 将跳过该项目,而是从构建缓存中获取结果。这样,构建就会在两个管道之间分配,并且在完美的并行度下,构建可能在半时间内完成。

但是,这个想法有一个缺陷:如果机器 #2 遇到一个机器 #1 已经开始构建但尚未完成的项目会怎么样?这个缓存未命中会导致机器 #2 开始构建同一个项目,而它可能最好是在等待机器 #1 完成该项目的同时进行其他工作。我们可以通过使用一个简单的键值存储来解决这个问题,该存储可以在机器之间进行进度通信。 (在本教程中,我们将使用 Rush 的 Redis 提供商,但是如果您的公司已经托管了一些其他服务,例如 Memcached,那么 实现您自己的提供商 相当容易。)

何时使用联合构建?

如果没有联合构建,Rush 已经在单台机器上并行化您的作业。 (这可能并不立即显而易见,因为 Rush 的输出是 "整理" 的,为了可读性,看起来好像项目是一次构建一个。) 您可以使用 --parallelism 命令行参数来微调最大并行度,但请记住,项目只能在彼此不依赖的情况下并发构建。因此,联合构建只有在您已经达到单台机器的极限 (考虑 cpu 内核、磁盘 I/O 速度和可用内存) 时才会有所帮助。并且只有在您的单体仓库的项目依赖关系图实际上允许进一步的并行化时才会有效。

联合构建功能启动 CI 管道的多个实例,假设机器将随时可用。例如,如果您的联合构建分配了 4 台机器,并且您的机器池有 40 台机器,那么在 10 个拉取请求等待在队列中之前,池竞争不会成为问题。相比之下,一个非常大的单体仓库可能需要数千台机器,这时使用 "构建加速器" (例如 BuildXL) 而不是联合构建会更有意义。 (还计划将 Rush 与 bazel-buildfarm 集成;Bazel 是 Google 的 BuildXL 等价物。) 构建加速器通常需要您将 CI 系统替换为他们自己的集中式作业调度程序,该调度程序管理其自己的专用机器池。这种系统需要大量的维护,并且可能具有更陡峭的学习曲线,因此我们通常建议从联合构建开始。

在采用联合构建之前,我们建议首先考虑更简单的解决方案

  1. 启用构建缓存构建缓存 是联合构建的先决条件。

  2. 识别瓶颈:如果您的单体仓库的依赖关系图实际上不允许大量项目并行构建,那么必须首先解决这个问题,然后再考虑分布式构建。您可以使用 Rush 的 --timeline 参数来识别导致太多项目等待才能开始构建的瓶颈。这些瓶颈可以通过以下方式解决

    • 消除项目之间不必要的依赖关系
    • 引入 Rush 阶段 将构建步骤分解为多个操作
    • 重构代码将大型项目分解为更小的项目
  3. 升级您的硬件:如果您的构建速度很慢,添加更多机器会有所帮助。我们通常建议根据 rush installrush build 的典型行为,为您的计划选择具有最大 RAM 和 CPU 内核数量的高端硬件。但是每个单体仓库都是不同的,因此收集不同硬件配置的基准来帮助您做出决定。加速构建使每个人都更高效;但是,由于硬件升级通常来自与工程工资不同的预算,因此管理层有时可能需要一些帮助才能看到这种联系。

  4. 在运行之间缓存状态:CI 机器通常使用完全干净的机器映像启动 rush install && rush build。缓存可以改善这一点,例如,使用 RUSH_PNPM_STORE_PATH 环境变量将 PNPM 存储重新定位到 CI 系统可以在运行之间保存和恢复的位置,可以提高 rush install 的时间。某些环境允许在多个作业中重复使用同一台机器,以便保留其他 Rush 缓存。

  5. 考虑使用合并队列:如果两个拉取请求正在等待合并,通常 CI 系统将构建 pr1+mainpr2+main 的热合并,以确保每个 PR 分支都在最新的 main 上进行测试。但是,在 pr1+main 合并后,我们通常不会强制 pr2+main 使用新的 main 重新执行;这种缺乏安全性偶尔会导致构建中断。 (例如,假设 pr1 删除了一个 API,但是 pr2 向该 API 添加了另一个调用。) "合并队列" (也称为 "提交队列") 通过构建 pr1+mainpr1+pr2+main 来提高安全性;如果第一个 PR 失败,那么它将使用 pr2+main 重新尝试。高级合并队列支持 "批次",它们直接测试拉取请求的 "列车" pr1+pr2+main,并且只有在失败时才会测试 pr1+main。这可以加快构建速度和/或减少机器竞争,同时仍然保证安全性。GitHub 的 合并队列 在本文撰写时不支持批次,但是 Mergify 第三方服务 实现了批次,并且已在 Rush 中进行过测试。

先决条件

为了使用联合构建功能,您将需要

  • 启用了云存储提供商的 Rush 构建缓存

  • 一个 Redis 服务器。如果您的公司使用其他键值服务,您可以通过遵循 rush-redis-cobuild-plugin 的示例来实现插件。 (并考虑将它贡献回 Rush Stack!)

  • 一个 CI 系统,能够在 CI 管道触发时分配多台机器。例如,使用 GitHub Actions,"工作流" 可以启动多个 "作业",其 "运行器" 是独立的机器。使用 Azure DevOps,"管道" 可以运行多个 "代理" 上的作业,这些代理可以位于不同的机器上。

  • Rush 阶段 建议用于提高并行度,但不是联合构建的必要条件

启用联合构建功能

  1. 将您的 rush.json 中的 rushVersion 升级到 5.104.1 或更高版本。

  2. 为 Rush 插件创建一个自动安装器

    rush init-autoinstaller --name cobuild-plugin

    使用现有自动安装器也可以。有关 Rush 插件和自动安装器的更多信息,请参阅 使用 Rush 插件自动安装器

  3. @rushstack/rush-redis-cobuild-plugin 插件添加到自动安装器。 (在本教程中,我们将使用 Redis。)

    common/autoinstallers/cobuild-plugin/package.json

    {
    "name": "cobuild-plugin",
    "version": "1.0.0",
    "private": true,
    "dependencies": {
    "@rushstack/rush-redis-cobuild-plugin": "5.104.0"
    }
    }

    👉 重要:

    随着时间的推移,请确保将 @rushstack/rush-redis-cobuild-plugin 的版本与您的 rush.json 中的 rushVersion 保持同步。

  4. 更新自动安装器的锁文件

    rush update-autoinstaller --name cobuild-plugin

    # Remember to commit the updated pnpm-lock.yaml file to git
  5. 接下来,我们需要更新 rush-plugins.json 以从我们的 rush-plugins 自动安装器加载插件。

    common/config/rush/rush-plugins.json

    {
    "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-plugins.schema.json",
    "plugins": [
    /**
    * Each item defines a plugin to be loaded by Rush.
    */
    {
    /**
    * The name of the NPM package that provides the plugin.
    */
    "packageName": "@rushstack/rush-redis-cobuild-plugin",

    /**
    * The name of the plugin. This can be found in the "pluginName"
    * field of the "rush-plugin-manifest.json" file in the NPM package folder.
    */
    "pluginName": "rush-redis-cobuild-plugin",

    /**
    * The name of a Rush autoinstaller that will be used for installation, which
    * can be created using "rush init-autoinstaller". Add the plugin's NPM package
    * to the package.json "dependencies" of your autoinstaller, then run
    * "rush update-autoinstaller".
    */
    "autoinstallerName": "cobuild-plugin"
    }
    ]
    }
  6. 通过创建其配置文件来配置 rush-redis-cobuild-plugin

    common/config/rush-plugins/rush-redis-cobuild-plugin.json

    {
    /**
    * The URL of your Redis server
    */
    "url": "redis://server.example.com:6379",

    /**
    * An environment variable that your CI pipeline will assign,
    * which the plugin uses to authenticate with Redis.
    */
    "passwordEnvironmentVariable": "REDIS_PASSWORD"
    }
  7. 您可以使用rush init命令创建用于启用 cobuild 功能的**cobuild.json** 配置文件。请确保将"cobuildFeatureEnabled": true设置为 true,如下所示:

    common/config/rush/cobuild.json

    /**
    * This configuration file manages Rush's cobuild feature.
    * More documentation is available on the Rush website: https://rush.node.org.cn
    */
    {
    "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/cobuild.schema.json",

    /**
    * (Required) EXPERIMENTAL - Set this to true to enable the cobuild feature.
    * RUSH_COBUILD_CONTEXT_ID should always be specified as an environment variable with an non-empty string,
    * otherwise the cobuild feature will be disabled.
    */
    "cobuildFeatureEnabled": true,

    /**
    * (Required) Choose where cobuild lock will be acquired.
    *
    * The lock provider is registered by the rush plugins.
    * For example, @rushstack/rush-redis-cobuild-plugin registers the "redis" lock provider.
    */
    "cobuildLockProvider": "redis"
    }
  8. 运行rush update命令,这将安装cobuild-plugin自动安装程序。它会下载其清单文件:

    common/autoinstallers/cobuild-plugin/rush-plugins/@rushstack/rush-redis-cobuild-plugin/rush-plugin-manifest.json

    将此文件也提交到 Git。 (作为插件系统的一部分,此文件缓存了重要信息,以便 Rush 可以访问它,而无需安装插件的 NPM 包。)

配置构建管道

每个 CI 系统都有不同的定义作业方式。在本教程中,我们将使用GitHub Actions 工作流,因为它包含在公共项目的免费计划中。

假设我们的非 cobuild CI 管道如下所示(启用构建缓存写入):

.github/workflows/ci-single.yml

name: ci-single.yml
on:
#push:
# branches: ['main']
#pull_request:
# branches: ['main']

# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
build:
name: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- uses: actions/setup-node@v3
with:
node-version: 16

- name: Rush Install
run: node common/scripts/install-run-rush.js install

- name: Rush build (install-run-rush)
run: node common/scripts/install-run-rush.js build --verbose --timeline
env:
RUSH_BUILD_CACHE_WRITE_ALLOWED: 1
RUSH_BUILD_CACHE_CREDENTIAL: ${{ secrets.RUSH_BUILD_CACHE_CREDENTIAL }}

以下是将它转换为具有 3 个运行器的 cobuild 的方法:

.github/workflows/ci-cobuild.yml

name: ci-cobuild.yml
on:
#push:
# branches: ['main']
#pull_request:
# branches: ['main']

# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
build:
name: cobuild
runs-on: ubuntu-latest
strategy:
matrix:
runner_id: [runner1, runner2, runner3]
steps:
- uses: actions/checkout@v3

- uses: actions/setup-node@v3
with:
node-version: 16

- name: Rush Install
run: node common/scripts/install-run-rush.js install

- name: Rush build (install-run-rush)
run: node common/scripts/install-run-rush.js build --verbose --timeline
env:
RUSH_BUILD_CACHE_WRITE_ALLOWED: 1
RUSH_BUILD_CACHE_CREDENTIAL: ${{ secrets.RUSH_BUILD_CACHE_CREDENTIAL }}
RUSH_COBUILD_CONTEXT_ID: ${{ github.run_id }}_${{ github.run_number }}_${{ github.run_attempt }}
RUSH_COBUILD_RUNNER_ID: ${{ matrix.runner_id }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}

runner_id 矩阵会导致作业在 3 台单独的机器上运行。REDIS_PASSWORD 变量名称是我们之前在 **rush-redis-cobuild-plugin.json** 中定义的。RUSH_COBUILD_CONTEXT_IDRUSH_COBUILD_RUNNER_ID 变量将在下面解释。

详细了解 cobuild 环境变量

RUSH_COBUILD_CONTEXT_ID

cobuild 运行器必须定义此环境变量;如果没有它,Rush 将执行常规构建,没有任何 cobuild 逻辑。

RUSH_COBUILD_CONTEXT_ID 变量控制缓存:假设拉取请求验证失败,因为某个项目存在错误。在没有 cobuild 的情况下,存在错误的项目不会保存到构建缓存中。如果用户访问 GitHub 网站并点击按钮以“重新运行此作业”,成功项目将从缓存中拉取,但失败项目将被迫重新构建,这是好的,因为这可能是暂时的错误。

而对于 cobuild,如果某个项目存在错误,我们不希望另外两台机器尝试构建该项目。错误日志将保存到构建缓存中,并将由其他运行器恢复和打印(在每台机器上提供完整的日志)。但是,如果用户点击“重新运行此作业”,如何在那种情况下强制失败项目重新构建?RUSH_COBUILD_CONTEXT_ID 标识符解决了这个问题。Rush 将它添加到失败项目的构建缓存键中,以确保如果重新尝试作业,它们会重新构建。

RUSH_COBUILD_CONTEXT_ID 在每个系统中都有不同的指定方式。它可以是具有以下属性的任何字符串:

  • 对于给定的管道,RUSH_COBUILD_CONTEXT_ID 在每台机器上必须相同
  • 每次运行管道时,RUSH_COBUILD_CONTEXT_ID 必须不同,包括“重新尝试”和“重试”
  • 它必须是一个短字符串,因为它将成为缓存键的一部分

一些示例:

CI 系统建议的 RUSH_COBUILD_CONTEXT_ID
Azure DevOps$(Build.BuildNumber)_$(System.JobAttempt)
CircleCI${CIRCLE_WORKFLOW_ID}_${CIRCLE_WORKFLOW_JOB_ID}
GitHub Actions${{ github.run_id }}_${{ github.run_number }}_${{ github.run_attempt }}

RUSH_COBUILD_RUNNER_ID

此环境变量唯一标识每台机器。如果未定义此变量,Rush 将在每次运行时生成一个随机标识符。

在示例中,我们将其指定为 RUSH_COBUILD_RUNNER_ID: ${{ matrix.runner_id }},以提高可读性。

技术细节

构建缓存正确性

您会发现 cobuild 功能提高了对每个项目的输出准确保存和恢复到缓存的要求。要了解原因,假设项目 A 直接依赖于项目 B。有几种方法可以使不准确的缓存仍然产生成功的构建:

  1. 项目 AB 都是缓存未命中,因此不会发生缓存。**- 或者 -**
  2. 项目 AB 都是缓存命中。B 未被准确恢复。A 本应无法编译,但我们不需要构建 AA 的最终结果仍然可用。**- 或者 -**
  3. 只有项目 A 是缓存未命中。B 未被准确恢复,但缺少的文件仍然位于同一机器上以前构建的磁盘上。因此,A 可以在没有错误的情况下编译。

这些幸运的情况在非 cobuild 场景中比较常见。如果您运气不好,重新尝试作业可能会导致问题“清除”(由于新的缓存命中)。在遇到以下情况之前,潜在问题不会始终被注意到:

  1. 只有项目 A 是缓存未命中。B 未被准确恢复,并且我们的构建从干净的磁盘开始。

cobuild 极大地增加了遇到 #4 的可能性,因为它们尽可能地构建依赖于缓存命中的缓存未命中。简而言之,在首次启用 cobuild 功能后,您可能需要花一些时间来修复不正确的构建缓存配置。

👉 排查构建缓存不准确性

如果您怀疑文件未被 Rush 构建缓存准确保存/恢复,请尝试使用rush-audit-cache-plugin。它通过监控构建操作期间的文件写入来检测此类问题。然后将写入的文件路径与项目的缓存配置进行比较,生成一份关于未被正确缓存的文件路径的报告。然后,您可以通过更正缓存配置或修复工具以在可缓存的位置写入其输出,来解决问题。

Redis 中存储了什么?

cobuild 功能使用 Redis 主要用于两个目的:

  1. 可重入锁定机制。与锁定相对应的键格式为 cobuild:lock:<context_id>:<cluster_id>,相应的值为 <runner_id>。在设置锁定键时,还会设置 30 秒的过期时间。这确保了同一运行器可以在尝试再次获取锁时重新获取锁,同时如果运行器在一段时间内没有响应,也会自动释放锁。

  2. 跟踪已完成的操作。与已完成状态相对应的键格式为 cobuild:completed:<context_id>:<cache_id>,相应的值为操作执行结果的序列化字符串和相应的 cache_id。在尝试获取锁之前,机器将首先查询此完成结果信息。如果有完成结果可用,将根据解析的信息重复使用结果。

另请参阅