
很多人在 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/.m2Maven 缓存、/usr/share/mavenMaven 本体、/usr/local/openjdk-11JDK时你很难说清楚某个文件到底是运行必需的还是构建残留。一旦出问题很难判断是哪一层导致的。我见过最夸张的一个案例是某个团队把一个 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 --frombuilder /app/target/demo.jar . EXPOSE 8080 CMD [java, -jar, demo.jar]这里的关键点AS builder给第一个阶段起了名为builder的别名。COPY --frombuilder /app/target/demo.jar .表示从builder阶段复制target/demo.jar文件到当前阶段。最终生成的镜像只包含第二个阶段openjdk:11-jre-slim的内容加上从--frombuilder复制过来的 jar 文件。第一个阶段的所有内容Maven 工具链、JDK、依赖缓存、源码都被丢弃了。这个示例跑完镜像体积从 700MB 左右直接降到 200MB 左右体积缩水 70% 以上。而这个优化几乎不需要付出任何代码层面的代价。2.2 阶段编号与自定义名称的使用规则多阶段构建中FROM是有顺序编号的。从 0 开始计数第一个FROM是 stage 0第二个是 stage 1以此类推。你可以用编号引用阶段也可以用别名引用。两种引用方式# 用别名引用推荐 COPY --frombuilder /app/target/demo.jar . # 用编号引用在简单场景中可用 COPY --from0 /app/target/demo.jar .用编号有个隐患一旦后续在开头插入新的阶段编号就会变化COPY --from0可能指向错误的阶段。所以我的建议是只要阶段数大于等于 2 个一律用别名。写别名时顺手起名的好处是自文档化别人包括三个月后的你自己看 Dockerfile 就知道每个阶段是干嘛的。多个阶段之间还可以串联。典型的构建流水线可以是阶段一编译原生库 → 阶段二编译应用 → 阶段三组装运行镜像。每一层都只承接上一层的最小产物逐层筛掉不需要的文件。2.3 直接复用外部镜像作为构建阶段多阶段构建还有一个容易被忽视的进阶用法COPY --from不一定非得是同一个 Dockerfile 里的阶段它也可以引用一个已存在的镜像。比如你想从alpine镜像里复制某个配置文件或从某个已有的工具镜像里提取二进制文件FROM alpine:3.18 COPY --fromnginx:latest /etc/nginx/nginx.conf /etc/nginx/nginx.conf这个特性特别适合用来“借”工具。比如你想在镜像里使用某个 shell 脚本工具但不想自己写安装流程可以直接FROM busybox:1.36 AS busybox FROM alpine:3.18 COPY --frombusybox /bin/busybox /bin/busybox这在写自动化构建管道时非常有用。拉取一个已知工具镜像从中提取需要的二进制文件放到精简运行环境里避免了在运行环境里执行下载、解压、安装等一堆容易出错的步骤。3. 三大主流语言实战案例Go、Java、Node 的多阶段写法原理说再多不如动手跑通一个完整的例子。这里我拿三个实际项目里最常见的语言栈来说Go 的静态二进制、Java 的 JRE 精简运行、Node 的静态资源部署。每个案例都是可以直接抄作业的模板。3.1 Go 服务CGO_ENABLED0 配合 scratch 极限瘦身Go 的优势在于可以编译成静态链接的二进制文件理论上不需要任何基础镜像就能跑。多阶段构建可以把这一点发挥到极致。# 构建阶段 FROM golang:1.21-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -ldflags-s -w -o /app/server . # 运行阶段 FROM scratch WORKDIR /app COPY --frombuilder /app/server . EXPOSE 8080 CMD [./server]几个关键细节CGO_ENABLED0是交叉编译纯静态二进制的前提。如果项目里用到数据库驱动比如 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 --frombuilder /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 --frombuilder /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 案例中的写法就体现了这一点。同样的逻辑也适用于 GoCOPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build ...以及 PythonCOPY 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 中使用 --mounttypecache 加速依赖安装默认的docker buildlegacy builder在多阶段构建时执行的每条RUN指令都是隔离的。RUN npm install产生的缓存会写进镜像层要么导致镜像体积变大要么在下一个阶段被丢弃。BuildKit 引入的挂载型缓存解决了这个问题。使用 BuildKit 构建Docker Desktop 默认开启服务器上需要设置环境变量DOCKER_BUILDKIT1可以在RUN指令中挂载临时缓存目录# syntaxdocker/dockerfile:1.4 FROM node:18-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN --mounttypecache,target/root/.npm npm ci COPY . . RUN npm run build这里的--mounttypecache,target/root/.npm会把 npm 的全局缓存挂载到一个宿主机层级的缓存区域。这个缓存不进入镜像层但在多次构建之间共享。实测效果一个中型前端项目的npm ci耗时可以从 120 秒降到 20-30 秒依赖本身已经缓存在宿主机上。同样的技巧适用于 MavenRUN --mounttypecache,target/root/.m2 mvn clean package -DskipTests以及 GoRUN --mounttypecache,target/go/pkg/mod go mod download还有一个隐藏技巧是--mounttypebindRUN --mounttypebind,source.,target/workspace make build这种挂载方式把源码以只读形式挂进构建容器适合那些需要在构建过程中动态读取源码又不想把源码打进镜像层的场景。使用 BuildKit 还有一个额外好处支持COPY --chmod、RUN --networknone等增强语法并且可以自动跳过某些无意义指令进一步缩短构建时间。如果你的 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的别名拼写。确认是否用到了编号引用--from0但中间增删过阶段导致编号错位。注意大小写敏感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 --frombuilder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --frombuilder /app/server . CMD [./server]或者干脆运行环境不用scratch改成FROM alpine:3.18 RUN apk add --no-cache ca-certificates tzdata COPY --frombuilder /app/server . CMD [./server]这两个基础依赖CA 证书和时区数据是生产环境最容易忽略的。具体踩过的坑是某次上线后日志里的时间全部变成 UTC功能没受影响但排查问题对不上时间戳白忙了大半天。后来我养成了习惯只要镜像里有时区相关需求一律加 tzdata 并把TZAsia/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 提到的--mounttypecache把依赖下载环节的缓存放到宿主机层避免每次构建重新拉全量依赖。确认产物路径构建阶段里mvn package的输出在target/go build的输出在-o指定的路径npm run build的输出在dist/或build/。生产环境里最常见的错误就是COPY --frombuilder /app/dist写错了目录名构建不报错运行起来页面全是空白。多阶段切换时注意工作目录。每个阶段都有自己的WORKDIR不要默认继承了上一阶段的路径。举例builder 阶段的WORKDIR是/build运行阶段如果不重新设置WORKDIR它默认继承/build这时COPY的源路径和目的路径都需要重新确认。多阶段构建不是一个“高深”的 Docker 特性但它确实能带来立竿见影的效果体积缩小、构建加速、安全面收敛。在我实际维护过的项目里这几乎是性价比最高的容器化优化手段之一。每次在评审 Dockerfile 时看到那种把编译工具和运行进程混在一起写的做法我基本都会建议先改成多阶段构建再考虑其它的架构调整。如果你还没有用过这个特性建议打开手边任意一个项目拿它试一次。先从最简单的 Java 或 Node 项目改起跑一次构建看下体积对比那种差距本身就会告诉你这个优化到底值不值得做。最后的经验补充多阶段构建为镜像瘦身但别为了瘦身牺牲可维护性。scratch适合纯静态的 Go 项目Java 用jre-slim更成熟稳定前端静态资源用 Nginx 最省心。选基础镜像时第一考虑的是生产环境需要的运行依赖第二才是体积。有条件的情况下尽量把 builder 阶段的依赖缓存留在 CI 机器上让每次构建都能稳定命中缓存这才是多阶段构建在生产环境发挥真正价值的关键。