社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  docker

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

Java知音 • 4 月前 • 325 次点击  

 

上周推了一个服务更新,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