Hotdry.
systems-engineering

拆解 GitHub Actions 内建包管理器为何被斥为“最差”:锁机制、缓存雪崩与不可重现构建的工程教训

面对锁文件=key 却≠校验、升级即雪崩、7 天 TTL 不暴露时间戳等黑盒规则,给出可落地的 key 设计、限速与可重现清单。

“GitHub Actions 自带的包管理缓存是我用过最差的一块积木。”—— 某云原生团队在 200 个 Runner 同时爆燃 35 分钟后留下这句吐槽。本文把骂声翻译成技术拆解,再给出能立即落地的参数与清单。

一、黑盒里到底装了多少 “雷”

  1. 锁文件只管 key,不管校验
    setup-node 默认把 hashFiles('**/package-lock.json') 直接当缓存 key;只要哈希对不上,整包废弃重拉,没有增量补丁。锁文件本身并不写入缓存 tarball,也无法在解压后做二次校验 —— 相当于用门牌号当房产证。

  2. 缓存 “只写不改”
    官方文档白纸黑字:「不能修改已有缓存」。依赖一旦升级,旧 key 瞬间失效;如果 50 个 feature 分支同时 rebase,50 份新缓存会并发上传,10 GB 上限秒满,触发 “缓存雪崩”——Runner 排队从 2 min 飙到 30 min。

  3. restore-keys 的 “前缀回退” 在 monorepo 里放毒
    服务 A 升级了 eslint@9,服务 B 的 key 没命中,于是回退到 runner.os-node- 前缀,把 A 的旧 node_modules 抱回家,结果 B 产物里混进两份不同版本的 eslint,幽灵 Bug 上线。

  4. TTL 与时间戳黑箱
    GitHub 全局缓存 7 天过期,但 setup-* 不暴露最后命中时间。团队只能靠 “感觉” 改 key,每周一上午集体 cache-miss,重新拉包 3 GB,咖啡喝完构建还没完。

二、三条官方规则如何叠加成灾难

规则 触发条件 实际表现
hashFiles 精确 key 锁文件任一字符变化 整包重建,无增量
不能修改缓存 同一 key 已存在 强制写新 key,旧包立刻失效
restore-keys 前缀匹配 精确 key 未命中 回退到 “最近” 前缀,可能拿到别人过期依赖

当大版本升级撞上周一早高峰,三条规则同时点火:

  • 大量分支同时推送 → 锁文件变更 → 精确 key 统一失效
  • 不能改缓存 → 人人写新 key → 并发上传打满 10 GB
  • restore-keys 回退 → 跨目录拉错包 → 构建成功但产物错

于是出现 “绿色对勾交付错误代码” 的奇观。

三、可立即落地的 4 组参数

  1. key 加 “周序号”,强制滚动失效
    %U 把一年中的周序号拼进 key,每周第一次构建必然失效,避免 7 天 TTL 的 “黑箱抽奖”。

    key: ${{ runner.os }}-node-w$(date +%U)-${{ hashFiles('**/package-lock.json') }}
    
  2. path 拆目录,减少单包体积
    把 “包管理器全局缓存” 与 “项目 node_modules” 分开,降低单次上传量,也避免跨项目污染。

    path: |
      ~/.npm
      **/node_modules
    
  3. restore-keys 去前缀,禁止 “跨服务乱亲”
    只保留同服务、同 OS 的最近一次缓存,拒绝回退到兄弟目录的旧包。

    restore-keys: |
      ${{ runner.os }}-node-w$(date +%U)-
    
  4. 雪崩时手动限速 30% Runner
    在 repo settings → Actions → Runners 里把最大并发数临时砍 30%,让缓存上传错峰,避免 10 GB 配额瞬间被打光。

四、可重现构建的 5 步清单

  1. 锁文件必须进仓库
    没有 package-lock.json / pnpm-lock.yaml 就别谈重现;setup-node 会直接抛 Dependencies lock file is not found

  2. pin 包管理器版本
    package.json 写死 "packageManager": "pnpm@9.1.0",防止 Runner 偷偷升级,导致缓存内容漂移。

  3. CI 内核打标
    在 job 末尾加一步:

    - name: Export cache hit
      run: echo "CACHE_HIT=${{ steps.cache.outputs.cache-hit }}" >> $GITHUB_ENV
    

    把命中状态写进产物,任意分支都能回溯最后一次真正用到缓存的时间。

  4. 多项目仓库显式写 cache-dependency-path
    避免 glob 回退到根目录:

    cache-dependency-path: 'frontend/pnpm-lock.yaml'
    
  5. 定期 “冷构建” 验证
    每月手动清一次缓存(Settings → Actions → Caches → Delete all),强制走全量安装,确保锁文件与远端 registry 仍能对齐。

五、结语:官方不改,就只能自己接管 key

GitHub Actions 的 “内建包管理器” 把锁机制做成了看起来省心、实则踩坑的黑盒:锁文件只当门牌,不当房产证;缓存只能写不能改;TTL 与命中时间完全不透明。只要这三条规则不变,“最差” 标签就撕不掉。工程上的唯一出路是:

  • 把 key 设计权从官方手里夺回来
  • 用周序号、拆路径、去前缀、限速并发四件套,把雪崩概率压到可接受区间
  • 用 5 步清单把 “可重现” 变成强制门禁,而不是口头愿景

锁机制本应是确定性之锚,但在默认策略里却成了随机数发生器。与其等官方发新版,不如现在就把 key 写死、把并发限死、把清单焊死 —— 让 Runner 排队时间从 30 min 回到 3 min,让 “绿色对勾” 第一次真正代表可信的构建。


参考资料

  1. GitHub Docs《缓存依赖项以加快工作流程》, 2025-02
  2. 社区讨论《schedule 时区与缓存失效》, https://github.com/community/community/discussions/13454
查看归档