资讯动态

双架构Docker JDK镜像精简实战:从jlink裁剪到manifest合并

发布时间:2026/10/22 9:10:51 来源:尧图企业网站定制
第一次写 JRE 精简的帖子时我还在一个只有 x86 服务器的环境里折腾JDK 镜像从三百多 MB 压到一百出头当时以为后面就是复制粘贴这条路了。第二弹的起因是有个服务需要同时部署到 x86 和 arm64 的机器上镜像得用一个 tag 拉下来两个架构都直接能跑。结果发现前面那套精简方案在 arm 上完全不是那么回事。这篇把我在双架构 docker jdk 基础镜像精简过程中踩过的坑、试过的方案、最后固定下来的流程完整捋一遍。主要涉及基础镜像选型、jlink 裁剪、buildx 模拟构建的陷阱、跨架构 manifest 合并以及裁剪完镜像之后运行期那些“启动了但跑不对”的隐性故障。适合正在做 Java 服务容器化、尤其是要给多架构集群出镜像的运维和平台工程师看看完能少走不少弯路。1. 镜像精简的瓶颈到底在哪1.1 第一弹的终点第二弹的起点第一弹的核心思路只有一个JDK 里塞了大量我们根本用不到的东西。一套完整的 OpenJDK 17 装进 Debian 系镜像解压后体积通常在 300MB 上下里面有大几十 MB 的 jmods模块描述文件、各种语言的翻译资源、文档、源码包、调试符号。对一个只跑 SpringBoot 服务的运行时来说这些全是死重。当时我的做法是走 jlink 裁剪把 JRE 按模块拆出来只保留服务实际用到的模块再配一个 Debian slim 基础镜像最终体积能压到 120MB 左右。第一弹发布之后我一度以为这套流程已经收敛了结果一接触双架构就发现裁剪只是第一步真正麻烦的是“同一套产物要能在两种 CPU 架构上各自跑起来”。1.2 为什么 x86 和 arm 不能共用一份 JRE 产物很多人第一次做多架构镜像时第一反应是“我直接把 amd64 的 jre 目录拷到 arm 机器上换个基础镜像不就行了”。不行。JRE 里有大量编译好的本地库具体来说就是libjvm.so、libjava.so、libzip.so这些动态链接库它们是跟 CPU 指令集强绑定的x86_64 编译出来的 .so 在 aarch64 上直接报“Exec format error”。更隐蔽的是构建产物之外的东西。同一个 JDK 版本x86 构建版和 arm 构建版依赖的系统库版本、glibc 的行为细节、JIT 编译策略都不一样。你在一台 x86 机器上裁剪好的 JRE即便理论上包含的 Java 模块完全正确也没法在 arm 上做任何冒烟验证。所以双架构这事儿从我拿到需求的第一天就该意识到不能用一份产物打天下而是要“各自构建、合并清单”。1.3 双架构镜像的三种落地方式我实际调研下来目前生产环境里可行的方案大致有三种。第一种是双镜像方案。给 amd64 和 arm64 各打一个镜像分别打成镜像名:版本号-amd64和镜像名:版本号-arm64然后通过 docker manifest 合并成一个多架构 tag。这种方式构建逻辑最简单x86 构建机出 x86 镜像arm 构建机出 arm 镜像互不干扰。第二种是 buildx 多架构一把梭。在 Dockerfile 里直接用FROM --platform$BUILDPLATFORM这种写法然后一条docker buildx build --platform linux/amd64,linux/arm64命令同时出两个架构的镜像。这种方案配置最省事但 JRE 裁剪这种“需要在构建期运行 JVM 工具”的场景踩坑概率极高后面会专门讲。第三种是基于容器的按架构运行时加载。也就是镜像里同时塞两个架构的 JRE或者启动脚本里根据uname -m选择对应的运行时目录。体积成倍增长我是直接放弃的。我的最终选择是第一种两台原生架构构建机分别出单架构镜像最后 buildx 合并 manifest。后面会详细说这么选的真正理由。2. 基础镜像选型Debian slim、Alpine、scratch 三选一2.1 三种基础镜像的实测数据对比做 JRE 精简时基础镜像是绕不开的变量。同样的 jlink 裁剪产物放进不同的基础镜像最终体积和运行稳定性差别巨大。我分别试过 Debian slim、Alpine、scratch实测数据如下以某第三方发行版 OpenJDK 17 裁剪后的 JRE 为例解压后体积约 95MB基础镜像基础层体积裁剪 JRE 体积最终镜像体积约稳定性备注Debian slim 系80MB 左右95MB120~150MB最稳glibc 原生兼容JDK 官方构建版直接跑Alpine5MB 左右95MB85~100MB看运气musl 与官方 glibc 版 JDK 兼容性差需特殊处理scratch0MB95MB90~130MB折腾需要额外补 glibc 库、系统基础文件体积并不一定占优distroless 系20~30MB95MB100~120MB较稳调试困难shell 都没有注意我标过“看运气”的 Alpine。这里必须先拆一个很多人认知里的误区Alpine 体积小是因为它用 musl libc而绝大多数官方 OpenJDK 构建版是动态链接到 glibc 的。换句话说你在 Alpine 上直接跑官方 OpenJDK通常会报libjava.so: cannot open shared object file或者启动时直接崩。这不是 JDK 本身的问题是 libc 不兼容的问题。2.2 Alpine 上那个让人头疼的 glibc 兼容层网上很多教程会告诉你去安装一个第三方 glibc 兼容层然后就能在 Alpine 里跑 glibc 版 JDK 了。我第一弹的时候还真信了当时在 x86 上确实跑通了镜像体积也压到了很漂亮的程度。但第二弹做 arm64 时这个方案直接翻车。现象是镜像构建没问题推上去拉到真机上也能启动 JVM但运行一段时间后会出现随机性的Segmentation fault崩溃栈完全看不出规律有时候是 GC 线程有时候是 JIT 编译线程。我一开始怀疑是 JDK arm 版本身的问题后来换了原生 arm64 的 Debian slim 基础镜像同样的 JRE 产物跑了好几天没出过任何异常。问题基本可以锁定在那个 glibc 兼容层在 arm64 架构上的实现有缺陷尤其是涉及线程本地存储和信号处理的部分。所以我的建议是如果你没有精力维护一层额外的 glibc 兼容层Alpine 这个选项在 arm64 上可以直接划掉。想在 Alpine 上跑 Java也不是完全没有出路可以找专门为 musl 编译的 OpenJDK 发行版但这类发行版更新节奏参差不齐在 arm64 上可选的版本更少我评估后觉得不值得为省几十 MB 背上这个不确定性。2.3 scratch 镜像的真相scratch 听起来是最极致的精简方案毕竟基础层体积为 0。但事实是一个裁剪好的 JRE 根本没法在 scratch 上直接跑原因很简单JRE 里的所有 .so 都是动态链接到 glibc 的scratch 里什么都没有连动态链接器都不存在。要让 Java 在 scratch 里跑起来你需要把 glibc 的ld-linux-aarch64.so.1、libc.so.6、libpthread.so.0、libdl.so.2、libm.so.6、libz.so.1这些库全部拷进镜像还可能要补/etc/passwd、/etc/group、/etc/nsswitch.conf、/etc/resolv.conf这些基础文件。折腾一圈下来镜像体积往往比 Debian slim 还大而且任何一次 glibc 版本升级都会让这套手工拼出来的运行环境变得更脆弱。我的结论很直接Java 不是 Go别为了“从零构建”的仪式感去硬刚 scratch。3. jlink 裁剪实战模块取舍和体积收益3.1 jlink 命令长什么样jlink 是 JDK 9 开始提供的工具作用是根据指定的模块集合生成一个最小化的 JRE。它的核心用法是--add-modules指定要保留的模块--output指定输出目录。以我的一个 SpringBoot 服务为例最终在 x86 构建机上使用的裁剪命令如下$JAVA_HOME/bin/jlink \ --module-path $JAVA_HOME/jmods \ --add-modules java.base,java.logging,java.naming,java.management,java.security.jgss,java.net.http,java.rmi,jdk.unsupported,jdk.crypto.ec,jdk.localedata,jdk.zipfs \ --output /opt/jre \ --strip-debug \ --no-header-files \ --no-man-pages \ --compress2简单解释一下每个参数的实际作用。--strip-debug会把 class 文件里的调试符号去掉这一下能省不少体积。--no-header-files和--no-man-pages分别是去掉 C 语言头文件和 man 文档对运行时毫无影响。--compress2会压缩资源和 class 文件体积进一步缩小但代价是 JVM 冷启动时多一步解压实测影响很小可以忽略。需要提醒的是JDK 21 上--compress2已经标记为弃用官方后续版本更推荐用--compress zip-6这类明确的压缩方式。如果你用的是 JDK 17--compress2依然稳定可用放心写。3.2 那些容易被漏掉的模块模块清单是整个裁剪过程里最容易翻车的地方。很多人只加java.base结果服务一启动就报ClassNotFoundException然后开始疯狂加模块加一个崩一次。我建议先用 jdeps 静态扫描你的应用 jar让工具告诉你它到底用了哪些模块jdeps --print-module-deps --ignore-missing-deps your-app.jar # 输出类似java.base,java.logging,java.naming,java.management,java.net.http这个输出可以直接拼进 jlink 的--add-modules。但 jdeps 只能覆盖代码里显式引用的 API框架在运行时通过反射调用的模块它看不到。下面这张表是我实际维护服务时总结的“容易被漏但必须保留”的模块清单建议对照着自己的服务逐一确认模块名典型用途漏掉的后果jdk.unsupportedsun.misc.UnsafeNetty/Spring/Caffeine 等大量依赖启动即报ClassNotFoundException: sun.misc.Unsafejdk.crypto.ecTLS 证书链里的 ECC 算法、x25519 等曲线HTTPS 握手时报Unsupported named groupjdk.localedata中文等非英文 Locale 的资源日期格式化、排序行为异常jdk.zipfsNIO 读取 zip/jar 文件系统FileSystems.getFileSystem(jar:file:...)抛异常jdk.management.agentJMX 远程监控运维平台连不上 JMX 端口java.rmiSpring 某些远程调用和 RMI 相关基础设施运行中报ClassNotFoundException或 RMI 功能不可用java.namingJNDI、数据库连接池、Spring 上下文刷新启动阶段即失败这里特别说一下jdk.localedata。这个模块体积不小里面包含了全球各语言的 CLDR 数据。如果只是为了中文环境可以加上--include-localesen,zh-CN参数只保留英文和简体中文的资源体积能再省出一截。另一个高频翻车点是jdk.crypto.ec。现代 HTTPS 证书链里 ECC 证书非常常见如果 JRE 里没有 EC 实现平时访问 HTTPS 接口完全正常一旦遇到椭圆曲线证书的站点就会报java.lang.IllegalArgumentException: Unsupported named group: x25519而且错误信息在启动时完全看不出来只有流量打过来才炸。3.3 裁剪后的体积变化一个 SpringBoot 服务的实测数据以我自己的服务为例完整 JDK 基础镜像约 320MB。使用 jdeps 分析后--add-modules加上了 java.base、java.logging、java.naming、java.management、java.net.http、jdk.unsupported、jdk.crypto.ec、jdk.localedata、jdk.zipfs配合--strip-debug和--compress2最终 jlink 输出的 JRE 目录约 95MB。再叠加 Debian slim 基础镜像最终镜像从 320MB 降到 135MB压缩比超过 58%。如果你连 JMX、RMI 都不需要模块还能再砍JRE 体积可以压到 80MB 上下加上基础镜像总体在 110MB 左右。对于只跑一个 Web 服务的场景这个体积已经非常健康了。再往下压就得牺牲运维能力和排障便利性我个人不建议。4. 多架构构建qemu 模拟的坑与原生构建矩阵4.1 为什么 qemu 里跑 jlink 会崩溃这一节是第二弹的重头戏。一开始我也想图省事直接用 buildx 跑一句命令同时出两个架构的镜像。第一次用docker buildx build --platform linux/amd64,linux/arm64构建时x86 侧一切正常arm64 侧日志刷到 jlink 执行阶段直接给我来了一份 JVM crash report# # A fatal error has been detected by the Java Runtime Environment: # # SIGSEGV (0xb) at pc0x0000000000XXXXXX, pidXXXX第一次遇到这个情况时我花了很长时间排查以为是 arm64 的 JDK 镜像有问题。后来单独在 arm 真机上跑同样的命令完全正常。最终确认问题出在 buildx 的默认模拟机制上。buildx 之所以能用一个 x86 构建机出 arm64 镜像是因为它用 qemu 的用户态模拟来跑 arm64 指令。qemu 对普通二进制工具的模拟基本没问题但 JVM 这种“编译执行 即时翻译 自修改代码”的运行时在 qemu 用户态模拟下极其容易踩到信号处理、原子指令、内存映射这些底层细节上的兼容性 Bug。表现就是 JVM 进程在模拟环境下随机崩溃或者慢到令人发指——一个 jlink 裁剪在原生机上三分钟完成在 qemu 模拟环境里跑半个多小时都出不来。如果你只是用 buildx 做一个纯 COPY 的镜像模拟构建完全没问题。但一旦构建过程里跑 JVM 工具jlink、keytool 之类的都可能中招就要慎之又慎。我后来听一个同行说他在 qemu 模拟环境里跑 keytool 生成证书也是随机崩溃最后老老实实改成原生构建机。这绝不是个例。4.2 原生构建矩阵的具体做法既然 qemu 模拟不靠谱那就回到最朴素的方案x86 的镜像在 x86 构建机上出arm64 的镜像在 arm64 构建机上出最后合并 manifest。整个过程思路非常简单就是两个字分流。我当前的构建流程是这样的。准备两台构建机一台 x86 一台 arm64都装好 Docker。Jenkins 或任意 CI 流水线上分别触发这两个构建任务各自执行相同的 Dockerfile构建出带架构后缀的镜像然后推送到镜像仓库# 在 x86 构建机上执行 docker build -t your-registry/jre-service:1.0.0-amd64 . # 在 arm64 构建机上执行 docker build -t your-registry/jre-service:1.0.0-arm64 .这两个镜像都推送到仓库后再跑一条合并命令把两个单架构镜像合并成一个多架构 manifestdocker buildx imagetools create \ -t your-registry/jre-service:1.0.0 \ your-registry/jre-service:1.0.0-amd64 \ your-registry/jre-service:1.0.0-arm64之后用户拉取your-registry/jre-service:1.0.0时Docker 会自动根据当前机器的 CPU 架构选择对应的镜像层。这就是双架构镜像的完整闭环。这个方案唯一的“成本”是你需要一台 arm64 的构建机。现在公有云上租一个 2C4G 的 arm64 虚拟机非常便宜比我最初纠结 qemu 模拟浪费的时间成本低太多了。如果公司有自建的 ARM 服务器那更是顺手的事。4.3 Dockerfile 跨架构写法与构建缓存问题原生双机构建方案下Dockerfile 可以写得非常干净。用多阶段构建把 jlink 裁剪放到构建阶段最终运行镜像只拷贝裁剪产物# 第一阶段在原生构建机上执行 jlink 裁剪 FROM openjdk:17-jdk AS jre-builder RUN $JAVA_HOME/bin/jlink \ --module-path $JAVA_HOME/jmods \ --add-modules java.base,java.logging,java.naming,java.management,java.security.jgss,java.net.http,jdk.unsupported,jdk.crypto.ec,jdk.localedata,jdk.zipfs \ --output /opt/jre \ --strip-debug \ --no-header-files \ --no-man-pages \ --compress2 # 第二阶段运行时镜像 FROM debian:bullseye-slim COPY --fromjre-builder /opt/jre /opt/jre ENV JAVA_HOME/opt/jre \ PATH/opt/jre/bin:$PATH CMD [java, -jar, /app/app.jar]这段 Dockerfile 在两台不同架构的构建机上执行时基础镜像会分别拉取本架构的 openjdk 镜像jlink 也使用的是本机架构的 JDK 可执行文件因此裁剪产物天然就是对应架构的。这也是原生构建矩阵最大的优势Dockerfile 完全不需要感知架构架构差异由构建机自身的环境天然抹平。还需要注意一个问题多架构构建时不要复用跨架构的构建缓存。Docker 的 layer 缓存是按架构隔离的如果你在 x86 构建机上把 amd64 的镜像缓存直接给 arm64 构建用轻则缓存 miss重则把架构不对的文件拷进镜像。我的做法是两台构建机各自独立保留本地缓存合并推送到仓库后立刻清理避免旧缓存影响下一次构建。5. 运行期故障实录证书、时区、字体、DNS 四件套5.1 证书文件被精简掉的典型症状jlink 裁剪之后JRE 目录里依然保留了lib/security/cacerts文件所以大多数情况下 HTTPS 访问是正常的。真正翻车的是那些“手动精简”的人——他们用 tar 命令手工删文件或者用一些网上流传的瘦身脚本把 JRE 里看起来能删的文件全删了连同lib/security目录一起干掉了。这种情况下服务启动完全正常直到你第一次调用外部 HTTPS 接口瞬间报错PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target这个错误的本质是 JRE 的信任库空了系统无法验证任何服务器证书于是所有 HTTPS 连接全部失败。如果你信任的是系统级 CA 证书可以把宿主机的/etc/ssl/certs/java/cacerts或者 JRE 自带的cacerts拷贝到你精简后的目录里同时用-Djavax.net.ssl.trustStore/opt/jre/lib/security/cacerts显式指定。注意cacerts是 JKS 格式的文件不是普通的 PEM 证书拷贝时别拿错文件。5.2 JDK 时区数据和系统时区是两回事这是我在双架构镜像上一个非常隐蔽的坑。现象是服务起来后日志里打印的时间比北京时间整整差 8 个小时但我在 Java 代码里调用ZoneId.systemDefault()返回的又是Asia/Shanghai完全正确。原因在于 JVM 的时区数据tzdb.dat和操作系统底层的localtime是两套东西。Java 代码通过ZoneId拿时区读的是 JRE 自带的时区数据库这个在你 jlink 裁剪后依然保留但很多本地库和日志框架会走系统调用localtime()读取的是/etc/localtime和/usr/share/zoneinfo这些系统文件。Debian slim 精简镜像里没有安装 tzdata 包系统默认就是 UTC于是出现了这种“Java 说东八区系统说 UTC”的诡异状态。解决办法很简单在 Dockerfile 里显式安装时区数据设置环境变量RUN apt-get update apt-get install -y tzdata \ ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone ENV TZAsia/Shanghaitzdata 包体积不大但能避免一类非常费解的日志时间问题。如果你用的是 Alpine也可以用apk add tzdata原理相同。5.3 字体相关的一串异常如果业务里涉及验证码生成、图片水印、PDF 导出、Excel 图表这类功能JRE 精简后经常会遇到下面这个经典错误java.lang.NullPointerException: Fontconfig head is null, check your fonts or fonts configuration原因有两个层面。第一层是 JVM 内部负责字体管理的java.desktop模块如果被 jlink 裁掉了AWT 就完全没有字体能力任何字体渲染相关的 API 都会出问题。第二层是系统层面即使保留了相关模块JRE 运行时要通过 fontconfig 去查询系统里的字体文件而精简镜像里通常既没有 fontconfig 配置也没有字体文件。如果你的服务根本没有图形处理需求最省事的做法是连java.desktop模块都不要加启动参数补上-Djava.awt.headlesstrue彻底关闭图形环境。但如果你确实需要生成验证码或者导出 PDF那就得在镜像里装 fontconfig 和一套中文字体。这个必须做没有捷径。5.4 DNS 引发的诡异网络问题最后一个坑比较冷门但一旦踩到你很难定位。scratch 或极端精简镜像里没有/etc/nsswitch.conf而 glibc 的域名解析流程依赖这个文件来决定用什么顺序读取/etc/hosts和/etc/resolv.conf。缺少这个文件时某些 glibc 版本会走一套不完整的默认策略表现为Java 里InetAddress.getByName()偶尔能解析、偶尔失败或者启动后第一次连数据库成功第二次连接就报UnknownHostException。这种问题在 Debian slim 上不会出现因为它自带nsswitch.conf。如果你坚持用 scratch就必须手工补上下面几个文件/etc/nsswitch.conf /etc/resolv.conf /etc/passwd /etc/group稍微想一想就会明白这已经不是在“精简镜像”了而是在手工拼一个迷你的操作系统发行版性价比极低。这也是我最终坚定使用 Debian slim 的原因之一。5.5 常见问题速查表问题现象根本原因处理办法HTTPS 请求报 PKIX path building failedcacerts 信任库缺失从完整 JDK 拷贝lib/security/cacerts并指定 trustStore日志时间差 8 小时但 ZoneId 正确系统时区数据与 JRE 时区数据不一致安装 tzdata设置/etc/localtime和TZSSL 握手报 Unsupported named groupjdk.crypto.ec模块缺失重新 jlink 时加回该模块启动即报ClassNotFoundException: sun.misc.Unsafejdk.unsupported模块缺失重新 jlink 时加回该模块字体渲染报 Fontconfig head is null缺少 fontconfig 与字体文件安装 fontconfig 和所需字体或删掉java.desktop模块主机名偶发无法解析缺少/etc/nsswitch.conf使用带 glibc 完整配置的基础镜像或手工补文件JVM 随机段错误arm64 下 glibc 兼容层不稳定放弃 Alpine 兼容层切换 Debian slim写在最后双架构镜像折腾了一圈我现在的固定流程基本是两台原生架构构建机各自跑同一个 Dockerfilejlink 负责把 JRE 裁剪到 90MB 左右Debian slim 作为运行基础层保证 glibc 兼容稳定构建完成后用 buildx 合并一个多架构 tag。整个过程最核心的教训就一句话不要在 qemu 模拟环境里跟 JVM 工具死磕换一条原生构建的路省下的时间比自己预期的多得多。如果你正好也在做 docker jdk 基础镜像精简而且马上就要接 x86 和 arm 双架构我的建议是先确认自己构建链路里有没有 JVM 进程需要在模拟环境下执行。如果有趁早规划原生 arm 构建机如果没有buildx 一把梭也不是不行。最后再分享一个小技巧每次调整 jlink 模块清单后别只验证“能启动”一定要把 HTTPS 调用、证书校验、日志时间、字体渲染这几条最容易出问题的链路都过一遍你会发现大部分坑都藏在运行期里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑