本节目标:把
libraryd打成一台没有 Maven、没有源码的机器也能直接运行的产物,并算清 jar 大小和启动成本。
适用版本:JDK 25(Temurin 25.0.5)、Maven Shade 3.6.2、Maven JAR Plugin 3.4.1、jlink/jdeps(JDK 25 自带)。
承接的问题
在 IDE 里点运行,libraryd 能启动;用 mvn exec:java 也能启动。但交付给运维时,这些方式都不成立。运维机器上只有 JRE,没有 Maven,也没有项目的 ~/.m2 仓库。
先看默认的 mvn package 产出什么。libraryd-app 模块自己的代码只有几个类,打出来的 jar 很小,依赖全部在外部:
libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar 约 20 KB
直接运行会得到:
no main manifest attribute, in libraryd-app-1.0.0-SNAPSHOT.jar
即使补上 Main-Class,下一步也会变成:
Exception in thread "main" java.lang.NoClassDefFoundError: io/javalin/Javalin
因为 Javalin、Jackson、HikariCP、Flyway、Logback 都不在 jar 里。要让它独立运行,有两条路:
- 把所有依赖打进一个 fat jar(uber jar),用
java -jar启动。 - 保留模块化 classpath,用
jlink裁一个运行时镜像,再把应用和依赖放进去。
libraryd 两条都做:Shade 负责可执行 fat jar,jlink 负责更小的 JRE。先看 Shade。
核心机制一:Shade 合并依赖并写 Main-Class
libraryd-app/pom.xml 里的 Shade 配置是:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<shadedArtifactAttached>false</shadedArtifactAttached>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.plume.library.AppMain</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
几个参数的作用:
phase=package:在 jar 打完之后执行,输入是libraryd-app的原始 jar 和它的运行时依赖。createDependencyReducedPom=false:不生成dependency-reduced-pom.xml。生成它会让 reactor 里的依赖关系变复杂,特别是libraryd-integration-tests依赖libraryd-app时。保持 false,POM 不变。shadedArtifactAttached=false:用 shaded jar 替换主 artifact,最终路径就是libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar。如果设为 true,会多出一个带-shaded后缀的 jar,部署时容易拿错。ManifestResourceTransformer:写Main-Class: com.plume.library.AppMain。没有它,java -jar仍然不知道入口。ServicesResourceTransformer:合并META-INF/services下的 SPI 文件,下一节展开。filters:排除签名文件和module-info.class。签名文件留在 fat jar 里会让 JVM 校验失败;module-info.class是模块描述符,放在 classpath fat jar 里没有意义,还可能让某些工具误判。
构建命令和本机输出:
mvn -q -DskipTests package
real 18.70
user 14.18
sys 1.15
18.7 秒包含了编译五个模块、打包和 Shade 合并。这个仓库把 Checkstyle、SpotBugs、JaCoCo 只放在父 POM 的 pluginManagement 里,没有绑定到默认生命周期,所以这个数字不含静态分析;如果团队在 verify 阶段绑定了它们,构建时间会更长。产物大小:
-rw-r--r-- 1 user staff 25532403 libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar
25,532,403 字节,约 24 MB。这个大小来自 Javalin/Jetty、Jackson、HikariCP、Flyway、PostgreSQL JDBC、Logback、Micrometer、OpenTelemetry、Caffeine、Jedis 等依赖。fat jar 的优点是“一个文件拿走就能跑”,缺点是“每次改一行代码,整个 24 MB 都要重新传输”。容器化会在第 16.2 节解决传输问题。
核心机制二:SPI 文件不能简单覆盖
fat jar 最容易踩的坑不是 Main-Class,而是 META-INF/services。
JDK 的 ServiceLoader 通过 META-INF/services/<接口全限定名> 找实现。很多库靠它工作:
- JDBC 驱动通过
java.sql.Driver注册。 - SLF4J 通过
org.slf4j.spi.SLF4JServiceProvider找 Logback。 - Jackson 通过
com.fasterxml.jackson.databind.Module找扩展模块。 - Flyway 通过
org.flywaydb.core.extensibility.Plugin找数据库支持。 - Jakarta Validation 通过
jakarta.validation.spi.ValidationProvider找 Hibernate Validator。
如果 Shade 只是把多个 jar 的文件简单合并,同名文件会互相覆盖,最后只剩一个。表现可能是:
ServiceConfigurationError: jakarta.validation.NoProviderFoundException:
Unable to create a Configuration, because no Jakarta Bean Validation provider could be found.
或者 Flyway 启动时报“不支持的数据库”,因为 PostgreSQL 的插件文件被覆盖了。
ServicesResourceTransformer 的做法是读取所有同名 SPI 文件,把行合并后写回一个文件。它不关心接口名,按路径合并,所以对 JDBC、SLF4J、Jackson、Flyway 都有效。
可以用 unzip -l 检查合并结果:
unzip -l libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar \
| grep 'META-INF/services'
本机输出里能看到 org.flywaydb.core.extensibility.Plugin 有 3102 字节,说明多个 Flyway 插件的服务声明被合并到了一起;如果只有几十字节,通常意味着覆盖而不是合并。
签名文件是另一个坑。某些依赖 jar 带 META-INF/*.SF、*.DSA、*.RSA。Shade 把这些文件带进 fat jar 后,JVM 会认为整个 jar 被签名,但合并后的内容已经和签名不匹配,启动时报:
java.lang.SecurityException: Invalid signature file digest for Manifest main attributes
过滤器里的三个 exclude 就是为此准备的。它们只影响 fat jar,不影响依赖本身。
核心机制三:Multi-Release 与 module-info
JDK 9 之后,很多 jar 是 multi-release jar:同一个类在 META-INF/versions/9/、META-INF/versions/17/ 下有不同实现,JVM 根据运行版本选择。要启用这个行为,jar 的 MANIFEST.MF 必须有:
Multi-Release: true
libraryd 当前的 fat jar manifest 是:
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.1
Build-Jdk-Spec: 25
Main-Class: com.plume.library.AppMain
没有 Multi-Release: true。unzip -l 却能看到 META-INF/versions/9/module-info.class 和 META-INF/versions/17/module-info.class。这两件事并不矛盾:Shade 把版本化目录带进来了,但 manifest 没声明,JVM 会忽略这些版本化条目。
对 libraryd 来说,这没有实际影响,因为被带进来的版本化文件主要是 module-info.class,不是业务类。但如果某个依赖真的靠 META-INF/versions/11/ 提供 Java 11+ 的实现,缺少 Multi-Release: true 就会悄悄退回旧实现。要显式声明,可以在 ManifestResourceTransformer 里加:
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.plume.library.AppMain</mainClass>
<manifestEntries>
<Multi-Release>true</Multi-Release>
</manifestEntries>
</transformer>
ManifestResourceTransformer 在 Shade 3.6.2 里支持 manifestEntries,它会把条目写进最终 manifest。另一个位置是 maven-jar-plugin 的 <archive><manifestEntries>,但 Shade 会重新生成 manifest,两个地方都配时要确认最终值。对 fat jar,优先在 Shade 的 transformer 里配。
module-info.class 的处理也要分清:
- 根目录的
module-info.class被过滤器排除,因为它描述的是某个依赖的模块,不是 fat jar 的模块。 META-INF/versions/*/module-info.class没有被排除,因为过滤器只写了module-info.class,匹配不到带路径的版本化文件。它们留在 jar 里,但在没有Multi-Release: true时不会生效。
这不是“配置漏了”,而是 fat jar 和 JPMS 本身的冲突:fat jar 是 classpath 产物,不是模块化产物。下一节的 jlink 也只解决运行时,不解决应用模块化。
核心机制四:jlink 裁出更小的运行时
fat jar 需要一台装了 JDK/JRE 的机器。完整 JDK 25 在本机是 294 MB,而 libraryd 只用其中一部分模块。jdeps 可以分析 fat jar 依赖哪些 JDK 模块:
jdeps --ignore-missing-deps --print-module-deps --multi-release 25 \
libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar
本机输出:
java.base,java.desktop,java.instrument,java.naming,java.rmi,java.scripting,java.security.jgss,java.sql,jdk.management,jdk.unsupported
这十个模块覆盖了 JDBC、Javalin/Jetty 的命名与安全、Jackson 的桌面类型、Micrometer 的管理接口等。然后用 jlink 生成运行时镜像:
jlink \
--add-modules "$(jdeps --ignore-missing-deps --print-module-deps --multi-release 25 \
libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar)" \
--output /tmp/libraryd-runtime \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=zip-6
本机结果:
53M /tmp/libraryd-runtime
17 # /tmp/libraryd-runtime/bin/java --list-modules | wc -l
294M # 完整 JDK 25
69 # 完整 JDK 25 的模块数
运行时从 294 MB 降到 53 MB,模块数从 69 降到 17。用这个运行时启动同一个 fat jar:
/tmp/libraryd-runtime/bin/java -version
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode)
再用它跑 libraryd,/health 返回 {"status":"UP","checks":{"db":"UP"}}。这说明裁出来的运行时足够支撑应用,不是“能启动但一连数据库就缺类”。
jlink 的限制要说清楚:
jlink生成的是 运行时镜像,不是应用镜像。它不会把libraryd变成一个模块,也不会把 fat jar 模块化。应用仍然以 classpath 方式运行。- 如果依赖是非模块化 jar(automatic module),不能直接把应用
--module-path丢给jlink做应用链接。automatic module 的名字不稳定,split package 也会让链接失败。 jdeps --ignore-missing-deps会隐藏缺失依赖。它能算出“当前 classpath 上能看到的模块”,但反射、ServiceLoader、JDBC 驱动注册可能在运行时才加载模块。比如 PostgreSQL 使用 SSL 时可能需要jdk.crypto.ec,jdeps不一定能推断出来。遇到NoSuchAlgorithmException或 TLS 握手失败,先补--add-modules jdk.crypto.ec。- 需要非英语 locale 或完整时区数据时,加
jdk.localedata;需要字体或桌面特性时,java.desktop已经被包含,但jdk.localedata不会自动进来。
如果目标是“连 JRE 都不用装”,下一步是 jpackage:它可以把运行时镜像和 fat jar 打成一个平台安装包(macOS 的 .pkg/.dmg、Linux 的 .deb/.rpm)。jpackage 的平台绑定很强,跨平台构建仍然要靠对应平台的工具链。容器化在第 16.2 节提供另一种跨平台交付方式。
在 libraryd 里落地:24 MB jar 的真实启动成本
打包后的完整启动命令:
export LIBRARYD_DB_URL=jdbc:postgresql://127.0.0.1:55436/libraryd
export LIBRARYD_DB_USER=libraryd
export LIBRARYD_DB_PASSWORD=libraryd
export LIBRARYD_HTTP_PORT=18081
export LIBRARYD_POOL_SIZE=5
export LIBRARYD_LOG_LEVEL=INFO
java -jar libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar
本机第一次启动(数据库为空,Flyway 执行 V1、V2)从进程启动到 /health 返回 200 用了 2.55 秒。日志里的关键行:
HikariPool - Start completed.
Flyway ... Successfully applied 2 migrations to schema "public", now at version v2
Javalin started in 176ms
libraryd started on port 18081 (db=jdbc:postgresql://127.0.0.1:55436/libraryd, poolSize=5)
libraryd is ready: http://localhost:18081
第二次启动(迁移已应用)在本机测得 2.325 秒和 1.976 秒。这个数字包含 JVM 启动、Hikari 建池、Flyway 校验、Javalin 启动和扫描器调度。它不是一个跨机器可比的基准,但能说明:24 MB 的 fat jar 在普通开发机上启动是秒级,不是几十秒。
如果只关心“jar 能不能独立跑”,到这里就够了。下一节把这个 jar 放进容器,解决“目标机器连 JRE 都没有”的问题,并比较 Dockerfile 与 Jib 两种构建方式。
常见错误与边界
错误一:只配了 Main-Class,没配 ServicesResourceTransformer。 应用能启动,但一用 Flyway 就报找不到 PostgreSQL 插件,或者 Hibernate Validator 报 NoProviderFoundException。fat jar 的 SPI 文件必须合并,不能覆盖。
错误二:把签名文件留在 fat jar 里。 启动时报 Invalid signature file digest for Manifest main attributes。过滤器要排除 META-INF/*.SF、*.DSA、*.RSA,不要试图重新签名整个 fat jar。
错误三:用 java -cp libraryd-app.jar com.plume.library.AppMain 代替 java -jar。 这样能绕过 manifest 问题,但会把 -cp 的写法带进启动脚本,运维容易漏掉依赖或写错路径。既然已经生成了 fat jar,就用 java -jar。
错误四:以为 jlink 会把应用也模块化。 jlink 只裁 JDK 模块。应用仍然是 classpath 上的 fat jar。要把应用做成模块,需要给每个模块写 module-info.java,处理所有依赖的 automatic module 名和 split package,成本和收益要单独评估。
错误五:jdeps 输出少了模块就直接上线。 --ignore-missing-deps 会让分析继续,但结果是“在缺失依赖的前提下能看到的模块”。运行时才加载的驱动、加密提供者、字符集可能不在列表里。上线前用裁出的运行时跑一遍完整端到端测试,比只看 jdeps 输出可靠。
边界:jar 大小不等于镜像大小。 24 MB 是压缩后的 jar;解压后类文件更多。容器镜像还包含基础 JRE、操作系统层和元数据。16.2 节会看到,基于 eclipse-temurin:25-jre-alpine 的镜像在本机是 352 MB,jar 只占其中很小一部分。
边界:可复现构建。 Shade 默认会把时间戳写进 jar,两次构建的字节可能不同。需要可复现产物时,配置 project.build.outputTimestamp,并确认 Shade 是否尊重它。libraryd 当前没有开启可复现构建,CI 只校验“能构建、能启动”,不校验字节一致。
小结
- 默认
mvn package产出的是瘦 jar,离开 Maven 仓库无法运行。 - Shade 在
package阶段合并依赖,ManifestResourceTransformer写Main-Class,ServicesResourceTransformer合并 SPI。 - 过滤器要排除签名文件和根目录
module-info.class;版本化META-INF/versions/*/module-info.class仍会保留,但没有Multi-Release: true时不会生效。 libraryd的 fat jar 是 25,532,403 字节(约 24 MB),本机首次启动约 2.55 秒,迁移已应用后约 2 秒。jdeps给出十个 JDK 模块,jlink裁出的运行时是 53 MB / 17 个模块,完整 JDK 是 294 MB / 69 个模块。- jlink 只裁运行时,不模块化应用;反射和
ServiceLoader加载的模块可能漏掉,要用完整测试兜底。
fat jar 解决了“依赖在哪”,但没有解决“目标机器有没有 JRE、用什么用户跑、怎么探活”。下一节把 jar 放进多阶段 Dockerfile,并用 Jib 在不依赖 Docker daemon 的情况下构建镜像。
阅读导航:上一节:15.3 端到端与 CI:把服务跑起来验证 · 下一节:16.2 容器化:多阶段 Dockerfile 与 Jib 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。