Py学习  »  docker

Docker push 都要花3分钟?Java 9这个被遗忘的神器,让镜像瘦90%

Java知音 • 2 月前 • 210 次点击  

 

上周推了一个服务更新,CI 流水线跑了将近八分钟,光是 docker push 就花掉三分钟。镜像大小 480MB,一点都不夸张。

查了下原因,FROM eclipse-temurin:21-jdk,完整 JDK,里面带着 javacjconsolejshell,一堆生产环境根本不会用的工具。但镜像每次都带着它们,绕不开。

其实 Java 9 就给了解法,只是大多数人没认真用过,就是 JMOD + jlink

这篇文章把这套工具从头到尾走一遍,讲清楚原理,写完整代码,顺带说说坑在哪里。


Java 9 加了一个新阶段

很多人对 Java 的编译流程的理解停在两段:编译、运行。Java 9 其实悄悄加了一段夹在中间的步骤,叫链接(link time)。

完整的四段是这样的:

编译(javac)→ 打包(jar/jmod)→ 链接(jlink)→ 运行(java)
JDK拆分图解
JDK拆分图解
阶段
做什么
编译
把源码变字节码
打包
把字节码和资源装进 JAR 或 JMOD
链接
分析依赖,组装一个精简的运行时
运行
跑起来

jlink 就是链接阶段的工具。它做的事很简单:你告诉它你的应用需要哪些模块,它就只把那些模块打包进去,多余的一概不要。


JMOD 是什么,和 JAR 有啥区别

JAR 大家都熟,JMOD 是 Java 9 新加的另一种打包格式,专门为链接阶段设计的。

两者最直接的区别,一张表就清楚了:

维度
普通 JAR
模块化 JAR
JMOD
包含 .class 文件
包含资源文件
包含 native 库
包含配置文件
包含法律声明
可以直接运行
可以用于链接时
可发布到 Maven Central

JDK 自带的那些标准模块(java.basejava.logging 之类的)在 $JAVA_HOME/jmods 目录下,都是 .jmod 格式。jlink 链接时就是去读这些文件,按需取用。

你自己写的业务应用,不需要打成 JMOD。用模块化 JAR 就够了,jlink 能识别。只有你在开发 JDK 平台级模块,或者需要把 native 库一起打包,才需要手动创建 JMOD。

这块我自己也搞混过,简单说清楚:JMOD 不是 JAR 的替代品,各有用途

Java 模块依赖关系图:不同模块之间的 requires 和 requires transitive 关系,显示了模块图的结构,jlink 就是按照这张图只担包需要的节点
Java 模块依赖关系图:不同模块之间的 requires 和 requires transitive 关系,显示了模块图的结构,jlink 就是按照这张图只担包需要的节点

JMOD 文件里装了什么

拿 JDK 自带的 jmod describe 命令能直接看一个 JMOD 文件的内容:




    
$ jmod describe $JAVA_HOME/jmods/java.logging.jmod
java.logging@21
exports java.util.logging
requires java.base mandated

如果把一个 JMOD 文件解压开,内部目录结构是这样的:

hello.jmod(解压后)
├── classes/      # .class 文件
├── lib/          # native 库(.so / .dll)
├── conf/         # 配置文件
├── legal/        # 法律声明
└── bin/          # native 可执行文件

这个结构解释了为啥 JAR 做不到 JMOD 能做的事:JAR 的格式从诞生之初就只管 .class 和资源,native 库和系统配置文件没有固定位置,打进去 jlink 也不知道怎么处理。JMOD 把这些都规范化了。


实战:从零构建自定义运行时

用一个最简单的模块化应用走一遍完整流程。

目录结构

src/
├── module-info.java
└── com/example/jmod_demo/
    └── Hello.java

第一步:写模块描述文件

module-info.java 声明模块名和依赖:

module com.example.jmod_demo {
    requires
 java.logging;
}

Hello.java

package com.example.jmod_demo;
import
 java.util.logging.Logger;

public
 class Hello {
    private
 static final Logger LOG = Logger.getLogger(Hello.class.getName());

    public
 static void  main(String[] args) {
        LOG.info("Hello from custom runtime!");
    }
}

第二步:编译

javac -d output $(find src -name "*.java")

第三步:打包成 JMOD(可选)

如果你的应用需要带 native 库,才需要这一步。普通应用跳过直接用 JAR。

jmod create \
  --class-path output/ \
  --main-class com.example.jmod_demo.Hello \
  --module-version 1.0.0 \
  hello.jmod

验证一下:

$ jmod describe hello.jmod
com.example.jmod_demo@1.0.0
requires java.base mandated
requires java.logging
main-class com.example.jmod_demo.Hello

第四步:用 jlink 生成自定义运行时

jlink \
  --module-path $JAVA_HOME/jmods:hello.jmod \
  --add-modules com.example.jmod_demo \
  --launcher hello=com.example.jmod_demo/com.example.jmod_demo.Hello \
  --strip-debug \
  --compress=2 \
  --no-header-files \
  --no-man-pages \
  --output custom-runtime

参数说明:

参数
作用
--module-path
去哪找模块,含 JDK 自带 jmods 和自己的模块
--add-modules
入口模块,自动分析依赖树
--launcher
生成一个可以直接执行的启动脚本
--strip-debug
移除调试符号,大约缩 20%
--compress=2
启用压缩(JDK 21+ 用 --compress=zip-6,效果一样)
--no-header-files
去掉 C 语言头文件
--no-man-pages
去掉 man 文档

第五步:验证

$ du -sh custom-runtime
35M    custom-runtime/

$ ./custom-runtime/bin/java --list-modules
com.example.jmod_demo@1.0.0
java.base@21.0.2
java.logging@21.0.2

$ ./custom-runtime/bin/hello
INFO: Hello from custom runtime!

整个运行时只有 3 个模块,35MB,跑起来完全正常。

用 dive 工具分析 Java Docker 镜像层结构,BellSoft Liberica JDK 基础镜像体积 114MB,其中 JDK 层就占 87MB
用 dive 工具分析 Java Docker 镜像层结构,BellSoft Liberica JDK 基础镜像体积 114MB,其中 JDK 层就占 87MB

Tips:这是极简示例的体积,真实 Spring Boot 项目通常在 80-130MB 区间,依然比完整 JDK 少 70% 以上。


Spring Boot 这类项目怎么处理

上面的例子是写 module-info.java 的模块化应用。很多小伙伴可能要问:Spring Boot 项目没有 module-info.java,这套行不行?

行的。需要用另一个工具先分析依赖:jdeps

用 jdeps 分析依赖模块

jdeps 能扫描 .jar 文件,列出应用实际用到了哪些 JDK 模块。

Spring Boot 的 fat jar 需要先解压,再跨分析:

# 解压 fat jar
mkdir
 unpacked && unzip target/app.jar -d unpacked

# 分析依赖,输出模块列表

jdeps \
  --ignore-missing-deps \
  --print-module-deps \
  --recursive \
  --multi-release 21 \
  --class-path="unpacked/BOOT-INF/lib/*" \
  target/app.jar > modules.txt

cat
 modules.txt
# 输出示例:java.base,java.desktop,java.logging,java.naming,java.sql

拿到模块列表后,直接调 jlink




    
jlink \
  --module-path $JAVA_HOME/jmods \
  --add-modules $(cat modules.txt) \
  --strip-debug \
  --no-header-files \
  --no-man-pages \
  --compress=2 \
  --output custom-jre

这里有个坑:静态分析找不到反射依赖

jdeps 只看字节码,它不知道运行时通过反射动态加载的类。Spring 内部用了很多反射和动态代理,这部分的模块可能漏找。

一般需要手动补几个常用模块避免运行时报错:

# 在 modules.txt 内容后面手动补充
java.base,java.sql,java.naming,java.management,jdk.unsupported,jdk.crypto.ec

各模块为什么要加:

模块
为什么要加
jdk.unsupported
Netty、Kryo 等框架用到 sun.misc.Unsafe,没有它会崩
jdk.crypto.ec
HTTPS/TLS 握手需要 EC 加密算法
java.sql
JDBC 相关,数据库连接常用
java.naming
JNDI 查找,Spring 内部依赖
java.management
JMX 监控

实际经验: 镜像构建完之后一定要跑完整集成测试,如果局部功能报 NoClassDefFoundError 或模块未找到,回来把缺少的模块补上就好。


Docker 多阶段构建实战

把上面的流程全部集成到 Dockerfile 里。多阶段构建的思路很直接:第一阶段用完整 JDK 构建和分析,第二阶段把生成的精简运行时拷过去,就这么简单。

# 阶段 1:构建 + 分析依赖 + 生成自定义 JRE
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app

# 拷入源码,假设用 Maven

COPY . .
RUN ./mvnw package -DskipTests

# 解压 fat jar 分析依赖

RUN mkdir -p unpacked && unzip target/*.jar -d unpacked
RUN jdeps \
    --ignore-missing-deps \
    --print-module-deps \
    --recursive \
    --multi-release 21 \
    --class-path "unpacked/BOOT-INF/lib/*" \
    target/*.jar > modules.txt

# 生成自定义运行时

RUN $JAVA_HOME/bin/jlink \
    --module-path "$JAVA_HOME/jmods" \
    --add-modules $(cat modules.txt),jdk.unsupported,jdk.crypto.ec \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=2 \
    --output /custom-jre

# 阶段 2:最终运行镜像

FROM debian:bookworm-slim

ENV JAVA_HOME=/opt/java
ENV PATH="$JAVA_HOME/bin:$PATH"

# 只拷自定义 JRE 和应用 JAR

COPY --from=builder /custom-jre $JAVA_HOME
COPY --from=builder /app/target/*.jar /app/app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

三种方式的镜像大小对比:

方式
大小(应用本身资产等)
完整 JDK 镜像
~450MB
完整 JRE 镜像
~200MB
jlink 自定义 JRE + debian-slim~50-80MB

对于一个常规 Spring Boot 应用,包含业务逻辑和依赖库,最终镜像一般落在 100-130MB,比完整 JDK 镜像少三分之二以上。


常见坑和注意事项

Alpine 黑坑问题

Alpine 基础镜像只有 5MB 左右,看着确实诱人。但它用的是 musl libc,而大多数 JDK 分发版本都基于 glibc。两者不兼容,直接用会报错。

两种处理方式:

方式 1:构建阶段和运行阶段都用 Alpine 版 JDK:

# 构建阶段
FROM eclipse-temurin:21-jdk-alpine AS builder
...

# 运行阶段

FROM alpine:latest
...

方式 2:构建用 Jammy,运行用 debian:bookworm-slim(推荐,兼容性最好)

总之记住一点:构建镜像和运行镜像要用同一系列的基础镜像。

jlink 生成的运行时不能跨平台

在 macOS 上跑 jlink 生成的运行时,到 Linux 容器里跑不了。必须在目标平台上构建。Docker 多阶段构建本身就是在容器里跑的,所以打镜像时这不是问题。如果你想在本地测试 jlink 输出,记得在对应平台跑。

jlink 和 jpackage 的区别

工具
主要用途
jlink
生成自定义运行时镜像(一个目录)
jpackage
生成原生安装包(.exe、.dmg、.deb)

部署到服务器用 jlink,分发桌面应用用 jpackage


jlink 和 GraalVM Native Image 怎么选

很多人会再面对一个选择:用 jlink 还是用 GraalVM Native Image?

两者都能大幅内缩镜像体积,但路径不一样:

维度
jlink 自定义 JRE
GraalVM Native Image
镜像大小
50-130MB
15-30MB
构建时长
快(1-2分钟)
慢(10-30分钟)
启动速度
正常 JVM(~1s)
极快(<100ms)
内存占用
正常 JVM 级别
明显更低
反射兼容性
完全兼容
需要额外配置
框架支持
Spring Boot 等拆开即用
部分功能需调整

我自己的感受:项目没有特别强烈的启动速度要求的话,jlink 是整体改动最小、收益最稳的选择。改个 Dockerfile 就能搞定,不用改一行业务代码。GraalVM 的效果确实更大,但它对项目模块化要求高,反射和动态代理要能配置就出,更适合新项目或滑层服务。已有工程要迁移过去还是要踩不少坑,我这块还在摸索,等踩坑完了再单独写。


一些常见问题

Q:jlink 报错 "module not found" 怎么办?

检查 --module-path 里有没有包含 $JAVA_HOME/jmods 路径。尤其在 Docker 里,有时候环境变量没展开,手动写绝对路径比较保险。

Q:JMOD 能发布到 Maven Central 吗?

不行。Maven Central 只接受 JAR 文件。JMOD 是给链接阶段用的,不用上传到仓库里。

Q:我代码里用了  Class.forName() 这种反射,jlink 打出来的运行时会缺模块吗?

会的。jlink 自己不做动态分析。遇到这种情况,手动在 --add-modules 里把缺少的模块补上,然后多跑几轮集成测试。

Q:--compress=2 和 --compress=zip-6 有啥区别?

--compress=2 是 JDK 17 及以下的写法,对应最高级压缩。JDK 21 开始改成了 --compress=zip-6,效果一样,只是参数名称改了,--compress=2 仍可用但已标记废弃。版本搞清楚再写。


总结一下

这一套下来,实际能干的事就是把 Docker 镜像里那些根本用不上的 JDK 组件全部剪掉。对于非模块化项目,jdeps + jlink + 手动补充几个常用模块,最多半个小时就能配好。

已经在用完整 JDK 打 Docker 镜像的小伙伴,这个改动成本很低,可以直接动。

你的镜像当前多大?试过 jlink 之后有没有明显差异,可以评论区讨论一下。

 

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/195800