第2章 依赖缓存与构建缓存区分:npm/Maven/Docker Layer 缓存机制

2.1 核心概念与对比

在 CI/CD 流水线中,依赖缓存与构建缓存是两个容易混淆但本质不同的优化手段。依赖缓存保存的是第三方包(如 npm 的 node_modules、Maven 的 .m2/repository、Docker 的基础镜像层),而构建缓存保存的是项目编译或打包过程中产生的中间产物(如 JAR 包、编译后的 .o 文件、Docker 业务层)。混淆二者会导致缓存策略失效或存储膨胀。

维度 依赖缓存 构建缓存
缓存对象 第三方库、包、镜像层 编译/打包中间产物
典型工具 npm cache, Maven local repo, Docker image layers ccache, Gradle build cache, Docker multi-stage build
生命周期 随依赖版本变化失效 随源文件变更失效
复用场景 同项目、同依赖版本可完全复用 仅增量编译时可部分复用
CI/CD 配置要点 持久化远程缓存(如 S3,Nexus) 明确 key by 变更文件 checksum

2.2 npm 依赖缓存机制

npm 将已下载的包存储在全局缓存目录中,避免每次 npm install 重复联网下载。缓存内容包含压缩包(.tgz)和解压后的元数据。

缓存位置与配置

# 查看当前缓存路径
npm config get cache
# 默认路径(Linux/macOS):
# ~/.npm/_cacache

# 设置自定义缓存目录(用于CI)
npm config set cache /cache/npm

# 清理缓存
npm cache clean --force

# 查看缓存内容统计
npm cache ls 2>/dev/null | wc -l

CI/CD 中的缓存策略

GitLab CI 示例:使用 cache 关键字持久化 npm 缓存,并基于 package-lock.json 的 hash 生成缓存键。

my-npm-job:
  stage: install
  cache:
    key: ${CI_COMMIT_REF_SLUG}-npm-${hashFiles('package-lock.json')}
    paths:
      - node_modules/
      - .npm/_cacache/    # 缓存 npm cache 目录
  script:
    - npm ci --prefer-offline  # 优先从缓存安装
注意:npm ci 比 npm install 更适合 CI,它会直接使用 package-lock.json,删除现有 node_modules 后重新安装,保证环境一致性。

2.3 Maven 依赖缓存机制

Maven 的依赖缓存位于本地仓库(默认 ~/.m2/repository),保存从远程仓库下载的 JAR、POM、SNAPSHOT 等文件。Maven 通过 checksum 和元数据判断缓存是否有效。

本地仓库配置

# 修改 settings.xml 中的本地仓库路径
<settings>
  <localRepository>/cache/maven/repository</localRepository>
</settings>

# 强制更新所有 SNAPSHOT 依赖(跳过缓存)
mvn clean install -U

缓存清理与排除干扰

# 删除特定依赖的缓存
mvn dependency:purge-local-repository -DmanualInclude="com.example:my-lib:1.0"

# 查看依赖解析路径(调试缓存命中)
mvn dependency:resolve -Dverbose

# CI 中缓存 .m2/repository 并设置 key
cache:
  key: ${CI_COMMIT_REF_SLUG}-maven-${hashFiles('pom.xml','**/pom.xml')}
  paths:
    - .m2/repository/
Maven 缓存的稳定性问题通常源于 SNAPSHOT 依赖的更新策略。建议在 CI 中定期执行 mvn clean install -U 强制刷新,或设置 -DupdatePolicy=always。

2.4 Docker Layer 缓存机制

Docker 构建时,每条 RUN、COPY、ADD 指令都会产生一个只读层(Layer)。如果某层输入未变,Docker 会复用之前构建的缓存层,大幅加速镜像构建。这是构建缓存的典型代表,但与依赖缓存密切相关:基础镜像层可视为依赖缓存。

利用 Layer 缓存的 Dockerfile 编写原则

# 1. 将不常变的依赖安装放在前面
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production   # 此时 layer 缓存命中最频繁

# 2. 复制源码(变化频繁,放在后面)
COPY . .
RUN npm run build

# 3. 多阶段构建:最终镜像只包含产物
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html

缓存失效的常见原因

  • RUN 指令:如果上一行变化(如 COPY 了新的文件),后续所有 RUN 层都失效。
  • 基础镜像 tag 变化:如 FROM node:18-alpine 指向了新的 digest,全部失效。
  • 构建时变量:通过 --build-arg 传入的变量变化也会导致层缓存失效。
# 查看每层是否命中缓存(构建日志中会显示 CACHED)
docker build --no-cache=false -t my-app .

# 显式使用 --cache-from 指定远端缓存
docker build --cache-from my-app:latest -t my-app:new .
Docker Layer 缓存的最佳实践:将依赖安装与源码复制分离,并在 CI 中为每个分支或 PR 使用独立的缓存 tag 以避免污染。

2.5 三种缓存机制对比与选择

根据项目类型和工具链,合理组合依赖缓存与构建缓存。以下是一个决策流程:

  • 前端项目(npm):优先依赖缓存(npm cache + node_modules),构建缓存意义较小(除非使用 Webpack 持久化缓存)。
  • Java/Maven 项目:依赖缓存(.m2/repository)是核心;构建缓存可通过 Gradle Build Cache 或 Maven POM 增量编译实现。
  • 容器化项目(Docker):既要缓存基础镜像层(依赖缓存),又要设计好多阶段构建的顺序(构建缓存)。
常见错误:在 CI 中同时缓存了整个项目工作目录(包括 node_modules/.m2),但缓存 key 设置过于宽泛(如仅按分支名),导致不同构建之间互相覆盖,反而降低缓存命中率。必须使用 hashFiles(file) 或 lock 文件来决定缓存 key。

2.6 本章核心流程:依赖缓存与构建缓存协作示意

依赖缓存与构建缓存协作流程(npm / Maven / Docker) CI/CD 流水线启动 npm 依赖缓存 ~/.npm/_cacache npm ci --prefer-offline 离线安装 Maven 依赖缓存 ~/.m2/repository mvn dependency:resolve 从本地仓库解析 Docker 依赖缓存 基础镜像层 COPY package*.json 层复用检测 构建缓存层(编译/打包) ccache / Gradle build cache / Docker layer 缓存一致性校验 checksum / digest 匹配 缓存键策略: hashFiles(lock文件)

上图展示了三种工具在 CI/CD 中的依赖缓存与构建缓存协作逻辑:先通过锁文件确定依赖版本,命中依赖缓存后进入构建阶段;构建缓存层基于文件变更增量复用,最终通过校验保证一致性。

2.7 稳定性优化建议

  • 统一缓存 Key 生成策略:使用 hashFiles(package-lock.json,pom.xml,**/Dockerfile*) 作为缓存唯一标识,避免跨版本复用。
  • 定期清理过期缓存:设置 TTL(如 7 天),通过脚本删除超过一定时间的缓存目录。
  • 监控缓存命中率:在 CI 日志中输出“Cache hit”或“Cache miss”统计,结合告警工具跟踪。
  • 隔离分支缓存:为 feature、develop、main 分支设置独立缓存命名空间,防止干扰。
在大型 monorepo 中,建议为每个子项目独立配置缓存路径和 key,避免一个子项目的变化导致整个仓库缓存失效。