目錄

Docker 多階段建置(Multi-stage builds)教學

許多程式在建置時需要編譯器、開發套件、建置工具,但執行時只需要編譯好的結果。如果全部放在同一個映像檔,最終映像檔會帶著一大堆用不到的東西。

多階段建置讓一個 Dockerfile 裡有多個 FROM,每個 FROM 是一個階段。最後只保留最後一個階段的內容,前面的階段只是用來產生檔案。

多階段建置:第一階段包含編譯工具與原始碼,只把編譯結果複製到最終的小映像檔
只有 COPY 到最後一個階段的檔案會進入映像檔

實驗:單階段 vs 多階段

用一個簡單的 Go 網頁程式(main.go)做比較:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello from a distroless container!")
	})
	fmt.Println("Listening on :8080")
	http.ListenAndServe(":8080", nil)
}

單階段(Dockerfile.single):在 Go 官方映像檔裡編譯,也直接用它來執行:

FROM golang:1.27
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server .
CMD ["/server"]

多階段(Dockerfile):

# 階段一:編譯
FROM golang:1.27 AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server .

# 階段二:執行
FROM gcr.io/distroless/static-debian13
COPY --from=builder /out/server /server
USER nonroot
ENTRYPOINT ["/server"]
  • AS builder 幫第一個階段命名。
  • COPY --from=builder 從那個階段複製檔案。
  • gcr.io/distroless/static-debian13 是 Google 維護的極簡映像檔:沒有 shell、沒有套件管理工具,只有執行靜態程式需要的最少檔案。

建置多階段版本:

$ docker build -t go-multi .

[+] Building 51.7s (14/14) FINISHED                                                  docker:default
 => [internal] load build definition from Dockerfile                                           0.0s
 => => transferring dockerfile: 326B                                                           0.0s
 => [internal] load metadata for gcr.io/distroless/static-debian13:latest                      2.1s
 => [internal] load metadata for docker.io/library/golang:1.27                                 0.4s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 2B                                                                0.0s
 => [builder 1/6] FROM docker.io/library/golang:1.27@sha256:e432b43af23a9328d56a7c499be047681  0.0s
 => => resolve docker.io/library/golang:1.27@sha256:e432b43af23a9328d56a7c499be0476810aa344ac  0.0s
 => [stage-1 1/2] FROM gcr.io/distroless/static-debian13:latest@sha256:58133991db06659feaabe0  3.4s
 => => resolve gcr.io/distroless/static-debian13:latest@sha256:58133991db06659feaabe0f4e97a35  0.0s
 => => …(下載與解壓縮各層,省略)
 => [internal] load build context                                                              0.0s
 => => transferring context: 120B                                                              0.0s
 => CACHED [builder 2/6] WORKDIR /src                                                          0.0s
 => [builder 3/6] COPY go.mod ./                                                               0.0s
 => [builder 4/6] RUN go mod download                                                          0.5s
 => [builder 5/6] COPY . .                                                                     0.0s
 => [builder 6/6] RUN CGO_ENABLED=0 go build -o /out/server .                                 47.6s
 => [stage-1 2/2] COPY --from=builder /out/server /server                                      0.0s
 => exporting to image                                                                         1.1s
 => => exporting layers                                                                        0.6s
 => => exporting manifest sha256:8b506df2e51c2cbf7df7b452669cde4652ac2d83d924e8e5b67ed8f37b6a  0.0s
 => => exporting config sha256:f1efccd3fdd70ffd1054b9ed0ff4154e3be0d93324c196bd46438e12a4b372  0.0s
 => => exporting attestation manifest sha256:4cf5049d1a21697ca953ccd67da3874da40c466c9db43ac9  0.0s
 => => exporting manifest list sha256:463874676a48cc064e399fd1968bcd5e301d77f20909360c40fff1c  0.0s
 => => naming to docker.io/library/go-multi:latest                                             0.0s
 => => unpacking to docker.io/library/go-multi:latest                                          0.4s

可以看到兩個階段的步驟分別標示為 [builder …] 和 [stage-1 …],BuildKit 會同時下載兩個階段需要的基礎映像檔。比較兩個映像檔的大小:

$ docker images --filter reference=go-single --filter reference=go-multi
IMAGE              ID             DISK USAGE   CONTENT SIZE   EXTRA
go-multi:latest    463874676a48       20.2MB         5.77MB
go-single:latest   c6c844f9076e       1.43GB          343MB

同一個程式:單階段 1.43 GB,多階段 20.2 MB,相差 70 倍。單階段的映像檔帶著整套 Go 編譯器與 Debian 系統;多階段的最終映像檔只有編譯好的執行檔加上 distroless 的基本檔案。

映像檔越小,下載與部署越快,可能有漏洞的套件也越少。執行看看:

$ docker run -d --name goapp -p 8080:8080 go-multi
d474e2de30d65e21a1f6395aac76c185fcfc8d4d6caa1240ab6ef3ebd7406e25

$ curl -s http://localhost:8080
Hello from a distroless container!

$ docker exec goapp sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

$ docker rm -f goapp
goapp

distroless 映像檔裡連 sh 都沒有(executable file not found),攻擊者就算入侵了程式,也很難在容器裡做什麼。代價是除錯不方便,需要時可以改用帶有除錯工具的 :debug 版本。

其他語言的多階段範例

Node.js 前端:建置後交給 Nginx

React、Vue、Astro 等前端專案建置後只剩靜態檔案,不需要 Node.js 執行環境:

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.30-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

建置工具和整個 node_modules 都不會出現在最終映像檔裡。

Node.js 後端:只保留正式環境的套件

FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .
USER node
CMD ["node", "dist/index.js"]

deps 和 build 兩個階段彼此獨立,BuildKit 會平行建置它們。

Java:用 JDK 編譯、JRE 執行

FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package -DskipTests

FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]

只建置某個階段:--target

利用階段可以在同一個 Dockerfile 裡做出開發版和正式版:

FROM node:24-alpine AS base
WORKDIR /app
COPY package.json ./

FROM base AS dev
ENV NODE_ENV=development
COPY . .
CMD ["npm", "run", "dev"]

FROM base AS prod
ENV NODE_ENV=production
COPY . .
CMD ["node", "server.js"]
$ docker build -q --target dev -t target-demo:dev .
sha256:3d0fdd2a77233426c90dfbc575aafaef652a1393cb2730d066e627362860ec09

$ docker build -q --target prod -t target-demo:prod .
sha256:9e14e3363d26d46ed628e49b4cd806a29e9699a5be2e6e4078976bd380632568

$ docker inspect --format '{{.Config.Env}} {{.Config.Cmd}}' target-demo:dev
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=24.21.0 YARN_VERSION=1.22.22 NODE_ENV=development] [npm run dev]

$ docker inspect --format '{{.Config.Env}} {{.Config.Cmd}}' target-demo:prod
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=24.21.0 YARN_VERSION=1.22.22 NODE_ENV=production] [node server.js]

--target dev 只建置到 dev 階段,--target prod 則跳過 dev。兩個映像檔的環境變數與啟動指令不同。-q 讓 docker build 只印出映像檔 ID。

從外部映像檔複製

COPY --from 也可以直接指定任何映像檔,不一定是本檔案裡的階段:

COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

第二行是常見的用法:直接從 uv 的官方映像檔複製執行檔,不用另外安裝,見 Python 應用。

重點回顧

  • 每個 FROM 開始一個新階段,用 AS 名稱 命名。
  • 最後一個階段就是輸出的映像檔,只包含 COPY --from 複製進去的東西。
  • 編譯工具、原始碼、開發套件留在前面的階段,映像檔可以縮小數十倍。
  • --target 只建置到指定的階段,例如測試階段。

動手練習

練習 1:在多階段的 Dockerfile 中加入一個 FROM base AS test 階段執行測試。怎麼在 CI 中只執行測試、不建置最終映像檔?

答案
docker build --target test .

實際範例見 docker build 的 --target 一節。

練習 2:用 docker history 比較本章單階段與多階段的 Go 映像檔,最終映像檔中有沒有 Go 編譯器那一層?

提示

多階段的最終映像檔只有 distroless 的基礎層和 COPY --from=build 那一層,看不到 Go 工具鏈的層。

常見問題

多階段建置會讓建置變慢嗎?

不會。BuildKit 會平行建置互不依賴的階段,而且只建置最後一個階段需要的階段,實測見 [Node.js 應用](/zh-tw/docker/nodejs/) 的 TypeScript 範例。

中間階段的映像檔會留在電腦上嗎?

中間階段不會產生有名稱的映像檔,但它們的層會留在建置快取中,可以用 docker builder prune 清除。

COPY --from 可以用其他映像檔嗎?

可以,例如 COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf .,直接從外部映像檔複製檔案,本章有範例。

更新於 2026年10月14日