提高CI构建时间

2020年2月18日,由Ethan Dennis,@erdennis13 和 João Moreno,@joaomoreno

Visual Studio Code 是一个大型项目,有许多移动部件和活跃的参与者列表。我们已经展示了我们如何积极使用 Azure Pipelines 来通过维护我们的构建和持续集成基础设施来跟上良好的工程实践。在这篇博客文章中,我们将讨论我们如何使用 Azure Pipelines Artifact Caching Tasks 来显著减少我们的 CI 构建时间。

我们在之前的博客文章中描述了如何将CI构建时间减少了33%。这是通过使用自定义构建任务来实现的,这些任务缓存了VS Code使用的节点模块,而不是在构建时解析包。虽然我们对这种性能提升感到满意,但我们想看看我们还能进一步推动我们构建的缓存任务。

当我们上次讨论我们的CI工程时,我们的目标平台涵盖了Windows、macOS和Linux。截至今天,VS Code的目标平台更加多样化,例如用于其远程服务器组件的Arm64和Alpine Linux。总的来说,我们有八个不同的目标,它们都共享相同的构建步骤。这篇文章概述了我们如何利用缓存任务来减少CI重复并进一步改善我们的构建时间。

改进空间

那么,所有构建作业中常见的步骤到底是什么?每个构建目标都有一个遵循类似步骤的作业。从高层次来看,每个作业必须:

  1. 恢复依赖项
  2. Lint TypeScript 和 JavaScript
  3. 将TypeScript编译为JavaScript
  4. 运行单元测试套件
  5. 运行集成测试套件
  6. 打包 VS Code

我们的缓存任务是加速恢复依赖项步骤的明显选择。例如,为什么要在package-lock.json文件很少更改的情况下运行昂贵的npm install步骤,而不是缓存之前运行的结果呢?由于我们之前已经讨论过缓存包的问题,这篇文章有趣的地方在于我们如何将缓存应用到其他步骤中。

由于代码检查和编译是平台无关的,这些步骤可以很容易地由一个单一的构建代理来执行,该代理会将其结果与其他平台相关的代理共享,而不是让所有代理重复执行这项工作。我们创建了一个Linux构建代理,其唯一职责正是如此:恢复包、检查代码并编译源代码。我们所要做的就是将结果与其他代理共享。

缓存所有内容

为了在构建代理之间共享缓存结果,我们需要平台无关的缓存,这在最初是不被缓存任务支持的。因此,在Azure Pipelines Artifact Caching Tasks中添加了一个可选的platformIndependent参数。

以下是VS Code如何使用platformIndependent参数:

- task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
  inputs:
    keyfile: keyfile
    targetfolder: target
    vstsFeed: $(ArtifactFeed)
    platformIndependent: true

在缓存节点模块时,使用package-lock.json文件作为缓存键是合理的。当此文件更改时,我们必须使缓存失效。在缓存编译输出时,整个代码库必须作为缓存键。为了简化操作,我们决定使用HEAD提交作为缓存键,因为新的提交将不可避免地创建一个新的缓存条目。这对于我们的目的来说效果很好,因为尽管在构建代理上运行,单个构建始终在单个提交上运行。

另一个缺失的功能是能够为每个构建作业创建多个缓存。我们现在发现自己需要同时处理两个缓存(node模块,编译),而无法单独处理每个缓存。缓存任务输出一个名为CacheRestored的环境变量,可用于乐观地跳过构建任务。这个环境变量在与单个缓存交互的构建中效果很好,但在多个缓存中效果不佳——让我们不确定CacheRestored指的是哪个缓存。再次,Azure Pipelines Artifact Caching Tasks中添加了另一个可选的alias参数。

以下是我们如何使用alias参数:

- task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
  inputs:
    keyfile: "yarn.lock"
    targetfolder: "node_modules"
    vstsFeed: "$(ArtifactFeed)"
    alias: "Packages"

- script: |
    yarn install
  displayName: Install Dependencies
  condition: ne(variables['CacheRestored-Packages'], 'true')

在这里,Packages的别名被附加到环境变量输出中,使我们能够在一个构建作业中缓存NPM包和编译输出。我们最终去重了许多CI工作,这些工作现在只需执行一次,并可以在特定平台的代理之间共享。

在特定的使用场景下,还有一个最终的优化空间:构建重新提交。我们有时必须在先前构建的提交上重新触发VS Code构建,因为测试可能不稳定或某些代理可能随机失败。理想情况下,共享代理不会恢复或重新编译公共代码,而是依赖于平台相关的代理来执行它们的工作。我们注意到的问题是,编译缓存包非常大,恢复它们需要大约8分钟——而这一切都是徒劳的,因为如果缓存存在,共享代理只会简单地放弃控制。因此,Azure Pipelines Artifact Caching Tasks再次添加了一个新的可选dryRun参数,它允许我们检查缓存包的存在而不恢复它——有效地为我们的构建重新提交节省了8分钟。

在我们的构建中使用dryRun参数如下所示:

- task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
  inputs:
    keyfile: commit
    targetfolder: output
    vstsFeed: "$(ArtifactFeed)"
    dryRun: true

- script: |
    npm run compile install
  displayName: Install Dependencies
  condition: ne(variables['CacheExists'], 'true')

请注意,这也引入了一个新的CacheExists变量,它与dryRun参数一起工作。

结果

一旦这些更改被实施,我们看到了总构建时间的显著减少。下表显示了VS Code针对的每个平台的总构建时间的变化:

Platform Before After Time Savings
Windows 58 min 44 min 24%
Windows 32 59 min 46 min 22%
Linux 38 min 23 min 39%
macOS 68 min 42 min 38%
Linux Arm 22 min 21 min 5%
Linux Alpine 23 min 26 min -13%

VS Code 构建前后时间对比

Linux Arm 和 Linux Alpine 目标仅构建 VS Code 远程服务器组件,因此它们的原始构建时间已经足够好。但由于它们与标准的 VS Code 客户端平台共享一些共同任务,我们决定让它们依赖于共同的构建代理。这导致在某些情况下由于开销增加而略微增加了构建时间。

由于可以完全跳过共享代理任务,重新提交的构建有了显著的改进。以下是一些macOS的示例数据:

Platform Before After Time Savings
macOS 68s 34s 50%

总的来说,我们非常高兴看到VS Code的CI构建时间总共减少了约50%!最好的消息是,你可以从我们的构建定义中汲取灵感,以实现你自己构建时间的改进。

缓存愉快,

Ethan Dennis,开发者服务高级软件工程师 @erdennis13

若昂·莫雷诺,VS Code 高级软件工程师 @joaomoreno