多階段構建

多階段構建對於那些在最佳化 Dockerfile 的同時又要保持其易讀性和可維護性的人來說非常有用。

使用多階段構建

使用多階段構建,您可以在 Dockerfile 中使用多個 `FROM` 語句。每個 `FROM` 指令都可以使用不同的基礎映象,並且每個指令都開始一個構建的新階段。您可以有選擇地將工件從一個階段複製到另一個階段,而將所有不需要的內容留在最終映象中。

以下 Dockerfile 有兩個獨立的階段:一個用於構建二進位制檔案,另一個用於將二進位制檔案從第一階段複製到下一階段。

# syntax=docker/dockerfile:1
FROM golang:1.24
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 和任何中間工件都被留下,不儲存到最終映象中。

命名您的構建階段

預設情況下,階段沒有名稱,您可以透過它們的整數編號(第一個 `FROM` 指令從 0 開始)來引用它們。但是,您可以透過在 `FROM` 指令中新增 `AS ` 來命名您的階段。這個示例透過命名階段並在 `COPY` 指令中使用該名稱來改進前一個示例。這意味著即使您的 Dockerfile 中的指令後來被重新排序,`COPY` 也不會中斷。

# syntax=docker/dockerfile:1
FROM golang:1.24 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` 不依賴於它。