先量体积,再选策略
CI 变慢的第一步永远是量化。用 `git count-objects -vH` 看对象库大小,用 `git rev-list --count HEAD` 数提交数,用 `du -sh .git` 看磁盘占用。超过 1GB 或提交数过万时,完整克隆的成本主要来自 pack 文件解包与索引构建,而不是网络传输。
定位大文件用 `git verify-pack -v .git/objects/pack/*.idx | sort -k3 -n -r | head -20`,配合 `git rev-list --objects --all` 找到体积最大的路径。绝大多数「仓库太大」的真实原因只有几个:误提交的二进制资源(视频、模型权重、构建产物)、多年未清理的历史分支、以及大体积依赖被 vendor 进仓库。
注意历史是只增不减的:即使当前目录树只有 1MB,只要历史里提交过一个 2GB 的 tar,克隆时仍要下载它。所以清理必须用 `git filter-repo` 重写历史并强推,而不是简单地 `git rm` 一个文件。
# 量化仓库体积
git count-objects -vH
git rev-list --count HEAD
du -sh .git
# 找出最大的历史对象及其路径
git verify-pack -v .git/objects/pack/*.idx | sort -k3 -n -r | head -20
git rev-list --objects --all | git cat-file --batch-check="%(objecttype) %(objectname) %(objectsize) %(rest)" \
| awk "$1==\"blob\" {print $3, $4}" | sort -n -r | head -20
# 清理历史大文件(会改写所有提交 sha,需强推)
git filter-repo --path build/ --path "*.zip" --invert-paths
git push --force --mirror origin浅克隆与 partial clone 的真实边界
`--depth 1` 只拉取最近一次提交,能把克隆从数分钟降到数秒,但它带来一个硬约束:历史操作会失败。`git log` 看不到历史、`git blame` 报 missing commits、`git describe --tags` 找不到标签。需要历史时再按需加深:`git fetch --deepen=100`,或直接 `git fetch --unshallow`。
`--filter=blob:none` 是 partial clone:只拉提交与树对象,blob(文件内容)按需从远端取。它对「历史里很多大二进制」的仓库效果显著,因为只 checkout 当前分支时需要的 blob 会自动补齐。代价是每个按需取回都是一次网络往返,在网络差时反而可能更慢。
两者可以叠加使用,但要注意服务器支持:`--filter` 需要服务器开启 `uploadpack.allowFilter`;`--depth` 与 partial clone 在部分老版本服务器上有兼容问题。稳妥的组合是 `--depth 50 --filter=blob:none --no-tags`,既保证 blame 能工作,又避免拉取全部历史 blob。
# 日常 CI 克隆:够用且可 blame
git clone --depth 50 --filter=blob:none --no-tags --branch "$CI_BRANCH" \
"https://github.com/org/repo.git" .
# 需要完整历史或 tag 时按需加深
git fetch --deepen=200
git fetch --tags
# 明确放弃历史(纯构建场景)
git clone --depth 1 --single-branch --no-tags "https://github.com/org/repo.git" .
# 提交历史很大时用 sparse-checkout 只取需要的目录
git sparse-checkout init --cone
git sparse-checkout set packages/api packages/shared缓存键设计:命中率与正确性
CI 缓存键(cache key)必须在命中率和正确性之间取舍。常见错误是把分支名放进键里,导致每个分支、每个 PR 都有独立缓存,命中率极低。更实用的做法是分层:主键用 lockfile 哈希 + 运行时版本 + 架构,次键用分支名做前缀并在主键未命中时回退查找。
回退查找(restore-keys)是命中率的关键。以 `node_modules-` 这类前缀做回退,命中一个较旧的缓存再执行 `npm ci` 增量修复,远快于从零安装。反之若把键设计得过于精确(包含 lockfile 全哈希),一次依赖微调就会让整条流水线回到冷启动。
另一个必须避免的陷阱是缓存 `node_modules` 却不在 restore 后重装。如果原生模块与平台或运行时版本相关,缓存会在跨版本时静默损坏。可靠做法是缓存包管理器的下载缓存(npm 的 `_cacache`、pnpm 的 store)而非安装结果,安装过程每次重跑。
# GitHub Actions:主键精确 + 前缀回退
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: pnpm-${{ runner.os }}-${{ runner.arch }}-node${{ hashFiles("pnpm-lock.yaml") }}
restore-keys: |
pnpm-${{ runner.os }}-${{ runner.arch }}-node
# GitLab CI:分阶段缓存,同样避免键过窄
stages: [deps, test]
cache:
key:
files:
- pnpm-lock.yaml
prefix: "pnpm-$CI_OS"
paths:
- .pnpm-store/
- node_modules/
deps:
script:
- corepack enable
- pnpm install --frozen-lockfile
test:
script:
- pnpm test制品与源码分离
把构建产物和源码混在一起是缓存设计崩坏的起点。正确结构是:源码仓库只存源码,CI 构建产出不可变制品(镜像 tar、tar.gz、jar、wheel),通过制品库(GHCR、Nexus、S3)分发,部署阶段按不可变 tag 拉取,不再 checkout 源码。
这一分离带来三个直接收益:部署可回滚到任意历史 tag(因为产物不可变);构建只需在源码变化时跑一次,部署变成纯拉取;生产环境不需要安装编译工具链,镜像可以做到极小与最小攻击面。
配一条强制规则:部署只接受由 CI 产出的带签名的制品 tag,禁止部署阶段执行任何构建命令。一旦允许部署时本地构建,产物就不再可复现,缓存与回滚的收益同时失效。
# CI:构建并推送不可变制品(tag 含 git sha,天然不可变)
docker build -t ghcr.io/org/app:$GIT_SHA -t ghcr.io/org/app:latest .
docker push ghcr.io/org/app:$GIT_SHA
echo "artifact=ghcr.io/org/app:$GIT_SHA" >> $GITHUB_OUTPUT
# 部署:只按 tag 拉取制品,不构建、不 checkout 源码
git checkout $TARGET_SHA
git pull --ff-only
docker pull ghcr.io/org/app:$ARTIFACT_TAG
docker tag ghcr.io/org/app:$ARTIFACT_TAG ghcr.io/org/app:running
docker compose up -d --no-build
# 回滚:换成旧 sha tag 重新拉取即可,无需重新构建
# ARTIFACT_TAG=<previous-sha> ./deploy.sh