第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 本章核心流程:依赖缓存与构建缓存协作示意
上图展示了三种工具在 CI/CD 中的依赖缓存与构建缓存协作逻辑:先通过锁文件确定依赖版本,命中依赖缓存后进入构建阶段;构建缓存层基于文件变更增量复用,最终通过校验保证一致性。
2.7 稳定性优化建议
- 统一缓存 Key 生成策略:使用
hashFiles(package-lock.json,pom.xml,**/Dockerfile*)作为缓存唯一标识,避免跨版本复用。 - 定期清理过期缓存:设置 TTL(如 7 天),通过脚本删除超过一定时间的缓存目录。
- 监控缓存命中率:在 CI 日志中输出“Cache hit”或“Cache miss”统计,结合告警工具跟踪。
- 隔离分支缓存:为 feature、develop、main 分支设置独立缓存命名空间,防止干扰。
在大型 monorepo 中,建议为每个子项目独立配置缓存路径和 key,避免一个子项目的变化导致整个仓库缓存失效。