很多人在 Docker 上踩过同一个坑:本地跑得好好的服务,打出来的镜像动辄几个 GB,推送到仓库慢到怀疑人生,拉到生产环境还要等半天。一开始我以为是网络问题,后来才发现根本不是——是 Dockerfile 本身写得太“脏”了,把所有构建工具、临时文件、缓存全塞进了同一个镜像里。这个问题在微服务架构下尤其致命,服务一多,镜像仓库和部署带宽都会被拖垮。多阶段构建就是用来解决这件事的,它能把一个几 GB 的构建镜像压缩到几百 MB 甚至几十 MB,同时让构建环境和运行环境彻底分离。这篇文章我会从单阶段构建的痛点讲起,把多阶段构建的语法、原理、实战案例和常见坑位一次说清楚。不管你是刚入门 Docker 还是已经在生产环境维护镜像,这篇都值得花几分钟看完。
1. 单阶段 Dockerfile 的三大问题:体积失控、职责混乱、维护痛苦
先别急着写多阶段构建,我们得知道它到底在解决什么问题。现在很多人写 Dockerfile 还是这样的习惯:拿一个官方基础镜像,把构建工具装上,把源码复制进去,然后一把梭 RUN 完所有命令,最后用这个镜像直接跑服务。这种写法在 demo 项目里没问题,一旦到了真实项目,三个问题会立刻冒出来。
1.1 一个典型的单阶段构建长什么样
假设你有一个 Java 项目,最常见的单阶段 Dockerfile 是这样:
FROM maven:3.8-openjdk-11 WORKDIR /app COPY . . RUN mvn clean package -DskipTests EXPOSE 8080 CMD ["java", "-jar", "target/demo.jar"]这个 Dockerfile 本身没有任何语法错误,也能正常构建、正常跑起来。但你打开镜像仓库看一眼,会发现镜像大小轻松超过 700MB。为什么?因为maven:3.8-openjdk-11这个基础镜像里不仅有 JRE,还有整套 JDK、Maven 工具链、各种依赖缓存和系统库。这些在“开发环境”是必需品,但在“运行环境”里完全是多余的。
同样的逻辑也适用于 Node 项目。很多人写:
FROM node:16 WORKDIR /app COPY . . RUN npm install COPY . . EXPOSE 3000 CMD ["npm", "start"]node:16 镜像自带 npm、node-gyp、Python、make 等一堆编译依赖,光node:16基础镜像就接近 900MB。如果项目里还有 sharp 这类带原生模块的依赖,安装过程中还会拉一堆临时编译文件,最终镜像体积只会更大。
1.2 构建工具和运行依赖混在一起,会带来连锁反应
镜像体积大只是表象,更深层的问题是职责混乱。你以为你交付的是应用,实际上交付的是一台“带着工地的服务器”。这里有三个实实在在的影响:
- 安全面扩大。构建工具链(编译器、包管理器、调试工具)里的漏洞,本来只影响开发环境,现在直接暴露在运行环境中。生产镜像里多一个编译器,就多一分被攻破的风险。
- 启动速度变慢。镜像分层越多、层内文件越多,容器启动时要加载的内容就越多,尤其是 Java 项目里大 jar 包 + 完整 JDK 的组合,冷启动明显变慢。
- 排障困难。当运行环境里的文件系统混着
/root/.m2(Maven 缓存)、/usr/share/maven(Maven 本体)、/usr/local/openjdk-11(JDK)时,你很难说清楚某个文件到底是运行必需的还是构建残留。一旦出问题,很难判断是哪一层导致的。
我见过最夸张的一个案例,是某个团队把一个 Python 项目构建成了 2.3GB 的镜像,里面甚至带着 pip 的 wheel 缓存目录。因为那个镜像本身包含整个 Anaconda 发行版,而项目只需要其中三个包。这个镜像在公司的私有仓库里占了将近 10GB 的存储(多个版本叠加),CI 每次构建耗时 15 分钟以上,其中大部分时间浪费在上传和下载镜像层上。
1.3 多阶段构建的思路转变:分离构建环境与运行环境
多阶段构建的核心思想其实很简单:你的 Dockerfile 里可以有多个FROM指令,每个FROM都是一个独立阶段,前面阶段负责“造轮子”,最后阶段负责“跑轮子”。你只需要把最终需要的产物复制到最后那个阶段去。
用生活化的方式理解:你要做一道菜,需要在厨房里又是切菜又是炖煮,但端上桌的只有那一盘菜,厨房里的锅碗瓢盆不会跟着上桌。单阶段构建相当于把整个厨房打包到餐桌上,多阶段构建则是只在餐桌上放成品。
这个思路一旦说透,再看具体语法就非常清爽了。
2. 多阶段构建的核心语法:FROM AS 与 COPY --from 的组合逻辑
多阶段构建语法非常简单,核心就是两件事:给阶段命名、跨阶段复制文件。掌握了这两个动作,你就掌握了 90% 的多阶段构建用法。
2.1 最小可用示例拆解:构建与运行分家
还是用 Java 项目的例子,改写为多阶段构建后:
# 阶段一:构建环境 FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY . . RUN mvn clean package -DskipTests # 阶段二:运行环境 FROM openjdk:11-jre-slim WORKDIR /app COPY --from=builder /app/target/demo.jar . EXPOSE 8080 CMD ["java", "-jar", "demo.jar"]这里的关键点:
AS builder给第一个阶段起了名为builder的别名。COPY --from=builder /app/target/demo.jar .表示从builder阶段复制target/demo.jar文件到当前阶段。- 最终生成的镜像只包含第二个阶段(
openjdk:11-jre-slim)的内容,加上从--from=builder复制过来的 jar 文件。第一个阶段的所有内容(Maven 工具链、JDK、依赖缓存、源码)都被丢弃了。
这个示例跑完,镜像体积从 700MB 左右直接降到 200MB 左右,体积缩水 70% 以上。而这个优化几乎不需要付出任何代码层面的代价。
2.2 阶段编号与自定义名称的使用规则
多阶段构建中,FROM是有顺序编号的。从 0 开始计数,第一个FROM是 stage 0,第二个是 stage 1,以此类推。你可以用编号引用阶段,也可以用别名引用。
两种引用方式:
# 用别名引用(推荐) COPY --from=builder /app/target/demo.jar . # 用编号引用(在简单场景中可用) COPY --from=0 /app/target/demo.jar .用编号有个隐患:一旦后续在开头插入新的阶段,编号就会变化,COPY --from=0可能指向错误的阶段。所以我的建议是:只要阶段数大于等于 2 个,一律用别名。写别名时顺手起名的好处是自文档化,别人(包括三个月后的你自己)看 Dockerfile 就知道每个阶段是干嘛的。
多个阶段之间还可以串联。典型的构建流水线可以是:阶段一编译原生库 → 阶段二编译应用 → 阶段三组装运行镜像。每一层都只承接上一层的最小产物,逐层筛掉不需要的文件。
2.3 直接复用外部镜像作为构建阶段
多阶段构建还有一个容易被忽视的进阶用法:COPY --from不一定非得是同一个 Dockerfile 里的阶段,它也可以引用一个已存在的镜像。比如你想从alpine镜像里复制某个配置文件,或从某个已有的工具镜像里提取二进制文件:
FROM alpine:3.18 COPY --from=nginx:latest /etc/nginx/nginx.conf /etc/nginx/nginx.conf这个特性特别适合用来“借”工具。比如你想在镜像里使用某个 shell 脚本工具,但不想自己写安装流程,可以直接:
FROM busybox:1.36 AS busybox FROM alpine:3.18 COPY --from=busybox /bin/busybox /bin/busybox这在写自动化构建管道时非常有用。拉取一个已知工具镜像,从中提取需要的二进制文件,放到精简运行环境里,避免了在运行环境里执行下载、解压、安装等一堆容易出错的步骤。
3. 三大主流语言实战案例:Go、Java、Node 的多阶段写法
原理说再多,不如动手跑通一个完整的例子。这里我拿三个实际项目里最常见的语言栈来说:Go 的静态二进制、Java 的 JRE 精简运行、Node 的静态资源部署。每个案例都是可以直接抄作业的模板。
3.1 Go 服务:CGO_ENABLED=0 配合 scratch 极限瘦身
Go 的优势在于可以编译成静态链接的二进制文件,理论上不需要任何基础镜像就能跑。多阶段构建可以把这一点发挥到极致。
# 构建阶段 FROM golang:1.21-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server . # 运行阶段 FROM scratch WORKDIR /app COPY --from=builder /app/server . EXPOSE 8080 CMD ["./server"]几个关键细节:
CGO_ENABLED=0是交叉编译纯静态二进制的前提。如果项目里用到数据库驱动(比如 go-sqlite3)且依赖 CGO,这个参数要谨慎,但大多数纯 Go 项目都可以关掉 CGO。-ldflags="-s -w"用来剔除调试信息和符号表,可以减少约 30% 的二进制体积。scratch是一个完全空白的基础镜像,没有 shell、没有系统库、没有时区数据。配合纯静态二进制,最终镜像可能只有 10-30MB。- 如果服务的
net包需要解析 DNS(依赖/etc/resolv.conf),或者用到了系统 CA 证书,scratch需要额外复制证书。更省事的替代方案是用alpine作为运行环境,体积也就多个 5MB 左右,但少了踩坑的烦恼。
实测数据:一个中等规模的 Go API 服务,直接golang:1.21-alpine构建并在运行阶段复用golang镜像,体积约 400MB;改成上面这种多阶段构建 + scratch 后,体积降到 18MB。效果不是“优化”级别的,是“脱胎换骨”级别的。
3.2 Java 服务:Maven 构建 + JRE 运行的精简路径
Java 项目不能像 Go 那样打出静态二进制,但多阶段构建同样大有可为。上一节已经展示过基础写法,这里补充几个进阶优化点。
# 构建阶段 FROM maven:3.8-openjdk-11 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests # 运行阶段 FROM openjdk:11-jre-slim WORKDIR /app COPY --from=builder /build/target/demo.jar . RUN groupadd -r app && useradd -r -g app app USER app EXPOSE 8080 CMD ["java", "-Xms256m", "-Xmx512m", "-jar", "demo.jar"]几个亮点:
- 先复制
pom.xml并执行dependency:go-offline,让 Maven 先下载全部依赖。这一步会在镜像层缓存中固化依赖,之后只要pom.xml不变,这个层就不会重建,构建时间大幅缩短。 - 运行阶段不用 JDK,用
jre-slim,体积少几百 MB。 - 显式创建非 root 用户运行,符合最小权限原则。很多安全扫描工具(包括 Docker Bench)会检查容器是否以 root 运行。
-Xms256m -Xmx512m限定 JVM 堆内存,防止容器内存被单个 Java 进程吃光。注意这需要根据你的服务实际负载调整,不是写死了就好。
这套方案跑下来的镜像从约 700MB 减到约 230MB,打开容器的安全扫描报告,高危漏洞数往往也会明显下降,因为 jre-slim 相比完整 JDK 少了很多用不到的网络工具和编译组件。
3.3 Node 静态资源:构建产物直接交给 Nginx 托管
前端项目用 Docker 部署时,多阶段构建是最标准的姿势。一个 React/Vue 项目构建后只是静态文件,完全不需要 Node 运行时。
# 构建阶段 FROM node:18-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # 运行阶段 FROM nginx:1.25-alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]这里有两个容易踩坑的点:
npm ci和npm install的区别在于:npm ci严格要求按package-lock.json安装,速度更快,构建更可复现。多人协作的项目一定要用npm ci。- 如果项目有跨域需求,记得把
nginx.conf写进镜像里的conf.d/目录。很多前端项目最终上生产环境才发现本地开发时的代理配置在生产环境不生效,根源就在这。
这套组合跑完后,镜像体积可以做到 50MB 以内(取决于 Nginx 基础镜像)。以一个包含几百个静态文件的中型后台管理系统为例,node:18直接跑是 1.2GB,多阶段 + Nginx 只有 45MB,推送镜像时间从几分钟降到几秒钟。
4. 构建缓存、BuildKit 与体积压缩的进阶玩法
多阶段构建解决体积问题只是第一步,真正让它在生产环境发挥价值的是“构建效率”的优化。这一节讲三个我在实际项目里反复使用的手段:COPY 顺序的缓存设计、--target 中间产物调试、BuildKit 的缓存挂载。
4.1 用对 COPY 顺序,精准命中层缓存
Docker 构建时的层缓存机制是:某个指令执行时,如果和上一次构建的上下文一致,就直接复用上次生成的镜像层,跳过重新执行。但这里有一个关键的约束——只要指令对应的文件内容有变化,之后的缓存全部失效。
很多人写 Dockerfile 时没有意识到这一点,上来就写:
COPY . . RUN npm install这样写的问题在于:源码里的任何一行变动(哪怕只是 README 改了),都会导致COPY . .这层缓存失效,紧接着的npm install(通常耗时最长)就得重新执行。随着项目越来越大,每次提交都要重复安装依赖,CI 时间越长越长。
正确的写法是分层复制,先复制依赖清单文件,装好依赖,再复制其余代码:
COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build这样只要package.json和package-lock.json不变,npm ci就会一直命中缓存。源码变动只触发最后的COPY . .和RUN npm run build。这个技巧在多阶段构建的 builder 阶段同样适用,我在上一节 Java 和 Go 案例中的写法就体现了这一点。
同样的逻辑也适用于 Go:
COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build ...以及 Python:
COPY requirements.txt ./ RUN pip install -r requirements.txt COPY . .4.2 使用 --target 调试中间阶段产物
多阶段构建的一个隐形好处是可以用--target参数构建某个特定的中间阶段。比如你在打磨 builder 阶段的构建命令时,不想每次都执行整个 Dockerfile 的全部阶段,可以:
docker build --target builder -t app-builder .这个命令只构建AS builder之前(包括 builder)的内容,不会执行后面的运行环境组装。在排查构建报错时特别有用,你可以快速拿到一个包含完整构建成果的镜像,进去 bash 检查依赖、临时文件、jar 包位置:
docker run -it --rm app-builder sh这在对比不同构建工具的产物时也方便。比如你想看 Maven 和 Gradle 各自的输出路径、体积差异,直接构建 target 到不同阶段再对比即可。
在 CI 里我常用这个特性做“测试 + 构建分离”:builder 阶段跑单元测试,运行阶段只打包最终产物。推送到镜像仓库的只有最终产物镜像,测试镜像只存在 CI 缓存里,不占用仓库空间。
4.3 在 BuildKit 中使用 --mount=type=cache 加速依赖安装
默认的docker build(legacy builder)在多阶段构建时,执行的每条RUN指令都是隔离的。RUN npm install产生的缓存会写进镜像层,要么导致镜像体积变大,要么在下一个阶段被丢弃。BuildKit 引入的挂载型缓存解决了这个问题。
使用 BuildKit 构建(Docker Desktop 默认开启,服务器上需要设置环境变量DOCKER_BUILDKIT=1),可以在RUN指令中挂载临时缓存目录:
# syntax=docker/dockerfile:1.4 FROM node:18-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY . . RUN npm run build这里的--mount=type=cache,target=/root/.npm会把 npm 的全局缓存挂载到一个宿主机层级的缓存区域。这个缓存不进入镜像层,但在多次构建之间共享。实测效果:一个中型前端项目的npm ci耗时可以从 120 秒降到 20-30 秒(依赖本身已经缓存在宿主机上)。
同样的技巧适用于 Maven:
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests以及 Go:
RUN --mount=type=cache,target=/go/pkg/mod go mod download还有一个隐藏技巧是--mount=type=bind:
RUN --mount=type=bind,source=.,target=/workspace make build这种挂载方式把源码以只读形式挂进构建容器,适合那些需要在构建过程中动态读取源码,又不想把源码打进镜像层的场景。
使用 BuildKit 还有一个额外好处:支持COPY --chmod、RUN --network=none等增强语法,并且可以自动跳过某些无意义指令,进一步缩短构建时间。如果你的 CI 用的还是 legacy builder,建议尽早切到 BuildKit,兼容性方面绝大多数现代 Docker 环境都没有问题。
5. 多阶段构建的坑位清单:我从失误中总结的五条排查经验
再完美的思路,落地过程中都会遇到坑。我把这几年用多阶段构建踩过的、帮别人排查过的问题整理成了一份清单,你遇到类似状况时可以直接对照。
5.1 COPY --from 找不到阶段名
现象:构建时报copy from stage_name: not found。
原因:最常见的两种,一是AS写成了小写as或漏写;二是COPY --from引用的阶段名在实际FROM中不存在(比如拼写不一致)。
排查思路:
- 检查 Dockerfile 里所有
FROM xxx AS yyy的别名拼写。 - 确认是否用到了编号引用(
--from=0)但中间增删过阶段,导致编号错位。 - 注意大小写敏感,
builder和Builder是两个名字。
我的建议:所有阶段名统一用小写加下划线命名,如builder,npm-cache,build-base。这个习惯在多阶段阶段数变多时非常省心。
5.2 scratch 或者 alpine 里跑不起来的隐秘原因
现象:构建成功,但运行时服务报错,最常见的是Get "https://xxx": x509: certificate signed by unknown authority或no matching files for glob pattern。
原因:scratch和部分精简镜像不带 CA 证书。Java 服务如果访问外部 HTTPS 接口,JRE 自带的cacerts里可能有也可能没有对应证书链。Go 服务在scratch里默认使用系统证书文件,但scratch里根本没有这个文件。
解决方案:
在 builder 阶段把系统证书复制进最终阶段:
FROM golang:1.21-alpine AS builder ... RUN apk add --no-cache ca-certificates FROM scratch COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /app/server . CMD ["./server"]或者干脆运行环境不用scratch,改成:
FROM alpine:3.18 RUN apk add --no-cache ca-certificates tzdata COPY --from=builder /app/server . CMD ["./server"]这两个基础依赖(CA 证书和时区数据)是生产环境最容易忽略的。具体踩过的坑是:某次上线后日志里的时间全部变成 UTC,功能没受影响,但排查问题对不上时间戳,白忙了大半天。后来我养成了习惯:只要镜像里有时区相关需求,一律加 tzdata 并把TZ=Asia/Shanghai写入环境变量。
5.3 构建上下文比镜像本身还大
现象:docker build一开始非常慢,发送上下文(context)到 Docker daemon 就需要好几分钟。
原因:Docker 的COPY和ADD指令默认使用构建上下文(build context)中的文件。如果项目目录里有node_modules、.git、target、dist等目录,它们会被全部发送给 Docker daemon,即使 Dockerfile 里没有显式引用它们。构建上下文大,上传慢,占用内存高,多阶段构建也救不了。
解决方案:在项目根目录写.dockerignore,把不需要进入镜像的文件全部排除:
node_modules .git .tmp dist target .idea *.log Dockerfile .dockerignore简单说,.dockerignore就是 Docker 版的.gitignore。它不仅能加快构建,还能避免意外把敏感文件(如.env、私钥)带进镜像层。
5.4 依赖文件变了,但缓存没有失效
现象:更新了package.json,但RUN npm ci好像还是用了旧的依赖,或者反过来——明明依赖没变,却重新安装了全部依赖。
原因:RUN指令的缓存是否命中,取决于它前面所有已执行指令的输入是否完全一致,其中一个关键输入是构建上下文的内容。如果执行COPY package.json package-lock.json ./之前先执行了COPY . .,那整个缓存链条会因源码变动而全部失效;反过来说,如果某种机制(比如 CI 里的包管理器代理)修改了package-lock.json的时间戳但内容没变,某些缓存策略可能会跳过重建,导致新旧依赖混淆。
排查思路:
- 确认
COPY的顺序符合“只复制依赖清单 → 安装依赖 → 复制全部源码”。这是缓存命中率最优的顺序。 - 在意缓存一致性时,可以给 Dockerfile 里需要强一致的阶段加
--no-cache构建,但代价是构建时间变长。 - 使用 BuildKit 时,
RUN指令的缓存键计算更精确,但仍需要遵循同样的 COPY 顺序逻辑。
5.5 镜像分层过多,multistage 反而拖慢了构建
现象:多阶段构建后镜像体积确实小了,但构建时间变长了,并且日志里反复出现重复下载依赖的耗时。
原因:多阶段构建的“产物复制”保证了运行镜像干净,但如果 builder 阶段没有利用好缓存,每次构建都会重新装一遍依赖。站在最终镜像角度,体积是缩了,但构建阶段的每次重复劳动都在浪费时间成本。还有一种是运行阶段引用了 builder 阶段没有生成的路径(路径拼写错误、构建工具输出位置不同),构建不会报错,但运行时会立刻报No such file or directory。
解决方案:
- builder 阶段严格遵循“依赖清单先行”的缓存设计。
- 使用 4.3 提到的
--mount=type=cache把依赖下载环节的缓存放到宿主机层,避免每次构建重新拉全量依赖。 - 确认产物路径:构建阶段里
mvn package的输出在target/,go build的输出在-o指定的路径,npm run build的输出在dist/或build/。生产环境里最常见的错误就是COPY --from=builder /app/dist写错了目录名,构建不报错,运行起来页面全是空白。 - 多阶段切换时注意工作目录。每个阶段都有自己的
WORKDIR,不要默认继承了上一阶段的路径。举例:builder 阶段的WORKDIR是/build,运行阶段如果不重新设置WORKDIR,它默认继承/build,这时COPY的源路径和目的路径都需要重新确认。
多阶段构建不是一个“高深”的 Docker 特性,但它确实能带来立竿见影的效果:体积缩小、构建加速、安全面收敛。在我实际维护过的项目里,这几乎是性价比最高的容器化优化手段之一。每次在评审 Dockerfile 时,看到那种把编译工具和运行进程混在一起写的做法,我基本都会建议先改成多阶段构建,再考虑其它的架构调整。如果你还没有用过这个特性,建议打开手边任意一个项目,拿它试一次。先从最简单的 Java 或 Node 项目改起,跑一次构建看下体积对比,那种差距本身就会告诉你,这个优化到底值不值得做。
最后的经验补充:多阶段构建为镜像瘦身,但别为了瘦身牺牲可维护性。scratch适合纯静态的 Go 项目,Java 用jre-slim更成熟稳定,前端静态资源用 Nginx 最省心。选基础镜像时第一考虑的是生产环境需要的运行依赖,第二才是体积。有条件的情况下,尽量把 builder 阶段的依赖缓存留在 CI 机器上,让每次构建都能稳定命中缓存,这才是多阶段构建在生产环境发挥真正价值的关键。