我接手过不少团队的项目,见过最夸张的一个 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 里就是分两个阶段:
- 构建阶段(builder):
FROM maven:3.8-jdk-11,在这里执行mvn clean package,完成所有编译、打包、单元测试等工作。这个阶段可以用完整的 JDK、Maven、各种编译工具,甚至可以把依赖缓存都挂上去,怎么方便怎么来。 - 运行阶段(runtime):
FROM openjdk:11-jre-slim,只装 JRE 运行时,把构建阶段生成的 jar 包通过COPY --from=builder拷过来,然后指定启动命令。
两个阶段在同一个 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 --from=0用数字索引也能指向第一个阶段,但可读性太差。命名还能让你用docker build --target=builder单独构建某个中间阶段——这个后面会细说,调试时特别好用。
COPY --from=<stage> <src> <dest>跨阶段拷贝
这是多阶段构建的灵魂指令。--from后面可以接阶段名(builder),也可以接镜像名(比如nginx:alpine),甚至可以接上一次构建缓存里某个镜像的 ID,用法很灵活。需要注意的是,--from拷贝的并不一定是“阶段构建产物”,它就是把目标阶段文件系统的某个路径原封不动拿过来,所以你可以只拷一个二进制、拷一组静态文件、拷一份配置文件,都行。
ARG与--build-arg参数传递
如果多阶段里不同阶段需要同一个版本号,比如 maven 阶段用 JDK 11、运行阶段也用 JRE 11,可以在FROM之前用ARG JAVA_IMAGE_VERSION=11定义变量,然后${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 --mount=type=cache可以把 apt、npm、go mod、pip 的缓存挂载到外部卷,让不同构建之间共享依赖缓存,能大幅减少重复下载。不过这个特性依赖 BuildKit,Docker 版本要 18.09 以上,构建时需要设置DOCKER_BUILDKIT=1或者在新版 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 TZ=Asia/Shanghai \ JAVA_OPTS="-Xms256m -Xmx512m" RUN useradd -r -u 1001 app WORKDIR /app # 从 builder 阶段只拷贝打好的 jar COPY --from=builder /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 --target=builder -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 --from=builder /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 --from=builder /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 wasn't detected。这通常发生在 Windows 或部分 Linux 虚拟化环境里。排查三步走:
- 进 BIOS 确认 CPU 虚拟化(Intel VT-x / AMD-V)已经开启;
- Windows 上确认 “Hyper-V” 和 “适用于 Linux 的 Windows 子系统” 功能已勾选启用;
- 如果用的是 VMware 或 VirtualBox,还要检查嵌套虚拟化选项是否打开。
4.3COPY --from找不到阶段或路径
多阶段构建里有的同学会写COPY --from=builder /app/target/app.jar,结果报错说找不到/app/target/app.jar或找不到builder阶段。两个原因:
- 阶段名写错了,注意
AS builder和--from=builder的大小写、拼写必须完全一致,Docker 的阶段名是区分大小写的。 - 路径不对。builder 阶段里
WORKDIR /app是全局作用域,COPY src ./src之后源码在/app/src,那mvn package生成的 jar 在/app/target。你运行阶段COPY --from=builder /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-V |
| COPY --from 找不到文件 | 阶段名写错或路径不对 | 核对AS名称和WORKDIR路径 |
| 容器启动报 exec format error | 镜像架构与宿主机不匹配 | 换官方镜像,确认多架构 tag |
| 镜像体积没缩小 | 没启用多阶段构建或层残留 | 检查 Dockerfile 是否多 FROM,清理缓存 |
| 构建的时候报 node-sass 编译失败 | Alpine 环境缺编译工具 | 换-slim镜像或补装python3 make g++ |
5. 多阶段构建的进阶玩法与后续扩展
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 --from=test /app/src ./src COPY pom.xml . RUN mvn package -DskipTests FROM openjdk:11-jre-slim WORKDIR /app COPY --from=builder /app/target/*.jar ./app.jar CMD ["java", "-jar", "app.jar"]本地想快速进入终端调试可以直接:
docker build --target=test -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),这能避免构建缓存把不必要的文件带入后续阶段。运行阶段的镜像越小,将来你排查线上问题时候钟声就越短。