尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Docker 化 Java 应用与镜像瘦身完全指南

Docker 化 Java 应用与镜像瘦身完全指南 一份面向初学者的实战教程:从写第一个 Dockerfile,到把镜像从 800MB 砍到 80MB,再到用 GraalVM 做出 50MB 的原生镜像。每一步都讲清楚为什么,而不只是复制粘贴。目录第一章 开篇:为什么我们要"折腾"Java 镜像一个真实的故事镜像大小到底重不重要这份指南覆盖什么假设你已经知道的第二章 Docker 基础温习:先把概念捋清楚镜像(Image)和容器(Container)的区别镜像的"分层"是什么意思Dockerfile 的常用指令快速过一遍镜像、容器、仓库的命名规则验证 Docker 装好了第三章 准备一个真实的 Java 示例应用项目结构pom.xmlHelloApplication.javaHelloController.javaapplication.yml本地构建并运行看一眼这个 Fat JAR 有多大第四章 第一个 Dockerfile:朴素到掉渣的写法v1: 朴素版 Dockerfile跑一下试试用 docker image history 看看每一层openjdk:17 里面到底有啥找到优化方向第五章 基础镜像的学问:JDK、JRE、Alpine、Slim、Distroless第一刀:JDK 改 JRE第二刀:换更瘦的底层 OSAlpine 是啥但是 Alpine 不是没有坑第三刀:DistrolessDistroless 的子变体第四刀:UBI Minimal一张表横向对比我应该选哪个第六章 多阶段构建:构建环境和运行环境的分家多阶段构建救场用 Maven Wrapper 让构建更可重现构建并对比多阶段构建的更多玩法玩法一:把不同环境的构建放进不同阶段玩法二:复用同一个构建结果做不同部署玩法三:多阶段 + 多平台基础镜像多阶段构建的注意事项第七章 构建缓存与分层:让 docker build 飞起来Docker 缓存的工作原理拆开 COPY,把依赖单独成层Gradle 项目的同款套路.dockerignore 配合缓存优化实测缓存效果缓存的"失效"陷阱第八章 Spring Boot 分层 JAR:Fat JAR 不胖了Fat JAR 在 Docker 里的痛点Layered JAR 来了提取层Dockerfile 用 Layered JARLayered JAR 在 CI/CD 中的优势自定义层第九章 JLink:给 Java 应用量身定做一个 JRE一个最小例子Spring Boot 需要哪些模块完整的 JLink + 多阶段 Dockerfile配合 Alpine 用 JLinkJLink 的一些坑第十章 JDeps:让机器告诉你需要哪些模块基本用法Fat JAR 里依赖也得算把 jdeps 集成到 Dockerfile但 jdeps 也不是万能的第十一章 AppCDS:让冷启动变热启动AppCDS 的基本用法(容器外)在 Docker 里用 AppCDSCDS 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 已经帮你做了端口选择的小坑只读文件系统漏洞扫描静态分析 DockerfileSecrets 不要硬编码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决策树:你应该选哪条路实战建议:渐进式优化第十八章 常见问题 FAQQ1:为什么我的中文显示乱码Q2:时区不对Q3:缺字体(生成 PDF、画图、验证码)Q4:DNS 解析慢Q5:容器启动几分钟才加 ready,但 startupProbe 已经超时了Q6:构建机器上有,但容器里没Q7:日志文件在哪,怎么看Q8:本地 docker run 跑得好好的,K8s 里 OOMQ9:怎么 attach 到运行中的容器看堆Q10:网络代理需求Q11:怎么调试 GraalVM Native Image 的反射缺失Q12:BuildKit 是必须的吗第十九章 进阶技巧:BuildKit、多架构、缓存挂载BuildKit 的缓存挂载多架构构建(amd64 + arm64)用 buildx 加速本地开发Inline cache 和 registry cacheBake 文件统一构建配置Squash:合并所有层(少用)Dive 工具:分析镜像每层镜像签名和供应链安全SBOM 和漏洞透明度第二十章 生产实战清单:上线前的最后一份 checklistDockerfile 本体.dockerignoreJVM 参数应用本身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" modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.0/version relativePath/ /parent groupIdcom.example/groupId artifactIdhello-app/artifactId version1.0.0/version packagingjar/packaging properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project为啥加spring-boot-starter-actuator?因为它带来/actuator/health端点,后面演示健康检查时会用到。spring-boot-starter-web是 HTTP 服务的基础。HelloApplication.javapackage 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.javapackage 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 MapString, 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.ymlserver: 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 改 JREJDK 全名 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。直接砍掉了一半。第二刀:换更瘦的底层 OSEclipse 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)。第三刀:DistrolessGoogle 推出的 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"]关键点:两个 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。
返回列表