目錄
Docker 多階段建置(Multi-stage builds)教學
許多程式在建置時需要編譯器、開發套件、建置工具,但執行時只需要編譯好的結果。如果全部放在同一個映像檔,最終映像檔會帶著一大堆用不到的東西。
多階段建置讓一個 Dockerfile 裡有多個 FROM,每個 FROM 是一個階段。最後只保留最後一個階段的內容,前面的階段只是用來產生檔案。
實驗:單階段 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 中只執行測試、不建置最終映像檔?
練習 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日