多阶段构建
多阶段构建对任何曾努力优化 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
目标意味着只处理 base
和 stage2
。没有对 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
不依赖于它。