一份面向初学者的实战教程:从写第一个 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 是干什么的。
- 用过命令行,知道
cd、ls、cat这些基本命令。
如果哪个概念你不熟,没关系,我会顺带解释。但我不会从"什么是 JVM"讲起——那个范围太大了。
好了,废话到此为止,让我们正式开始。
第二章 Docker 基础温习:先把概念捋清楚
在开始动手之前,我想先把几个最关键的概念用大白话讲清楚。如果你已经懂了,跳过这章也没问题;但如果你只是"会用 docker run"但没真正理解它在干嘛,强烈建议读完这一章,因为后面的优化思路全都建立在这些概念上。
镜像(Image)和容器(Container)的区别
我最爱用的比喻是:镜像就是模具,容器就是用模具做出来的饼干。
- 镜像是一个静态的、只读的东西。它包含了运行你应用所需要的一切:操作系统的一部分、JDK、你的 JAR 包、配置文件等等。它躺在硬盘上不会动。
- 容器是镜像被"启动"之后的运行实例。一个镜像可以启动出很多容器,每个容器之间互不干扰,就像一个模具可以做出很多饼干,每块饼干你想咬一口、画个笑脸都不影响其他饼干。
当你执行docker run my-app:1.0的时候,Docker 做了三件事:
- 找到
my-app:1.0这个镜像(如果本地没有,去远程仓库拉)。 - 基于这个镜像创建一个容器,给它一个独立的文件系统、网络、进程空间。
- 在容器里启动你指定的命令(比如
java -jar app.jar)。
容器跑起来之后,你写文件、改配置,改的是容器自己的"层",不影响原始镜像。容器一删,这些改动就没了(除非你做了挂载)。
镜像的"分层"是什么意思
这是理解镜像优化的核心概念。Docker 镜像不是一个整体的"大文件",而是一层一层叠起来的,像三明治。
你写的 Dockerfile 里,几乎每一条指令(FROM、RUN、COPY、ADD这些)都会创建一个新的层。每一层只记录"相对于上一层的变化"。比如:
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.ymlpom.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: trueshutdown: 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或者amazoncorretto、bellsoft/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(编译已经在外面做完了),也不需要文档、不需要日语和阿拉伯语的本地化数据。
找到优化方向
至此,我们看到了第一个明显的浪费:基础镜像里有很多我们不需要的东西。这就是后面"换基础镜像"的优化逻辑。
但还有别的浪费:
- 构建工具混进运行环境:现在我们是先在外面
mvn package再 COPY 进去,看起来还可以。但很多人会直接在 Dockerfile 里跑 Maven 构建,这就把 Maven 和源代码也装进了最终镜像,浪费几百 MB。 - JAR 是 Fat JAR:每次代码变了,整个 22 MB 的 JAR 都要重新进入镜像层,分发时也要整个传输。其实里面 90% 是依赖库,几乎不变。
- 没有专门的运行用户:默认 root 用户跑应用,安全性不好。
- 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 MB | Ubuntu 完整支持 | 比 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"]关键点:
- 两个 FROM:一个 Dockerfile 里出现两次 FROM,每个 FROM 开始一个新的"阶段"。
AS builder起个名:方便后面引用。COPY --from=builder:从前一个阶段复制文件过来。- 最终镜像只来自第二个 FROM:所有第一阶段的 JDK、Maven、源代码、缓存——全部不会进入最终镜像。
这就完美解决了上面说的问题:
- 谁都能用同样的 Dockerfile 一键构建(只要装 Docker)。
- 最终镜像不带任何构建副产物。
- 利用了 Maven Wrapper(mvnw),连 Maven 都不用预先装。
用 Maven Wrapper 让构建更可重现
./mvnw是 Maven Wrapper,它是一个 shell 脚本(Windows 上是 mvnw.cmd),第一次运行时会自动下载指定版本的 Maven。这样无论谁、什么环境,用的都是同一个 Maven 版本。
如果你的项目还没 mvnw,在项目根目录跑:
mvn wrapper:wrapper会自动生成mvnw、mvnw.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 文件的内容(哈希)。ENV、WORKDIR等:参数本身。
关键规则:一旦某一层的缓存失效,它后面的所有层全部跟着失效。
举个例子:
FROM eclipse-temurin:17-jdk WORKDIR /app COPY pom.xml . COPY src ./src RUN ./mvnw package如果你只改了src/main/java/.../HelloController.java:
FROM、WORKDIR、COPY 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。