Docker多阶段构建实战:从原理到镜像瘦身的完整指南

Docker多阶段构建实战:从原理到镜像瘦身的完整指南 1. 先从痛点说起为什么非得多阶段构建说实话我接触 Docker 的前两年一直用的是那种“一个 Dockerfile 从环境装到最后运行”的土办法。打个 Java Jar 包Dockerfile 里先装 JDK再装 Maven代码 COPY 进去编译完直接 CMD 跑。当时觉得挺顺的直到有个生产环境镜像被甲方要求瘦身我一看docker images列表一个业务镜像1.2GB里面光是构建工具链就占了600多MB那一刻才意识到问题有多严重。这里先把多阶段构建Multi-stage Build是什么说清楚它允许你在一个 Dockerfile 里写多个FROM指令每个 FROM 可以基于不同的基础镜像后一个阶段可以只把前一个阶段里“需要的东西”拷贝过来。打个不严谨但好懂的比方这就像你先在一个堆满工具的车间里把零件加工好最后只把成品拿出去车间的车床、扳手、焊机统统留下。它能解决什么问题最直接的两个镜像体积大幅缩小。编译、依赖安装阶段用的大体积工具链不需要进入最终镜像。构建环境和运行环境彻底解耦。编译环境要的是最新版本编译器、完整工具链运行环境要的是干净、小、安全。两者以前被塞在一个镜像里互相妥协现在各干各的。适合谁来参考我的答案是只要你在用 Docker 打包编译型语言Java、Go、Node、C/C、Rust的业务都值得把多阶段构建用起来。尤其是维护 CI/CD 流水线的同事、做交付物给客户部署的朋友以及有强迫症、见不得镜像动辄几个G的开发者。我写这篇东西不是复述官方文档而是把我从踩坑、比对、参数调优到生产落地这几个阶段攒下来的体会连同可直接抄的 Dockerfile 模板一起整理出来希望对你有实际参考价值。2. 多阶段构建核心机制拆解2.1 多阶段到底“多”在哪里传统结构FROM ubuntu:20.04 RUN apt-get update apt-get install -y build-essential ... COPY . /app RUN cd /app make CMD [./app]这个镜像最终包含了什么一堆编译工具、中间文件、源码、运行时依赖全都混在同一层里。哪怕你最后RUN apt-get remove卸载编译工具文件占用也很难真正缩回去——因为 Docker 镜像是分层存储的你删除文件只是在新的层里打个“删除标记”被删除的数据块依然躺在下一层里。这就是很多人觉得“我明明清理了为什么镜像还是大”的根源。多阶段构建写法则是FROM ubuntu:20.04 AS builder RUN apt-get update apt-get install -y build-essential COPY . /app RUN cd /app make FROM ubuntu:20.04 COPY --frombuilder /app/app /usr/local/bin/app CMD [app]两个FROM一共生成了两层结构前一段是构建产物生成段后一段是运行段。最终镜像只保留第二段的内容第一段在构建结束后会被丢弃但它生成的二进制文件却通过COPY --frombuilder传给了下一阶段。2.2 关键语法逐个讲清楚多阶段构建的核心套路就三件事起别名、跨阶段拷文件、临时镜像丢弃。阶段命名FROM openjdk:17-jdk-slim AS builderAS builder就是给这个阶段起名字后面的阶段都可以用这个名字引用它。跨阶段拷贝COPY --frombuilder /path/to/output /target/path这个指令不管是写法还是行为都跟普通COPY不同。普通COPY是从宿主机往镜像里拷而--frombuilder是从上一个阶段生成的文件系统里拷。更灵活的是你甚至可以引用外部镜像COPY --fromnginx:1.25-alpine /usr/share/nginx/html /usr/share/nginx/html这在一些需要复用别人已编好的二进制或者静态文件时特别方便。丢弃不需要的东西默认情况下只有最后一个FROM阶段的产物会进入最终镜像。也就是说前面的阶段不管装了多少东西只要最后没有被拷贝走生成最终镜像时自动丢弃。这个行为从一开始设计上就是如此不需要你手动清理。2.3 为什么这比“构建完再清理”更靠谱有朋友可能会问我在一个 Dockerfile 里老老实实装工具、编译、然后rm -rf清理临时文件最后 RUN 一条apt-get autoremove不也行吗表面看行实际有两大问题。第一前面说的分层存储机制决定了删除不能真正回收体积。镜像由只读层叠加而成每一条 RUN、COPY 都会生成一个新层。就算你在这层删了文件被删文件的数据还是躺在更早的层里占的空间一分不少。第二安全性和可维护性差。把编译器和一堆源码留在最终镜像里一旦镜像泄露攻击者可以直接翻出你的源码甚至用残留工具链做后续攻击。而多阶段构建最终只有运行文件、运行时库和必要的配置文件暴露面小得多。我举个真实数字之前把一个 Java 服务从单阶段改为多阶段镜像从650MB降到180MB体积缩到原来的 27% 左右。而这个 Java 服务总代码量其实才几十 MB那 600 多 MB 全是 JDKMaven 依赖编译中间文件。3. 实操案例从零写一个多阶段构建的 Dockerfile3.1 案例一Go 应用最典型、最能体现意义Go 很适合讲解多阶段构建因为 Go 可以编译成静态链接的纯二进制文件理论上甚至能用scratch空镜像来做最终运行镜像达到极致瘦身。# 阶段1编译 FROM golang:1.21-alpine AS builder WORKDIR /src # 先拷 go.mod、go.sum利用缓存加速依赖下载 COPY go.mod go.sum ./ RUN go mod download # 再拷贝全部源码 COPY . . # 交叉编译 Linux 平台二进制静态链接禁用 CGO RUN CGO_ENABLED0 GOOSlinux go build -a -trimpath -ldflags-s -w -o /out/app main.go # 阶段2制作精简运行镜像 FROM alpine:3.20 RUN apk add --no-cache ca-certificates WORKDIR /app COPY --frombuilder /out/app /app/app EXPOSE 8080 USER 10001 ENTRYPOINT [/app/app]这一份正常构建完镜像体积一般在 10MB 上下而只用一个golang:1.21-alpine镜像直接跑程序体积至少 300MB 起。这里有几个值得展开说的点先拷贝go.mod和go.sum。Dockerfile 每一步都会形成缓存层当代码文件变化而依赖没变时RUN go mod download能直接命中之前的缓存不需要重新拉取所有依赖。这个顺序对 CI 来说能省几条流水线的时间。CGO_ENABLED0。把 CGO 关掉编译出纯静态二进制这样最终镜像里不需要任何动态链接库。否则Go 程序里一旦用到 cgo 相关的库运行镜像就必须配套装 glibc 之类的东西镜像体积立刻上来。-ldflags-s -w。-s 是去掉符号表-w 是去掉 DWARF 调试信息。如果不需要用 delve 调试或 pprof 抓链路日常线上镜像建议都带上。对 Go 二进制能砍掉大约 20%~30% 的体积。USER 10001。新建一个非 root 用户来跑服务这是安全基线要求不算炫技但很多人会漏掉。最终阶段里没有 root 权限的容器即使被攻破权限也受限。3.2 案例二Node.js 前端项目产物只有静态文件前端项目过去常见的错误写法是整条流水线在 node 镜像里跑完构建然后直接用这个镜像部署里面带着 node_modules、构建缓存体积轻松上 G。其实构建产物只是一堆静态文件用一个轻量 Web 服务器就够跑了。# 阶段1构建 FROM node:20-alpine AS build-stage WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 阶段2运行 FROM nginx:1.25-alpine COPY --frombuild-stage /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80这里有两个容易翻车的地方npm install 和 npm ci 的选择。CI 环境一定要用npm ci它会严格按照package-lock.json安装依赖速度更快、版本更确定npm install在某些情况下会更新 lock 文件并挂到 package.json 上导致最终构建出来的依赖集合与锁文件版本不一致。nginx 配置别漏。前端项目如果是 Vue/React 的 history 路由要自己写一个 nginx.conf把路由 fallback 到index.html。不然刷新一个二级页面就直接 404。这个跟多阶段构建本身无关但属于前端容器化上线的高频坑顺手提一句。3.3 案例三Java Spring Boot体积优化重点Java 类的多阶段构建模板也是一类高频需求。重点说一下 jlink 和瘦 JRE 的思路。# 阶段1构建 jar FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . # 提前拉取依赖充分利用缓存 RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 阶段2制作精简 JRE FROM eclipse-temurin:17-jre-jammy AS jre-build WORKDIR /opt # 把 jar 先拷进精简阶段 COPY --frombuilder /build/target/app.jar /opt/app.jar # 用 jlink 裁出程序需要的模块生成精简运行时 RUN jlink --add-modules java.base,java.sql,java.naming,java.management,java.desktop \ --strip-debug --no-man-pages --no-header-files \ --compress2 \ --output /opt/jre-min # 阶段3运行 FROM debian:bookworm-slim ENV JAVA_HOME/opt/jre-min ENV PATH$JAVA_HOME/bin:$PATH WORKDIR /app COPY --fromjre-build /opt/jre-min /opt/jre-min COPY --frombuilder /build/target/app.jar /app/app.jar EXPOSE 8080 USER 10001 ENTRYPOINT [java, -XX:MaxRAMPercentage75.0, -jar, /app/app.jar]三个步骤要注意mvn dependency:go-offline的作用是把项目依赖一次性拉到本地 Maven 仓库后续只要 pom.xml 不变这层缓存就不会失效。这一条写不写直接决定你在 CI 上每次构建要多等几分钟还是几秒钟。jlink 模块列表不是随便选的。少了模块会直接报ClassNotFoundException多了体积又降不下来。先跑一遍应用看报错再补模块是一个可行的笨办法也可以先用jdeps分析 jar 的依赖再决定模块列表。我在生产上通常这么处理第一次构建时Add-Opens之类报错就把模块补上稳定之后锁死列表。最终镜像不直接用 eclipse-temurin 的 JRE 镜像而是用 debian slim 自裁 jre。因为 jlink 出来的jre-min比官方 JRE 还小——官方 JRE 镜像包含完整运行时、字体、扩展机制等而 jlink 只保留应用真正用到的模块。两种方案在多数场景下差 40~70MB遇到极端情况纯 Web 服务这个差距会更大。4. 构建缓存与性能优化进阶4.1 缓存命中率直接决定你一天能挤出多少时间多阶段构建不是把 Dockerfile 写完就完事了。构建时长、缓存命中率、buildkit 的差异这些才是真正影响日常开发体验的部分。按我的经验镜像缓存失效的最大凶手是 COPY 和 RUN 的顺序不合理。拿 Java 举例子# 不推荐的写法依赖和源码混在一起 COPY . . RUN mvn clean package这会导致任何源码文件一改动Maven 重新解析所有依赖。一个几百个依赖模块的服务重新拉依赖能浪费不少时间。推荐的写法是COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package逻辑是把变化频率低的依赖层放在前面变化频率高的源码层放在后面。只要 pom.xml 没变所有后续构建都复用缓存。对于 Go同理COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build ...对于 NodeCOPY package*.json ./ RUN npm ci COPY . . RUN npm run build这些写法的思路完全一致先锁定依赖锁定文件再引入易变的源码。这一套用好了CI 流水线的一次全量构建时间可以缩短不少尤其在依赖上百个的工程里非常明显。4.2 这版构建是 BuildKit还是传统 builderDocker 默认已经支持 BuildKit可以通过DOCKER_BUILDKIT1显式启用。多阶段构建在 BuildKit 下有一些重要变化我挑重点讲并发执行阶段。传统 builder 是串行构建每个阶段的BuildKit 会分析阶段间的依赖关系互不依赖的阶段可以并行执行。对多阶段构建来说这种并行能力能带来一定的时间收益。仅构建目标阶段。构建 Dockerfile 时加--target参数可以跳到指定的阶段而不是必须跑到最后一个阶段docker build --target builder -t myimage:debug .这在调试阶段很有用。比如你想确认编译产物的内容但不希望继续生成最终镜像直接构建 builder 阶段就行。输出挂载与 BuildKit 缓存挂载RUN --mounttypecache,target/root/.m2 \ mvn clean package--mounttypecache会在构建期间挂载一个持久缓存目录即使 Dockerfile 的其他部分发生变化导致该 RUN 层缓存失效这个 cache 挂载仍然保留。对 Java 构建来说效果最明显的是 Maven 本地仓库不重建对 npm 也有类似收益。首次构建时效果不明显第二次、第三次构建的差距会非常直观。有一点要留意cache 挂载的目录不会出现在最终镜像里。它只是构建时的一个临时挂载卷所以不用担心把 ~/.m2 的残留带进运行镜像。4.3 镜像基础镜像选型的几分钟纠结多阶段构建中每个阶段的基础镜像选择也是一个可以优化的点。这里给出我常用的选择逻辑阶段用途推荐基础镜像原因编译阶段需完整工具链golang:1.21-alpine、maven:3.9-eclipse-temurin-17、node:20-alpine提供完整的编译器/依赖管理工具体积大点无所谓反正后面会丢弃运行阶段轻量需求alpine:3.20、debian:bookworm-slim、distroless体积小、安全维护及时能装必要运行时库极致精简scratch只适合纯静态编译的程序比如 CGO 关闭后的 Go 二进制已有运行时产物不写构建逻辑nginx:alpine、busybox直接用现成镜像或仅拷静态文件这里想单独说一下distroless。它是 Google 提供的一组“仅包含运行时依赖”的基础镜像没有 shell、没有包管理器、没有多余命令。安全性很好但有一个代价进容器排查问题时没有 bash、没有 ls、没有 cat这些基础命令都被精简掉了。如果你习惯了docker exec -it进去看文件用 distroless 会不太顺手。我的建议是生产环境追求安全性和小体积选 distroless开发环境或运维排查需求较多的服务用 alpine 或 slim 更省心。体积差距通常在几十 MB 之内但排障体验差距却很明显。别为了“极客感”选一个连日志文件都没法 cat 的镜像。5. 常见问题与排查技巧实录5.1 踩过的坑镜像尺寸没有明显减小有朋友照抄我的写法把 Dockerfile 改成多阶段构建后发现镜像体积只小了一点点。第一个要检查的是最终阶段是否无意中又把构建产物的大依赖 COPY 过去了。比如 Java 场景你在 builder 阶段配合用了COPY --frombuilder /build/target/app.jar这是对的但如果你手滑把/build整个目录拷过去那依赖和源码也跟着过去了。Go 同理COPY --frombuilder /out/ /out/如果 /out 里不止有 app 二进制还带了临时文件体积自然不对。第二个要检查的是基础镜像选用是否合理。最终阶段从ubuntu:22.04换成alpine:3.20体积立刻能减 50MB 以上。这一条经常被忽略。第三个要检查的是是否用了docker build的旧缓存。有时候你已经改了 Dockerfile但本地缓存导致构建没有走新路径。这时用docker build --no-cache重新构建确认体积是否真的是改完后的数字。5.2 阶段间拷贝时文件权限和用户问题COPY --frombuilder从编译阶段拷文件时文件权限默认保留的是源阶段的权限。如果编译阶段用的是 root最终阶段里你设置了USER 10001而拷贝出来的文件没给到运行用户读取权限启动时就会报permission denied。我在实际项目中遇到过Go 二进制在 builder 阶段编译时权限是-rwxr-xr-x没问题但有些编译脚本会顺手把输出文件 chmod 成 700结果运行阶段用非 root 用户一执行就报权限错误。有些人会建议最终阶段RUN chown -R 10001:10001 /app这当然可行但更好的方案是在编译阶段的 RUN 里就处理 numRUN CGO_ENABLED0 go build -o /out/app main.go \ chmod 755 /out/app构建阶段设置好权限最终镜像里就不需要额外的 chown 指令层也更干净。5.3 BuildKit 缓存失效的“疑难杂症”明明只改了一行代码但后面所有 RUN 都重新执行了。这多半不是多阶段构建的问题而是你的缓存被某条指令“打断”了。Docker 缓存是基于每条指令结果哈希的只要指令的上下文发生变化后面的缓存全部失效。常见打断因素COPY . .把临时文件、日志、IDE 配置都拷进去了内容一变缓存失效。解决办法是在项目里加.dockerignore把node_modules、target、dist、.git、*.log等全部排除掉。apt-get install没固定版本导致每次 RUN 的镜像源返回结果不同仓库更新了元数据哈希结果不同缓存失效。.dockerignore看起来不起眼我认为它和多阶段构建是“最佳拍档”。有了 .dockerignoreCOPY . .发送的上下文大幅减小既能减少构建时间也能减少缓存失效概率。5.4 常见问题速查表症状可能原因排查与对策镜像体积降不下去最终阶段把整个构建产物目录 COPY 过去了或基础镜像选太大只 COPY 需要的产物用 alpine/slim/distroless 替代完整发行版docker build --no-cache验证运行阶段二进制执行报 Permission denied文件权限是 root-only编译阶段 chmod 755/644或在最终阶段用 RUN chown 调整构建时依赖下载极慢基础镜像源是海外源且未设置代理或镜像源配置合适的镜像源或代理可参考国内精品加速镜像某个 RUN 每次构建都重新执行COPY 的上下文内容发生变化或 RUN 的依赖源更新导致哈希变化检查 .dockerignore给 RUN 加固定版本号把变化小的步骤前置npm/go build 报找不到模块阶段间没有正确拷贝文件或在错误阶段执行依赖安装检查 COPY --from 的路径Go 需要在 builder 阶段 go mod downloadCOPY --fromxxx提示找不到阶段阶段名拼错或没有AS xxx标记检查 Dockerfile 中AS的拼写注意大小写镜像里没有lscat可排查用了 distroless 或 scratch换 alpine/debug 版本或用docker run --entrypoint /bin/sh的方式进入5.5 排障手段真到容器启动不了时怎么办多阶段构建时运行镜像里精简得厉害一旦启动失败排查手段也少得可怜。这里分享一个我常用的办法先不要直接 docker run 最终镜像而是跑到中间阶段去排查。比如 Go 应用报错怀疑是系统库缺失可以先跑一个临时的 debian slim 容器手动 COPY 进去二进制跑一遍把系统库逐步补上确认能跑之后再回到 Dockerfile 里补相关 RUN 指令。这个方式比在 distroless 里干瞪眼高效得多。Java 应用报模块缺失同样先用 jlink 生成大一点的 JRE跑通之后再逐步精简模块列表直到刚好够用。这种“先搭个大一点的环境验证功能再逐步精简环境”的思路其实不止适用于 Docker 镜像。凡是做“减法”的工作先做“加法”验证再做“减法”收敛都是稳定可靠的路线。6. 一点实操体会多阶段构建本身的技术难度并不高真正难的是把“构建逻辑”和“运行逻辑”在同一个 Dockerfile 里拆干净。我见过不少项目做了一堆阶段最后 COPY 来 COPY 去比不用多阶段还难维护。有几个体会想share一下第一别为了追求“绝对最小镜像”牺牲可维护性。scratch和distroless很酷但如果你没有配套的日志采集、监控探针等能力排查问题会非常痛苦。实用主义一点alpine、slim 在 90% 场景下已经够用。第二Dockerfile 也是代码要按代码的标准维护。阶段名要起得有意义COPY 路径要留注释依赖版本最好显式 lock基础镜像 Tag 别用latest。这些细节在项目交接时会成为重要的可维护性资产。第三把多阶段构建和 CI 配合起来用才有最大价值。本地构建再快也不如 CI 流水线里别人接手你的镜像时构建顺手。缓存策略、.dockerignore、基础镜像镜像源配置这些都要提前定好不然接手的同事很容易在“镜像构建慢”“体积大”这类问题上绕圈。最后再分享一个小技巧如果你的项目里既有前端又有后端完全可以在同一个 Dockerfile 里分三个阶段——前端构建、后端构建、最终合并阶段。这样一次构建能把前后端最终打到一个镜像里副本、运维、联调都会省事不少。多阶段构建的可组合性往往比它的体积优化能力更值得你挖掘。以上是我在实战中积累的全部内容希望能帮到正被镜像体积和构建效率困扰的你。