news 2026/9/14 17:20:06

Docker多阶段构建实战:镜像从GB级瘦身到百MB级

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Docker多阶段构建实战:镜像从GB级瘦身到百MB级

我接手过不少团队的项目,见过最夸张的一个 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 里每一行指令(RUNCOPYADD)都会生成一个只读层,这些层叠加起来构成最终镜像。问题就出在——如果你在一个阶段里又装依赖又编译又清理,每一层的体积都会保留下来。

很多人说“我编译完就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 多阶段构建出现之前,业界常见的瘦身方案有几种,但各有各的问题:

  1. 单阶段 + 手动清理RUN apt-get update && apt-get install ... && mvn package && rm -rf ...。看着合理,实际上因为层存在,删掉的东西还在镜像里,只是看不见了。除非你把所有安装、编译、清理都写在同一个RUN指令里,靠 shell 的&&串行执行,让它们最后只生成一层,否则清理无效。
  2. 使用 Dockerfile 的.dockerignore排除文件:这解决的是“构建上下文太大”的问题,不是镜像层残留的问题。你不上传.git目录,但编译工具链还是安安静静躺在镜像里。
  3. 构建完手动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.jsonpackage-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.jsonpackage-lock.json再执行npm ci,等依赖层缓存命中之后才拷贝源码。npm cinpm 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拉取远端镜像,两种模式互不冲突。注意buildimage同时存在时,compose up会先构建、再启动,如果你只想要构建结果不想要临时容器,可以单独用docker compose build

多阶段构建出来的镜像还有一个额外福利:因为层少、体积小,推送到镜像仓库(不管是公网 Docker Hub 还是自建 registry)都快很多,遇到镜像下载慢或者网络抖动导致unexpected EOF的概率也低一些。这不是玄学,大文件传输中断的概率本来就比小文件高。

4. 常见问题与排查技巧实录

4.1 遇到“unexpected EOF”怎么办

docker pulldocker build过程中突然报unexpected EOF,一般是网络传输被中断的典型信号。有可能是网速太慢导致连接超时,也有可能是磁盘满了导致无法完成层解压。

排查思路:

  1. 先看磁盘:df -h确认/var/lib/docker所在分区有没有写满,Docker 对磁盘空间极其敏感,满了就报各种莫名错误。
  2. 再查网络:镜像源是否稳定,如果有条件配一个国内可访问的镜像源,拉取大镜像会快很多。配置路径在/etc/docker/daemon.json,加一行"registry-mirrors": ["https://替换成你的镜像加速地址"],然后systemctl restart docker
  3. 如果是一次构建中报的错,多半是基础镜像拉取时中断,重试或者换一个小体积的基础镜像(比如-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 虚拟化环境里。排查三步走:

  1. 进 BIOS 确认 CPU 虚拟化(Intel VT-x / AMD-V)已经开启;
  2. Windows 上确认 “Hyper-V” 和 “适用于 Linux 的 Windows 子系统” 功能已勾选启用;
  3. 如果用的是 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

解决方式很简单:基础镜像统一用官方openjdkpythonnode等 Docker Official Images,它们会自动匹配当前架构。如果你在树莓派上跑容器,也要确认你拉的镜像标签支持linux/arm/v7linux/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),这能避免构建缓存把不必要的文件带入后续阶段。运行阶段的镜像越小,将来你排查线上问题时候钟声就越短。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 17:19:37

HarmonyOS Progress进度条组件开发实战指南

1. HarmonyOS Progress进度条组件深度解析 在HarmonyOS应用开发中&#xff0c;进度条(Progress)是最基础却至关重要的UI组件之一。作为一位经历过多个HarmonyOS项目实战的开发者&#xff0c;我发现很多新手容易低估这个"简单"组件的复杂性。实际上&#xff0c;一个优…

作者头像 李华
网站建设 2026/9/14 17:16:14

COMSOL相场模拟:从零长出六角雪花与金属枝晶

有朋友在后台留言问我&#xff0c;能不能用COMSOL把雪花“长”出来。这个想法听起来像是个物理题&#xff0c;其实是个典型的相场模拟问题。只要把凝固过程中的界面稳定性、各向异性和温度扩散耦合对&#xff0c;COMSOL完全能在电脑里长出一朵六角雪花&#xff0c;也能模拟金属…

作者头像 李华
网站建设 2026/9/14 17:15:30

Sentinel流控模式解析与生产实践指南

1. Sentinel流控模式核心概念解析在分布式系统高可用保障体系中&#xff0c;流量控制&#xff08;Flow Control&#xff09;是防止服务雪崩的第一道防线。Sentinel作为阿里巴巴开源的流量治理组件&#xff0c;其流控规则配置灵活度直接决定了系统抗压能力。根据多年线上治理经验…

作者头像 李华
网站建设 2026/9/14 17:14:36

SpringBoot医院管理系统开发与架构设计实践

1. 项目概述这个基于SpringBoot的医院管理系统是一个典型的医疗行业信息化解决方案&#xff0c;我去年为某三甲医院实施过类似项目。这类系统本质上是通过数字化手段重构传统医院管理模式&#xff0c;将挂号、问诊、药品管理等核心业务流程从线下搬到线上。SpringBoot的快速开发…

作者头像 李华