目录

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 中只运行测试,而不构建最终镜像?

答案
docker build --target test .

实际示例见 docker build 中的 --target 一节。

练习 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日