GitHub Actions 缓存没命中?安全凭据乱飞?一次把流水线摸透

最近把一个中型 Java 项目从 Jenkins 迁到 GitHub Actions,本以为能少操不少心,结果第一条流水线跑了四十几分钟。其中拉 Maven 依赖就花了二十分钟,而且每次 Push 都重新拉,缓存形同虚设。排查后发现,自己在 actions/cache 的用法上漏了一个关键细节:key 里的 commit hash 永远在变,缓存自然永远 miss。

这个坑不大,但足以说明一个问题:GitHub Actions 的易用性是有边界的。你用得越深,越需要理解它的工作原理,否则随手复制的模板只会带来隐蔽的性能问题和安全隐患。

这篇文章想把自己在这条流水线上踩过的坑、查过的资料、最终落地的方案整理一遍。不追求面面俱到,只讲实际遇到的问题及其解法。

一、缓存的原理与常见误区

GitHub Actions 构建 CI 流水线:缓存加速与安全实践

GitHub Actions 的缓存机制基于 actions/cache,底层是 GitHub 提供的对象存储。每次 workflow run 开始时,Runner 会检查缓存 key 是否命中;命中则自动恢复,未命中则正常执行任务,并在结束后保存新缓存。

理解这一流程很重要,因为它直接决定了缓存策略的设计。

1.1 Key 的设计才是核心

actions/cache 的核心配置是 path 和 key:

- uses: actions/cache@v4
  with:
    path: ~/.m2
    key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      ${{ runner.os }}-maven-

这里用了三层设计:

  • path:要缓存的目录,通常是依赖目录,如 ~/.m2、node_modules、vendor 等
  • key:缓存的标识,变化时会自动创建新缓存
  • restore-keys:兜底匹配,支持前缀匹配,防止 key 微小变化导致全部 miss

常见的错误写法:

# 错误:commit hash 永远变化,缓存永远 miss
key: ${{ runner.os }}-maven-${{ github.sha }}

# 错误:没有 restore-keys,pom.xml 变化时旧缓存直接失效
key: maven-cache

github.sha 是每次提交的新值,用它做 key 等于每次都没命中。正确做法是用 hashFiles 对依赖描述文件做 hash,只有 pom.xml 或 build.gradle 变化时才重建缓存。

1.2 缓存大小的硬限制

GitHub 对每个仓库的缓存总量有限制:默认 10 GB,超出后 newest cache 会被优先删除。单个缓存条目最大 5 GB。

这个限制对大多数项目够用,但遇到以下几种情况容易踩坑:

  • 依赖目录包含大量无用文件(如 Maven 的 repository 下缓存的 debug 源码)
  • 多个 job 重复缓存相同路径,消耗双倍空间
  • 长期不清理,历史缓存堆积

可以通过 API 查看当前缓存使用情况:

gh api repos/{owner}/{repo}/actions/cache-used-storage

1.3 哪些路径值得缓存,哪些不值得

缓存的收益取决于两个因素:下载耗时和路径大小。对照这两点,可以给出一个粗略的判断框架:

路径典型大小下载耗时是否推荐缓存
~/.m2/repository1-3 GB5-15 分钟推荐
node_modules200-500 MB1-5 分钟推荐
vendor (PHP)100-300 MB1-3 分钟推荐
.gradle/caches500 MB-1 GB3-8 分钟视情况
构建产物 target/50-200 MB已构建完成不推荐
Git 仓库本身动态不可控用 actions/checkout 自带

构建产物通常不需要缓存,因为每次构建本身就是生产该产物的过程,缓存产物只会带来一致性问题——你不确定缓存里的是否是最新代码编译的结果。

1.4 多 Job 场景下的缓存传递

一个常见的设计是:build job 负责构建,test job 负责测试。如果两个 job 都配置了相同的 actions/cache,它们会各自缓存一份依赖目录,浪费空间。

更优的做法是让 test job 复用 build job 的缓存,通过 artifacts 传递:

# build job
- run: mvn dependency:go-offline -B
- uses: actions/upload-artifact@v4
  with:
    name: m2-cache
    path: ~/.m2/repository

# test job
- uses: actions/download-artifact@v4
  with:
    name: m2-cache
    path: ~/.m2/repository

这样依赖只缓存一份,test job 直接从 artifact 获取,速度更快且节省缓存配额。

但这个方案也有代价:artifact 存储在 GitHub 的对象存储中,有 7 天过期时间,且单次上传下载有大文件 IO 开销。对于构建时间短的项目,直接用 actions/cache 反而更简单。

二、安全实践:凭据管理与权限控制

GitHub Actions 构建 CI 流水线:缓存加速与安全实践

CI 流水线最常见的安全问题不是漏洞扫描没过,而是凭据泄露。GitHub Actions 提供了 secrets 机制,但用不对反而会暴露更多信息。

2.1 Secrets 的基本用法

最基础的用法是在 workflow 中引用:

- name: Deploy
  run: echo "$DEPLOY_TOKEN" | docker login ...
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

这里 secrets.DEPLOY_TOKEN 的值在日志中会被自动遮蔽,不会明文输出。但有几个细节需要注意:

  • 在 run 命令中直接引用 ${{ secrets.XXX }} 也是安全的,GitHub 会自动处理遮蔽
  • 但通过 echo $SECRET 或 printenv SECRET 查看值是危险的,日志中会明文显示
  • 脚本文件中引用 secret 同样会被遮蔽,前提是文件内容不通过 cat 或 echo 输出

2.2 分支保护与权限边界

GitHub 的分支保护规则可以限制 workflow 的触发条件。推荐在 main 分支开启以下保护:

  • 要求 Pull Request 审核
  • 禁止强制推送
  • 限制可触发 workflow 的人或 group

更重要的是 workflow 中的权限设置。GitHub Actions 默认会给 GITHUB_TOKEN 授予 contents: read 权限,但在需要写操作时,很多人会直接提升权限:

permissions:
  contents: write
  pull-requests: write

权限提升要遵循最小原则:需要什么就给什么,不要给 admin 或 all。

一个容易被忽视的场景是 fork pull request。当外部贡献者 fork 你的仓库并提交 PR 时,workflow 运行在 fork 的上下文里,GITHUB_TOKEN 没有写入权限,即使你配置了 permissions 也不会生效。这是一个安全设计上的故意限制,避免恶意 PR 直接操作主仓库。

如果需要在这种场景下访问 secrets,可以临时启用 write 权限,但这是有风险的:

permissions:
  contents: write

启用后,fork PR 触发的 workflow 可以访问仓库 secrets。这个选项在 repository settings → Actions → General → Workflow permissions 中全局控制,也可以在单个 workflow 中通过 permissions 覆盖。

2.3 第三方 Action 的安全风险

引入第三方 Action 时,需要关注几个风险点:

1. Action 的代码是否可信

actions/checkout、actions/setup-node 这些官方 Action 由 GitHub 维护,风险较低。但社区 Action 质量参差不齐,有些可能存在供应链攻击风险。

一个基本的检查方式是看 Action 的仓库:star 数、维护频率、issue 响应情况。

2. persist-credentials 参数的陷阱

actions/checkout 默认会持久化 git 凭据到本地配置:

- uses: actions/checkout@v4
  with:
    persist-credentials: false  # 推荐关闭

如果不关闭,后续步骤中的 git 操作可能使用这个凭据,意外推送代码或访问其他仓库。对于不需要 git push 的场景,建议显式设置为 false。

3. Action 版本锁定

使用具体 SHA 而不是 tag:

# 不安全:tag 可能被重新指向恶意代码
- uses: some-action@v1

# 安全:锁定到具体提交
- uses: some-action@a1b2c3d4e5f6...

Tag 指向的 commit 可以被推送方修改,SHA 则不可变。对于生产环境使用的 Action,锁定 SHA 是更稳妥的做法。

2.4 环境变量与日志泄露

除了 secrets,还有一些信息容易在日志中意外暴露:

  • IP 地址:通过 curl ifconfig.me 等方式获取公网 IP 并打印
  • 内部主机名:容器或 Runner 的内部网络信息
  • 系统配置:磁盘路径、环境变量等

排查这类问题的工具是 GitHub 的 audit log:

gh api repos/{owner}/{repo}/actions/runs/{run_id}/logs

可以直接下载某个 workflow run 的全部日志,然后搜索敏感关键词。

2.5 依赖扫描与漏洞检测

CI 流水线的另一个安全职责是依赖漏洞检测。对于 Java 项目,推荐集成 OWASP Dependency-Check:

- name: OWASP Dependency-Check
  uses: owasp/dependency-check-action@v2
  with:
    project: my-project
    format: HTML
    out: reports

这个 Action 会在流水线中生成漏洞报告,可以作为质量 gate 阻止合并。

对于 Node.js 项目,npm audit 或 yarn audit 同样可以在 build 阶段运行。

需要注意的是,依赖扫描的结果受数据库更新频率影响,存在滞后性。扫描通过不代表完全安全,只是代表已知漏洞已修复或无严重风险。

三、流水线设计取舍

缓存和安全解决了性能和凭据问题,但流水线的整体设计同样重要。这里分享几个常见的取舍决策。

3.1 单 workflow vs 多 workflow

GitHub Actions 支持在一个 repository 下定义多个 workflow 文件,也可以在一个文件中通过 jobs 定义多个任务。

单 workflow 的优势是逻辑集中,方便理解整体流程。多 workflow 的优势是职责分离,不同分支可以触发不同流程。

我的取舍标准是:

  • 如果所有任务有强依赖关系(如 build 后才 test,test 后才 deploy),放一个 workflow
  • 如果有独立触发的场景(如 nightly build、依赖更新、文档部署),拆成独立 workflow
# 独立 workflow:依赖更新
name: Dependabot
on:
  schedule:
    - cron: '0 2 * * *'
  workflow_dispatch:

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm audit

3.2 自托管 Runner vs GitHub-hosted Runner

GitHub-hosted Runner 开箱即用,但存在几个限制:

  • 资源固定:Ubuntu 默认 2 CPU、7 GB 内存,无法满足大构建需求
  • 网络受限:无法访问内网服务
  • 成本:GitHub Actions 每分钟有免费额度,超出后按量计费

自托管 Runner 可以解决这些问题,但需要自己维护机器、处理安全更新、监控状态。

我的建议:

  • 个人项目或小团队:用 GitHub-hosted,够用且省事
  • 企业环境:用自托管,可以接入内网服务、使用专属镜像、控制数据不出域

自托管 Runner 的注册很简单:

# 在 repository settings 中获取注册 token
mkdir actions-runner && cd actions-runner
curl -O https://actions-runner-downloads.s3.amazonaws.com/latest/github-actions-runner-linux-x64-*.tar.gz
tar xzf ./github-actions-runner-*.tar.gz
./config.sh --url https://github.com/{owner}/{repo} --token <registration-token>
./run.sh

3.3 并发控制与队列管理

多分支同时提交 PR 时,GitHub Actions 会并发运行多个 workflow。如果没有并发控制,可能同时触发数十个构建任务,占用大量 runner 资源。

可以通过 concurrency 配置来控制:

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

这会在同一个 group 下取消正在运行的 workflow,只保留最新的一个。对于 PR 场景很有用——开发者频繁推送时,不需要等待前面的构建完成。

但要注意,cancel-in-progress: true 会中断正在运行的任务,可能导致部分日志丢失。如果需要保留完整日志用于排查,可以改为 false。

3.4 构建矩阵的合理使用

matrix 配置可以同时运行多个不同环境的构建:

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        java: [17, 21]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: 'temurin'

矩阵适合测试多版本兼容性的场景,但不适合无限扩展。矩阵组合过多会显著增加构建时间和 runner 消耗。

我的经验是,矩阵维度控制在 2-3 个,每个维度 2-4 个值,总组合数不超过 12 个。超过这个数量,考虑拆成多个 workflow 或按需触发。

四、常见问题排查

4.1 缓存未命中的排查步骤

缓存是 CI 中最容易出问题的环节之一。遇到缓存未命中时,按以下顺序排查:

第一步:确认 key 是否正确

在 workflow run 的输出中搜索 Cache,可以看到 key 的计算结果:

Cache restored from key: Ubuntu-maven-a1b2c3d4...

如果显示 Cache not found,说明 key 匹配失败。

第二步:检查 hashFiles 的路径

hashFiles 支持 glob 模式,但路径必须相对于仓库根目录。常见的错误是路径写错:

# 错误:路径不存在或拼写错误
key: ${{ hashFiles('**/pom.xml') }}

# 正确:确认文件路径
key: ${{ hashFiles('**/pom.xml', '**/build.gradle') }}

多个文件可以用逗号分隔,任意一个变化都会导致 key 变化。

第三步:确认 restore-keys 的优先级

如果配置了 restore-keys,GitHub 会按顺序尝试匹配。优先级从高到低:

  1. 精确匹配 key
  2. 前缀匹配第一个 restore-keys
  3. 前缀匹配第二个 restore-keys
  4. ...

如果没有精确匹配,会回退到 restore-keys,恢复最近的匹配缓存。

第四步:检查缓存大小和配额

使用 gh api 查看缓存总量:

gh api repos/{owner}/{repo}/actions/cache-used-storage

如果已接近限额,旧缓存会被自动清理。

4.2 Secrets 未生效的排查

最常见的症状是 workflow 运行时报 undefined secret 错误。排查顺序:

确认 secret 已添加

GitHub → Repository → Settings → Secrets and variables → Actions,确认 secret 存在。

确认 secret 名称大小写

Secret 名称是大小写敏感的,DEPLOY_TOKEN 和 deploy_token 是两个不同的 secret。

确认 workflow 触发了正确的权限

如前所述,fork PR 触发的 workflow 默认无法访问 secrets。需要在 repository settings 中开启 "Allow GitHub Actions to create and approve pull request" 或在 workflow 中配置 permissions。

确认没有在日志中泄露

搜索 workflow run 的日志,确认没有 echo $SECRET 或类似操作。GitHub 会自动遮蔽 ${{ secrets.XXX }} 的值,但脚本中通过变量引用的值不会被遮蔽。

4.3 Runner 超时与内存不足

GitHub-hosted Runner 的资源限制是固定的。遇到 OOM 或超时错误时:

超时调整

timeout-minutes: 30

默认超时是 6 小时,但多数命令级 step 没有单独超时,整个 job 的超时是全局的。

内存不足

如果构建过程内存超过 7 GB,GitHub-hosted Runner 会被 OOM kill。解决方案:

  1. 使用 self-hosted runner,配置更大内存的机器
  2. 优化构建过程,减少内存占用,如 Maven 的 MAVEN_OPTS:
    env:
      MAVEN_OPTS: "-Xmx2g -XX:MaxMetaspaceSize=512m"
    
  3. 拆分构建步骤,避免一次性加载全部依赖

4.4 网络问题与下载失败

国内访问 GitHub 有时不稳定,导致 actions/checkout 或依赖下载失败。解决方案:

使用镜像源

- uses: actions/checkout@v4
  with:
    repository: actions/checkout
    ref: v4
    # 或使用淘宝镜像
    token: ${{ secrets.GITHUB_TOKEN }}

配置 Maven 镜像

- name: Configure Maven
  run: |
    mkdir -p ~/.m2
    cat > ~/.m2/settings.xml << EOF
    <settings>
      <mirrors>
        <mirror>
          <id>aliyun-maven</id>
          <mirrorOf>*</mirrorOf>
          <url>https://maven.aliyun.com/repository/public</url>
        </mirror>
      </mirrors>
    </settings>
    EOF

注意:镜像源的选择要考虑安全风险,内网私有仓库不应指向公共镜像。

五、总结

GitHub Actions 的入门门槛很低,但要做好需要理解几个底层机制:

  1. 缓存的 key 设计:用内容 hash 而不是提交 hash,配合 restore-keys 做兜底
  2. 权限的最小化:permissions 按需配置,第三方 Action 锁定 SHA
  3. 流水线的职责分离:构建、测试、部署分阶段,独立触发
  4. 问题的系统化排查:缓存未命中查 key,secrets 未生效查权限和名称

这些都是容易踩坑的地方,但一旦理解了原理,配置起来就会从容很多。流水线不是配完就一劳永逸的东西,随着项目增长和团队变化,需要定期review和优化。

我的习惯是每个季度跑一次 gh api repos/{owner}/{repo}/actions/runs,看看有没有长期不用的 workflow、过大的缓存、频繁的失败构建,然后逐一清理。保持流水线的简洁,和保持代码的简洁一样重要。

posted @ 2026-10-29 21:26  框架师  阅读(8)  评论(0)    收藏  举报