
我接手过不少团队的项目见过最夸张的一个 Java 服务打完镜像居然有 3 个多 G。当时第一反应是这哥们儿是不是把源码、编译中间产物、甚至本地的.git目录全塞进去了。后来一查Dockerfile 写得很规矩就是FROM maven一顿mvn package再FROM openjdk把 jar 拷过去逻辑没问题但镜像体积就是下不来。直到我改用 Docker 多阶段构建同一套代码同一个 Dockerfile镜像直接从 1.2G 干到 380M再配合 JRE 精简最后稳定在 280M 左右。这篇文章就把这段时间用多阶段构建踩过的坑、总结的经验和一套可以直接抄的写法完整梳理一遍适合刚把服务容器化、或者正在为镜像体积发愁的同学参考。先说结论Docker 多阶段构建不是个新功能Docker 17.05 之后就正式支持了但很多人还在用最原始的“一个大镜像搞定一切”的写法。多阶段构建的核心优势是——你可以在一个 Dockerfile 里面放多个FROM前面的阶段负责编译、打包、装依赖后面的阶段只要把编译好的产物拷过去就行。这样最终镜像里只留下运行必要的东西没有编译器、没有源码、没有一堆没用的系统包镜像自然小攻击面也小。1. 为什么需要多阶段构建1.1 镜像膨胀的根源要理解多阶段构建的价值得先明白 Docker 镜像的构建方式。Dockerfile 里每一行指令RUN、COPY、ADD都会生成一个只读层这些层叠加起来构成最终镜像。问题就出在——如果你在一个阶段里又装依赖又编译又清理每一层的体积都会保留下来。很多人说“我编译完就rm -rf删掉临时文件了”镜像是能小一点但只是表象。Docker 的层是叠加的你在第二层删掉的文件在第一个层里依然存在它只是被标记为“已删除”但物理上还占着空间。这就是为什么单阶段构建怎么清理都瘦不下来的根本原因。生活化一点单阶段构建就像在厨房做完一顿饭然后直接把这个厨房搬到餐厅去招待客人——菜刀、油烟机、燃气灶、没用完的葱姜蒜全都摆在客人面前。你说是为了展示厨艺吗不是你只是不会分开装盘而已。1.2 多阶段构建的核心思路多阶段构建的思路特别朴素先把菜在厨房做好然后只端菜去餐厅厨房留在原地。放到 Dockerfile 里就是分两个阶段构建阶段builderFROM maven:3.8-jdk-11在这里执行mvn clean package完成所有编译、打包、单元测试等工作。这个阶段可以用完整的 JDK、Maven、各种编译工具甚至可以把依赖缓存都挂上去怎么方便怎么来。运行阶段runtimeFROM openjdk:11-jre-slim只装 JRE 运行时把构建阶段生成的 jar 包通过COPY --frombuilder拷过来然后指定启动命令。两个阶段在同一个 Dockerfile 里构建时 Docker 会按顺序执行最终镜像只有运行阶段的内容builder 阶段的镜像层会作为构建缓存保留在本地但不会被推送到镜像仓库。这种写法的好处不只是体积小安全性也提升了。最终镜像里没有源码、没有编译工具、没有敏感的环境变量和构建时用的密钥外面的人就算把镜像拖下来逆向能拿走的东西也极其有限。1.3 和旧方案的对比为什么“手动清理”不靠谱在 Docker 多阶段构建出现之前业界常见的瘦身方案有几种但各有各的问题单阶段 手动清理RUN apt-get update apt-get install ... mvn package rm -rf ...。看着合理实际上因为层存在删掉的东西还在镜像里只是看不见了。除非你把所有安装、编译、清理都写在同一个RUN指令里靠 shell 的串行执行让它们最后只生成一层否则清理无效。使用 Dockerfile 的.dockerignore排除文件这解决的是“构建上下文太大”的问题不是镜像层残留的问题。你不上传.git目录但编译工具链还是安安静静躺在镜像里。构建完手动docker export/docker import可以把多层打平成一个镜像体积确实能减但镜像历史信息全丢了而且每次构建都手动操作根本没法自动化更没法进 CI/CD 流程。多阶段构建从机制上解决了分层残留的问题——不同阶段天然是隔离的编译阶段和运行阶段各管各的最终产物只有最后一个阶段。这也是为什么它现在成了生产环境的标准写法。2. 多阶段构建的原理与核心指令拆解2.1 几个必须吃透的指令多阶段构建用到的指令就那么几个但每个都有讲究。FROM ... AS name给阶段命名第一个FROM可以不加AS后面要引用的阶段最好都加上名字不然COPY --from0用数字索引也能指向第一个阶段但可读性太差。命名还能让你用docker build --targetbuilder单独构建某个中间阶段——这个后面会细说调试时特别好用。COPY --fromstage src dest跨阶段拷贝这是多阶段构建的灵魂指令。--from后面可以接阶段名builder也可以接镜像名比如nginx:alpine甚至可以接上一次构建缓存里某个镜像的 ID用法很灵活。需要注意的是--from拷贝的并不一定是“阶段构建产物”它就是把目标阶段文件系统的某个路径原封不动拿过来所以你可以只拷一个二进制、拷一组静态文件、拷一份配置文件都行。ARG与--build-arg参数传递如果多阶段里不同阶段需要同一个版本号比如 maven 阶段用 JDK 11、运行阶段也用 JRE 11可以在FROM之前用ARG JAVA_IMAGE_VERSION11定义变量然后${JAVA_IMAGE_VERSION}引用。这样升级 JDK 版本时只需要改一个参数。2.2 构建上下文多阶段构建同样绕不开的坑镜像体积的问题解决了不代表构建速度就快了。Docker 构建时构建上下文build context会被整体打包发送到 Docker daemon——如果你在项目根目录执行docker build那这个目录下所有文件都会被打包包括 node_modules、target、.git 这种好几百兆的东西。多阶段构建不会自动帮你跳过这些目录。解决方案急是.dockerignore文件在构建上下文根目录创建一个写上.git node_modules target dist *.log .idea .vscode这个文件能明显加快构建速度尤其是当你把 Dockerfile 放到一个很大的项目仓库里时。网络上有句话说得好“Dockerfile 写得再花哨不如.dockerignore写得干净。”我深以为然。2.3 缓存与层复用多阶段构建的加速器多阶段构建不直接解决构建时间问题但配合 Docker 的层缓存机制效果非常明显。核心原则是把不常有变化的操作放前面把频繁变动的操作放后面。拿 Java 项目举例你的源码几乎每次构建都会变但pom.xml里的依赖列表不会天天变。如果你先把pom.xml复制进去并执行mvn dependency:go-offline那只要 pom 不变这一层就不会失效缓存后面的mvn package只编译增量变化的部分速度会快很多。类似地Node 项目要把package.json和package-lock.json先复制进去执行npm install再复制源码执行npm run build。Python 项目则先pyproject.toml/requirements.txt再装依赖。这个“先依赖后源码”的顺序对多阶段构建同样适用因为你构建阶段要是缓存失效慢整体构建速度都会被拖垮。顺便提一个高级技巧RUN --mounttypecache可以把 apt、npm、go mod、pip 的缓存挂载到外部卷让不同构建之间共享依赖缓存能大幅减少重复下载。不过这个特性依赖 BuildKitDocker 版本要 18.09 以上构建时需要设置DOCKER_BUILDKIT1或者在新版 Docker Desktop 里默认开启。3. 实操三个场景的多阶段构建完整配置3.1 场景一基于 Maven 的 Java/Spring Boot 微服务这是很多后端团队的标准姿势尤其是部署微服务项目时一个服务一个镜像体积直接关系到磁盘占用和拉取速度。我的推荐配置如下# 阶段一Maven 构建 FROM maven:3.8-openjdk-11 AS builder WORKDIR /app # 先只拷贝 pom利用缓存装依赖 COPY pom.xml . RUN mvn dependency:go-offline -B # 再拷源码并打包 COPY src ./src RUN mvn clean package -DskipTests # 阶段二JRE 运行 FROM openjdk:11-jre-slim ENV TZAsia/Shanghai \ JAVA_OPTS-Xms256m -Xmx512m RUN useradd -r -u 1001 app WORKDIR /app # 从 builder 阶段只拷贝打好的 jar COPY --frombuilder /app/target/*.jar ./app.jar USER app EXPOSE 8080 ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar app.jar]这段 Dockerfile 里有几个细节值得说为什么用openjdk:11-jre-slim而不是openjdk:11-jdk因为运行阶段只需要 JRE不需要编译器。jdk 镜像比 jre-slim 大一两百兆没必要。为什么在运行阶段创建普通用户app因为默认 root 用户跑容器一旦容器被入侵整个宿主机的 root 权限都危险。用非 root 用户运行能降低风险。ENV里把时区设成了Asia/Shanghai不然容器里默认是 UTC日志时间会跟你差八个小时排查问题会严重怀疑人生。构建命令也很简单docker build -t myapp:1.0.0 .如果你只需要调试构建阶段不想执行后面的运行阶段可以加--target参数docker build --targetbuilder -t myapp-builder:debug .这样会生成一个只包含构建阶段内容的镜像方便你进去检查 jar 包是否生成、依赖是否完整。3.2 场景二基于 Node 的前端静态资源打包前端项目打包的痛点跟后端类似——打包需要 node 环境甚至还需要 node-sass 这种编译型依赖但运行时就只需要 nginx 挂个静态文件目录。多阶段构建再合适不过# 阶段一Node 构建 FROM node:18-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # 阶段二Nginx 运行 FROM nginx:1.25-alpine COPY --frombuilder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80跟 Java 那边一样我先拷贝package.json和package-lock.json再执行npm ci等依赖层缓存命中之后才拷贝源码。npm ci和npm install的区别在于npm ci严格按 lock 文件安装干净且可复现适合 CI/CD 环境。有个常见的坑是node-sass或者sharp这类原生模块在 Alpine 镜像里需要额外编译有时候会失败。如果遇到这个问题最简单的办法是把基础镜像换成node:18-slim基于 Debian虽然体积大一点但兼容性更好缺什么系统库再手动补。3.3 场景三Python 应用的依赖管理与精简镜像Python 项目的多阶段构建核心在于把 pip 安装的依赖和她运行时的解释器分开处理。一个比较典型的配置# 阶段一安装依赖 FROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --prefix/install -r requirements.txt # 阶段二运行 FROM python:3.11-slim WORKDIR /app COPY --frombuilder /install /usr/local COPY . . EXPOSE 8000 CMD [uvicorn, main:app, --host, 0.0.0.0, --port, 8000]这里用--prefix/install把依赖安装到一个临时目录再整个拷进运行阶段避免把 pip 本身的缓存和中间文件带过去。如果你用requirements.txt的话同样建议先拷依赖文件后装依赖再拷源码因为改了源码不该触发依赖重装。Python 相对 Java/Node 简单但裁员率并不低比如pip install需要编译扩展的时候--no-cache-dir加上都不一定能规避所有体积问题直接在运行时阶段用.whl预编译包会更稳。3.4 构建、推送与 compose 集成的建议多阶段构建产出的镜像我通常会用带版本号的 tag 来标记而不是直接打latest。打 tag 的规则就一条跟代码版本走Git 提交号、语义化版本、日期流水号选一种团队通用的方案就行。docker build -t registry.example.com/myapp:1.0.0 . docker push registry.example.com/myapp:1.0.0构建好的镜像如果要在 docker compose 里跑docker-compose.yml里可以写成services: app: image: myapp:1.0.0 build: context: . dockerfile: Dockerfile ports: - 8080:8080这样本地开发时直接docker compose up就会走 Dockerfile 构建生产环境则是docker compose pull拉取远端镜像两种模式互不冲突。注意build和image同时存在时compose up会先构建、再启动如果你只想要构建结果不想要临时容器可以单独用docker compose build。多阶段构建出来的镜像还有一个额外福利因为层少、体积小推送到镜像仓库不管是公网 Docker Hub 还是自建 registry都快很多遇到镜像下载慢或者网络抖动导致unexpected EOF的概率也低一些。这不是玄学大文件传输中断的概率本来就比小文件高。4. 常见问题与排查技巧实录4.1 遇到“unexpected EOF”怎么办docker pull或docker build过程中突然报unexpected EOF一般是网络传输被中断的典型信号。有可能是网速太慢导致连接超时也有可能是磁盘满了导致无法完成层解压。排查思路先看磁盘df -h确认/var/lib/docker所在分区有没有写满Docker 对磁盘空间极其敏感满了就报各种莫名错误。再查网络镜像源是否稳定如果有条件配一个国内可访问的镜像源拉取大镜像会快很多。配置路径在/etc/docker/daemon.json加一行registry-mirrors: [https://替换成你的镜像加速地址]然后systemctl restart docker。如果是一次构建中报的错多半是基础镜像拉取时中断重试或者换一个小体积的基础镜像比如-alpine能显著减小网络传输量。4.2 权限错误和容器服务启动失败常见的权限错误有两类。一类是 Docker 命令本身没权限报permission denied还没把用户加进 docker 组sudo usermod -aG docker $USER newgrp docker注意这等于给了用户相当于 root 的容器管理权限有安全要求的团队要谨慎处理。另一类是容器服务本身启动失败报Virtualization support not detected或者Docker Desktop failed to start because virtualisation support wasnt detected。这通常发生在 Windows 或部分 Linux 虚拟化环境里。排查三步走进 BIOS 确认 CPU 虚拟化Intel VT-x / AMD-V已经开启Windows 上确认 “Hyper-V” 和 “适用于 Linux 的 Windows 子系统” 功能已勾选启用如果用的是 VMware 或 VirtualBox还要检查嵌套虚拟化选项是否打开。4.3COPY --from找不到阶段或路径多阶段构建里有的同学会写COPY --frombuilder /app/target/app.jar结果报错说找不到/app/target/app.jar或找不到builder阶段。两个原因阶段名写错了注意AS builder和--frombuilder的大小写、拼写必须完全一致Docker 的阶段名是区分大小写的。路径不对。builder 阶段里WORKDIR /app是全局作用域COPY src ./src之后源码在/app/src那mvn package生成的 jar 在/app/target。你运行阶段COPY --frombuilder /app/target/*.jar ./app.jar这里 * 通配符需要 Dockerfile 解析器支持如果你构建环境里用的 shell 提前把通配符展开了可能就匹配不上。稳妥起见如果 jar 包只有一个直接用全路径写死。4.4 镜像构建成功但运行报 “exec format error”这问题我见过不止一次。构建阶段用的镜像架构和运行阶段的宿主机架构不一致比如你在 x86 的机器上用FROM arm64v8/maven构建出一个 arm64 的 jar不对jar 是跨平台的问题出在基础镜像上。如果你的运行阶段是FROM arm64v8/openjdk容器里实际跑的是 arm64 的 JVM而宿主机是 x86肯定会报exec format error。解决方式很简单基础镜像统一用官方openjdk、python、node等 Docker Official Images它们会自动匹配当前架构。如果你在树莓派上跑容器也要确认你拉的镜像标签支持linux/arm/v7或linux/arm64。4.5 多阶段构建后镜像历史里还能看到源码吗有人问编译阶段的源码层最终会留在本地缓存里别人拿到我的最终镜像会不会也能翻出来这个问题要区分清楚最终镜像里只有运行阶段的文件系统内容builder阶段的层没有被引用所以不会一起发布。但是 Docker daemon 的构建缓存里确实还留着 builder 阶段的层如果攻击者能直接访问宿主机/var/lib/docker那他本来就拥有 root 权限什么都能看这不在镜像泄露的讨论范围内。如果你实在不放心构建之后可以执行docker builder prune清理掉不需要的构建缓存或者设置 CI 环境变量让构建不落缓存。不过大多数场景下只要你的最终镜像里的运行阶段没有源码就已经够安全了。4.6 常用排查速查表症状原因排查方向构建中途 unexpected EOF网络中断或磁盘满查磁盘、换镜像源、重试docker 命令 permission denied用户不在 docker 组usermod -aG docker后重新登录Docker Desktop 无法启动虚拟化BIOS 虚拟化关闭进 BIOS 开启 VT-x/AMD-VCOPY --from 找不到文件阶段名写错或路径不对核对AS名称和WORKDIR路径容器启动报 exec format error镜像架构与宿主机不匹配换官方镜像确认多架构 tag镜像体积没缩小没启用多阶段构建或层残留检查 Dockerfile 是否多 FROM清理缓存构建的时候报 node-sass 编译失败Alpine 环境缺编译工具换-slim镜像或补装python3 make g5. 多阶段构建的进阶玩法与后续扩展5.1 用--target分离测试和打包阶段多阶段构建不仅能分“编译”和“运行”你可以在中间插入“测试”阶段。比如一次构建里先跑单元测试、再打包、最后产出精简运行镜像FROM maven:3.8-openjdk-11 AS test WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn test FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY --fromtest /app/src ./src COPY pom.xml . RUN mvn package -DskipTests FROM openjdk:11-jre-slim WORKDIR /app COPY --frombuilder /app/target/*.jar ./app.jar CMD [java, -jar, app.jar]本地想快速进入终端调试可以直接docker build --targettest -t app-test .CI/CD 流水线里想把“测试失败就停止发布”的控制权拿到手就不必把一个流程压进 Dockerfile可以分两个构建任务来处理docker 层面负责产物安全CI 层面负责策略控制。5.2 结合 Compose 做开发、生产分离构建除了单镜像构建多阶段构建在 docker compose 场景里也特别好使。docker-compose.yml里可以用target指定构建的阶段services: web: build: context: . target: builder这样在开发环境里你可以把带完整调试工具的 builder 阶段跑起来配合热更新生产环境再切到运行阶段使用精简镜像部署。一个 Dockerfile开发生产两套用法不用维护两份文件这种模式我目前一直觉得是最干净的。5.3 镜像瘦身之外的收益多阶段构建带来的最直接收益是镜像体积缩小但后续的收益是连锁的推送和拉取时间缩短部署时更快磁盘占用减少日志和临时文件清理压力也小。安全扫描比如 trivy、grype只需要扫运行阶段依赖和漏洞列表短很多排查起来效率更高。最终镜像暴露的包少了攻击面收窄没有 shell 的distroless镜像甚至可以直接去掉 bash但那会牺牲可调试性审慎评估后再用。从一个团队协作的角度看沿用统一的 Dockerfile 模板还能减少不同项目之间的差异。「哪份 Dockerfile 是对的」这个问题会慢慢消失因为多阶段构建这种表达方式天然引导你往生产可用的方向走。6. 经验总结与实际体会最后分享一段踩坑换来的体会多阶段构建看起来只是拆了几个FROM但它背后反应的是“构建环境”和“运行环境”分离的意识——这个问题想通了Dockerfile 就是一个逻辑清晰的装配流水线先做零件再组装最后只留下彻底装好的成品。按我自己的习惯每次写 Dockerfile 都会先问四个问题这个镜像最终要跑什么进程他需要哪些文件哪些东西只存在于构建阶段运行阶段能不能用更小的基础镜像这四个问题答案都清晰了Dockerfile 基本就不会写得太差。如果你现在正在维护一个动不动几个 G 的镜像不妨花一个小时把 Dockerfile 改成多阶段构建。改完之后把镜像打上版本号推一次观察一下 push 的耗时和最终镜像的大小大概率你会回来把剩下的服务也改了。一个小技巧收尾多阶段构建里我习惯在构建阶段的RUN指令后面加上--no-cache参数比如apt-get install --no-cache或者pip install --no-cache-dir这能避免构建缓存把不必要的文件带入后续阶段。运行阶段的镜像越小将来你排查线上问题时候钟声就越短。