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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
name: CI/CD Pipeline

run-name: >-
${{ github.event_name == 'release'
&& format('[Path-B · prod train] {0}', github.event.release.tag_name)
|| github.event_name == 'pull_request'
&& format('[PR gate] {0}', github.event.pull_request.title)
|| format('[Path-A · dev/qa] {0} #{1}', github.ref_name, github.run_number) }}

permissions:
contents: read
id-token: write
actions: write
pull-requests: write
issues: write

on:
pull_request:
branches:
- main
push:
branches: [main]
paths-ignore: ['tf/**', '**.md']
release:
types: [published]
workflow_dispatch:

concurrency:
group: cicd-${{ github.repository }}-${{ github.event_name }}
cancel-in-progress: true

jobs:
pipeline:
uses: <org>/devops/.github/workflows/reusable-java-ecs-pipeline.yml@main
with:
ecr_repository: <ecr-prefix>
codeartifact_domain: <codeartifact-domain>
secrets: inherit

真正的逻辑都在 DevOps 仓库里,调用链是:

1
2
3
4
服务仓库 pipeline.yml
-> reusable-java-ecs-pipeline.yml (facade:PR 门禁、build、Maven 发布、deploy 的编排)
-> reusable-ci.yml (pre-build:测试 + Sonar;build:打包、镜像、扫描、签名)
-> reusable-cd.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
2
3
4
5
6
7
8
9
10
11
pre-build:
# Skip push to refs/heads/main: tests/Sonar ran on PR; branch protection gates before merge.
if: >-
github.event_name == 'release'
|| github.event_name == 'pull_request'
|| (
github.event_name == 'push'
&& github.ref != 'refs/heads/main'
&& ...
)
|| ...

build 用 always() 配合 needs.pre-build.result == 'skipped' 来判断,只在 main push 这一种情况下接受 pre-build 被跳过:

1
2
3
4
5
6
7
8
9
10
11
12
13
build:
needs: pre-build
if: >-
always()
&& github.event_name != 'pull_request'
&& (
needs.pre-build.result == 'success'
|| (
needs.pre-build.result == 'skipped'
&& github.event_name == 'push'
&& github.ref == 'refs/heads/main'
)
)

前提是 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
2
3
4
5
6
7
8
9
build:
if: >-
always()
&& ...
&& (
github.event_name != 'push'
|| !contains(github.event.head_commit.message || '', 'chore(main): release')
|| contains(github.event.head_commit.message || '', '-SNAPSHOT')
)

在 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
2
3
4
5
6
7
8
source_tag_prefix="${SOURCE_SHA:0:7}-"
source_tag="$(jq -r --arg prefix "${source_tag_prefix}" \
'[.imageDetails[0].imageTags[]? | select(startswith($prefix))][0] // empty' \
<<< "${lookup_output}")"
if [[ -z "${source_tag}" ]]; then
echo "::error::Refusing to reuse an immutable release image built from a different source revision."
exit 1
fi

签名那一步本来就有一个类似的判断:如果这个 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
2
3
4
5
6
7
8
9
- name: Checkout DevOps repository
uses: actions/checkout@v7
with:
repository: <org>/devops
token: ${{ secrets.GH_PAT }}
path: .devops
sparse-checkout: |
.github/ci
${{ inputs.dockerfile_root }}/${{ inputs.project_name || github.event.repository.name }}

基础镜像校验脚本也通过 TARGET_DIR 限定为只校验当前服务的 Dockerfile。

这样做更主要的好处是解耦:以前校验脚本会遍历所有服务的 Dockerfile,一个无关服务的基础镜像出问题,会把其他服务的 CI 也卡住。

6. 按事件拆分 concurrency

1
2
3
concurrency:
group: cicd-${{ github.repository }}-${{ github.event_name }}
cancel-in-progress: true

同一事件类型的新 run 会取消旧 run,比如同一个仓库连续推两次 PR,前一次会被取消。group 里带上了 event_name,所以 main 合并后的 push 部署不会被同时进行的 Release PR 的 pull_request run 取消掉。

二、让必要的工作更快

7. ECR 扫描结果轮询:退避代替固定等待

镜像推到 ECR 后,我们用 ECR 增强扫描(Amazon Inspector)的结果做门禁:CRITICAL 和 HIGH 数量超过阈值就让 job 失败。

旧逻辑是内联在 workflow 里的一段 shell:

  1. 先固定 sleep 20;
  2. 每 15 秒查一次,最多 20 次;
  3. 发现扫描完成(imageScanCompletedAt 有值)后,再固定 sleep 20 等结果汇总,然后才读取 findings。

也就是说,即使扫描已经完成,至少也要等 40 秒。

新逻辑抽成了 .github/ci/poll-ecr-scan.sh,参数集中在 workflow 的 env 里:

1
2
3
4
5
INSPECTOR_POLL_INITIAL_SLEEP: 5
INSPECTOR_POLL_MAX_ATTEMPTS: 30
INSPECTOR_POLL_INITIAL_BACKOFF: 5
INSPECTOR_POLL_MAX_BACKOFF: 15
INSPECTOR_POST_COMPLETE_WAIT: 5

变化有三点:

  • 首次等待从 20 秒改成 5 秒;
  • 轮询间隔分档递增:第 1 到 6 次间隔 5 秒,第 7 到 12 次 10 秒,之后封顶 15 秒;
  • 扫描完成后不再固定等 20 秒,而是检查 findings 是否已经汇总(enhancedFindings 或 findingSeverityCounts 有内容),已经汇总就立刻判定,没有才等 5 秒再查。
1
2
3
4
5
6
7
8
9
backoff_for_attempt() {
local attempt=$1
local tier=$(( (attempt - 1) / 6 ))
local value=$(( INSPECTOR_POLL_INITIAL_BACKOFF * (tier + 1) ))
if [[ "$value" -gt "$INSPECTOR_POLL_MAX_BACKOFF" ]]; then
value=$INSPECTOR_POLL_MAX_BACKOFF
fi
echo "$value"
}

门禁阈值本身没有变化。脚本还带了一组本地测试用的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- name: Restore Maven cache
id: maven-cache
uses: actions/cache/restore@v6
with:
path: ~/.m2/repository
key: >-
setup-java-${{ runner.os }}-${{
runner.arch == 'X64' && 'x64' || runner.arch
}}-maven-${{ hashFiles(
'**/pom.xml',
'**/.mvn/wrapper/maven-wrapper.properties',
'**/.mvn/extensions.xml'
) }}
restore-keys: |
setup-java-${{ runner.os }}-${{
runner.arch == 'X64' && 'x64' || runner.arch
}}-maven-
1
2
3
4
5
6
- name: Save Maven cache
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v6
with:
path: ~/.m2/repository
key: ${{ steps.maven-cache.outputs.cache-primary-key }}

要点:

  • 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
2
3
4
5
6
7
8
9
10
11
- name: Build and push Docker image (single-module)
uses: docker/build-push-action@v7
with:
context: .
file: ${{ env.DOCKERFILE_PATH }}
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_IMAGE_TAGS }}
provenance: false
cache-from: type=gha,scope=${{ env.PROJECT_NAME }}
cache-to: type=gha,mode=max,scope=${{ env.PROJECT_NAME }}

有几点说明:

  • type=gha 缓存需要 job 有 actions: write 权限,所以 build job 和服务仓库的 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
2
3
4
5
6
7
8
9
10
11
12
13
FROM <account-id>.dkr.ecr.ap-southeast-1.amazonaws.com/<ecr-prefix>/eclipse-temurin:17-jre-alpine@sha256:...

RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*

WORKDIR /app

COPY target/*.jar app.jar

RUN mkdir -p /app/logs && \
chown -R appuser:appgroup /app/logs

RUN chown -R appuser:appgroup /app
...

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 缓存都起了作用。

最后说一句:在这份工作里,这轮流水线优化和这篇复盘,算是我最重要的收获。

参考资料