Docker 化 Java 应用与镜像瘦身完全指南
2026/4/30 9:07:30 网站建设 项目流程

一份面向初学者的实战教程:从写第一个 Dockerfile,到把镜像从 800MB 砍到 80MB,再到用 GraalVM 做出 50MB 的原生镜像。每一步都讲清楚为什么,而不只是复制粘贴。


目录

第一章 开篇:为什么我们要"折腾"Java 镜像

一个真实的故事

镜像大小到底重不重要

这份指南覆盖什么

假设你已经知道的

第二章 Docker 基础温习:先把概念捋清楚

镜像(Image)和容器(Container)的区别

镜像的"分层"是什么意思

Dockerfile 的常用指令快速过一遍

镜像、容器、仓库的命名规则

验证 Docker 装好了

第三章 准备一个真实的 Java 示例应用

项目结构

pom.xml

HelloApplication.java

HelloController.java

application.yml

本地构建并运行

看一眼这个 Fat JAR 有多大

第四章 第一个 Dockerfile:朴素到掉渣的写法

v1: 朴素版 Dockerfile

跑一下试试

用 docker image history 看看每一层

openjdk:17 里面到底有啥

找到优化方向

第五章 基础镜像的学问:JDK、JRE、Alpine、Slim、Distroless

第一刀:JDK 改 JRE

第二刀:换更瘦的底层 OS

Alpine 是啥

但是 Alpine 不是没有坑

第三刀:Distroless

Distroless 的子变体

第四刀:UBI Minimal

一张表横向对比

我应该选哪个

第六章 多阶段构建:构建环境和运行环境的分家

多阶段构建救场

用 Maven Wrapper 让构建更可重现

构建并对比

多阶段构建的更多玩法

玩法一:把不同环境的构建放进不同阶段

玩法二:复用同一个构建结果做不同部署

玩法三:多阶段 + 多平台基础镜像

多阶段构建的注意事项

第七章 构建缓存与分层:让 docker build 飞起来

Docker 缓存的工作原理

拆开 COPY,把依赖单独成层

Gradle 项目的同款套路

.dockerignore 配合缓存优化

实测缓存效果

缓存的"失效"陷阱

第八章 Spring Boot 分层 JAR:Fat JAR 不胖了

Fat JAR 在 Docker 里的痛点

Layered JAR 来了

提取层

Dockerfile 用 Layered JAR

Layered JAR 在 CI/CD 中的优势

自定义层

第九章 JLink:给 Java 应用量身定做一个 JRE

一个最小例子

Spring Boot 需要哪些模块

完整的 JLink + 多阶段 Dockerfile

配合 Alpine 用 JLink

JLink 的一些坑

第十章 JDeps:让机器告诉你需要哪些模块

基本用法

Fat JAR 里依赖也得算

把 jdeps 集成到 Dockerfile

但 jdeps 也不是万能的

第十一章 AppCDS:让冷启动变热启动

AppCDS 的基本用法(容器外)

在 Docker 里用 AppCDS

CDS Archive 的限制

Spring Boot 3.3+ 的 CDS 支持

第十二章 GraalVM Native Image:把 Java 编译成原生程序

Native Image 的核心特性

Spring Boot 3 + Native Image

手写 Native Image Dockerfile

配合 Distroless cc 镜像

用静态链接做到极致

启动时间对比

Native Image 的"反射注册"问题

Native Image 适合什么场景

第十三章 .dockerignore 与构建上下文:被忽视的"减肥神器"

构建上下文 vs 镜像

.dockerignore 的写法

等等,把 Dockerfile 也忽略?

验证 .dockerignore 效果

一个完整的 Java 项目 .dockerignore 模板

第十四章 安全加固:非 root 用户与最小攻击面

默认 root 是个雷

创建并切换到非 root 用户

Distroless 已经帮你做了

端口选择的小坑

只读文件系统

漏洞扫描

静态分析 Dockerfile

Secrets 不要硬编码

Capabilities 最小化

第十五章 容器里的 JVM 调优:内存、CPU、GC

容器感知(Container Awareness)

-XX:MaxRAMPercentage 比 -Xmx 更好

给系统/堆外留点余地

-XX:MaxRAM 显式指定

CPU 与并发

GC 选择

完整推荐配置

OOMKilled 排查

第十六章 健康检查与优雅停机

Dockerfile 里的 HEALTHCHECK

用 Java 自己做健康检查

Kubernetes Liveness / Readiness / Startup

优雅停机

preStop hook 配合

Spring Boot 3 + 优雅停机的完整模板

第十七章 实战对比:六种方案、六种体积、六种启动速度

方案 1:朴素版(v1)

方案 2:换 JRE Alpine

方案 3:多阶段构建 + Alpine

方案 4:Layered JAR + 缓存优化

方案 5:JLink 定制 JRE

方案 6:GraalVM Native Image

决策树:你应该选哪条路

实战建议:渐进式优化

第十八章 常见问题 FAQ

Q1:为什么我的中文显示乱码

Q2:时区不对

Q3:缺字体(生成 PDF、画图、验证码)

Q4:DNS 解析慢

Q5:容器启动几分钟才加 ready,但 startupProbe 已经超时了

Q6:构建机器上有,但容器里没

Q7:日志文件在哪,怎么看

Q8:本地 docker run 跑得好好的,K8s 里 OOM

Q9:怎么 attach 到运行中的容器看堆

Q10:网络代理需求

Q11:怎么调试 GraalVM Native Image 的反射缺失

Q12:BuildKit 是必须的吗

第十九章 进阶技巧:BuildKit、多架构、缓存挂载

BuildKit 的缓存挂载

多架构构建(amd64 + arm64)

用 buildx 加速本地开发

Inline cache 和 registry cache

Bake 文件统一构建配置

Squash:合并所有层(少用)

Dive 工具:分析镜像每层

镜像签名和供应链安全

SBOM 和漏洞透明度

第二十章 生产实战清单:上线前的最后一份 checklist

Dockerfile 本体

.dockerignore

JVM 参数

应用本身

Kubernetes 部署(如适用)

CI/CD

监控和可观测性

第二十一章 总结与建议

一、镜像是分层的,要把"易变"放在"不变"之上

二、构建环境和运行环境要分开

三、运行时环境越小越好

四、安全是镜像的一等公民

五、容器化的 JVM 需要参数适配

六、不要追求极致

七、写得清楚比写得巧妙重要

八、永远更新

给初学者的最后一句话

附录 A:常用命令速查

镜像操作

构建操作

容器操作

调试

附录 B:完整生产级 Dockerfile 模板

附录 C:知识地图

Java 生态

容器与 K8s 生态

镜像与供应链安全

可观测性

进阶 Dockerfile

附录 D:Jib 简介——不写 Dockerfile 也行

用法极其简单

Jib 的优点

Jib 的缺点

我的建议

结语


第一章 开篇:为什么我们要"折腾"Java 镜像

一个真实的故事

我有个朋友,刚学完 Spring Boot,兴冲冲地把第一个项目打包成 Docker 镜像,准备部署到云服务器上。他写了一个 Dockerfile,简简单单几行,构建、推送、部署,一切顺利。直到他打开镜像仓库的页面,看到那个数字:842 MB

他懵了:我的代码加上依赖才 30 MB 啊,怎么镜像变成将近 1 GB 了?而且他用的服务器流量是按量计费的,每次拉取镜像都要花钱,每次部署到 Kubernetes 集群也要把这接近 1 GB 的东西在节点之间搬来搬去。

更难受的是,他后来发现公司同事用 Go 写的微服务,镜像才 15 MB。这是 50 多倍的差距。

他来问我:"Java 是不是天生就笨重?"

我的回答是:Java 应用的镜像可以做得很小,但你需要懂一些门道。这份指南要做的,就是把这些门道一个一个讲清楚,让你在看完之后,能从容地面对任何 Java 镜像构建的场景。

镜像大小到底重不重要

有人会说:"硬盘那么便宜,几百 MB 算啥?"这种想法在你只有一台服务器、一天部署一次的时候是对的。但在现代云原生场景下,镜像大小会以你想不到的方式影响系统:

第一,部署速度。在 Kubernetes 里做滚动升级,每个节点都要先把新镜像拉下来。100 个节点拉一个 800 MB 的镜像,和拉一个 80 MB 的镜像,整体升级时间能差出一个数量级。如果你正在做紧急修复,这十几分钟的差距可能就是事故等级的差距。

第二,自动伸缩。流量突然涨了,需要快速扩容十个新 Pod。如果镜像 800 MB,节点上还没缓存,光拉镜像就得一两分钟,等容器跑起来流量高峰可能都过去了。镜像越小,弹性伸缩越快,云资源利用率越高。

第三,存储成本。镜像仓库(比如阿里云、AWS ECR)按存储计费。你保留近 30 天的版本,每个版本 800 MB,几十个微服务下来一年的账单不是小数目。

第四,安全攻击面。镜像里多一个工具,就多一个潜在漏洞。一个完整的 OS 里装着几百个二进制工具,绝大多数你的 Java 应用根本用不到,但它们都会被漏洞扫描器扫到,给你的安全报告打上一片红色。镜像越小,能被攻击的"面"就越小。

第五,CI/CD 流水线时间。每次构建、推送、拉取,都和镜像大小直接相关。一个团队如果一天构建几百次,省下来的时间是惊人的。

所以,镜像瘦身不是"洁癖",而是工程素养。

这份指南覆盖什么

我会从最基础的 Dockerfile 开始,一步一步带你走完整个优化路径。每一步我都会:

  • 告诉你"为什么":每个技术点都讲原理,不是黑魔法。
  • 给出可运行的例子:所有 Dockerfile、命令、代码都能直接复制运行。
  • 对比数据:每个优化做完,都看实际的镜像大小变化。
  • 指出陷阱:哪些坑我替你踩过了,你不用再踩。

我们不会泛泛而谈,会用同一个示例应用,反复改造它的 Dockerfile,从将近 1 GB 一步步压到 100 MB 以下,最后用 GraalVM 做成 50 MB 的原生镜像。每一步的差距你都能亲手测出来。

假设你已经知道的

为了把节奏控制好,我假设你:

  • 在自己电脑上装过 Docker,跑过docker run hello-world
  • 用 Java 写过哪怕最简单的程序,知道 Maven 或 Gradle 是干什么的。
  • 用过命令行,知道cdlscat这些基本命令。

如果哪个概念你不熟,没关系,我会顺带解释。但我不会从"什么是 JVM"讲起——那个范围太大了。

好了,废话到此为止,让我们正式开始。


第二章 Docker 基础温习:先把概念捋清楚

在开始动手之前,我想先把几个最关键的概念用大白话讲清楚。如果你已经懂了,跳过这章也没问题;但如果你只是"会用 docker run"但没真正理解它在干嘛,强烈建议读完这一章,因为后面的优化思路全都建立在这些概念上。

镜像(Image)和容器(Container)的区别

我最爱用的比喻是:镜像就是模具,容器就是用模具做出来的饼干

  • 镜像是一个静态的、只读的东西。它包含了运行你应用所需要的一切:操作系统的一部分、JDK、你的 JAR 包、配置文件等等。它躺在硬盘上不会动。
  • 容器是镜像被"启动"之后的运行实例。一个镜像可以启动出很多容器,每个容器之间互不干扰,就像一个模具可以做出很多饼干,每块饼干你想咬一口、画个笑脸都不影响其他饼干。

当你执行docker run my-app:1.0的时候,Docker 做了三件事:

  1. 找到my-app:1.0这个镜像(如果本地没有,去远程仓库拉)。
  2. 基于这个镜像创建一个容器,给它一个独立的文件系统、网络、进程空间。
  3. 在容器里启动你指定的命令(比如java -jar app.jar)。

容器跑起来之后,你写文件、改配置,改的是容器自己的"层",不影响原始镜像。容器一删,这些改动就没了(除非你做了挂载)。

镜像的"分层"是什么意思

这是理解镜像优化的核心概念。Docker 镜像不是一个整体的"大文件",而是一层一层叠起来的,像三明治。

你写的 Dockerfile 里,几乎每一条指令(FROMRUNCOPYADD这些)都会创建一个新的层。每一层只记录"相对于上一层的变化"。比如:

FROM ubuntu:22.04 # 第一层:Ubuntu 基础系统 RUN apt-get update # 第二层:apt 包索引的变化 RUN apt-get install -y curl # 第三层:装了 curl 之后的变化 COPY app.jar /app/app.jar # 第四层:多了一个 JAR 文件 CMD ["java", "-jar", "/app/app.jar"] # 元信息,不算层

这样分层有几个好处:

第一,缓存复用。如果你只改了app.jar,前三层不需要重新构建,Docker 直接用缓存,构建速度飞快。但要注意:一旦某一层变了,它后面的所有层都会失效。这就是为什么后面我们讨论 Dockerfile 顺序时会反复强调"把变化频率低的放前面"。

第二,存储节约。如果两个镜像都基于同一个ubuntu:22.04,这一层在硬盘上只存一份。10 个 Java 微服务都用同一个 JDK 基础镜像,那一层只下一次、只存一份,剩下的就是各自上层的差异。

第三,分发优化docker pull拉镜像时,已经存在的层会直接跳过,只下载缺失的层。

但分层也有"陷阱":层是只能加、不能减的。你在第二层装了一堆软件,第三层把它们删掉,并不会让镜像变小!因为第二层依然存在,第三层只是"标记这些文件被删了"。镜像仍然包含第二层那些被装上去的内容。

正因如此,下面这种写法是反模式:

RUN apt-get install -y some-tool RUN do-something-with-some-tool RUN apt-get remove -y some-tool # 这行不会让镜像变小!

正确的写法是把它们合到同一层里:

RUN apt-get install -y some-tool && \ do-something-with-some-tool && \ apt-get remove -y some-tool

这样三步合成一层,最后一步删除的东西真的不会进入镜像。这是镜像优化最最最常用的小技巧之一。

Dockerfile 的常用指令快速过一遍

后面我们会反复用到这些,简单介绍一下:

  • FROM:指定基础镜像。一个 Dockerfile 必须以 FROM 开头(除非用了 ARG)。
  • RUN:在构建时执行命令,结果会保存在新的层里。
  • COPY:从构建上下文(你执行 docker build 的目录)把文件拷进镜像。
  • ADD:和 COPY 类似,但还能解压压缩包、下载 URL。一般推荐用 COPY,更可控。
  • WORKDIR:设置当前工作目录,相当于 cd。
  • ENV:设置环境变量。
  • EXPOSE:声明容器会监听某个端口(仅是声明,不实际开放)。
  • CMD:容器启动时默认执行的命令。
  • ENTRYPOINT:容器启动时执行的"入口",CMD 会作为它的参数。
  • USER:切换到某个用户运行后续命令。
  • ARG:构建时变量,只在构建期生效。

镜像、容器、仓库的命名规则

完整的镜像引用长这样:

registry.example.com/namespace/repo:tag

举几个例子:

  • nginx:等价于docker.io/library/nginx:latest,省略部分用默认。
  • eclipse-temurin:17-jre:Docker Hub 上的 Eclipse Temurin 17 JRE 版本。
  • gcr.io/distroless/java17-debian12:Google 容器仓库里的 Distroless Java 镜像。

tag是版本标识,latest是默认 tag。强烈建议生产环境永远不用latest,因为它会随着上游更新而漂移,今天的 latest 和明天的 latest 可能是两个完全不同的东西,会让线上行为变得不可预测。

验证 Docker 装好了

后续所有例子都假设你能跑这两条命令并看到正常输出:

docker --version docker run --rm hello-world

如果第二条能看到 "Hello from Docker!" 的字样,那就准备好了。

OK,基础温习完了。下一章我们来准备一个真实的 Java 应用,作为后面所有实验的"小白鼠"。


第三章 准备一个真实的 Java 示例应用

为了让所有优化都能"看得见",我们用同一个应用贯穿全文。这个应用故意做得不太复杂,但又有"真实感"——它有依赖、有 HTTP 接口、有 JSON 处理、还有日志,差不多是一个微服务最小可用版本。

项目结构

我们用 Maven 构建一个 Spring Boot 项目,结构是这样的:

hello-app/ ├── pom.xml ├── Dockerfile └── src/ └── main/ ├── java/ │ └── com/ │ └── example/ │ └── hello/ │ ├── HelloApplication.java │ └── HelloController.java └── resources/ └── application.yml

pom.xml

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>hello-app</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

为啥加spring-boot-starter-actuator?因为它带来/actuator/health端点,后面演示健康检查时会用到。spring-boot-starter-web是 HTTP 服务的基础。

HelloApplication.java

package com.example.hello; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class HelloApplication { public static void main(String[] args) { SpringApplication.run(HelloApplication.class, args); } }

HelloController.java

package com.example.hello; import java.util.Map; import java.time.Instant; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { @GetMapping("/hello") public Map<String, Object> hello(@RequestParam(defaultValue = "World") String name) { return Map.of( "message", "Hello, " + name + "!", "time", Instant.now().toString(), "host", System.getenv().getOrDefault("HOSTNAME", "unknown") ); } }

简单到爆:访问/hello?name=Tom就返回一个 JSON。这个HOSTNAME在容器里会自动是容器 ID,方便我们看到底是哪个容器在响应。

application.yml

server: port: 8080 shutdown: graceful spring: application: name: hello-app lifecycle: timeout-per-shutdown-phase: 20s management: endpoints: web: exposure: include: health,info endpoint: health: probes: enabled: true

shutdown: graceful让 Spring Boot 支持优雅停机,后面会用到。actuator暴露了 health 端点。

本地构建并运行

确认你装了 Maven 和 JDK 17:

mvn -v java -version

然后在项目根目录执行:

mvn clean package -DskipTests

构建完成后会在target/下生成hello-app-1.0.0.jar。这个就是 Spring Boot 著名的"Fat JAR"——它把所有依赖(包括 Tomcat、Jackson 等等)都打到一个 JAR 里,能直接java -jar跑起来。

java -jar target/hello-app-1.0.0.jar

看到日志里出现 "Started HelloApplication" 就说明跑起来了。打开另一个终端:

curl http://localhost:8080/hello?name=Docker

应该返回类似:

{"message":"Hello, Docker!","time":"2026-04-28T10:00:00Z","host":"unknown"}

到这里,我们的小白鼠准备好了。它会反复出现在后续的每一章。

看一眼这个 Fat JAR 有多大

ls -lh target/hello-app-1.0.0.jar

我这边大概是22 MB左右。请记住这个数字。后面我们会反复对比"原始 JAR 22 MB"和"最终镜像 800 MB"之间巨大的差距是从哪里来的。

好,万事俱备。下一章,我们写第一个 Dockerfile,看看朴素的写法会得到一个多胖的镜像。


第四章 第一个 Dockerfile:朴素到掉渣的写法

让我们假装是第一次接触 Docker 的新手,写一个最直观的 Dockerfile。看到 Java 应用,我们想:

  • "我得有个有 JDK 的环境"
  • "我把 JAR 放进去"
  • "启动时候 java -jar 一下"

逻辑是这样:

v1: 朴素版 Dockerfile

在项目根目录创建Dockerfile(注意没有扩展名):

FROM openjdk:17 WORKDIR /app COPY target/hello-app-1.0.0.jar app.jar EXPOSE 8080 CMD ["java", "-jar", "app.jar"]

构建:

docker build -t hello-app:v1 .

构建完成后查看大小:

docker images hello-app:v1

你大概率会看到一个让你倒吸一口凉气的数字:接近 700 MB 到 800 MB

顺带一说:你可能会看到openjdk:17这个镜像在 Docker Hub 上被标记为 deprecated(废弃)。官方推荐用eclipse-temurin或者amazoncorrettobellsoft/liberica-openjdk-debian这些。但是为了演示"最朴素"的写法,我们暂时还用 openjdk:17,你能搜到大量过去的教程都是这么写的。

跑一下试试

docker run --rm -p 8080:8080 hello-app:v1

新开一个终端:

curl http://localhost:8080/hello

能跑通。但这个 700+ MB 的镜像让我们很不爽。让我们来理解为什么这么大。

docker image history看看每一层

docker image history hello-app:v1

你会看到类似这样的输出:

IMAGE CREATED CREATED BY SIZE abc123... 1 minute ago CMD ["java" "-jar" "app.jar"] 0B def456... 1 minute ago EXPOSE map[8080/tcp:{}] 0B ghi789... 1 minute ago COPY target/hello-app... 22MB jkl012... 1 minute ago WORKDIR /app 0B mno345... <missing> /bin/sh -c #(nop) CMD ... 0B ... (还有十几行) 700+ MB

我们自己加的层只贡献了 22 MB(那个 JAR),剩下的 700 多 MB 全是基础镜像openjdk:17自己带来的。

openjdk:17里面到底有啥

让我们进去看看。Docker 提供了一种"用调试模式启动"的方式,但有些镜像可能没有 shell。openjdk:17是基于 Debian 的,有完整 bash,可以直接进去:

docker run --rm -it openjdk:17 bash

进入容器之后:

ls / # 看根目录 du -sh /usr/local/openjdk-17/ # 看 JDK 多大 apt list --installed 2>/dev/null | wc -l # 看装了多少软件包

你会发现:

  • 这是一个完整的 Debian 系统,几百个 apt 包,包括各种文档、man page、locale 数据。
  • JDK 本身就有 300 多 MB,因为它包含了 javac(Java 编译器)、jar 工具、jshell、javadoc 等等一大堆开发工具。
  • /usr/share/doc/usr/share/man/usr/share/locale这些目录加起来也好几十 MB。

我们的应用只是一个跑起来的服务,根本不需要 javac(编译已经在外面做完了),也不需要文档、不需要日语和阿拉伯语的本地化数据。

找到优化方向

至此,我们看到了第一个明显的浪费:基础镜像里有很多我们不需要的东西。这就是后面"换基础镜像"的优化逻辑。

但还有别的浪费:

  1. 构建工具混进运行环境:现在我们是先在外面mvn package再 COPY 进去,看起来还可以。但很多人会直接在 Dockerfile 里跑 Maven 构建,这就把 Maven 和源代码也装进了最终镜像,浪费几百 MB。
  2. JAR 是 Fat JAR:每次代码变了,整个 22 MB 的 JAR 都要重新进入镜像层,分发时也要整个传输。其实里面 90% 是依赖库,几乎不变。
  3. 没有专门的运行用户:默认 root 用户跑应用,安全性不好。
  4. JVM 默认参数:在容器里跑 JVM,默认参数在容器内存限制下会有问题。

我们一个一个解决。下一章先解决最大头:基础镜像的选择


第五章 基础镜像的学问:JDK、JRE、Alpine、Slim、Distroless

要让镜像变小,最直接的办法就是换一个更瘦的基础镜像。但市面上的 Java 基础镜像五花八门,每种各有优缺点,选错了不光体积没下来,还可能引入运行时问题。这一章把所有主流选择讲明白。

第一刀:JDK 改 JRE

JDK 全名 Java Development Kit(Java 开发套件),里面除了 JRE(Java Runtime Environment,Java 运行时),还有编译器 javac、调试工具 jdb、监控工具 jconsole 等等开发用的东西。

你的应用在生产环境运行时,只需要 JRE,不需要 JDK。代码已经编译成 .class 或者 JAR 了,不需要在生产服务器上再编译。

让我们改一下 Dockerfile:

FROM eclipse-temurin:17-jre WORKDIR /app COPY target/hello-app-1.0.0.jar app.jar EXPOSE 8080 CMD ["java", "-jar", "app.jar"]

注意我把基础镜像从openjdk:17换成了eclipse-temurin:17-jre。Eclipse Temurin 是目前社区推荐的 OpenJDK 发行版(之前叫 AdoptOpenJDK),由 Eclipse 基金会维护。-jre后缀表示只装了 JRE。

构建一下:

docker build -t hello-app:v2 . docker images hello-app:v2

我这边的结果:从 700+ MB 降到了大约280 MB。直接砍掉了一半。

第二刀:换更瘦的底层 OS

Eclipse Temurin 默认基于 Ubuntu,体积偏大。它还提供了几个变体:

  • eclipse-temurin:17-jre:默认,基于 Ubuntu。
  • eclipse-temurin:17-jre-jammy:明确写出 Ubuntu 22.04 (Jammy)。
  • eclipse-temurin:17-jre-noble:Ubuntu 24.04。
  • eclipse-temurin:17-jre-alpine:基于 Alpine Linux。
  • eclipse-temurin:17-jre-ubi9-minimal:基于 RedHat UBI Minimal。
Alpine 是啥

Alpine Linux 是一个非常迷你的 Linux 发行版,整个根文件系统加起来才 5 MB 左右。它用 musl libc 替代 glibc,用 BusyBox 替代 GNU 工具,能压到极致。

把基础镜像换成 Alpine:

FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY target/hello-app-1.0.0.jar app.jar EXPOSE 8080 CMD ["java", "-jar", "app.jar"]

构建:

docker build -t hello-app:v3 . docker images hello-app:v3

我这边大约180 MB左右。继续往下走了一截。

但是 Alpine 不是没有坑

Alpine 用的是 musl libc,和 glibc 不兼容。绝大多数 Java 应用没问题,但只要你引入了"使用 JNI 调用本地库"的依赖,就可能出问题。常见的坑:

  • Netty 的 native transport:Netty 有针对 epoll 的本地优化,编译的时候默认是 glibc 的 .so 文件,在 Alpine 上加载不了,会回退到普通 NIO。一般无害,但性能略损。
  • 某些图像处理库:比如 OpenCV、ImageMagick 的 Java 绑定,几乎都没有 musl 版本的 native。
  • 数据库驱动:极少数老的 Oracle JDBC 驱动可能有 native 代码。
  • 机器学习/科学计算:用了 BLAS、LAPACK 之类的,多半没 musl 版本。

如果你是普通 Web 应用,Alpine 是个好选择。如果你的项目重度依赖 native 库,老老实实用 glibc 系(Ubuntu/Debian/UBI)。

第三刀:Distroless

Google 推出的 Distroless 镜像把"瘦"做到了几乎极致。它去掉了几乎所有不必要的东西:没有 shell(连 sh 都没有)、没有包管理器、没有任何调试工具,只有运行你应用所需的最小依赖

FROM gcr.io/distroless/java17-debian12 COPY target/hello-app-1.0.0.jar /app/app.jar EXPOSE 8080 CMD ["/app/app.jar"]

注意几个变化:

  • 没有WORKDIR:因为没 shell,写 WORKDIR 也行但意义不大。
  • CMD直接是 JAR 路径:Distroless Java 镜像把 java 设为 ENTRYPOINT,所以 CMD 就是 java 的参数。

构建:

docker build -t hello-app:v4 . docker images hello-app:v4

我这边大约220 MB。哎,这怎么比 Alpine 还大?

原因是 Distroless 用的还是 Debian 的 glibc,所以基础没法做到 Alpine 那么小。但它的优势不在体积,而在安全性和最小攻击面

  • 没有 shell,黑客就算通过应用漏洞拿到容器,没法sh进来交互。
  • 没有 curl、wget,没法下载恶意工具。
  • 没有 apt、apk,没法装新东西。
  • 几乎没有任何可执行的二进制工具,CVE(公开漏洞)扫描器扫出来的漏洞数比有 shell 的镜像少几十个。

很多大厂生产环境用 Distroless 就是冲着安全性去的。

Distroless 的子变体

Distroless 还有更小的版本:

  • gcr.io/distroless/java17-debian12:标准版。
  • gcr.io/distroless/java17-debian12:nonroot:默认非 root 用户运行。
  • gcr.io/distroless/java17-debian12:debug:带 BusyBox shell,方便调试。

强烈推荐生产用:nonroot,开发或排错时用:debug

第四刀:UBI Minimal

如果你用 RedHat 系,可以用 UBI(Universal Base Image):

FROM eclipse-temurin:17-jre-ubi9-minimal WORKDIR /app COPY target/hello-app-1.0.0.jar app.jar EXPOSE 8080 CMD ["java", "-jar", "app.jar"]

UBI Minimal 是 RedHat 出品,企业环境兼容性好,体积大约介于 Alpine 和标准 Ubuntu 之间。如果你有 RedHat 订阅或者强需求,是个稳妥选择。

一张表横向对比

基础镜像大致大小(含我们的 JAR)优点缺点
openjdk:17~700 MB兼容性最好太大;官方废弃
eclipse-temurin:17-jre~280 MBUbuntu 完整支持比 Alpine 大
eclipse-temurin:17-jre-alpine~180 MB最小的"完整"镜像musl 兼容性
gcr.io/distroless/java17-debian12~220 MB安全性最佳难调试
eclipse-temurin:17-jre-ubi9-minimal~250 MB企业级支持体积优势不明显

我应该选哪个

  • 新手、内部项目、对体积没洁癖:用eclipse-temurin:17-jre,稳。
  • 追求体积、纯 Java 没 native 依赖:用:17-jre-alpine
  • 生产环境、追求安全:用gcr.io/distroless/java17-debian12:nonroot
  • 企业 RedHat 体系:UBI 系列。

其实选完基础镜像,"换底盘"这一步的主要工作就做完了。但我们还有更多手段可以让镜像更小。下一章讲多阶段构建,这是另一个数量级的优化。


第六章 多阶段构建:构建环境和运行环境的分家

到目前为止,我们都是先在外面mvn package,再把 JAR COPY 进容器。这样有个好处:Dockerfile 简单。但也有个坏处:构建环境和容器构建过程脱钩了

具体什么意思?想象一下:

  • 你的同事拿到代码,但他电脑上装的是 JDK 8,构建出的 JAR 跑不起来。
  • 你的 CI 服务器没装 Maven,构建脚本第一步就挂了。
  • 你的 Maven 配置依赖本地的~/.m2缓存,新同事第一次构建慢得要死。

理想情况下,我们希望把构建过程也搬到 Docker 里——只要装了 Docker,谁都能一键构建出一模一样的镜像。但天真的做法是这样:

FROM eclipse-temurin:17-jdk WORKDIR /app COPY pom.xml . COPY src ./src RUN apt-get update && apt-get install -y maven RUN mvn clean package -DskipTests EXPOSE 8080 CMD ["java", "-jar", "target/hello-app-1.0.0.jar"]

这能跑,但镜像里现在不光有 JRE,还有:

  • 完整 JDK(多一两百 MB)
  • Maven 本身
  • Maven 缓存(~/.m2/repository里的所有依赖,几百 MB)
  • 你的 src 源代码
  • target/ 下的 .class 文件、原始 JAR、解压结构等等

最终镜像可能会膨胀到 1.5 GB 以上!这是反优化。

多阶段构建救场

Docker 17.05 版本引入了"多阶段构建",简单到你看一眼就明白:

# ====== 第一阶段:构建 ====== FROM eclipse-temurin:17-jdk AS builder WORKDIR /build COPY pom.xml . COPY src ./src COPY mvnw . COPY .mvn ./.mvn RUN ./mvnw clean package -DskipTests # ====== 第二阶段:运行 ====== FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=builder /build/target/hello-app-1.0.0.jar app.jar EXPOSE 8080 CMD ["java", "-jar", "app.jar"]

关键点:

  1. 两个 FROM:一个 Dockerfile 里出现两次 FROM,每个 FROM 开始一个新的"阶段"。
  2. AS builder起个名:方便后面引用。
  3. COPY --from=builder:从前一个阶段复制文件过来。
  4. 最终镜像只来自第二个 FROM:所有第一阶段的 JDK、Maven、源代码、缓存——全部不会进入最终镜像。

这就完美解决了上面说的问题:

  • 谁都能用同样的 Dockerfile 一键构建(只要装 Docker)。
  • 最终镜像不带任何构建副产物。
  • 利用了 Maven Wrapper(mvnw),连 Maven 都不用预先装。

用 Maven Wrapper 让构建更可重现

./mvnw是 Maven Wrapper,它是一个 shell 脚本(Windows 上是 mvnw.cmd),第一次运行时会自动下载指定版本的 Maven。这样无论谁、什么环境,用的都是同一个 Maven 版本。

如果你的项目还没 mvnw,在项目根目录跑:

mvn wrapper:wrapper

会自动生成mvnwmvnw.cmd.mvn/wrapper/这几个文件。提交到 Git 即可。

构建并对比

docker build -t hello-app:v5 . docker images hello-app:v5

我这边大约180 MB。和上一章用 Alpine 的版本差不多——因为我们的最终阶段就是用eclipse-temurin:17-jre-alpine

多阶段构建的更多玩法

玩法一:把不同环境的构建放进不同阶段
FROM eclipse-temurin:17-jdk AS builder # ... 构建 ... FROM eclipse-temurin:17-jdk AS tester COPY --from=builder /build /build WORKDIR /build RUN ./mvnw test FROM eclipse-temurin:17-jre-alpine AS runtime COPY --from=builder /build/target/*.jar /app/app.jar CMD ["java", "-jar", "/app/app.jar"]

可以用docker build --target tester只构建到测试阶段。

玩法二:复用同一个构建结果做不同部署
FROM eclipse-temurin:17-jdk AS builder # ... 构建 ... FROM eclipse-temurin:17-jre-alpine AS prod COPY --from=builder /build/target/*.jar /app/app.jar ENV SPRING_PROFILES_ACTIVE=prod CMD ["java", "-jar", "/app/app.jar"] FROM eclipse-temurin:17-jdk AS dev COPY --from=builder /build/target/*.jar /app/app.jar ENV SPRING_PROFILES_ACTIVE=dev CMD ["java", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "/app/app.jar"]

docker build --target prod -t app:prod .出生产镜像,docker build --target dev -t app:dev .出带远程调试端口的开发镜像。

玩法三:多阶段 + 多平台基础镜像
FROM --platform=$BUILDPLATFORM eclipse-temurin:17-jdk AS builder # 构建在原生平台上跑(更快),不用 emulator # ... 构建 ... FROM --platform=$TARGETPLATFORM eclipse-temurin:17-jre-alpine COPY --from=builder /build/target/*.jar /app/app.jar CMD ["java", "-jar", "/app/app.jar"]

配合docker buildx可以做多架构(amd64、arm64)镜像,构建阶段在原生 amd64 跑,运行阶段构建多个架构。这是高级用法,后面进阶章再讲。

多阶段构建的注意事项

第一,阶段名要规范。如果AS xxx后面带了大写或者特殊字符,有些老版本 Docker 不认。建议用全小写 + 连字符。

第二,阶段之间默认是隔离的。前一阶段建的环境变量、用户、工作目录在后一阶段不会延续,每个阶段都从对应 FROM 开始。

第三,阶段顺序不影响构建顺序。Docker 会按需构建:你最终引用了哪些阶段,才构建那些。所以--target prod不会构建 dev 阶段。

第四,COPY --from也可以从外部镜像复制

COPY --from=alpine:latest /etc/ssl/certs /etc/ssl/certs

可以从任意已存在的镜像里偷文件,挺有用的。

OK,多阶段构建讲完了。下一章讲构建缓存与分层优化,这是让你的 docker build 飞起来的关键。


第七章 构建缓存与分层:让 docker build 飞起来

构建慢是 Docker 老大难问题。改一行代码,整个mvn package重跑一次,几分钟才能出新镜像,开发体验很糟。理解 Docker 的缓存机制后,你能让 90% 的构建在几秒钟内完成。

Docker 缓存的工作原理

回顾一下:每条 Dockerfile 指令产生一个层。Docker 会给每一层算一个"指纹",下次构建时如果发现某一层的指纹一样,就直接复用缓存。

指纹是怎么算的?这取决于指令类型:

  • FROM:基础镜像的 ID。
  • RUN cmd:cmd 字符串本身。
  • COPY src dst:src 文件的内容(哈希)。
  • ENVWORKDIR等:参数本身。

关键规则:一旦某一层的缓存失效,它后面的所有层全部跟着失效

举个例子:

FROM eclipse-temurin:17-jdk WORKDIR /app COPY pom.xml . COPY src ./src RUN ./mvnw package

如果你只改了src/main/java/.../HelloController.java

  • FROMWORKDIRCOPY pom.xml:缓存命中(pom.xml 没变)。
  • COPY src ./src缓存失效(src 内容变了)。
  • RUN ./mvnw package:跟着失效,重新跑 Maven。

这就是为什么每次改代码都要重跑 Maven。

拆开 COPY,把依赖单独成层

我们可以利用"层缓存"的特性,把变化频率不同的东西放在不同的层:

FROM eclipse-temurin:17-jdk AS builder WORKDIR /build # 1. 先复制构建配置和 wrapper(变化少) COPY mvnw . COPY .mvn ./.mvn COPY pom.xml . # 2. 预下载依赖(这一层只在 pom.xml 变化时才重跑) RUN ./mvnw dependency:go-offline -B # 3. 再复制源代码(变化频繁) COPY src ./src # 4. 打包(这一步不再下载依赖,只编译) RUN ./mvnw package -DskipTests FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=builder /build/target/*.jar app.jar EXPOSE 8080 CMD ["java", "-jar", "app.jar"]

关键就是RUN ./mvnw dependency:go-offline -B这一行。它会把 pom.xml 里所有依赖下载到 Maven 本地仓库,但不需要源代码。下次只改了 src,dependency 这一层缓存命中,依赖一个不下,构建从几分钟变成几秒。

dependency:go-offline不是 100% 完美的——某些 Maven 插件直到 package 阶段才会下载额外的依赖。如果你发现还是有依赖在 package 阶段下载,可以试试dependency:resolve+dependency:resolve-plugins,或者直接mvn -B verify一次再删 target。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询