AB
AiBoss站
教程

加速 GitHub Actions CI:定位慢步骤、缓存、并行分片与跳过无用任务

教程

加速 GitHub Actions CI:定位慢步骤、缓存、并行分片与跳过无用任务

编码代理让提交量暴涨,CI 从偶尔等待变成常态排队。这篇教程按「先测量、再优化」的顺序,讲清五类可落地的 GitHub Actions 提速手段:用运行数据找出最慢的步骤、用稳定 key 缓存依赖与 Docker 层、把重活放到更快的 runner、用矩阵分片并行跑测试,以及用路径过滤、affected-only 测试和并发组砍掉不必要的运行。每一步都给出可直接改的 workflow 片段。

编码代理把写代码的速度提上去了,但检查代码的速度没跟上。当代理一个下午开出十个 PR,原本「偶尔等一会儿」的流水线就变成了排队系统:人等结果,代理等重试,写代码省下来的时间又在检查环节还了回去。这篇教程面向维护 GitHub Actions 工作流的工程师,讲的是同一件事——在不牺牲检查质量的前提下,把 CI 的墙钟时间压下来。全文按「先测量、再优化」的顺序展开五类手段:定位慢步骤、缓存可复用的东西、换更快的硬件、把测试拆到并行任务、以及干脆不跑不需要跑的任务。每一类都给出可以直接改的 workflow 片段,最后用一个完整示例把它们串起来。

准备工作

动手之前,先确认你手上有这些东西:

  • 仓库的 Actions 权限:能查看 workflow 运行记录、能修改 .github/workflows/ 下的 YAML 文件。缓存、矩阵、并发组都是 workflow 级别的配置,不需要额外开通什么服务。
  • 最近若干次运行的历史数据:GitHub 会为每次运行记录每个 job 的执行时间,并细分到每个 step。这是后面所有判断的依据,没有它就只能靠猜。
  • 一个能本地跑通的测试命令:分片依赖测试框架自带的分片参数,先确认你的测试运行器支持它(Jest 与 Playwright 都提供 --shard)。
  • 对仓库目录结构的了解:路径过滤和 affected-only 测试都要求你知道哪些目录对应哪些构建产物,monorepo 尤其如此。

另外要建立一个预期:这五类手段的收益差别很大,而且取决于你的工作流长什么样。一个每天跑五十次、每次省两分钟的改动,价值远高于一个每天只跑一次的任务上的精巧优化。所以顺序很重要,先测量。

操作步骤

第一步:先测量,再优化

大多数团队会凭直觉猜哪一步最慢,而且经常猜错。一个感觉像「测试太慢」的 job,拆开看可能是 90 秒装依赖加 30 秒跑测试。所以第一步不是改配置,而是读数据。

打开你最慢的那个 workflow 最近十次运行,逐个 job 看 step 级别的耗时,记下最长的三个 step。这里有三个判断要点:

  • 用中位数,不要用最差的一次。单次运行的异常值可能只是网络抖动,按最差情况优化会把你引向错误的方向。
  • 把「等待」和「干活」分开。排队时间、环境准备时间、实际执行时间,三者的解法完全不同。
  • 如果 job 在队列里等了一分钟,调测试是没用的。那属于并发与调度问题,对应后面的并行与并发组部分。

把这三类时间分别记下来,你就知道接下来该动哪一刀了。

第二步:缓存依赖与构建层

最快的工作是从不重复的工作。每次运行都从一台干净的机器开始,没有缓存就意味着同样的包要一遍遍重新下载。actions/cache 的模型很简单:你给它一个路径和一个 key,key 命中就恢复文件,没命中就正常执行,并在 job 结束时把文件存下来。整套机制的关键全在 key 上。

常见做法是对锁文件做哈希,这样只有依赖真的变了,key 才会变:

- uses: actions/cache@v6
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-

restore-keys 是最容易被漏掉的一行。当精确 key 没有命中时,它会退而求其次恢复最接近的旧缓存。结果是你仍然要装几个新包,但不用全量重装。

反过来,要避免把时间戳或 commit SHA 写进 key。这类 key 永远不会命中第二次,于是每次运行都保存一份新缓存,却一次也用不上,既浪费存储又拖慢流程。

Docker 构建需要同样的思路。镜像按层构建,只有上面所有层都没变,某一层才会被复用。所以正确的 Dockerfile 顺序是:先复制锁文件并安装依赖,再复制源码。这样改代码不会触发依赖安装层的重建。

要让层在多次运行之间保留下来,把 BuildKit 指向 GitHub Actions 的缓存后端,cache-from 与 cache-to 都设为 type=gha。这里有一个容易忽略的代价:这些缓存存放在远端存储,每次恢复都是一次网络下载,缓存很大时,下载耗时可能和它替代掉的安装过程差不多。缓存不是免费的,值得在测量时把它算进去。

第三步:把重活放到更快的硬件上

缓存解决的是「不重复做」,这一步解决的是「做得更快」。两者不冲突,可以叠加。

GitHub 为私有仓库提供的标准托管 runner 是 2 vCPU、7 GB 内存。如果瓶颈确实在 CPU 或 I/O 上,换 runner 是改动最小、收益最直接的一招。托管 runner 的规格与计费方式会变化,具体以 GitHub 官网当前信息为准。

换 runner 通常只改一行:把 runs-on: ubuntu-latest 换成目标服务提供的标签,例如 runs-on: avrea-ubuntu-latest,workflow 其余部分保持不变。这类服务通常还自带缓存能力,把缓存放在与 runner 相同的数据中心,恢复时不必跨网络。多数情况下无需改 workflow:GitHub Actions 缓存以及 npm、pip、Go modules、Cargo 的包下载可以直接工作;Go、Bazel、Gradle、Turborepo、Nx、sccache 等构建缓存已在 runner 上配置好,个别工具需要少量 workflow 补充。

不同服务商的路线不一样,选型时值得对照自己的情况:

  • Depot 提供更快的 GitHub Actions runner 与远程 Docker 构建,并且有自己的 CI 引擎,可以接受 GitHub Actions workflow。
  • RunsOn 是自托管路线:在你自己的 AWS 账户里启动 EC2 实例,计算费用付给 AWS,另加一笔固定的年度授权费,而不是按分钟计费。
  • GitHub 自家的 larger runners 在 Team 与 Enterprise Cloud 方案中提供,增加 CPU、内存与磁盘,并可选静态 IP、GPU 等,按分钟计费。

这些服务的规格、价格与可用区域变动频繁,务必以各家官网当前信息为准。更稳妥的做法是拿你自己的 workflow 去对比,而不是只看厂商公布的加速倍数——公开基准测试通常关闭构建缓存,测的是纯硬件差异,和你的实际场景未必一致。

第四步:并行与测试分片

每个 job 都变快之后,下一个问题是别让它们排队。一个跑在单台 runner 上的长测试套件是典型瓶颈。

先拆独立 job。 lint、类型检查、单元测试、构建之间通常没有依赖关系。GitHub 默认并行执行 job,除非你写了 needs。检查一遍 workflow,把那些只是出于习惯而存在的 needs 删掉。

再用矩阵分片。矩阵策略会为每个取值组合创建一个 job,它们同时运行。团队常用它测多个 Node 版本,但它同样是分片测试最简单的办法:

strategy:
  fail-fast: false
  matrix:
    shard: [1, 2, 3, 4]
steps:
  # 每个 job 跑整个套件的四分之一
  - run: npx jest --shard=${{ matrix.shard }}/4

Jest 的 --shard 按测试文件切分套件。如果各文件耗时接近,四个分片能把十二分钟的套件压到三分钟左右,再加上各自的准备时间。fail-fast: false 让所有分片都跑完,这样你能一次看到全部失败,而不是修一个再等下一轮。

Playwright 的用法相同:每个矩阵 job 跑 npx playwright test --shard=1/4,最后把报告合并起来。

分片有上限。每个分片都要付自己的准备成本,超过某个点之后,增加分片只会增加总成本而不省时间。另外,如果少数几个文件特别重,会有一个分片明显跑得更久,这时应该把那些文件本身再拆开。在单个 job 内部,pytest-xdist 这类工具可以把测试铺到本机所有 CPU 上,这是分片之外的另一种并行。

第五步:每次运行少做点事

这是最便宜的一类优化:不需要跑的东西就别跑。

路径过滤是最容易上手的。workflow 语法里的 paths 与 paths-ignore 可以让 workflow 只在特定文件变化时触发。改一个 docs 目录下的文件,不应该触发完整的后端构建。

但这里有个坑:如果某个必需的状态检查因为路径过滤而根本没有运行,PR 会一直卡在 pending 状态。对必需检查,正确做法是让 workflow 照常运行,再用 if: 条件跳过具体 job——GitHub 把被跳过的 job 计为通过。

在 monorepo 里只跑受影响的测试。Nx、Turborepo、Bazel 这类工具能算出一次改动触及了哪些包,跳过其余的。Nx 里是 nx affected -t test,Turborepo 里是 turbo run test --affected。两者都会把当前分支与 main 做比较,所以必须检出足够的 Git 历史——actions/checkout 默认只取一个 commit,不补历史这个比较就无从谈起。大多数提交只触及大型代码库的一小部分,省下来的量会累积起来;当代理频繁开出只改一两个包的小 PR 时,这个收益尤其明显。

取消没人需要的运行。连续推送两次,第一次的运行已经过时了。并发组可以把它取消掉:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  # 新推送到来时取消较旧的运行
  cancel-in-progress: true

AI 工具可能一小时内多次更新同一个分支,每次推送都启动一次完整运行,这个配置的价值会随推送频率上升。但不要把它用在部署 workflow 上:把一次部署中途取消可能留下不一致的状态,部署应该排队而不是取消。

一个完整示例

下面是一个把上述手段串起来的最小 workflow。它包含:依赖缓存、矩阵分片测试、路径过滤、并发取消,以及一个被跳过的必需检查。示例中的 runner 标签使用标准托管 runner,换成其他服务时只需改 runs-on。

name: CI

on:
  pull_request:
    paths-ignore:
      - 'docs/**'
      - '**/*.md'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - uses: actions/cache@v6
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ runner.os }}-
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - uses: actions/cache@v6
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ runner.os }}-
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/4

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "build steps here"

  # 必需检查:workflow 照常触发,用 if 跳过 job,GitHub 计为通过
  integration:
    runs-on: ubuntu-latest
    if: ${{ !contains(github.event.pull_request.changed_files, 'docs/') }}
    steps:
      - uses: actions/checkout@v4
      - run: echo "integration tests here"

这个例子能跑通的最小闭环是:开一个只改 docs/ 下文件的 PR,观察 workflow 是否被路径过滤挡掉、必需检查是否仍然显示通过;再开一个改源码的 PR,观察四个测试分片是否同时启动、缓存是否在第二次运行时命中。连续推送两次,确认第一次运行被并发组取消。

跑通之后,按这个顺序继续改:先看测量数据决定哪一刀最值得动;缓存用稳定 key,Dockerfile 把慢层放在前面;重活迁到更快的 runner,并用你自己的 workflow 对比服务商;分片加到准备时间开始占主导为止;最后用路径过滤、affected-only 测试和取消过期运行砍掉无用工作。

注意事项

  • 一次只改一处,改完重新看耗时。同时改缓存、分片和 runner,你无法判断收益来自哪里,也无法发现某一项其实让流程变慢了。
  • 缓存 key 不要包含时间戳或 commit SHA。这类 key 永不重复命中,结果是每次运行都存一份新缓存却从不复用。
  • 远端缓存有网络成本。Docker 层缓存存放在远端存储,恢复是一次下载,缓存很大时可能抵消掉它省下的安装时间。
  • 路径过滤会让必需检查卡住。如果必需状态检查因路径过滤而未运行,PR 会停留在 pending。对必需检查改用 if: 跳过 job,被跳过的 job 计为通过。
  • affected-only 测试需要足够的 Git 历史。actions/checkout 默认只取一个 commit,与 main 比较前必须补足历史。
  • 不要在部署 workflow 上启用 cancel-in-progress。中途取消部署可能留下不一致状态,部署应排队执行。
  • 分片不是越多越好。每个分片都付一份准备成本,超过临界点后只增加成本不省时间;耗时严重不均的文件需要单独再拆。
  • runner 规格、价格、可用方案变动频繁。本文提到的 vCPU 与内存数字、各服务商的计费方式,均以各家官网当前信息为准,不要按旧数据做预算。
  • 公开基准测试不等于你的场景。厂商公布的加速倍数通常在关闭构建缓存的条件下测得,测的是纯硬件差异,选型时请用自己的 workflow 实测。