CI 构建缓存分层设计:依赖层、源码层与产物层分离缓存
一、一次 docker build 要跑 8 分钟,90% 的时间在下载依赖
翻看项目 CI 构建日志,npm install 占 3 分钟,apt-get install 占 2 分钟,pip install 占 2 分钟,实际编译源码只占 1 分钟。依赖下载成了一道无形的墙——每次改一行代码,也要重新下载 500MB 的第三方包。团队开始"忍"了,后来一天构建 40+ 次的 CI 费用月均涨到 2000 块,终于决定重构构建缓存。
分层缓存的核心思路很简单:把不变的、变化慢的、变化快的分离到不同的缓存层。但实施细节全是坑——层间依赖关系、失效策略、存储空间管理和跨节点的缓存共享,每个点都需要仔细设计。
二、三明治式的缓存分层架构
graph TD A["CI Pipeline 触发"] --> B["Layer 1: 系统依赖层<br/>缓存周期:月级"] B --> C["Layer 2: 语言依赖层<br/>缓存周期:周级"] C --> D["Layer 3: 源码层<br/>缓存周期:commit 级"] D --> E["Layer 4: 构建产物层<br/>缓存周期:branch 级"] B --> B1["apt packages / yum / apk"] B --> B2["系统级工具链<br/>(gcc, cmake, python3...)"] C --> C1["npm node_modules"] C --> C2["pip venv / maven .m2"] C --> C3["go mod cache"] D --> D1["源代码编译"] D --> D2["Lint / Type Check"] D --> D3["Unit Test"] E --> E1["docker image"] E --> E2["dist bundle"] E --> E3["doc / coverage report"] style B fill:#87CEEB,color:#000 style C fill:#50B86C,color:#fff style D fill:#F5A623,color:#fff style E fill:#FF6B6B,color:#fff分层原则:
- Layer 1(系统依赖层):变化最慢,一个月才更新一次。缓存重建时只追加不覆盖,按
{os_version}/{timestamp}命名。 - Layer 2(语言依赖层):依赖 lock 文件(
package-lock.json、go.sum、requirements.txt)的 hash 作为缓存 key。lock 文件不变化,这层缓存永久有效。 - Layer 3(源码层):变化最快,不存长缓存,而是利用 Docker BuildKit 的
--mount=type=cache将编译中间产物(node_modules/.cache、__pycache__)挂载为临时卷。 - Layer 4(产物层):按 Git branch 区分,main 分支的产物缓存独立保留,feature 分支的产物按 LRU 淘汰。
缓存 Key 计算策略
System Layer Key = sha256(os:version + packages:hash) Dep Layer Key = sha256(runtime:version + lockfile:hash) Source Layer Key = sha256(source:tree_hash) Artifact Key = sha256(branch_name + commit_hash)Lockfile hash 而非文件名作为 key 是精要——多人共用同一分支时,lockfile 内容相同则缓存命中。用文件名则可能出现 "main" 分支的 lockfile 缓存被某人本地修改污染的情况。
三、生产级多层缓存实现
Docker BuildKit 的多阶段 + 缓存挂载
# syntax=docker/dockerfile:1.4 # 必须声明 syntax 版本,BuildKit 的 cache mount 需要 1.3+ # ============ Layer 1: 系统依赖层 ============ FROM node:20-slim AS system-base # 系统工具链安装,这层极少变化 RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && \ apt-get install -y --no-install-recommends \ build-essential \ python3 \ git \ && rm -rf /var/lib/apt/lists/* # ============ Layer 2: 语言依赖层 ============ FROM system-base AS deps WORKDIR /app # 先只复制 lock 文件,利用 Docker 的层缓存 # 为什么先复制 lock 而非全部源码:lock 文件变化远少于源码, # 只要 lock 不变,这层就能命中 Docker 层缓存 COPY package.json package-lock.json ./ # npm ci 而非 npm install: # ci 依赖 lock 文件做确定性安装,并行度更高, # 配合 BuildKit 的 cache mount 缓存 npm 的下载文件 RUN --mount=type=cache,target=/root/.npm,sharing=locked \ npm ci --prefer-offline --no-audit --no-fund # ============ Layer 3: 源码编译 ============ FROM deps AS builder COPY . . # TypeScript 编译 + Lint RUN npx tsc --noEmit && \ npx eslint src/ --quiet # 生产构建 RUN npm run build # ============ Layer 4: 产物(运行镜像) ============ FROM node:20-slim AS production WORKDIR /app # 从 builder 复制 build 产物 + 运行时依赖 COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./ EXPOSE 3000 CMD ["node", "dist/server.js"]CI 配置:GitHub Actions 多缓存目标
name: CI Pipeline with Layered Cache on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # ===== Layer 1: 系统包缓存 ===== # 系统依赖变更是月级,缓存超时设 30 天 - name: Cache System Dependencies uses: actions/cache@v4 with: path: | /var/cache/apt /var/lib/apt key: system-${{ runner.os }}-${{ hashFiles('.github/system-packages.txt') }} restore-keys: | system-${{ runner.os }}- # ===== Layer 2: Node 依赖缓存 ===== - name: Cache Node Modules uses: actions/cache@v4 with: path: | ~/.npm node_modules key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} # 不设置 restore-keys 作降级命中: # 如果 lock 变了说明依赖变了,降级命中旧缓存会导致版本错乱 # 宁可全量安装也不吃旧缓存 # ===== Layer 3: 编译中间缓存 ===== # TypeScript 编译缓存(.tsbuildinfo),与源码相关 - name: Cache TypeScript Build Info uses: actions/cache@v4 with: path: | tsconfig.tsbuildinfo .eslintcache key: tsc-${{ runner.os }}-${{ hashFiles('src/**/*.ts') }} restore-keys: | tsc-${{ runner.os }}- # ===== Layer 4: Docker BuildKit 缓存 ===== # 使用 GitHub Actions 的 buildx cache,跨 Workflow Run 复用 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and Push Docker Image uses: docker/build-push-action@v5 with: context: . push: ${{ github.ref == 'refs/heads/main' }} tags: ${{ secrets.REGISTRY }}/app:${{ github.sha }} # cache-from / cache-to 配合 BuildKit 实现跨构建的层缓存 cache-from: type=gha,scope=buildkit-${{ github.ref_name }} cache-to: type=gha,mode=max,scope=buildkit-${{ github.ref_name }}四、分层缓存的陷阱与边界
缺点:
- 缓存体积膨胀:GitHub Actions 的缓存容量限制 10GB/仓库,Layer 2 + Layer 3 的 node_modules + .eslintcache 轻松突破 1GB。必须配置缓存淘汰策略,如保留最近 3 次构建的缓存。
- 跨平台缓存不兼容:
npm ci在 macOS 和 Linux 上安装的二进制包不同,如果 CI 是 Linux runner 而开发者本地是 macOS,缓存不能共享。必须用runner.os作为缓存 key 的一部分。 - Docker layer cache 的绑定问题:BuildKit 的
cache-to只能推送到 registry 或本地文件系统。跨 CI Agent 共享时需要用 registry 模式,拉取和推送各需额外 10-30s。
禁用场景:
- 微小型项目(编译 < 30s):缓存管理的复杂度已经超过编译本身。
- 有强制安全扫描要求的项目:缓存了旧依赖可能绕过依赖审计。
五、总结
CI 缓存分层设计的本质,是按变更频率把构建过程切成独立的缓存单元。Layer 1(系统)按 OS 版本缓存、Layer 2(依赖)按 lockfile hash 缓存、Layer 3(源码)用增量缓存、Layer 4(产物)按分支隔离。实施时注意三个关键点:缓存 key 的设计决定了命中率、跨平台缓存的兼容性需要显式处理、缓存空间上限和淘汰策略必须提前规划。