优化构建中的缓存使用

使用 Docker 构建时,如果指令及其依赖文件自上次构建以来没有更改,则会从构建缓存中重用一个层。从缓存中重用层可以加快构建过程,因为 Docker 无需再次构建该层。

以下是一些可用于优化构建缓存和加快构建过程的技术

  • 调整层顺序:将 Dockerfile 中的命令按逻辑顺序放置有助于避免不必要的缓存失效。
  • 保持上下文精简:上下文是发送给构建器以处理构建指令的文件和目录集。保持上下文尽可能小可以减少需要发送给构建器的数据量,并降低缓存失效的可能性。
  • 使用绑定挂载:绑定挂载允许您将主机上的文件或目录挂载到构建容器中。使用绑定挂载可以帮助您避免图像中不必要的层,这可能会减慢构建过程。
  • 使用缓存挂载:缓存挂载允许您指定在构建期间使用的持久包缓存。持久缓存有助于加快构建步骤,尤其是涉及使用包管理器安装包的步骤。拥有包的持久缓存意味着即使您重新构建一个层,您也只下载新的或更改的包。
  • 使用外部缓存:外部缓存允许您将构建缓存存储在远程位置。外部缓存图像可以在多个构建之间以及不同环境之间共享。

调整层顺序

将 Dockerfile 中的命令按逻辑顺序放置是一个很好的开始。由于更改会导致后续步骤的重新构建,因此请尝试将开销较大的步骤放在 Dockerfile 的开头附近。经常更改的步骤应放在 Dockerfile 的末尾附近,以避免触发未更改层的重新构建。

考虑以下示例。一个 Dockerfile 片段,它从当前目录中的源文件运行 JavaScript 构建

# syntax=docker/dockerfile:1
FROM node
WORKDIR /app
COPY . .          # Copy over all files in the current directory
RUN npm install   # Install dependencies
RUN npm build     # Run build

这个 Dockerfile 效率相当低下。每次构建 Docker 镜像时,更新任何文件都会导致所有依赖项重新安装,即使依赖项自上次以来没有更改。

相反,`COPY` 命令可以分为两部分。首先,复制包管理文件(在本例中为 `package.json` 和 `yarn.lock`)。然后,安装依赖项。最后,复制项目源代码,项目源代码经常更改。

# syntax=docker/dockerfile:1
FROM node
WORKDIR /app
COPY package.json yarn.lock .    # Copy package management files
RUN npm install                  # Install dependencies
COPY . .                         # Copy over project files
RUN npm build                    # Run build

通过在 Dockerfile 的较早层中安装依赖项,当项目文件更改时,无需重新构建这些层。

保持上下文精简

确保您的上下文不包含不必要文件的最简单方法是在构建上下文的根目录中创建 `.dockerignore` 文件。`.dockerignore` 文件类似于 `.gitignore` 文件,它允许您从构建上下文中排除文件和目录。

这是一个 `.dockerignore` 文件的示例,它排除了 `node_modules` 目录以及所有以 `tmp` 开头的文件和目录

.dockerignore
node_modules
tmp*

`\.dockerignore` 文件中指定的忽略规则适用于整个构建上下文,包括子目录。这意味着它是一种相当粗粒度的机制,但它是排除您知道在构建上下文中不需要的文件和目录的好方法,例如临时文件、日志文件和构建工件。

使用绑定挂载

您可能熟悉在使用 `docker run` 或 Docker Compose 运行容器时使用的绑定挂载。绑定挂载允许您将主机上的文件或目录挂载到容器中。

# bind mount using the -v flag
docker run -v $(pwd):/path/in/container image-name
# bind mount using the --mount flag
docker run --mount=type=bind,src=.,dst=/path/in/container image-name

要在构建中使用绑定挂载,您可以在 Dockerfile 中使用 `RUN` 指令的 `--mount` 标志

FROM golang:latest
WORKDIR /app
RUN --mount=type=bind,target=. go build -o /app/hello

在此示例中,当前目录在 `go build` 命令执行之前被挂载到构建容器中。源代码在 `RUN` 指令执行期间在构建容器中可用。当指令执行完毕后,挂载的文件不会持久化到最终镜像或构建缓存中。只保留 `go build` 命令的输出。

Dockerfile 中的 `COPY` 和 `ADD` 指令允许您将文件从构建上下文复制到构建容器中。使用绑定挂载有利于构建缓存优化,因为您不会向缓存添加不必要的层。如果您的构建上下文较大,并且仅用于生成工件,那么最好使用绑定挂载临时挂载生成工件所需的源代码到构建中。如果您使用 `COPY` 将文件添加到构建容器中,BuildKit 将把所有这些文件都包含在缓存中,即使这些文件未在最终镜像中使用。

在构建中使用绑定挂载时需要注意以下几点

  • 绑定挂载默认是只读的。如果需要写入挂载的目录,则需要指定 `rw` 选项。但是,即使使用 `rw` 选项,更改也不会持久保存在最终镜像或构建缓存中。文件写入在 `RUN` 指令执行期间持续,并在指令完成后被丢弃。

  • 挂载的文件不会持久保存在最终镜像中。只有 `RUN` 指令的输出会持久保存在最终镜像中。如果需要将构建上下文中的文件包含在最终镜像中,则需要使用 `COPY` 或 `ADD` 指令。

  • 如果目标目录不为空,则目标目录的内容会被挂载的文件隐藏。原始内容在 `RUN` 指令完成后恢复。

    例如,给定一个只包含 `Dockerfile` 的构建上下文

    .
    └── Dockerfile

    以及一个将当前目录挂载到构建容器中的 Dockerfile

    FROM alpine:latest
    WORKDIR /work
    RUN touch foo.txt
    RUN --mount=type=bind,target=. ls
    RUN ls

    第一个带绑定挂载的 `ls` 命令显示挂载目录的内容。第二个 `ls` 列出原始构建上下文的内容。

    构建日志
    #8 [stage-0 3/5] RUN touch foo.txt
    #8 DONE 0.1s
    
    #9 [stage-0 4/5] RUN --mount=target=. ls -1
    #9 0.040 Dockerfile
    #9 DONE 0.0s
    
    #10 [stage-0 5/5] RUN ls -1
    #10 0.046 foo.txt
    #10 DONE 0.1s

使用缓存挂载

Docker 中的常规缓存层对应于指令及其依赖文件的精确匹配。如果指令及其依赖文件自该层构建以来已更改,则该层将失效,构建过程必须重新构建该层。

缓存挂载是一种指定在构建期间使用的持久缓存位置的方法。缓存是跨构建累积的,因此您可以多次读取和写入缓存。这种持久缓存意味着即使您需要重新构建一个层,您也只下载新的或更改的包。任何未更改的包都会从缓存挂载中重用。

要在构建中使用缓存挂载,您可以在 Dockerfile 中使用 `RUN` 指令的 `--mount` 标志

FROM node:latest
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm npm install

在此示例中,`npm install` 命令为 `/root/.npm` 目录(npm 缓存的默认位置)使用缓存挂载。缓存挂载在跨构建中持续存在,因此即使您最终重新构建该层,您也只下载新的或更改的包。对缓存的任何更改都会在跨构建中持续存在,并且缓存会在多个构建之间共享。

您如何指定缓存挂载取决于您使用的构建工具。如果您不确定如何指定缓存挂载,请参阅您使用的构建工具的文档。以下是一些示例

RUN --mount=type=cache,target=/go/pkg/mod \
    go build -o /app/hello
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  apt update && apt-get --no-install-recommends install -y gcc
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
RUN --mount=type=cache,target=/root/.gem \
    bundle install
RUN --mount=type=cache,target=/app/target/ \
    --mount=type=cache,target=/usr/local/cargo/git/db \
    --mount=type=cache,target=/usr/local/cargo/registry/ \
    cargo build
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore
RUN --mount=type=cache,target=/tmp/cache \
    composer install

请务必阅读您正在使用的构建工具的文档,以确保您使用正确的缓存挂载选项。包管理器对如何使用缓存有不同的要求,使用错误的选项可能会导致意外行为。例如,Apt 需要独占访问其数据,因此缓存使用 `sharing=locked` 选项,以确保使用相同缓存挂载的并行构建相互等待,并且不会同时访问相同的缓存文件。

使用外部缓存

构建的默认缓存存储是您正在使用的构建器(BuildKit 实例)的内部存储。每个构建器都使用自己的缓存存储。当您在不同的构建器之间切换时,缓存不会在它们之间共享。使用外部缓存允许您为推送和拉取缓存数据定义一个远程位置。

外部缓存对于 CI/CD 管道特别有用,因为在 CI/CD 管道中,构建器通常是短暂的,并且构建时间宝贵。在构建之间重用缓存可以显著加快构建过程并降低成本。您甚至可以在本地开发环境中利用相同的缓存。

要使用外部缓存,请在 `docker buildx build` 命令中指定 `--cache-to` 和 `--cache-from` 选项。

  • `--cache-to` 将构建缓存导出到指定位置。
  • `--cache-from` 指定用于构建的远程缓存。

以下示例展示了如何使用 `docker/build-push-action` 设置 GitHub Actions 工作流,并将构建缓存层推送到 OCI 注册表镜像

.github/workflows/ci.yml
name: ci

on:
  push:

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

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

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: user/app:latest
          cache-from: type=registry,ref=user/app:buildcache
          cache-to: type=registry,ref=user/app:buildcache,mode=max

此设置告诉 BuildKit 在 `user/app:buildcache` 镜像中查找缓存。当构建完成后,新的构建缓存将被推送到同一个镜像,覆盖旧缓存。

此缓存也可以在本地使用。要在本地构建中拉取缓存,您可以使用 `docker buildx build` 命令的 `--cache-from` 选项

$ docker buildx build --cache-from type=registry,ref=user/app:buildcache .

摘要

优化构建中的缓存使用可以显著加快构建过程。保持构建上下文精简、使用绑定挂载、缓存挂载和外部缓存都是您可以用来充分利用构建缓存和加快构建过程的技术。

有关本指南中讨论的概念的更多信息,请参阅

© . This site is unofficial and not affiliated with Kubernetes or Docker Inc.