一、问题从一次构建失败说起

你有没有遇到过这种场景:项目在自己电脑上构建Docker镜像,几十秒就完了,感觉很轻松。结果一推到GitHub,GitHub Actions里构建同一个小项目,却要花上三分钟甚至更久,而且每次构建都像是从零开始,慢得让人抓狂。更气人的是,你只是改了几行业务代码,Docker却把之前辛辛苦苦装好的依赖又重装了一遍。我当时第一次遇到这个情况,第一反应是“GitHub Actions的机器是不是太弱了?”后来才发现,问题出在我对Docker镜像构建一个关键机制的理解上——缓存。

其实不只是GitHub Actions,任何CI系统里跑Docker构建,只要不主动管理缓存,都会出现类似的“重复劳动”。今天我们就来把这个事彻底讲明白,尤其是多阶段构建配合层缓存时那些容易踩的坑,以及怎么用GitHub Actions把缓存真正利用起来,让构建速度快起来。

二、Docker镜像构建的缓存机制

2.1 层是什么?

要理解缓存失效,先得理解Docker镜像是怎么组成的。我们平时写一个Dockerfile,里面有很多行指令,比如FROMRUNCOPY。每一行指令执行完,都会生成一个“层”。你可以把层想象成一本便签纸,每贴一张新的便签,都不会动到下面已经贴好的内容。Docker构建的过程,就是一层一层往上叠加的过程。

举个例子,一个最简单的Node.js项目Dockerfile,里面大致是这样:

# 技术栈:Node.js
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD ["node", "app.js"]

这里FROMWORKDIRCOPY package.jsonRUN npm installCOPY . .CMD,每一条对应一个层。其中RUN npm install这个层,是基于之前的层再执行命令产生的。

2.2 缓存命中的条件

Docker在构建时,会检查每一层是否已经有缓存。怎么判断有没有缓存呢?主要看两个东西:一是这条指令前面的所有层是否和上次构建完全一样;二是这条指令本身的内容是否一样。

比如RUN npm install这条指令,它依赖的上一个层是由COPY package.json ./产生的。如果package.json的内容变了,那么COPY生成的层就变了,后面的RUN npm install缓存自然就失效了,必须重新执行。反过来,如果你只改了项目里的业务代码文件,而package.json没变,理论上RUN npm install这一层是可以命中的,因为它的输入没有变化。

但是问题来了:如果我们在Dockerfile里写的顺序不对,比如先COPY . .RUN npm install,那不管代码怎么改,整个目录和上次都不一样了,RUN的缓存永远会失效。这也就是“层缓存失效”最常见的根源之一。

三、多阶段构建的好处与陷阱

3.1 多阶段构建怎么用

多阶段构建可以让我们在一个Dockerfile里写多个FROM,最终只保留最后一个阶段产生的文件。好处很明显:前面阶段装的构建工具、下载的依赖包,都不会出现在最终镜像里,镜像能小很多。

比如我们要构建一个前端项目,需要先安装依赖,然后执行构建命令生成静态文件,最后用一个轻量级的Nginx镜像来托管这些静态文件。用多阶段构建可以这样写:

# 技术栈:Node.js + Nginx
# 第一阶段:构建前端资源
FROM node:20-alpine AS build
WORKDIR /app
# 空格后面加注释,美观
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build

# 第二阶段:运行环境
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

第一阶段安装了所有npm依赖,执行npm run build生成了dist目录。第二阶段只要把dist复制过来,Nginx自身负责托管静态文件,这样最终镜像里没有源代码,也没有node_modules,体积小了很多。

3.2 为什么缓存容易失效

多阶段构建看起来很美,但它的缓存失效问题也更容易被忽视。上面这个例子,第一阶段里的COPY package.json ./RUN npm install是两层。如果package.json没有变,那RUN npm install理论上能命中缓存。但问题出在下一行COPY . .。只要你改了任何一个业务文件,这一层就会发生变化,但它并不会让RUN npm install重新执行,因为RUN npm install在它前面,已经执行完了。真正会重新执行的是RUN npm run build,因为它的上一层是COPY . .,而COPY . .已经变了。

所以这时候缓存策略应该是:把“能复用的指令”写在前面,把“容易变化的指令”写在后面。依赖安装放在源码复制之前,就是一个经典优化。

但GitHub Actions上还有一个更隐蔽的问题:即使你的Dockerfile写得很完美,比如先复制package.json再安装依赖,然后复制源码,但每次构建时Docker默认不会保持之前构建产生的层缓存。因为GitHub Actions的runner是一个全新的虚拟机,上一次构建的缓存并不存在。你需要显式地告诉它:请把缓存保存下来,下次构建时再加载。

四、GitHub Actions中的缓存提供商

4.1 默认的GHA缓存与Docker BuildKit

GitHub Actions内置了一种缓存机制,叫actions/cache,它可以把指定路径保存下来,下次运行同一个workflow时再恢复。对于Docker构建,我们可以配合BuildKit来使用缓存导出功能。BuildKit是Docker的下一代构建引擎,它比传统的builder更快,也支持更复杂的缓存方式。

在GitHub Actions中,Docker构建默认使用的是BuildKit,我们可以通过两个参数来控制缓存:cache-fromcache-tocache-from用来指定从哪里加载缓存,cache-to指定构建完成后把缓存保存到哪里。常见的做法是直接把缓存保存到GitHub Actions的缓存服务里,用type=gha这个类型。

4.2 使用Buildx和cache-from/to

docker buildx是BuildKit的命令行工具,它在GitHub Actions里已经预装了。我们可以用它来构建镜像,并且把缓存写入GHA缓存中。注意,build-push-action是GitHub官方提供的一个Action,它可以很方便地调用buildx,我们只需要在它下面配置cache-fromcache-to即可。

下面是一个完整的workflow示例,展示如何为上面的Node.js多阶段构建配置缓存。

五、一个完整的示例:Node.js项目

5.1 Dockerfile示例

我们以最简单的Node.js应用为例,整个项目结构如下:

  • package.json
  • app.js
  • public/ 静态文件目录

Dockerfile适合用多阶段构建,但为了展示缓存效果,我们把阶段拆成两个:一个用来安装依赖和打包,另一个用来运行。当然,如果只是Node服务,其实一个阶段就够了。但多阶段更符合常见场景,所以我们仍然用两个阶段。

# 技术栈:Node.js
# 第一阶段:构建生产依赖
FROM node:20-alpine AS build
WORKDIR /app

# 先复制package.json和package-lock.json(如果有)
# 这样可以利用依赖层的缓存
COPY package.json ./
# 如果存在package-lock.json,也复制过去
COPY package-lock.json ./

# 安装生产依赖
RUN npm install --production

# 复制业务源码
COPY . .

# 第二阶段:运行阶段
FROM node:20-alpine
WORKDIR /app
# 从build阶段复制node_modules和源码
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/app.js .
COPY --from=build /app/public ./public

# 指定启动命令
CMD ["node", "app.js"]

注意我们在第一阶段里先复制了package.jsonpackage-lock.json,然后执行npm install,最后才复制整个项目目录。这样源码的变化不会影响到npm install这一层的缓存。

5.2 GitHub Actions workflow示例

下面是一个完整的workflow,它会在每次推送代码到main分支时构建镜像并推送到Docker Hub(或GitHub Container Registry)。关键是配置cache-fromcache-to

# 技术栈:Node.js + GitHub Actions
name: Build and Push Docker Image

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    # 给予足够的权限,才能推送镜像和写缓存
    permissions:
      contents: read
      packages: write

    steps:
      # 检出代码
      - name: Checkout code
        uses: actions/checkout@v4

      # 设置Docker Buildx,它和多阶段缓存配合得很好
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # 登录到GitHub Container Registry
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # 构建并推送镜像,同时导出和导入缓存
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .           # 构建上下文为当前目录
          file: ./Dockerfile   # 指定Dockerfile路径
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha  # 从GHA缓存中读取构建缓存
          cache-to: type=gha,mode=max  # 保存缓存到GHA,mode=max保存所有层的缓存

5.3 解释cache-from和cache-to参数

cache-from: type=gha意味着在构建之前,先从GitHub Actions的缓存服务里调取之前构建产生的层缓存。如果没有,那就从零开始。cache-to: type=gha,mode=max表示构建成功之后,把当前构建的缓存保存到GitHub Actions的缓存服务里,mode=max表示尽量保存更多层的信息,这样下次命中概率更高。

这里有几个细节值得注意:

  • cache-to只在构建成功后才执行,如果构建失败,缓存不会更新。
  • type=gha要求runne有权限写缓存,通常需要actions: write权限,我们在workflow里已经用permissions字段开启了。
  • cache-to使用了mode=max,这会让缓存体积比较大,但命中率更好。如果你希望减小缓存体积,可以改成mode=min,但可能缓存不了多阶段构建的中间层。

下面我们再看一个更贴近实际的前端项目,里面有一个麻烦:构建阶段需要安装开发依赖(devDependencies),而运行阶段只需要生产依赖。我们依然可以用多阶段构建来处理。

# 技术栈:Node.js (前端)
FROM node:20-alpine AS build
WORKDIR /app

# 复制依赖清单
COPY package.json package-lock.json ./
# 安装全部依赖(包括devDependencies)
RUN npm ci

# 复制源码
COPY . .
# 执行构建,生成dist目录
RUN npm run build

# 运行阶段:用Nginx托管静态文件
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

这个Dockerfile里的npm ci是更严格的安装命令,它要求必须有package-lock.json,并且会严格按照锁文件安装,缓存效果比npm install更稳定。

对应的workflow可以写成这样:

# 技术栈:Node.js 前端 + GitHub Actions
name: Frontend Docker Build

on:
  pull_request:
    paths:
      - 'src/**'
      - 'Dockerfile'
      - '.github/workflows/*.yml'

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: false   # 仅构建,不推送
          cache-from: type=gha
          cache-to: type=gha,mode=max

这里push: false只是构建镜像,不推送到仓库,但缓存依然会被保存。这很适合在PR中验证镜像是否构建成功,不需要实际推送。

六、常见坑和解决思路

6.1 依赖文件变化导致缓存失效

即使配置了cache-fromcache-to,缓存也不是万无一失的。最典型的坑是package.jsonpackage-lock.json经常变动,导致npm ci这一层每次都会缓存失效。

解决思路有两个:一是尽量减少依赖文件的变动频率,比如只修改业务代码,不要随便升级依赖版本;二是可以将依赖文件所在的层拆得更细,比如先复制package.json,再复制package-lock.json,但实际意义不大,因为锁文件一变,package.json也会跟着变。更有效的做法是在Dockerfile里利用Docker的--mount=type=cache特性,为npm或yarn配置专用的包缓存目录。

比如,我们可以把RUN npm ci改成:

# 技术栈:Node.js + BuildKit
RUN --mount=type=cache,target=/root/.npm \
    npm ci

这样npm下载的包会存储在BuildKit的缓存里,即使层缓存失效,重新构建时也不需要从网络重新下载大量依赖包,速度会快很多。注意--mount=type=cache是BuildKit的语法,传统Docker builder不支持。

6.2 构建上下文太大

如果你把整个项目目录都当作构建上下文,而里面又包含node_modulesdist.git等大目录,那每次构建都需要把上下文传给Docker引擎,会非常慢。即使缓存命中,传输这些文件也要花时间。

解决方法是使用.dockerignore文件,把不需要的文件排除掉。这样构建上下文会小很多。一个典型的.dockerignore内容如下:

# 技术栈:通用
.git
node_modules
dist
*.log
Dockerfile
.dockerignore

.dockerignore.gitignore作用类似,它告诉Docker哪些文件不要发送给构建上下文。如果不排除node_modules,你会发现第一次构建时,COPY . .会把本地几百MB的node_modules也复制进去,不仅慢,而且很容易导致层缓存失效。

6.3 缓存导出导入的影响

使用type=gha缓存,本质上是将缓存打包后存到GitHub的缓存服务中。这个缓存有大小限制,比如单个缓存最大10GB,但免费套餐可能有更小限制。如果镜像构建层数多、依赖大,缓存可能超过限制,导致保存失败。

另外要注意,cache-to: type=gha,mode=max时,缓存会包含所有层的压缩包,体积较大。如果担心缓存太大,可以改用mode=min,但这样可能只缓存最后一个阶段的结果,前面阶段的缓存不会被导出,多阶段构建的缓存效果会大打折扣。

一个折中方案是:只对比较稳定的依赖安装层使用type=gha,而对容易变化的层放弃缓存。这可以通过在Dockerfile中把不同的阶段分开,然后在workflow中仅对特定阶段启用缓存来实现,但操作起来比较复杂。对于大多数项目,mode=max即可。

6.4 多阶段构建中“复制”带来的缓存陷阱

一个容易忽略的细节是:多阶段构建中,COPY --from=build这一行会使得当前阶段的层依赖那个“上游阶段”的某个层。如果上游阶段的缓存失效,那么这段COPY也会失效。比如前面build阶段里,如果npm run build重新执行了,那它生成的dist目录就变了,那么运行阶段中的COPY --from=build这层肯定也要重新执行。这是合理的,因为我们确实需要新的dist

但有一个坑:如果你在运行阶段并不是只复制必要文件,而是直接复制了整个build阶段的文件系统(比如COPY --from=build /app .),那么上游一点小小的变化(比如源码权限变了),都会导致运行阶段缓存失效。所以最好的做法是只复制必要的东西,比如dist目录,而不是整个阶段目录。

七、总结

GitHub Actions里的Docker构建,如果没配置缓存,每跑一次都是“冷启动”,又慢又浪费。多阶段构建能让我们把构建环境和运行环境分离,缩小镜像体积,但同时也把缓存依赖关系变复杂了。根源在于Docker的层缓存机制:每一层依赖它的输入层,只要输入层有任何变化,该层和所有后续层的缓存都会失效。

要真正利用好缓存,需要做到以下几点:

  1. 在Dockerfile里把“不经常变的指令”放在前面,比如复制依赖清单和安装依赖,把“经常变的指令”放在后面,比如复制源码。
  2. 在GitHub Actions里使用docker/build-push-action,并配置cache-from: type=ghacache-to: type=gha,mode=max
  3. 使用.dockerignore缩小构建上下文,更别把node_modules这种大目录传给Docker。
  4. 熟悉BuildKit的--mount=type=cache,它可以让包管理器缓存不依赖层缓存。
  5. 多阶段构建时,只从构建阶段复制必要文件,避免物理上毫无意义的层变化。

缓存带来的收益很直观。我自己的项目在没有配置缓存前,一次构建要跑四五分钟,配置后只要几十秒。尤其是那些依赖很多的项目,差别会更大。虽然缓存配置本身只需要几行YAML,但它背后表达的是对Docker构建机制的理解。如果你也遇到过“昨天构建很快,今天却等了半天”的问题,可以回去看看你们Dockerfile的指令顺序,以及Actions里有没有配缓存。很可能,你只是差了一个cache-to

构建这件事,本来就是“慢工出细活”的活儿,但我们可以让它更聪明一点,不重复做那些已经做完的事。这不仅是技术上的优化,也是一种省时间的好习惯。