多阶段构建

多阶段构建对任何曾努力优化 Dockerfile 并同时保持其易读性和可维护性的人来说都很有用。

使用多阶段构建

使用多阶段构建,您在 Dockerfile 中使用多个 FROM 语句。每个 FROM 指令可以使用不同的基础,并且它们中的每一个都会开始构建的新阶段。您可以有选择地将工件从一个阶段复制到另一个阶段,留下您不想在最终镜像中包含的所有内容。

以下 Dockerfile 包含两个独立的阶段:一个用于构建二进制文件,另一个用于将二进制文件从第一阶段复制到下一阶段。

# syntax=docker/dockerfile:1
FROM golang:1.21
WORKDIR /src
COPY <<EOF ./main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=0 /bin/hello /bin/hello
CMD ["/bin/hello"]

您只需要单个 Dockerfile。无需单独的构建脚本。只需运行 docker build 即可。

$ docker build -t hello .

最终结果是一个微小的生产镜像,其中只包含二进制文件。构建应用程序所需的构建工具都不包含在生成的镜像中。

它是如何工作的?第二个 FROM 指令使用 scratch 镜像作为其基础,启动一个新的构建阶段。COPY --from=0 行仅将构建的工件从先前阶段复制到此新阶段。Go SDK 和任何中间工件都会被留下,并且不会保存在最终镜像中。

命名您的构建阶段

默认情况下,阶段没有命名,您可以通过它们的整数编号来引用它们,从 0 开始,对应第一个 FROM 指令。但是,您可以通过在 FROM 指令中添加 AS <NAME> 来命名您的阶段。此示例通过命名阶段并在 COPY 指令中使用名称来改进之前的示例。这意味着,即使 Dockerfile 中的指令后来重新排序,COPY 也不会中断。

# syntax=docker/dockerfile:1
FROM golang:1.21 AS build
WORKDIR /src
COPY <<EOF /src/main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

在特定构建阶段停止

构建镜像时,您并不一定需要构建整个 Dockerfile,包括每个阶段。您可以指定目标构建阶段。以下命令假设您使用的是之前的 Dockerfile,但会在名为 build 的阶段停止。

$ docker build --target build -t hello .

以下是一些可能有用处的场景

  • 调试特定构建阶段
  • 使用包含所有调试符号或工具的 debug 阶段和精简的 production 阶段
  • 使用 testing 阶段,其中您的应用程序填充了测试数据,但使用另一个使用真实数据的阶段构建生产环境

使用外部镜像作为阶段

使用多阶段构建时,您不仅限于从 Dockerfile 中较早创建的阶段复制。您可以使用 COPY --from 指令从单独的镜像中复制,使用本地镜像名称、本地或 Docker 注册表上可用的标签或标签 ID。Docker 客户端会在必要时拉取镜像,并从那里复制工件。语法如下:

COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

使用先前阶段作为新阶段

您可以从先前阶段中断的地方继续,在使用 FROM 指令时引用它。例如

# syntax=docker/dockerfile:1

FROM alpine:latest AS builder
RUN apk --no-cache add build-base

FROM builder AS build1
COPY source1.cpp source.cpp
RUN g++ -o /binary source.cpp

FROM builder AS build2
COPY source2.cpp source.cpp
RUN g++ -o /binary source.cpp

传统构建器和 BuildKit 之间的区别

传统 Docker Engine 构建器会处理 Dockerfile 中所有阶段,直到到达选定的 --target。它会构建一个阶段,即使选定的目标不依赖于该阶段。

BuildKit 只构建目标阶段依赖的阶段。

例如,给定以下 Dockerfile

# syntax=docker/dockerfile:1
FROM ubuntu AS base
RUN echo "base"

FROM base AS stage1
RUN echo "stage1"

FROM base AS stage2
RUN echo "stage2"

使用 启用的 BuildKit,构建此 Dockerfile 中的 stage2 目标意味着只处理 basestage2。没有对 stage1 的依赖关系,因此它会被跳过。

$ DOCKER_BUILDKIT=1 docker build --no-cache -f Dockerfile --target stage2 .
[+] Building 0.4s (7/7) FINISHED                                                                    
 => [internal] load build definition from Dockerfile                                            0.0s
 => => transferring dockerfile: 36B                                                             0.0s
 => [internal] load .dockerignore                                                               0.0s
 => => transferring context: 2B                                                                 0.0s
 => [internal] load metadata for docker.io/library/ubuntu:latest                                0.0s
 => CACHED [base 1/2] FROM docker.io/library/ubuntu                                             0.0s
 => [base 2/2] RUN echo "base"                                                                  0.1s
 => [stage2 1/1] RUN echo "stage2"                                                              0.2s
 => exporting to image                                                                          0.0s
 => => exporting layers                                                                         0.0s
 => => writing image sha256:f55003b607cef37614f607f0728e6fd4d113a4bf7ef12210da338c716f2cfd15    0.0s

另一方面,在没有 BuildKit 的情况下构建相同的目标会导致所有阶段都被处理

$ DOCKER_BUILDKIT=0 docker build --no-cache -f Dockerfile --target stage2 .
Sending build context to Docker daemon  219.1kB
Step 1/6 : FROM ubuntu AS base
 ---> a7870fd478f4
Step 2/6 : RUN echo "base"
 ---> Running in e850d0e42eca
base
Removing intermediate container e850d0e42eca
 ---> d9f69f23cac8
Step 3/6 : FROM base AS stage1
 ---> d9f69f23cac8
Step 4/6 : RUN echo "stage1"
 ---> Running in 758ba6c1a9a3
stage1
Removing intermediate container 758ba6c1a9a3
 ---> 396baa55b8c3
Step 5/6 : FROM base AS stage2
 ---> d9f69f23cac8
Step 6/6 : RUN echo "stage2"
 ---> Running in bbc025b93175
stage2
Removing intermediate container bbc025b93175
 ---> 09fc3770a9c4
Successfully built 09fc3770a9c4

传统构建器会处理 stage1,即使 stage2 不依赖于它。