
Docker镜像越删越不小?多阶段构建只复制运行产物

缩小生产镜像的关键,是在构建阶段完成编译,再让独立的运行阶段通过 COPY --from 只接收应用启动所需的产物。Docker 官方 Java 示例中,同一 Spring Boot 应用的单阶段镜像为 880 MB,改用 JRE 运行阶段后为 428 MB;这是该示例的结果,具体项目的体积仍取决于产物和基础镜像。
如果源码、JDK 或依赖缓存已经进入最终镜像的较早层,后续执行删除命令也无法抹去那些层占用的空间。下面的 Dockerfile 将编译和运行分开,同时调整依赖文件的复制顺序;验收时既要比较镜像体积,也要确认应用能够启动并响应请求。
为什么删掉文件,镜像却没明显变小

镜像由文件系统层叠加而成。某个 COPY 或 RUN 指令把文件写入一层后,后面的 RUN rm 只能在新层记录删除结果,较早层中的数据仍占空间。Red Hat 的分层示例用包管理器缓存说明了这一点:安装、清理和删除分属不同指令时,缓存仍留在前面的层里。
临时文件如果必须在同一阶段生成,应尽量在产生它们的同一条 RUN 指令中清理。对于编译器、源码和 Maven 下载缓存,更清楚的边界是重新以运行镜像开始最终阶段,只复制 JAR。前一阶段执行过的构建指令不会自动成为最终阶段的文件系统层;真正进入最终阶段的应用文件,由其中的 COPY 等指令决定。
把 Spring Boot 构建和运行分成两阶段
以下写法适用于项目根目录含有 mvnw、.mvn、pom.xml 和 src,且 Maven 打包后只生成一个匹配的 JAR 的应用。把列表中的内容按顺序逐行写入 Dockerfile;示例采用相同 Java 主版本的 JDK 和 JRE 镜像。项目使用其他 Java 版本时,应一起调整两个 FROM,并确认所选标签可用。
- FROM eclipse-temurin:21.0.8_9-jdk-jammy AS build
- WORKDIR /opt/app
- COPY .mvn/ .mvn/
- COPY mvnw pom.xml ./
- RUN ./mvnw -B dependency:go-offline
- COPY src/ src/
- RUN ./mvnw -B package && cp target/*.jar /opt/app/app.jar
- FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime
- WORKDIR /opt/app
- COPY --from=build /opt/app/app.jar ./app.jar
- EXPOSE 8080
- ENTRYPOINT ["java", "-jar", "/opt/app/app.jar"]
第一个 FROM 提供编译环境,第二个 FROM 从 JRE 基础镜像重新开始。构建阶段把打包结果固定命名为 app.jar,运行阶段只复制这个文件,因此 Maven Wrapper、源码和依赖下载目录不会随该 COPY 进入最终镜像。ENTRYPOINT 也使用确定的文件名:数组形式的启动命令不会替你展开 *.jar 这样的通配符。
如果 target 中有多个匹配的 JAR,示例里的 cp 可能失败;应改成项目实际生成的确定文件名。多模块项目还需调整源码复制范围和产物路径。JAR 能被复制并不等于运行依赖已经齐备:应用用到的外置配置、证书、字体或原生库,都要明确由运行镜像、挂载或部署环境中的哪一处提供。
让依赖缓存跟着构建文件,而不是跟着源码失效
先复制 .mvn、mvnw 和 pom.xml,再运行 dependency:go-offline,最后才复制 src,可以让普通源码改动保留前面依赖步骤的可复用层。Docker 的构建缓存指南说明,指令及其依赖文件不变时可复用缓存;将较少变化的依赖文件放在常变源码之前,能避免源码改动反复触发依赖安装。
pom.xml、Maven Wrapper 文件或其前面的构建步骤变化时,相关缓存仍会失效;这是依赖内容需要重新计算的结果。dependency:go-offline 也不保证所有项目在后续 package 阶段完全不访问仓库,插件配置和构建目标可能带来额外下载。如果重建时经常重新获取相同依赖,可在确认基础写法正确后,将两个 Maven RUN 指令改用指向 /root/.m2 的 BuildKit cache mount。这个挂载服务于构建过程,不会因为被使用就自动进入运行镜像。
项目根目录还应有 .dockerignore,用来排除本地 target/、.git/、日志等无需发送给构建器的内容,但不要排除示例依赖的 .mvn/。它控制构建上下文;最终镜像包含哪些应用文件,仍由运行阶段的指令决定。不要为了缓存命中把整个项目提前 COPY 到 dependency:go-offline 之前,否则普通源码变化就会使该步骤重新执行。
同时验收镜像体积和应用运行

先将旧 Dockerfile 保存为 Dockerfile.single,在同一项目目录分别执行 docker build -f Dockerfile.single -t app:before . 和 docker build --progress=plain -t app:lean .。然后运行 docker image ls app,比较两个标签的 SIZE;再运行 docker image history app:lean,查看最终镜像的层。比较时记录构建平台、基础镜像标签和同一份应用源码,避免把环境差异误当成 Dockerfile 的效果。
体积只是第一项验收。对于监听容器内 8080 端口、且根路径应返回成功响应的示例应用,可执行 docker run -d --name app-check -p 127.0.0.1:18080:8080 app:lean,再运行 docker logs app-check 和 curl -fsS http://127.0.0.1:18080/;检查完用 docker rm -f app-check 清理容器。若实际项目使用其他端口或请求路径,就替换映射和地址,并检查该项目真正依赖的配置、健康检查与外部服务连接。
EXPOSE 只是镜像中的端口说明,不会替代 docker run 的 -p 映射。docker image ls 的 SIZE、构建器占用的缓存、运行中容器写入的数据,以及仓库传输时的压缩大小,也不是同一个指标。若新镜像变小却无法正常响应请求,应先处理运行依赖,而不是仅凭体积宣布改造完成。
体积仍大或启动失败,先查哪一处
- 源码一改就重新下载依赖:检查是否在 dependency:go-offline 前执行了 COPY . .,以及 pom.xml 或 .mvn 是否确实变化。将依赖文件和源码分开复制,再从构建输出判断哪一步失去缓存。
- 最终镜像仍含编译工具或源码:确认默认构建目标是最后一个 FROM,运行阶段没有复制整个 /opt/app。用 docker image history app:lean 区分基础镜像本身和后续 COPY 带来的体积。
- JAR 找不到或复制失败:核对 package 输出目录及文件名。项目产生多个 JAR 时,应指定唯一的可执行产物,不要依赖含糊的通配符。
- 镜像更小却无法启动:先核对构建与运行的 Java 版本,再排查配置、证书、字体和原生依赖。考虑更小的基础镜像时,用同一 JAR 在候选环境中完成启动和实际请求测试;需要原生库的应用还应核对候选镜像提供的系统库。
相关阅读:
相关文章
订阅我们的新闻通讯
将最新 Web3、AI 和加密货币新闻直接发送到您的邮箱。




