目录
Docker 多阶段构建教程:分离构建与运行环境
许多程序在构建时需要编译器、开发包和构建工具,但运行时只需要编译好的结果。如果把所有内容都放在同一个镜像里,最终镜像会带上一大堆用不到的东西。
多阶段构建可以在一个 Dockerfile 中使用多个 FROM,每个 FROM 都是一个阶段。最终只保留最后一个阶段的内容,前面的阶段只用于生成文件。
实验:单阶段与多阶段
用一个简单的 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/docker/nodejs/) 的 TypeScript 示例。
中间阶段的镜像会留在电脑上吗?
中间阶段不会生成有名称的镜像,但它们的层会留在构建缓存中,可以使用 docker builder prune 清理。
COPY --from 可以使用其他镜像吗?
可以,例如 COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf .,直接从外部镜像复制文件,本章中有示例。
更新于 2026年10月14日