Skip to content

分层缓存优化:掌握 Docker 构建加速的核心机制

在现代 CI/CD 流水线中,Docker 构建往往是耗时最长的环节之一。一个低效的 Dockerfile 可能导致每次代码变更都触发全量依赖安装,将构建时间从秒级拉长至数分钟。而优化的关键,就在于理解并利用 Docker 的分层缓存机制

Docker 并非每次都从头构建镜像,而是采用分层缓存(Layer Caching) 策略:每条 Dockerfile 指令的执行结果会被缓存为一个只读层。当下次构建时,如果某一层的输入未变,Docker 就直接复用缓存,跳过执行。

掌握这一机制,是实现“秒级构建”的前提。

一、Docker 缓存机制:自顶向下的缓存复用

Docker 缓存的规则是:

缓存从第一层开始逐层比对,一旦某一层的输入发生变化,其后的所有层都将失效,必须重新执行。

这里的“输入”包括:

  • 指令本身的内容(如 RUN npm ci vs RUN npm install
  • COPYADD 的文件内容
  • 构建参数(--build-arg)等

这意味着:变更越靠后的指令,缓存失效范围越小;变更越靠前的指令,缓存失效越严重。

二、COPY package*.json ./RUN npm ciCOPY . . 为何是黄金顺序?

在 Node.js、TypeScript、Nest 项目中,依赖安装(npm ci)通常是构建中最耗时的步骤。我们希望:仅当 package.jsonpackage-lock.json 变更时,才重新安装依赖;源码变更不应触发重装。

为此,必须精心设计 Dockerfile 指令顺序:

dockerfile
# 阶段 1:仅复制依赖描述文件
COPY package*.json ./

# 阶段 2:安装依赖(耗时操作)
RUN npm ci

# 阶段 3:复制所有源码
COPY . .

为什么这个顺序关键?

  1. 分离变更频率不同的文件

    • package*.json:变更频率低(通常仅在添加/删除依赖时修改)。
    • .(源码):变更频繁(每次代码提交都可能修改)。
  2. 最大化缓存命中

    • 如果你只修改了 src/main.ts,Docker 会:
      • 检查 COPY package*.json ./:文件未变 → 命中缓存
      • 执行 RUN npm ci:上层缓存命中 → 命中缓存
      • 执行 COPY . .:源码变更 → 缓存失效,重新复制
    • 结果:跳过耗时的 npm ci,构建速度极快。
  3. 避免“缓存污染”
    如果顺序颠倒:

    dockerfile
    COPY . .
    RUN npm ci  # 每次代码变更都触发重装!

    即使只改了一行代码,COPY . . 层也会失效,导致 npm ci 必须重新执行,构建时间剧增。

三、npm ci vs npm install:为何 CI 环境必须用 ci

Dockerfile 中,应始终使用 npm ci 而非 npm install 进行依赖安装。原因如下:

特性npm installnpm ci
输入依据package.jsonpackage-lock.jsonnpm-shrinkwrap.json
安装速度较慢(可能解析依赖树)极快(按锁定文件精确安装)
输出一致性可能生成新的 package-lock.json严格遵循锁定文件,输出完全一致
node_modules 处理增量更新,可能保留旧包删除现有 node_modules,全新安装
适用场景开发环境CI/CD、生产构建

关键优势:npm ci 删除 node_modules 重装

  • 避免缓存污染:Docker 构建可能复用旧的 node_modules 目录(如来自缓存或构建上下文),其中可能包含:
    • 已废弃的依赖
    • 不一致的版本
    • 污染的缓存文件
  • npm ci 强制清空并重新安装,确保依赖状态纯净,符合“不可变构建”原则。

正确做法:

dockerfile
COPY package*.json ./
RUN npm ci --only=production

错误做法:

dockerfile
COPY . .
RUN npm install --production  # 可能受旧 node_modules 影响

四、--cache-from:CI 中如何复用远程缓存?

在本地开发时,Docker 守护进程会自动管理缓存。但在 CI/CD 环境(如 GitHub Actions、GitLab CI)中,每次构建通常在全新虚拟机上执行,本地缓存为空,导致每次都从头构建。

解决方法是使用 --cache-from 指令,从远程 Registry 拉取先前构建的镜像作为缓存源

工作原理

--cache-from 允许 Docker 在构建时“预加载”指定镜像的层作为缓存候选。即使这些镜像不会被直接使用,它们的层仍可加速当前构建。

CI 流水线示例(GitHub Actions)

yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Load cache
        run: |
          docker pull myapp:latest || echo "No cache found"

      - name: Build and push
        run: |
          docker build \
            --cache-from myapp:latest \
            --tag myapp:${{ github.sha }} \
            --tag myapp:latest \
            --push .

关键点:

  • --cache-from myapp:latest:告诉构建器可以复用 latest 镜像的层作为缓存。
  • 即使 latest 镜像未被运行,其层仍可加速当前构建。
  • 构建完成后,新镜像推送到 Registry,为下次构建提供缓存。

高级选项:使用 Buildx 和 Registry Cache

bash
docker buildx build \
  --cache-to type=registry,ref=myapp:buildcache \
  --cache-from type=registry,ref=myapp:buildcache \
  -t myapp:latest \
  --push .

此方式将缓存直接存储在 Registry 中,不占用镜像标签空间,更适用于多分支并行构建场景。

五、总结:构建高性能 CI/CD 流水线的关键

  1. 指令顺序至关重要
    COPY package*.jsonRUN npm ciCOPY . 是 Node.js 项目的标准模式,确保依赖缓存最大化。

  2. 使用 npm ci 而非 npm install
    在 CI 环境中,npm ci 提供更快、更一致、更纯净的依赖安装,是生产级构建的标配。

  3. 启用远程缓存
    通过 --cache-from 或 Buildx 的 Registry Cache,让 CI 构建也能享受本地缓存的速度。

  4. 结合 .dockerignore
    避免 node_modules.env 等文件进入构建上下文,防止缓存意外失效。

当你将这些实践融入 Dockerfile 和 CI 配置后,你会发现:即使在 CI 环境中,90% 的代码变更也能在 10 秒内完成构建。这不仅是效率的提升,更是开发体验的飞跃。