Maven构建缓存实践:在Azure DevOps中显著缩短CI构建时间
Maven 构建缓存可以显著缩短构建时间,但启用它只是第一步。在本文中,我们分享在 Azure DevOps 流水线中引入该缓存的经验、遇到的问题以及一路走来得到的教训。
引言
Apache Maven 如今在大多数国家已经到了可以合法驾车和饮酒的年龄。作为整个 Java 生态系统中成熟且稳定的构建工具,Maven 被大型企业和开源项目广泛使用。尽管如此,Maven 背后的社区仍在持续改进 Maven 本身、其插件以及围绕它的其他工具。由于 Maven 是开源的,社区一直欢迎提交者之外的修复和贡献,并由此构建了一个不断壮大的社区,其中的人们努力让 Maven 成为更好的产品。
Maven 构建缓存
2019 年,德意志银行的一个团队联系了 Apache Maven 社区,宣布他们实现了“增量构建”和“共享缓存”——这些功能与 Gradle 构建缓存非常相似。借助这些功能,他们将平均构建时间从 45–60 分钟缩短到 1–2 分钟。这是显著的改进!而最棒的是:他们希望将这些功能捐赠给 Apache 软件基金会。
构建缓存如何工作?思路相当简单:
- CI 服务器运行构建,并将所有输出存储到共享缓存中。
- CI 上的下一次构建可以复用这些输出,前提是其输入没有变化。
- 开发者在开始处理某个功能并构建项目时,会从远程缓存中获取其分支的快照。
- 修改代码后,开发者再次构建项目。只有发生变更的 Maven 模块会被重新构建。
为了实现这一点,Maven 构建缓存不查看文件时间戳。这些时间戳在多个平台、机器和文件系统之间并不可靠。相反,它利用非加密安全但速度非常快的哈希(例如 XX-hash)来验证输入文件的内容是否发生变化。还有更多启发式规则用于防止不必要的重新构建。讨论它们不是本文的主要目标;相反,我们希望重点介绍如何在真实企业项目中利用这一点。
为此,我们将分享在几个真实企业项目中是如何做到这一点的。这些项目托管在 Azure DevOps 中,但我们推测在其他环境(例如 GitLab 或 GitHub)中也会有类似效果。
基础
启用构建缓存扩展
我们首先将构建缓存扩展添加到项目本身。这意味着,在项目根目录的 .mvn 文件夹中添加一个 extensions.xml 文件,将其加入 Maven 项目,内容如下:
配置构建缓存扩展
在讨论我们遇到的问题之前,有必要先介绍我们最初采用的最小配置。基础配置相对较少,而且确实有效,但真正重要的工作是在此之后才开始的。我们观察到,一些流水线步骤开始失败,因为较早步骤的输出文件缺失。为了配置该扩展,我们在 .mvn 文件夹中添加了另一个名为 maven-build-cache-config.xml 的文件。在这里,我们可以配置应还原哪些额外的构建输出。这对于确保后续流水线步骤正常工作很有必要。其他配置值使用默认值即可,因此我们没有改动它们。所有可配置选项都可以在 构建缓存扩展网页上找到。
在评估缓存是否值得采用时,我们了解到该扩展特别适合多模块 Maven 项目。它不会检查“整个项目是否发生变化”,而是按 Maven 模块进行判断。如果一个模块及其依赖的模块没有变化,该模块的输出就可以从缓存中还原。因此,对于我们那些包含更多更小模块的项目,收益会更大。如果变更仅限于系统的某一部分,Maven 不一定需要重新构建所有内容。如果大部分代码都集中在一个大型模块中,复用先前工作成果的机会就会更少。
对我们的项目而言,这种模块级缓存正是有趣之处。我们的 CI 构建花费大量时间编译、测试和打包那些在拉取请求中通常未被改动的模块。构建缓存为我们提供了一种避免重复这些工作的方法。
在 Azure DevOps 流水线中设置
将扩展添加到 Maven 项目只是设置工作的一半。在开发者机器上,构建缓存可以存放在 ~/.m2/build-cache 中。然而,在 Azure DevOps 中,构建代理启动时工作区是干净的。如果没有额外的流水线配置,一次流水线运行产生的缓存在下一次运行时不可用。
有两种解决方法:一种是在配置文件中配置远程仓库;第二种是使用 Azure DevOps Cache@2 任务,我们将在本文中说明。这种方式不允许开发者复用缓存,但设置起来容易得多。如果你还希望在开发者机器上利用缓存,使用远程仓库是一种很好的方式。我们预计自己不需要这样做。
如前所述,为了在 Azure DevOps 流水线运行之间持久化缓存,我们在流水线模板中使用 Cache@2 任务。该任务在作业开始时还原缓存文件夹,并添加一个作业后步骤,在作业成功完成后保存缓存。本质上,我们是在缓存缓存本身 🤡。
在我们的场景中,配置如下:
这会创建一个不可变的 Azure DevOps 缓存,作用域限于该分支。该分支上的首次构建会填充缓存,后续构建无法覆盖它。我们通常看到后续构建只推送很小的改动,因此不认为这会成为严重问题。最大的收益在于至少拥有了缓存。
$(MAVEN_BUILD_CACHE_FOLDER) 指向构建代理环境中 Maven 构建缓存扩展使用的目录。在我们的流水线定义中,将该变量定义为 $(Pipeline.Workspace)/.m2/build-cache,这也是缓存通常存储的位置。
此外,我们还定义了一个键(以及作为备用的还原键)来标识缓存。起初,很容易想把缓存键设得非常具体。例如,你可能想包含项目所有的 pom.xml 文件。对于 Maven 构建缓存而言,这通常没有必要。因为该扩展已经根据每个模块的输入计算指纹。它“知道”源文件是否变化、插件配置是否变化,或模块依赖是否变化。如果我们把过多同样的信息编码进 Azure DevOps 缓存键,最终就会重复 Maven 的逻辑,并造成不必要的缓存未命中。
在不同流水线中使用缓存
在我们的场景中,构建缓存主要用于拉取请求验证和依赖检查流水线。拉取请求构建应快速提供反馈,而且它们通常只包含对代码库一小部分的更改。这正是模块级缓存带来最大收益的地方。运行依赖检查器不应需要完整构建;这里完全可以正常使用缓存的产物。
我们有意没有在部署流水线中依赖 Maven 构建缓存。对于部署构建,我们希望生成的产物来自一次干净构建。部署流水线并不是我们想通过还原先前构建输出来激进优化性能的地方。
总结来说,我们的设置包括三个部分:
- 拉取请求流水线:使用 Maven 构建缓存加速验证。
- 依赖检查流水线:无需仅仅为了运行依赖检查插件而重新构建整个项目。
- 部署流水线:不依赖还原的构建输出进行构建。
需要特别注意的是,Azure DevOps 缓存的作用域是流水线中检出的分支。这意味着针对不同分支的每个拉取请求都需要先进行一次完整构建,然后才能复用缓存。之后,我们可能会考虑让主部署流水线创建缓存但不消费缓存;这样甚至能改善每个拉取请求的首次构建。由于拉取请求构建只能读取目标分支的缓存,我们届时可以通过构建主分支来“预热”缓存。之后,针对主分支的拉取请求就可以复用该缓存。
我们遇到的具体问题
在将扩展集成到项目时,我们不可避免地遇到了一些问题。
仅还原产物还不够
我们遇到的最初问题之一是,仅还原最终产物对我们的流水线来说还不够。
默认情况下,构建缓存扩展不会还原整个 target 目录。这很重要,因为流水线或构建中的其他工具或插件通常不会只看最终的 .jar、.war 或 .ear 文件。它们还期望模块的 target 文件夹中存在文件。
例如,JaCoCo 和报告工具可能会在如下目录中查找文件:
target/classestarget/test-classestarget/surefire-reportstarget/site/jacoco
当 Maven 从缓存还原模块,但这些目录缺失时,后续流水线步骤可能会失败或生成不完整的报告。从 Maven 的角度看,该模块在技术上可能已经“构建”完成,但流水线的其余部分仍然期望通常的构建输出布局。
为了解决这个问题,我们配置构建缓存扩展以附加额外的输出目录。在我们的场景中,相关目录如下:
这告诉扩展将这些目录作为缓存输出的一部分进行存储和还原。经过这项更改后,还原的模块再次拥有了流水线下一步所需的文件。
SonarCloud 分析与 Maven 反应器
下一个问题出现在 SonarCloud 分析相关环节。
在引入构建缓存之前,我们的流水线在 Maven 任务本身上使用 Sonar 集成。这意味着构建、测试、打包和 Sonar 分析都属于同一次 Maven 调用。启用缓存后,这种设置变得有问题。
与 compile、test、package 或 install 不同,sonar:sonar 是独立的 Maven 目标,而不是生命周期阶段。初始构建成功完成并创建缓存条目。然而在后续构建中,将生命周期目标与独立的 Sonar 分析目标组合在一起(正如 Maven@4 任务所做的那样)会失败。我们看到构建缓存扩展试图把 sonar:sonar 当作生命周期阶段来还原。
解决方案是将 Sonar 分析拆分到单独的 Maven@4 调用中:
拆分步骤解决了一个问题,但又引入了另一个问题。
在原始设置中,Sonar 与构建运行在同一个 Maven 反应器内。Maven 可以直接从反应器解析模块之间的依赖。将 Sonar 拆分到第二次 Maven 调用后,该反应器上下文就不存在了。第二个 Maven 进程必须从本地 Maven 仓库解析模块间依赖。
我们正是在这里看到了如下错误:
这表明我们自己的内部模块无法被解析。
根本原因是第一个 Maven 步骤过去运行的是 package。package 目标会创建模块产物,但不会将它们安装到本地 Maven 仓库。因此,当第二次 Maven 调用开始时,产物存在于 target 中,但不在 Maven 查找它们所依据的本地仓库中。
在这里只缓存 target 还不够。单独的 Maven 调用需要产物满足以下条件之一:
- 位于同一个反应器中,或
- 已安装到本地 Maven 仓库中。
由于 Sonar 现在运行在单独的 Maven 进程中,我们将构建步骤改为运行 install,而不再只运行 package。
这样一来,即使某个模块是从缓存中还原的,它的产物也会被放入本地 Maven 仓库,供同一流水线中后续的 Maven 调用使用。
为了让这一点在构建缓存下可靠,我们通过将以下 XML 添加到 maven-build-cache-config.xml 文件,配置 Maven install 目标始终运行:
构建缓存仍然可以还原构建中开销较大的部分,例如编译、测试和打包。但 install 步骤足够廉价,可以每次运行,并且它能确保后续 Maven 调用可以解析模块产物。
OWASP 依赖检查
我们在 OWASP 依赖检查插件上遇到了类似问题,该插件只在一个专用流水线中运行。最初,依赖检查被集成到主 Maven 执行中。在我们的项目里,它绑定到 verify 阶段并使用 aggregate 目标,这对多模块项目很有用,因为它会生成合并报告。
然而,它也不能很好地与构建缓存配合。依赖检查不会产生我们想从缓存中还原的那种可复用构建输出。它依赖于当前的依赖图和漏洞数据,因此最好将其视为需要在需要时显式运行的步骤。
简化后的设置如下: