GitHub Actions 高级实战——矩阵构建、Reusable Workflow、缓存策略
GitHub Actions 高级实战——矩阵构建、Reusable Workflow、缓存策略
适读人群:有 GitHub Actions 基础、希望提升流水线效率的工程师 | 阅读时长:约18分钟 | 核心价值:掌握矩阵构建、可复用工作流与缓存策略三板斧,把构建时间砍掉一半
去年我们团队有一次比较惨痛的教训。
那是一个周五下午四点,我们要发布一个跨平台的 SDK,需要在 Linux、macOS、Windows 三个平台上跑测试,再加上 Node.js 12、14、16 三个版本,理论上要跑 9 个组合。
当时的流水线写法极其原始——九个 job 几乎是复制粘贴,每个 job 里都有 npm install,每次都要重新下载依赖。整条流水线跑一遍要 47 分钟。下班前来不及发布,我就在公司一直等到晚上八点,等到最后一个 job 绿了才推出去。
那之后我痛定思痛,花了几周时间把 GitHub Actions 的高级特性吃透了。现在同样的构建,14 分钟跑完。这篇文章把我学到的东西都写下来。
矩阵构建:一份配置,N 种组合
基础矩阵
最基础的矩阵是这样的:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [16, 18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test这会自动生成 9 个并行 job,每个 job 对应一种 os+node 组合。比手写 9 个 job 优雅太多了。
矩阵排除与包含
但真实场景往往更复杂。比如我们有个项目,Windows 上不需要跑 Node 16(有个历史遗留依赖在 Win+Node16 组合下有 bug,短期修不了),但又需要专门针对 macOS+Node20 做一个额外的 E2E 测试步骤。
exclude 和 include 就派上用场了:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [16, 18, 20]
exclude:
- os: windows-latest
node: 16
include:
- os: macos-latest
node: 20
run-e2e: true然后在 steps 里通过条件判断:
- name: Run E2E Tests
if: ${{ matrix.run-e2e == true }}
run: npm run test:e2eexclude 会从矩阵中移除指定组合,include 可以新增组合,或者给已有组合附加额外的变量。
踩坑一:matrix fail-fast 把你坑惨
默认情况下,矩阵的 fail-fast 是 true。这意味着一旦矩阵中有一个 job 失败,其他所有还在跑的 job 都会被取消。
听起来挺合理?但实际上这会让你很痛苦。
有一次我在 Windows 上的 job 一直挂(是个环境问题,和代码没关系),导致 Linux 和 macOS 的 job 全被取消了,我完全不知道真正的代码改动有没有问题。
解决方法就是显式关掉 fail-fast:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]关掉之后,所有 job 都会跑完,你能看到完整的结果。代价是会多消耗一些 Actions 分钟数,但这个代价值得付。
动态矩阵
还有一个进阶玩法:矩阵可以从上一个 job 的输出里动态生成。这在 monorepo 场景下特别有用——你只想测试本次 PR 涉及到的那些包,而不是所有包。
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.set-matrix.outputs.packages }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: set-matrix
run: |
# 找出有变更的 packages
CHANGED=$(git diff --name-only origin/main...HEAD | grep '^packages/' | cut -d'/' -f2 | sort -u | jq -R -s -c 'split("\n")[:-1]')
echo "packages=$CHANGED" >> $GITHUB_OUTPUT
test:
needs: detect-changes
if: ${{ needs.detect-changes.outputs.packages != '[]' }}
strategy:
matrix:
package: ${{ fromJson(needs.detect-changes.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/${{ matrix.package }} && npm ci && npm test这个方案可以把 monorepo 的 CI 时间压缩到极致,只跑真正需要测试的东西。
Reusable Workflow:告别复制粘贴
为什么需要可复用工作流
我接手过一个公司的 GitHub 仓库,里面有 20 多个微服务,每个微服务的 .github/workflows/ci.yml 都是从第一个复制粘贴过来的。
后来安全团队要求所有 CI 流程加一步 Trivy 镜像扫描,我的同事改了一天,20 多个仓库,每个都要手改,改完还要逐个检查有没有改漏、改错的。
Reusable Workflow 就是为了解决这个问题。
定义可复用工作流
在一个中心化的仓库(比如叫 org/ci-templates)里创建可复用工作流文件:
# .github/workflows/docker-build-push.yml
name: Docker Build and Push (Reusable)
on:
workflow_call:
inputs:
image-name:
required: true
type: string
dockerfile-path:
required: false
type: string
default: './Dockerfile'
build-args:
required: false
type: string
default: ''
secrets:
registry-token:
required: true
registry-username:
required: true
outputs:
image-tag:
description: "The full image tag that was pushed"
value: ${{ jobs.build.outputs.tag }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.image-name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.registry-username }}
password: ${{ secrets.registry-token }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ${{ inputs.dockerfile-path }}
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: ${{ inputs.build-args }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ steps.meta.outputs.tags }}
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'调用可复用工作流
在各个微服务仓库里,CI 变得极其简洁:
# 微服务 A 的 .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- run: ./mvnw test
build-and-push:
needs: test
uses: org/ci-templates/.github/workflows/docker-build-push.yml@main
with:
image-name: ghcr.io/org/service-a
secrets:
registry-token: ${{ secrets.GITHUB_TOKEN }}
registry-username: ${{ github.actor }}以后要给所有服务加安全扫描、改构建策略,只需要改 ci-templates 仓库里的一个文件,所有服务下次触发 CI 就自动用上了。
踩坑二:secrets 在 Reusable Workflow 里的传递
这是一个让我踩了很久的坑。
Reusable Workflow 里的 secrets 不会自动继承,必须显式传递。但如果你嫌麻烦,可以用 secrets: inherit,这样调用方的所有 secrets 都会透传给被调用的工作流:
jobs:
build-and-push:
uses: org/ci-templates/.github/workflows/docker-build-push.yml@main
with:
image-name: ghcr.io/org/service-a
secrets: inherit # 偷懒用法,把当前仓库所有 secrets 都传过去但这里要注意安全性——secrets: inherit 会把所有 secrets 都传过去,包括你可能不想传的。如果被调用的工作流来自外部(不是你自己的组织),一定要显式指定需要传递哪些 secrets。
Reusable Workflow 的版本管理
uses 字段支持引用具体的 tag、branch 或 commit SHA:
uses: org/ci-templates/.github/workflows/docker-build-push.yml@v2.1.0
# 或者
uses: org/ci-templates/.github/workflows/docker-build-push.yml@a1b2c3d # commit SHA在生产环境里,我建议引用具体的 tag 而不是 @main。用 @main 意味着中心仓库一旦有改动(哪怕是其他同事不小心推了个有问题的改动),所有服务的下次 CI 都会受影响。用 tag 更可控。
缓存策略:把构建时间砍掉一半
actions/cache 基础用法
最基础的缓存配置:
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-key 是精确匹配的缓存键——package-lock.json 变了就用新缓存。restore-keys 是前缀匹配的降级策略——精确键没找到时,会找最近一次匹配该前缀的缓存来用。
踩坑三:缓存键设计不当导致缓存永远不命中
我们有个项目,一开始把缓存键写成这样:
key: ${{ runner.os }}-${{ github.sha }}-npm把 commit SHA 放进了缓存键。结果每次 commit 都是新的 SHA,缓存永远不命中,白费力气。
缓存键的设计原则是:依赖文件的哈希值,而不是代码的哈希值。代码变了,但如果 package-lock.json 没变,依赖就不需要重新下载。
另外,restore-keys 的设计也很重要。下面这个配置是我在生产中用的:
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-精确键是 runner.os + pom.xml 的哈希,降级键是 runner.os + maven-。这样即使 pom.xml 改了,也能用上之前的缓存(大部分依赖没变),不用从头下载所有依赖。
Docker 层缓存
Docker 构建的缓存是另一个大头。用 docker/build-push-action 时,可以利用 GitHub Actions Cache 或者 registry 来存 Docker 层:
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/org/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha 就是用 GitHub Actions 的内置缓存存 Docker 层。mode=max 会缓存所有中间层,缓存效果最好但占空间也最多。
如果你的镜像有 registry 访问权限,用 registry 模式效果更好:
cache-from: type=registry,ref=ghcr.io/org/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/org/myapp:buildcache,mode=max缓存大小管理
GitHub Actions 的缓存有总量限制(免费账号是 10GB,超出后旧缓存会被自动淘汰)。如果你有很多仓库,要注意各仓库的缓存使用情况。
可以在 GitHub 仓库的 Actions > Caches 页面查看所有缓存的大小和最后使用时间,手动删除不再需要的缓存。
也可以用 API 自动清理:
- name: Delete old caches
run: |
gh cache list --json id,key,createdAt \
--jq '.[] | select(.createdAt < (now - 7*24*3600 | todate)) | .id' \
| xargs -I{} gh cache delete {}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}实战:一个完整的 Java 微服务 CI 流水线
把上面所有技巧综合起来,下面是一个完整的 Java 微服务 CI 流水线:
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
JAVA_VERSION: '17'
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
# 代码质量检查(并行)
code-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # SonarQube 需要完整的 git 历史
- uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: 'temurin'
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Run tests with coverage
run: ./mvnw verify -Pcoverage
- name: SonarQube Analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
run: |
./mvnw sonar:sonar \
-Dsonar.projectKey=my-service \
-Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml
# 多架构矩阵测试
test-matrix:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-22.04]
java: [17, 21]
exclude:
- os: ubuntu-22.04
java: 21
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
distribution: 'temurin'
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-j${{ matrix.java }}-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-j${{ matrix.java }}-
- run: ./mvnw test
# 构建并推送镜像(仅 main 分支)
build-push:
needs: [code-quality, test-matrix]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: org/ci-templates/.github/workflows/docker-build-push.yml@v2.0.0
with:
image-name: ${{ env.IMAGE_NAME }}
secrets: inherit这个流水线的特点:
- 代码质量检查和矩阵测试并行,不互相等待
- Maven 缓存按 OS + Java 版本分别存储,不会互相污染
- 镜像构建复用中心化模板,包含 Trivy 扫描
- 只有 main 分支的 push 才触发镜像构建,PR 只跑测试
深度解析:为什么矩阵构建能大幅节省 CI 时间
很多人知道矩阵构建是什么,但不一定理解它的时间效益是从哪里来的。
在没有矩阵之前,假设你需要跑 9 种环境组合的测试,你会怎么做?要么顺序执行(总时间是 9 个 job 的时间之和),要么手工写 9 个并行 job(代码重复量极大,维护困难)。
矩阵构建的价值在于:它把"声明组合"和"执行组合"两件事分开了。你只需要声明维度(操作系统、Java 版本),GitHub Actions 自动帮你展开成所有可能的组合,并以并行方式执行。
举个具体的数字例子:假设每个 job 跑 10 分钟,3 个 OS × 3 个 Java 版本 = 9 个 job。
- 串行执行:总时间 90 分钟
- 矩阵构建(并行):总时间约 10 分钟(加上排队和启动时间,实际约 12-15 分钟)
节省了 75-80% 的时间!这对开发者的体验影响是巨大的。一个 PR 如果要等 90 分钟才能合并,开发者的思路早就断了;如果 15 分钟就有结果,可以连续工作。
除了时间效益,矩阵构建还解决了一个很隐蔽的问题:配置漂移。当你手工维护 9 个 job 时,随着时间推移,这些 job 之间的配置会出现细微差异——某个 job 多了一个步骤,某个 job 用了不同的 timeout 设置,某个 job 的缓存键不一样……这些差异会导致测试结果不一致,让你误以为代码在某些环境下有问题,实际上是流水线配置有问题。
矩阵构建让所有组合共享同一份配置,配置漂移的问题从根本上消失了。这是它比"手工写多个并行 job"更优越的地方,而不只是代码量的节省。
还有一个值得注意的实践是矩阵与缓存的配合。矩阵中每个 job 的缓存键应该包含矩阵参数,否则不同 Java 版本的 job 会争用同一个缓存,导致缓存内容与环境不匹配的问题:
缓存键里包含矩阵变量,比如 key: ${{ matrix.os }}-${{ matrix.java-version }}-maven-${{ hashFiles('**/pom.xml') }},这样每个矩阵维度组合都有独立的缓存,互不干扰。
深度解析:Reusable Workflow 的设计哲学
很多人第一次看到 Reusable Workflow 的概念,会觉得它和普通的 GitHub Actions 差不多,不就是把代码提取出来吗?
但 Reusable Workflow 解决的问题比代码复用更深层:它解决的是跨仓库的一致性问题。
想象这样一个场景:你的公司有 50 个微服务仓库,每个仓库都有自己的 CI 流水线。某天安全团队要求所有服务的 CI 必须包含 Trivy 镜像扫描。如果不用 Reusable Workflow,你需要给 50 个仓库各提一个 PR,每个 PR 改 CI 配置,50 个 PR 审查合并,还要监控每个 PR 是否真的按要求改了。
有了 Reusable Workflow,你只需要在中心库里修改一个 YAML 文件,所有引用这个工作流的仓库,下次触发 CI 就自动包含了 Trivy 扫描。从改 50 个仓库变成了改 1 个文件,这才是它真正的价值所在。
进一步思考:这也意味着 CI 配置本身成了一种"平台能力",由平台团队维护,各业务团队消费。平台团队可以持续改进这个能力(加更好的缓存策略、更严格的安全扫描),而业务团队不需要感知这些变化——只要 Reusable Workflow 的接口(inputs/outputs)不变,底层实现怎么改都行。
这个设计哲学背后有一个更大的概念:内部开发者平台(Internal Developer Platform,IDP)。大型工程团队会把 CI/CD 基础设施视为一种内部平台产品,平台团队负责建设和维护,业务团队是消费者。Reusable Workflow 正是这种思想在 GitHub Actions 层面的具体实践。
使用 Reusable Workflow 时,有一个常见的误解需要纠正:Reusable Workflow 和 Composite Action 的区别。两者都能复用,但层级不同。Composite Action 在 job 内部复用步骤(相当于"函数"),Reusable Workflow 复用完整的 job 集合(相当于"模块")。如果你的复用单元需要自己的运行环境、需要在矩阵里使用、需要并行跑多个 job,用 Reusable Workflow;如果只是一组 steps,用 Composite Action。选错工具会让工作流结构变得混乱,所以理解这个区别很重要。
深度解析:缓存命中率的计算和优化
很多人加了缓存配置之后,不知道缓存到底有没有生效。一个实用的办法是在 CI 日志里找缓存命中的信息。
actions/cache@v4 的日志输出:
Cache hit for key: ubuntu-latest-npm-abc123
Restored cache from key: ubuntu-latest-npm-abc123或者缓存未命中:
Cache not found for key: ubuntu-latest-npm-abc123
Falling back to restore keys:
ubuntu-latest-npm-
Cache hit for restore key ubuntu-latest-npm-
Restored cache from key: ubuntu-latest-npm-xyz789 (fallback)通过查看 CI 日志里的缓存命中情况,可以判断缓存配置是否有效。如果每次都是 Cache not found for key,说明缓存键设计有问题,可能每次都在用不同的键。
另外,GitHub 的 Actions 缓存有大小限制(每个仓库默认 10GB)。如果缓存频繁被驱逐,可以在仓库设置里查看缓存使用情况,或者用 API 清理旧缓存。
提高缓存命中率的关键策略:选对缓存键。缓存键应该基于依赖描述文件的哈希(package-lock.json、pom.xml、requirements.txt),而不是代码本身的哈希。只有依赖真正变了,才需要创建新缓存。一个好的缓存策略,可以把每次 CI 里"下载依赖"这一步从几分钟缩短到几秒钟,累积起来是非常可观的时间节省。对于一个每天有 100 次 CI 运行的活跃项目,哪怕每次节省 3 分钟,一天就是 300 分钟的机器时间,折算到钱也是真实的成本。
几个值得收藏的小技巧
用 concurrency 控制并发:防止同一 PR 的多次 push 触发多个并行流水线互相干扰:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true环境保护规则:对生产环境部署加审批:
jobs:
deploy-prod:
environment:
name: production
url: https://myapp.com
# 在 GitHub 仓库设置里给 production 环境配置 Required reviewers
# 这个 job 就会等待审批才继续GITHUB_TOKEN 的权限控制:默认的 GITHUB_TOKEN 权限很宽,建议在工作流顶层限制:
permissions:
contents: read
packages: write
security-events: write只给需要的权限,最小权限原则。
总结
矩阵构建、Reusable Workflow、缓存策略,这三个是 GitHub Actions 从"能用"到"好用"的关键跨越。
矩阵构建解决的是多环境测试的组合爆炸问题;Reusable Workflow 解决的是多仓库重复维护的问题;缓存策略解决的是构建速度问题。
三个合在一起用,你会发现流水线既快又好维护。
值得一提的是,这三项技术不是 GitHub Actions 独有的,它们代表了 CI/CD 工具设计中的三种通用模式:并行化(矩阵)、复用(Reusable Workflow)、增量构建(缓存)。在 GitLab CI 里有相似的概念,在 Jenkins Pipeline 里也有对应的实现方式。掌握了这些模式背后的思想,换任何一个 CI/CD 工具都能快速上手。这也是为什么学习工具要学原理而不只是学语法——语法会变,原理不会。
最后说一句:CI 流水线也是代码,要像对待业务代码一样认真对待。写清楚注释,定期 review,别让它变成没人敢动的黑盒子。
