Spring Boot 应用的 GitHub Actions 构建优化:哪些真的快了,哪些只是看起来快了
缘起
我们的 Spring Boot 服务都跑在 ECS 上,CI/CD 用的是 GitHub Actions。
最早每个服务仓库里都有一份自己的 pipeline.yml,里面依次调用公共的 reusable-ci.yml(构建、扫描、签名、推送镜像)和 reusable-cd.yml(部署)。前段时间,我们集中对这套流水线做了一轮优化。
这篇文章以一个单模块的 Spring Cloud Gateway 服务(下文简称 gateway)为例,把这轮已经落地的优化点逐个过一遍,并且用 gateway 仓库真实的运行记录说明:哪些优化有明确收益,哪些优化的收益在数据里其实看不出来。
先看现在的入口
gateway 仓库里现在只剩一个很薄的 workflow:
1 | name: CI/CD Pipeline |
真正的逻辑都在 DevOps 仓库里,调用链是:
1 | 服务仓库 pipeline.yml |
下面按”省掉不必要的工作”和”让必要的工作更快”两类来讲。
一、省掉不必要的工作
1. main 分支 push 不再重复跑测试和 Sonar
reusable-ci.yml 里有两个 job:
pre-build:mvn test、JaCoCo 报告、SonarQube 扫描和 Quality Gate;build:mvn clean package -DskipTests,然后构建、扫描、签名、推送镜像。
PR 只跑 pre-build,不构建镜像。合并到 main 之后的 push,则直接跳过 pre-build:
1 | pre-build: |
build 用 always() 配合 needs.pre-build.result == 'skipped' 来判断,只在 main push 这一种情况下接受 pre-build 被跳过:
1 | build: |
前提是 main 有分支保护,PR 必须先通过 pre-build 才能合并。
这一步省下来的时间是实打实的。gateway 最近两次 PR 的 pre-build job 分别用了 5 分 10 秒和 5 分 16 秒,其中光 Test with Maven 就占了 4 分多钟。如果 main push 再跑一遍,每次合并都要多等 5 分钟左右。
release 事件仍然会跑 pre-build,正式发版前再完整测一遍。
2. Release PR 合并产生的 push 不再构建
我们用 Release Please 管理版本。Release PR 合并后会先产生一次 push,紧接着 GitHub Release 发布又会触发一次 release 事件。如果两次都构建镜像,就重复了。
facade 里对 push 加了一个判断:提交信息包含 chore(main): release 且不是 -SNAPSHOT 的 push,跳过 build,由随后的 release:published 事件负责构建和部署。
1 | build: |
在 gateway 的运行记录里,某次发版时,Release PR 合并那次 push 的 run 结论就是 skipped,耗时 1 秒;随后的 release run 才真正去构建。
3. 发布重跑时复用已存在的不可变镜像
ECR 仓库开了 tag 不可变,release 镜像会同时打两个 tag:<短SHA>-<时间>-<run号>-<重试次数> 和版本号 tag。
问题出在重跑上。比如 release 流程里 Maven 发布那一步失败了,修好之后重新触发 release,如果再构建一次镜像,推送同名版本 tag 会被 ECR 拒绝。
现在 build job 在 release 事件下会先查一下版本号 tag 是否已经存在:
- 存在,并且镜像上有一个以当前提交短 SHA 开头的 tag:直接复用,跳过 Buildx 和构建推送步骤,后续扫描和签名都针对这个已有镜像;
- 存在,但找不到匹配当前提交的 tag:直接失败,拒绝复用一个由其他源码版本构建的镜像;
- 不存在(
ImageNotFoundException):正常构建推送。
核心逻辑:
1 | source_tag_prefix="${SOURCE_SHA:0:7}-" |
签名那一步本来就有一个类似的判断:如果这个 digest 已经签过名,就不再重复签。原因是 ECR 的 tag 不可变,Notation 写入的 sha256-<digest> 引用索引 tag 不能被覆盖。
这一项主要解决的是重跑会失败的问题,顺带省掉了一次镜像构建。
4. 去掉 Cosign,只保留 AWS Signer(Notation)
以前镜像签名和基础镜像校验同时支持 Cosign 和 Notation,build job 里有一个单独的 Install Cosign 步骤。后来统一成只用 AWS Signer(Notation),这一步就去掉了。
在 gateway 的旧运行记录里,Install Cosign 每次大约 4 到 10 秒。
5. 跨仓库 checkout 只拉需要的目录
Dockerfile、基础镜像校验脚本、ECR 扫描脚本都放在 DevOps 仓库里,build job 需要 checkout 这个仓库。现在用的是 sparse checkout,只拉 .github/ci 和当前服务自己的 Dockerfile 目录:
1 | - name: Checkout DevOps repository |
基础镜像校验脚本也通过 TARGET_DIR 限定为只校验当前服务的 Dockerfile。
这样做更主要的好处是解耦:以前校验脚本会遍历所有服务的 Dockerfile,一个无关服务的基础镜像出问题,会把其他服务的 CI 也卡住。
6. 按事件拆分 concurrency
1 | concurrency: |
同一事件类型的新 run 会取消旧 run,比如同一个仓库连续推两次 PR,前一次会被取消。group 里带上了 event_name,所以 main 合并后的 push 部署不会被同时进行的 Release PR 的 pull_request run 取消掉。
二、让必要的工作更快
7. ECR 扫描结果轮询:退避代替固定等待
镜像推到 ECR 后,我们用 ECR 增强扫描(Amazon Inspector)的结果做门禁:CRITICAL 和 HIGH 数量超过阈值就让 job 失败。
旧逻辑是内联在 workflow 里的一段 shell:
- 先固定
sleep 20; - 每 15 秒查一次,最多 20 次;
- 发现扫描完成(
imageScanCompletedAt有值)后,再固定sleep 20等结果汇总,然后才读取 findings。
也就是说,即使扫描已经完成,至少也要等 40 秒。
新逻辑抽成了 .github/ci/poll-ecr-scan.sh,参数集中在 workflow 的 env 里:
1 | INSPECTOR_POLL_INITIAL_SLEEP: 5 |
变化有三点:
- 首次等待从 20 秒改成 5 秒;
- 轮询间隔分档递增:第 1 到 6 次间隔 5 秒,第 7 到 12 次 10 秒,之后封顶 15 秒;
- 扫描完成后不再固定等 20 秒,而是检查 findings 是否已经汇总(
enhancedFindings或findingSeverityCounts有内容),已经汇总就立刻判定,没有才等 5 秒再查。
1 | backoff_for_attempt() { |
门禁阈值本身没有变化。脚本还带了一组本地测试用的 fixture(扫描中、扫描失败、有 findings、无 findings 等 JSON),可以在本地直接跑测试脚本验证逻辑。
这是这轮优化里数据最明确的一项。gateway 的 Poll ECR scan and check vulnerability thresholds 步骤:
| 样本 | 轮询逻辑 | 耗时 |
|---|---|---|
| 样本 1 | 旧 | 63 秒 |
| 样本 2 | 旧 | 61 秒 |
| 样本 3 | 新 | 33 秒 |
| 样本 4 | 新 | 26 秒 |
| 样本 5 | 新 | 14 秒 |
样本编号按时间先后排列,和下文第三部分的表格是同一批运行。
8. Maven 依赖缓存:restore 和 save 分开
以前用的是 actions/setup-java 自带的 cache: 'maven'。现在 setup-java 升级到 v6,只保留 cache-jdk: true,Maven 本地仓库改成用 actions/cache 的 restore 和 save 两个子 action 分开处理:
1 | - name: Restore Maven cache |
1 | - name: Save Maven cache |
要点:
- key 由
pom.xml、Maven Wrapper 配置和extensions.xml的哈希组成,依赖变了就生成新 key; - 配了
restore-keys前缀:精确 key 没命中时,回退到同前缀下最近的一份缓存,只需要补下载有变化的依赖; - 只在 main 上 save。GitHub Actions 的缓存有访问范围限制:
pull_request触发的 run 产生的缓存挂在 PR 的 merge ref 上,只能被这个 PR 的重跑读取,而默认分支的缓存可以被其他分支和 PR 读取。所以只有 main 上的缓存能被后续 PR 复用,只在 main 上保存可以避免 PR 产生大量别人用不上的缓存条目; - 加了一个
Report Maven cache key步骤,把 primary key、是否精确命中、实际命中的 key 打到日志里,方便排查。
另外有个小插曲:最初的写法是在 step 的 env 里拼接 key 前缀,再在 with 里引用 env.XXX。后来又专门提交了一次修复,把表达式直接内联到 key 和 restore-keys 里。
9. Docker 构建:改用 Buildx 和 GHA 层缓存
以前是 docker build、docker tag、docker push 三个独立步骤。现在改成 docker/setup-buildx-action 加 docker/build-push-action,构建和推送一步完成,并开启 GitHub Actions 缓存后端:
1 | - name: Build and push Docker image (single-module) |
有几点说明:
type=gha缓存需要 job 有actions: write权限,所以buildjob 和服务仓库的pipeline.yml都加了这个权限;scope用服务名区分,避免不同服务的缓存互相覆盖;- 只构建
linux/amd64单平台,provenance: false,保证推上去的是单个 manifest,因为 Inspector 扫描不支持 manifest list; - 版本号 tag 和 SHA tag 通过
tags一次推送,不用再单独docker tag和docker push。
10. Notation CLI 安装包缓存
签名用的 aws-signer-notation-cli 安装包被封装成一个 composite action,里面用 actions/cache 缓存 .deb 文件,缓存 key 是下载 URL,并且会校验 sha256。
三、实际效果:用数据说话
下面是 gateway 几次 main push 的 build job 关键步骤耗时。这些数据都来自 GitHub Actions 的运行记录,只是不同时间点的单次样本,并不是控制变量的基准测试。
| 样本 | 当时的状态 | Build with Maven | 镜像构建和推送 | ECR 扫描轮询 | build job 总时长 |
|---|---|---|---|---|---|
| 样本 1 | 全部优化前 | 3 分 15 秒 | 44 秒(build 21 + push 23) | 63 秒 | 6 分 10 秒 |
| 样本 2 | 全部优化前 | 19 秒 | 43 秒(build 16 + push 27) | 61 秒 | 2 分 51 秒 |
| 样本 3 | 已有 Buildx 和新轮询,Maven 缓存未改 | 2 分 34 秒 | 28 秒(Buildx 初始化 6 + 构建推送 22) | 33 秒 | 4 分 20 秒 |
| 样本 4 | 全部优化后,Maven 缓存前缀命中 | 11 秒 | 42 秒(Buildx 初始化 11 + 构建推送 31) | 26 秒 | 2 分 19 秒 |
| 样本 5 | 全部优化后,Maven 缓存未命中 | 68 秒 | 58 秒(Buildx 初始化 7 + 构建推送 51) | 14 秒 | 3 分 6 秒 |
逐项来看。
ECR 扫描轮询:收益明确。 从 60 多秒降到 14 到 33 秒,和”至少固定等待 40 秒”变成”最少 5 秒”的逻辑变化对得上。
Maven 缓存:命中时收益明显,但命中率取决于构建频率。 样本 4 那次 key 没有精确命中(pom 变了),但通过 restore-keys 前缀回退命中了上一份缓存,mvn package 只用了 11 秒。
样本 5 那次就完全没命中,连前缀回退都落空了。回头查日志:样本 4 在 main 上保存过缓存之后,gateway 将近两个月没有 main push;样本 5 之前的两次 PR 在 pre-build 里 restore,也都是”Cache not found”,连前缀都没匹配上。也就是说,到样本 5 运行之前,main 分支上已经没有这个服务的 Maven 缓存了。GitHub 文档写明,超过 7 天没有被访问的缓存条目会被删除,这和我们看到的情况是一致的。
这暴露了”只在 main 上 save”的一个副作用:对于提交不频繁的服务,main 上的缓存一旦过期,后面的 PR 都只能冷启动,要等下一次 main push 才会重新生成缓存。
至于旧的 setup-java 自带缓存,在样本 1 和样本 2 这两次运行里是否命中,我没有逐条核对日志,所以 3 分 15 秒和 19 秒的差异不归因于缓存。
Docker 层缓存:在这个服务上看不出收益。 样本 4 的日志里确实能看到 importing cache manifest from gha,RUN apk upgrade 和 WORKDIR 两层都是 CACHED。但从表里看,镜像构建推送的总耗时并没有比旧方式短,而且 Buildx 初始化本身还要多花 6 到 11 秒。
原因要看 gateway 的 Dockerfile:
1 | FROM <account-id>.dkr.ecr.ap-southeast-1.amazonaws.com/<ecr-prefix>/eclipse-temurin:17-jre-alpine@sha256:... |
Maven 构建是在 Actions 里完成的,Dockerfile 只负责把 jar 拷进运行时镜像。每次构建 jar 都会变,所以从 COPY 开始往后的层都必然重建,能命中缓存的只有前面的 apk upgrade 和 WORKDIR。对这种”薄 Dockerfile”,层缓存能省的空间本来就不大。
还有一点需要注意:apk upgrade 这一层被缓存了,就意味着它没有真正执行。只要基础镜像 digest 没变、缓存也还在,OS 安全补丁就不会随着每次构建自动更新,需要靠更新基础镜像 digest 来带入新补丁。
Notation 安装包缓存:同样看不出收益。 样本 4 缓存命中,这一步用了 15 秒;样本 5 没命中,反而只用了 8 秒。
多说几句
回头看这轮优化,有几点体会。
第一,最有效的优化往往是”不做”。跳过 main push 上重复的测试和 Sonar(每次省 5 分钟左右),比任何缓存技巧的收益都大。缓存只能让一件事做得快一点,而判断一件事根本不用做,省下的是全部时间。当然前提是分支保护规则真的能保证 PR 门禁一定跑过。
第二,固定 sleep 是流水线里最容易被忽视的浪费。旧的 ECR 轮询逻辑没什么问题,只是保守地写了两个 20 秒,每次构建就白白多等几十秒。改成”短间隔起步、逐步退避、拿到结果立即退出”之后,效果立竿见影。
第三,缓存的收益要结合具体的仓库来看。Docker 层缓存对”在镜像里跑 Maven 构建”的重型 Dockerfile 可能很有用,但对只拷一个 jar 的薄 Dockerfile 基本没意义。Maven 缓存对天天提交的服务很有用,对一两个月才合并一次的服务,缓存早就过期了。
第四,做优化之后,最好去看真实的运行记录,而不是想当然地认为”加了缓存就会变快”。这次如果不翻日志,我也会默认 Docker 层缓存和 Notation 缓存都起了作用。
最后说一句:在这份工作里,这轮流水线优化和这篇复盘,算是我最重要的收获。