1. 先想清楚为什么要自己写这份 JDK 基础镜像
把一个跑了三年的 Java 服务从通用发行版底包换到 alpine,镜像体积从 480MB 掉到 120MB,构建时间还快了将近一半——这事我干过不止一次。第一次干的时候挺得意,直到容器起来三分钟后日志里刷出一屏UnsatisfiedLinkError,接着是导出报表里的中文全变成方框,最后连日志时间都差 8 小时。三个问题,三个层次:C 库、字体、时区。它们共同说明一件事——用 Dockerfile 在 alpine linux 上构建 JDK 基础镜像,根本不是把FROM后面换个名字那么简单,它是把整条运行时依赖链重新过一遍。
这篇东西写给谁?给那些已经会用docker build、能看懂基础 Dockerfile 语法,但每次写 Java 镜像都直接抄一个网上的片段、出事之后只会docker run -it --entrypoint sh进去瞎翻的同行。也写给正在做镜像瘦身、被安全扫描报告追着跑的团队里那位负责打包的人。我会把 alpine 这条路上的选型逻辑、每一层为什么这么写、jlink 裁剪怎么落地、以及那几个一定会撞上的坑,全部摊开讲。
1.1 直接拉现成镜像省下的时间,往往在后面加倍还回去
最省事的做法当然是FROM openjdk:17-jdk或者某个社区维护的 Java 镜像,一条命令搞定,java -version也跑得通。问题出在三个地方。
第一是体积不可控。通用底包动辄两三百 MB,里面塞了完整的包管理器、一堆用不上的系统工具、甚至还留着 apt 的缓存索引。业务镜像再叠上去,动辄七八百 MB,每次发布推送到镜像仓库都要等,节点拉取也慢,扩容时的冷启动时间全耗在下载上。
第二是内容不可追溯。现成镜像里到底装了什么、装了哪个补丁版本的 JDK、有没有多余的特权组件,你不docker history加上逐层解包是看不出来的。安全扫描报出一堆 CVE 的时候,你连哪些是底包自带的、哪些是业务层引入的都分不清。
第三是升级被动。底包作者什么时候更新、更不更新,你完全没有话语权。某天上游改了个基础层,你的构建突然挂了,排查半天发现是别人的改动。
自己写一份 Dockerfile,换来的是这三件事的完全掌控:层数你数得清、每层装了什么你写得明、JDK 补丁版本由你的ARG决定。代价是你得对 alpine 这套体系有基本认识。
1.2 alpine 省下的那几百 MB,本质是换了一套 C 库
很多人对 alpine 的理解停留在"体积小",5MB 左右的底包确实诱人。但它小的根本原因不是"精简",而是它用的是musl libc而不是 glibc,配套的是一套 busybox 工具链。这两个事实决定了后面所有的坑。
Java 本身是跨平台的,但 JVM 不是纯 Java 写的。libjvm.so、libnio.so、网络和文件系统的 native 实现,全都要跟 C 库打交道。所以一个 JDK 发行版要么针对 glibc 编译,要么针对 musl 编译,两者二进制不兼容。你把一个为 glibc 编译的 JDK 直接塞进 alpine,java命令连启动都做不到,报的是not found这种极具误导性的错误——它不是文件不存在,是动态链接器找不到。
理解了这一层,你才能明白为什么 alpine 上的 JDK 选型不是"随便下一个 tar.gz 解压",而是必须先回答:这个 JDK 是谁为 musl 编译的。
1.3 这份镜像适合谁,不适合谁
适合的场景:Spring Boot 单体或微服务、命令行批处理、定时任务、内部 API 网关这类纯后端负载,镜像里只需要 JVM 加一个 jar,不需要任何本地编译工具链。这类场景下 alpine + JDK 的组合非常舒服。
不适合的场景我也说清楚:应用里有大量 JNI 调用、依赖厂商提供的 native 库(某些数据库驱动、图像编解码库、硬件 SDK)、或者需要在容器里跑gcc编译本地代码的。这类情况下 musl 的兼容性风险会显著放大,老老实实用 glibc 底包更省心。还有一种情况是团队对镜像体积不敏感、但对可维护性要求极高,那也没必要为了省 200MB 折腾。
判断标准很朴素:你的应用有没有 native 依赖。没有,alpine 大胆上;有,先做个小验证再决定。
2. alpine 上的 JDK 选型:能跑和"看起来能跑"是两回事
选型这一步决定了后面 80% 的麻烦。我见过太多人在这里凭感觉走,结果在最后一步被一个Error loading shared library拦住。
2.1 musl 与 glibc 的差异具体落在哪些地方
不用把两者当成两个世界,绝大多数标准 C 库接口行为和语义一致。真正会咬人的是这几类:
- 动态链接器路径不同。glibc 是
/lib64/ld-linux-x86-64.so.2,musl 是/lib/ld-musl-x86_64.so.1。二进制里硬编码的 interpreter 路径对不上,直接启动失败。 - NSS 相关实现不同。glibc 用
libnss_*插件机制做名字解析,musl 是静态编译进 libc 的,/etc/nsswitch.conf的行为不完全一样。这会在容器 DNS 解析上体现出来。 - locale 体系不同。musl 没有 glibc 那套
locale-gen,只有C.UTF-8一个内置 locale。任何依赖zh_CN.UTF-8之类 locale 名的代码都会落空。 - 符号版本不同。glibc 有
GLIBC_2.28这种版本化符号,musl 没有。某个预编译的 native 库引用了高版本 glibc 符号,在 musl 上就是找不到。
前三条影响不大,改成对应写法就行;第四条基本无解,只能换库或换底包。
2.2 三条可选路线,代价各不一样
| 路线 | 具体做法 | 优势 | 代价与风险 |
|---|---|---|---|
| 发行版包管理器 | apk add openjdk17-jdk | C 库天然一致,体积小,依赖自动解析,升级跟仓库走 | JDK 版本和补丁受发行版仓库节奏约束,可能落后一两个季度 |
| 官方 alpine 变体镜像 | FROM <发行版名>:17-jdk-alpine | 开箱即用,社区维护,版本较新 | 底包内部结构不由你控制,层数偏多,通常还带了没必要的东西 |
| 自行下载 tar.gz + 兼容层 | 解压 glibc 版 JDK,apk add libc6-compat | 版本最新最全 | 兼容层只覆盖常见符号,JNI 与 native 库大概率炸,不推荐用于生产 |
我自己的默认选择是第一条。原因很实际:alpine 仓库里的 openjdk 包就是为 musl 编译的,跟底包是同一套工具链产出,C 库层面零风险;包管理器还能帮你把libstdc++、zlib、freetype这些依赖一起装对,省掉一大堆手工排查。版本确实可能不是最新,但对绝大多数业务来说,JDK 17 的某个补丁号差一两个版本,影响远小于镜像跑不起来。
第二条适合快速验证或者对版本有硬要求的场景,但它本质上是把选型决定权交给了别人,你只是在用别人的成品。既然这篇文章讲的是"自己写 Dockerfile",我们重点走第一条。
第三条我要多劝一句:libc6-compat不是万能胶。它提供的是一组符号转发,能骗过一部分简单的二进制,但一旦涉及线程、DNS、文件锁、locale 这些深层调用,行为偏差就出现了,而且报错信息往往极其隐晦。我踩过一次,一个定时任务在兼容层里跑了两周才暴露出时间计算偏差,排查成本远超省下的那点版本收益。
2.3 标签里 jdk / jre / headless 到底该挑哪个
这是另一个高频困惑点。三个词的含义差别很大:
jdk包含编译器javac、jlink、jdeps、jcmd等开发与诊断工具,体积最大。构建阶段需要它,运行阶段基本不需要。jre只包含运行时,能跑 jar,但没有编译工具。headless表示去掉图形界面相关的 native 库支持(AWT/Swing 的 X11 依赖)。服务端应用几乎百分之百应该选 headless,除非你要做桌面程序或者需要完整图形栈。
alpine 仓库里对应的包名形如openjdk17-jdk、openjdk17-jre-headless。我在运行层一律用-jre-headless,体积比完整 jdk 小一半以上。构建阶段才用-jdk,而且通过多阶段构建把它挡在最终镜像之外——这个下面第 4 章会展开。
有一个细节提前提醒:安装完之后JAVA_HOME的路径不要凭记忆写。alpine 的 openjdk 包安装目录形如/usr/lib/jvm/java-17-openjdk,不同大版本、不同仓库里路径可能微调。最稳的做法是在构建时ls /usr/lib/jvm确认一次,或者用readlink -f $(which java)反推。
3. 逐行拆解 Dockerfile:每一层都在解决一个具体问题
下面这份是运行层的基础镜像,我按功能把它切成几块。你先看完整版,后面逐段解释为什么这么写。
FROM alpine:3.19 ARG JDK_PKG=openjdk17-jre-headless ARG TZ=Asia/Shanghai # 1) 确认软件源里包含 community,openjdk 与 noto 字体都在这个仓库 RUN set -eux; \ if ! grep -q '/community$' /etc/apk/repositories; then \ sed -i 's|^\(.*/\)main$|\1main\n\1community|' /etc/apk/repositories; \ fi # 2) 运行期依赖一次装完,--no-cache 避免留下索引文件 RUN apk add --no-cache \ "${JDK_PKG}" \ ca-certificates \ tzdata \ tini \ fontconfig \ ttf-dejavu \ font-noto-cjk \ && fc-cache -f # 3) 时区落盘,不依赖外部挂载 RUN cp "/usr/share/zoneinfo/${TZ}" /etc/localtime \ && echo "${TZ}" > /etc/timezone # 4) 非 root 用户,固定 UID/GID 便于跨环境对齐挂载权限 RUN addgroup -g 10001 -S app \ && adduser -u 10001 -S -G app -h /app -s /sbin/nologin app \ && mkdir -p /app /app/logs \ && chown -R app:app /app ENV LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ TZ=${TZ} \ JAVA_HOME=/usr/lib/jvm/java-17-openjdk \ PATH=/usr/lib/jvm/java-17-openjdk/bin:$PATH WORKDIR /app USER 10001:10001 ENTRYPOINT ["/sbin/tini", "--"] CMD ["java", "-version"]3.1 基础层与软件源:很多"包找不到"都是从这行开始的
FROM alpine:3.19这行没什么好说的,但版本号一定要钉死。用alpine:latest的后果是某天上游发布新大版本,你的构建突然开始报一堆莫名其妙的问题。生产镜像的基础层必须是确定值。
真正容易被忽略的是软件源。alpine 官方镜像默认的/etc/apk/repositories通常同时包含main和community两个仓库,但经过二次精简的底包、或者某些基于 alpine 的衍生镜像,会只留main。而openjdk*系列包和font-noto-cjk恰恰都在community里。现象就是apk add openjdk17-jre-headless报no such package,你搜半天以为是包名写错了,其实仓库根本不在列表里。
所以我习惯加一段幂等检查:先grep看有没有 community,没有就sed补一行。set -eux也是刻意加的——-e让任何一条命令失败就中断构建(否则apk失败了后面的层还会继续往下走,最后报一个跟真实原因毫无关系的错误);-x把每条命令打印出来,构建日志里能清楚看到执行到哪一步挂了。这两个 flag 在调试阶段的收益极高,正式镜像里留着也没有坏处。
--no-cache的作用是让 apk 在内存里完成索引查找,不把索引文件写进/var/cache/apk。用--no-cache之后绝对不要再写apk update或者rm -rf /var/cache/apk/*,前者会多下一份索引,后者是脱裤子放屁。
3.2 运行期依赖:字体、时区、init 进程一个都不能少
这一层的每一个包都是我踩过坑之后加进去的,逐个说明。
ca-certificates:容器里发起 HTTPS 请求(调用外部 API、连数据库 TLS、拉配置中心)需要根证书。alpine 底包里通常已经间接带了一个 bundle,但显式声明一次成本极低,能避免某些衍生镜像里被裁掉的情况。
tzdata:不装这个,/usr/share/zoneinfo目录不存在,TZ环境变量设了也白设,JVM 起来会老老实实读 UTC。日志时间全差 8 小时,跟下游系统对日志时最痛苦。
tini:PID 1 进程的特殊性导致的。容器里第一个进程如果是 JVM,它要负责回收孤儿进程。JVM 虽然对SIGTERM有响应(会触发 shutdown hook),但对僵尸进程回收的处理并不完整。加一个极小的 init 进程做 PID 1,JVM 变成它的子进程,信号转发和进程回收都由 tini 负责,docker stop时应用能优雅退出而不是等 10 秒超时被SIGKILL。alpine 自带的tini包只有几十 KB,性价比极高。
fontconfig+ttf-dejavu+font-noto-cjk:这是中文环境的分水岭。JVM 里做 PDF 导出(比如用某些报表库)、生成验证码图片、渲染图表,底层都会走 Java2D,而 Java2D 在 headless 模式下依然需要字体文件和 fontconfig 才能找到它们。不装字体,Graphics2D.drawString画出来的就是一串方框,或者干脆抛java.lang.InternalError: Can't connect to X11 window server。ttf-dejavu提供拉丁字符的兜底,font-noto-cjk提供中日韩字形。装完必须跑一次fc-cache -f刷新字体缓存,否则新装的字体在部分场景下要等很久才被识别。
提示:
font-noto-cjk这个包体积不小(几十 MB 量级)。如果你的应用完全不涉及任何图形渲染、不导出 PDF、不做图片处理,可以把它去掉,能省下可观的体积。判断方法是搜一遍项目依赖里有没有 PDF、图片、图表相关的库,或者本地构建后跑一次渲染测试。
3.3 非 root 用户与目录权限:别等安全扫描来提醒你
addgroup -S/adduser -S里的-S是创建系统用户,不建家目录(所以我手动mkdir -p /app)。固定 UID/GID 为 10001 是刻意为之:容器内用户 ID 必须固定,否则在挂载宿主机目录、或者跟其他服务共享 volume 的时候,权限对不上,你会看到一堆Permission denied,而且每次重建镜像 UID 都可能变。
-s /sbin/nologin禁止该用户登录 shell,进一步收紧攻击面。USER 10001:10001写在WORKDIR之后,保证工作目录已经创建且归属正确。
这里有个实操顺序问题:所有需要 root 权限的操作(apk add、cp到/etc、chown)必须放在USER之前。一旦切到非 root,后面任何一条apk命令都会失败。同时要留意,RUN指令的执行用户是构建时的当前用户,所以如果你在USER之后还有RUN,那些操作就是以 10001 身份执行的。
3.4 环境变量与 ENTRYPOINT:几个必须显式设置的项
LANG和LC_ALL都设成C.UTF-8。musl 只有这一个内置 locale,设zh_CN.UTF-8是无效的,JVM 会回退到默认。而C.UTF-8能保证字符编码按 UTF-8 处理,中文日志不出现乱码。注意大小写:是C.UTF-8不是C.utf8,虽然部分场景两者等价,但统一写标准形式更保险。
JAVA_HOME和PATH一起设。只设JAVA_HOME不设PATH,直接敲java会找不到命令;只设PATH不设JAVA_HOME,某些框架启动时要读这个变量做校验,会报错。
ENTRYPOINT ["/sbin/tini", "--"]加上CMD ["java", "-version"]的组合,用的是exec 形式(JSON 数组),这一点非常关键。shell 形式(不带方括号)会让/bin/sh -c成为 PID 1,信号不会转发给真正的工作负载,docker stop时应用收不到SIGTERM,只能等超时被强杀。所有 Java 镜像都应该用 exec 形式。
业务镜像在此基础上继承即可,只需要覆盖CMD:
FROM registry.internal/base/jre-alpine:17 COPY target/app.jar /app/app.jar CMD ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]4. 用多阶段构建和 jlink 把镜像压到极限
装完jre-headless的 alpine 镜像大约在 150MB 出头,其中 JDK 运行时本身占绝大部分。如果你的目标是极致的镜像体积,jlink 是绕不开的一步。
4.1 为什么构建阶段和运行阶段必须分开
jlink 这个工具本身在jdk包里,不在jre包里。要让构建阶段能调jlink,就必须装完整的 jdk;但最终镜像里显然不该有编译器和头文件。多阶段构建解决的就是这个矛盾:构建阶段用一个"什么都装"的临时镜像,产出物只有/opt/jre这一个目录,运行阶段只拷贝它。
FROM alpine:3.19 AS builder RUN apk add --no-cache openjdk17-jdk WORKDIR /build COPY app.jar /build/app.jar RUN jdeps --print-module-deps \ --ignore-missing-deps \ --multi-release 17 \ /build/app.jar > /build/modules.txt \ && cat /build/modules.txt \ && jlink \ --add-modules "$(cat /build/modules.txt),jdk.crypto.ec,jdk.localedata" \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress=2 \ --output /opt/jre4.2 模块推断:jdeps的输出永远只是起点
jdeps --print-module-deps会扫描 jar 的字节码,推断出需要哪些 JDK 模块,输出形如java.base,java.logging,java.sql。这一步是自动化的核心,但它有几个天然盲区:
- 反射加载的类扫不出来。框架通过
Class.forName动态加载的模块不在静态分析范围内。 --ignore-missing-deps是必须的。处理非模块化应用(没有module-info.class的普通 jar)时,不加这个参数,jdeps会因为找不到依赖而直接报错退出。加了之后它会跳过无法解析的部分,代价是推断结果更保守或者更不完整。- 服务加载机制(SPI)涉及的类型,比如 JDBC 驱动、加密算法提供者,往往需要在运行时才暴露。
所以我习惯在jdeps输出上手工补两个几乎必加的模块:jdk.crypto.ec(椭圆曲线加密,TLS 握手基本都要)和jdk.localedata(非英语环境的区域数据,缺了会导致日期格式化、货币符号异常)。如果你的应用涉及 XML 处理、HTTP Client、JNDI 之类,还要补对应的java.xml、java.net.http、java.naming。
保守做法是把--add-modules写成ALL-MODULE-PATH,这样不会漏,但产物会大到接近完整 JRE,jlink 的意义就没了。折中方案是在预发环境跑一轮全量回归,把启动日志里ClassNotFoundException/NoClassDefFoundError出现的模块记下来补进去。我一般会把这条命令的产物modules.txt打印到构建日志里,出问题时一眼能看到当前镜像包含了什么。
关于--compress=2:这是老版本的写法。较新的 JDK 里--compress的参数形式改成了zip-0到zip-9,数字形式被标记为废弃,更新的版本里会直接报错。如果你的构建阶段用的是 JDK 21 及以上,改成--compress=zip-6;如果团队里多个项目用的 JDK 版本不一,写个判断分支比硬编码更稳。
4.3 拷贝 jlink 产物时最容易漏的那几个共享库
这是我最想强调的一条,因为它造成的报错信息极具迷惑性。
jlink 生成的/opt/jre里包含libjvm.so、libjava.so这些 JVM 自己的库,但它不包含操作系统级的依赖库。你把/opt/jre拷到一个干净的 alpine 里,java -version可能直接报:
Error loading shared library libstdc++.so.6: No such file or directory原因很简单:jlink 只负责拼装 Java 模块,C++ 运行时、压缩库这些属于系统层,得你自己装。必须补齐的通常是这几个:
| 库 | 提供包 | 缺了会怎样 |
|---|---|---|
libstdc++.so.6 | libstdc++ | JVM 启动即失败,报 shared library 找不到 |
libgcc_s.so.1 | libgcc | 异常抛出、栈展开相关场景崩溃 |
libz.so.1 | zlib | 加载 zip/jar 时UnsatisfiedLinkError |
libfreetype.so.6 | freetype | 字体渲染失败(仅涉及图形时) |
不确定到底缺什么,用readelf查一下libjvm.so的动态依赖,比一个个试快得多:
apk add --no-cache binutils readelf -d /opt/jre/lib/server/libjvm.so | grep NEEDED输出里出现libc.musl-x86_64.so.1就说明这确实是为 musl 编译的产物,方向对了;列出哪些NEEDED条目,就对着装哪些包。
4.4 体积对比与收益判断
以下是我在同一个 Spring Boot 应用上实测的几组数字(体积为压缩后镜像大小量级,不同项目差异较大,仅供参考):
| 方案 | 镜像体积 | 启动耗时 | 适用性 |
|---|---|---|---|
| 通用底包 + 完整 JDK | 约 480MB | 基准 | 不推荐 |
alpine +jre-headless | 约 155MB | 略优于基准 | 绝大多数项目 |
| alpine + jlink 裁剪 | 约 80MB | 与基准持平 | 追求极致体积、可接受回归成本 |
| alpine + jlink + 去字体 | 约 60MB | 与基准持平 | 纯 API 服务,无图形渲染 |
从 155MB 压到 80MB,靠的是 jlink;从 80MB 再往下,靠的是砍字体这种"按需裁剪"。收益递减很明显,而风险在上升——jlink 裁剪掉的模块一旦缺失,问题往往在运行一段时间后才暴露。我的建议是:业务项目默认用jre-headless,稳定优先;只有当镜像体积确实成为瓶颈(边缘部署、大规模扩容、镜像仓库有配额限制)时,才上 jlink,并且必须配套完整的回归测试。
5. 构建完不等于能用:一套上线前的自检清单
镜像docker build成功、docker run能启动,离"可以上生产"还有距离。下面这套检查我每次都跑,五分钟,能拦住绝大多数问题。
5.1 五条命令验证核心能力
# 1. 版本与 C 库确认 docker run --rm image:tag sh -c "java -version 2>&1 && readelf -d \$(readlink -f \$(which java)) 2>/dev/null | head -5" # 2. 时区确认,输出应为设定时区的时间 docker run --rm image:tag sh -c "date && java -XshowSettings:properties -version 2>&1 | grep -i timezone" # 3. 字符编码确认,应为 UTF-8 docker run --rm image:tag sh -c "java -XshowSettings:properties -version 2>&1 | grep -i encoding" # 4. 字体确认,应能列出 noto 相关条目 docker run --rm image:tag sh -c "fc-list | grep -ci noto" # 5. 运行用户确认,应为 10001 而非 root docker run --rm image:tag id第 2 条要特别注意:date命令走的是系统时区(由/etc/localtime决定),JVM 走的是user.timezone属性。两个都要看,因为可能出现系统时间对了但 JVM 时间不对的情况——比如TZ环境变量设了,但tzdata没装,/usr/share/zoneinfo是空的,JVM 读不到时区数据就回退 UTC。
第 4 条返回 0 就说明中文字体没装上。别等业务方反馈"导出的 PDF 打不开"才想起来查这个。
5.2 时区、字符集与字体的联合验证
单独验证容易漏,我更喜欢用一个小的测试类一把过:
public class EnvCheck { public static void main(String[] args) { System.out.println("tz = " + java.util.TimeZone.getDefault().getID()); System.out.println("now = " + java.time.ZonedDateTime.now()); System.out.println("file = " + System.getProperty("sun.jnu.encoding")); System.out.println("zh = " + "中文测试".getBytes(java.nio.charset.StandardCharsets.UTF_8).length); java.awt.Font f = new java.awt.Font("Noto Sans CJK SC", java.awt.Font.PLAIN, 12); System.out.println("font = " + f.getFontName() + " / canDisplay=" + f.canDisplay('中')); } }最后一个canDisplay('中')返回true,字体才算真正可用。注意getFontName()有时会返回Dialog之类的逻辑字体名,那是 Java2D 的字体回退机制在工作,不代表失败,关键看canDisplay。
sun.jnu.encoding而非常见的file.encoding,是因为文件名编码和内容编码在 JDK 18 之后语义有变化。这个属性反映的是操作系统层面处理文件名的编码,在容器里如果出现乱码文件名,查的就是它。
5.3 放进 CI 之后需要盯住的两件事
构建缓存会骗你。apk add那一层只要包列表不变就一直命中缓存,你可能几个月后重建镜像,才发现上游仓库里的包已经被替换或者移除了。我的做法是每周固定跑一次--no-cache的全量构建,把结果推到测试环境验证。另外ARG变化会导致其后的层缓存全部失效,所以把ARG尽量放在靠前的位置、只放真正会变的参数,别把 jar 拷贝和依赖安装的顺序搞反。
漏洞扫描报告要看得懂。alpine 因为用 musl 和精简的包集合,很多针对 glibc 的 CVE 天然不受影响,但也因为包更新策略保守,某些组件的补丁会滞后。看到高危告警先确认三件事:这个包在你的镜像里是否真的存在(多阶段构建的 builder 层应该已经被丢弃)、对应的代码路径是否被用到、上游是否有更新版本。盲目升级基础镜像大版本,换来的是另一堆兼容性问题。
6. 几个真实撞上的坑,以及我是怎么一步步查出来的
前面讲的都是"应该怎么做",这一章讲"做错了会怎样"。这几个坑我都亲自踩过,排查过程原样保留,你可以直接拿去对照。
6.1 中文变成方框:从字体目录一路查到 fontconfig 缓存
现象:导出 Excel 转 PDF 的功能在本地正常,容器里生成的 PDF 打开后,中文全是方块,数字和英文正常。
排查链路是这样的:
第一步,确认是不是字体缺失。fc-list | grep -i cjk返回空,说明容器里确实没有中文字体。这个快。
第二步,装font-noto-cjk。装完再fc-list,能看到字体了。以为完事,结果重新生成 PDF,还是方框。这一步最容易误判为"字体装了但没用"。
第三步,怀疑字体名对不对。Java 代码里指定的是"SansSerif"这种逻辑字体名,理论上应该走字体回退到任意支持中文的字体。但在 headless 环境里,回退链依赖于 fontconfig 的配置和缓存。此时fc-cache -f就派上用场了——装完字体不刷缓存,fontconfig 在部分场景下要等到下次扫描才认识新字体,而你重启容器又是新的一次扫描,行为看起来像是随机的。
第四步,指定具体字体名。把代码里的逻辑字体名换成"Noto Sans CJK SC"之后立刻正常。这也印证了问题出在回退配置上,而不是字体本身。
最终我两手都做了:Dockerfile 里装完字体紧跟fc-cache -f,业务代码里对渲染场景显式指定具体字体名,不再依赖逻辑字体的隐式回退。教训是——headless 环境的字体回退行为跟桌面环境不完全一致,能显式就别隐式。
6.2UnsatisfiedLinkError的三种来源与各自的判别方法
这个异常信息量极低,但成因基本收敛在三种情况,判别方法各不相同。
第一种:glibc 版 JDK 塞进了 musl 环境。特征是java命令都跑不起来,报not found或者Error loading shared library ld-linux-x86-64.so.2。判别方法:readelf -l $(which java) | grep interpreter,看 interp 路径是 musl 还是 glibc。修复只能换 JDK 构建版本。
第二种:多阶段拷贝时漏了系统库。特征是java -version能跑,但一到某个具体功能就抛UnsatisfiedLinkError: /opt/jre/lib/libzip.so: Error loading shared library libz.so.1。判别方法:报错信息里通常会带上完整路径和缺失的库名,直接apk add对应包即可。上面 4.3 那张表就是为此准备的。
第三种:应用自带的 JNI 库不兼容。特征是只有特定功能报错,其他都正常,且报错里提到的是你自己的.so文件名。判别方法:readelf -d /path/to/your.so | grep NEEDED,如果列出libc.so.6(注意不是libc.musl-*.so.1),说明这个库是为 glibc 编译的,musl 上跑不了。这时候要么找 musl 版本、要么自己重编、要么整个底包换回 glibc。
这三种情况的排查路径完全不同,千万别一上来就怀疑 JDK 本身。先看报错里的库文件名,是你自己的一律先归到第三种。
6.3 容器时间差 8 小时:不只是设个 TZ 就完了
最早的写法是只在ENV里加TZ=Asia/Shanghai,date命令输出对了,但 JVM 里new Date()还是 UTC。
原因在于:TZ环境变量只对支持它的程序生效,而 JVM 读取时区信息的实际路径是javahome/lib/tzdb.dat加上系统时区文件。当/usr/share/zoneinfo整个目录不存在时(tzdata没装),JVM 找不到对应时区的二进制数据,就静默回退到 UTC,而且不报任何警告。
修复要三步齐全:装tzdata、cp /usr/share/zoneinfo/${TZ} /etc/localtime、echo ${TZ} > /etc/timezone,再加ENV TZ。少任何一步在某些场景下都可能不生效。另外 JVM 还有个-Duser.timezone=启动参数可以作为兜底,但那属于应用层的事,基础镜像里还是把系统层做对更靠谱。
顺带说一个观察:某些数据库驱动会把时间戳按 JVM 默认时区解读,容器时区错了,存进去的时间就整体偏移,而且这个错不会立刻暴露,等业务方核对报表才发现。所以时区这件事,验证成本低、出错代价高,值得在基础镜像里一次做对。
6.4 启动阶段的 DNS 解析慢与随机数阻塞
两个表现相似但成因完全不同的启动卡顿。
DNS 解析慢。现象是应用启动时卡在某个外部服务连接上,几十秒后才超时成功,ping和curl却都正常。这类问题在 alpine 上有个特定成因:musl 的名字解析走的是静态编译进 libc 的路径,与 glibc 的 NSS 插件机制不同,对/etc/nsswitch.conf的处理有自己的逻辑,在某些 DNS 配置下会出现较长的超时重试。
快速验证方法:在容器里跑一个纯 Java 的解析测试,看耗时。如果是解析慢,可以在 JVM 侧调整 DNS 缓存策略,把正向和负向缓存时间显式设短,避免一次失败被长期记住:
java -Dsun.net.inetaddr.ttl=30 \ -Dsun.net.inetaddr.negative.ttl=0 \ -Djava.net.preferIPv4Stack=true \ -jar app.jar最后那个preferIPv4Stack是另一类常见诱因——容器网络没开 IPv6,但 JVM 优先尝试 IPv6 地址,每个连接都要等一次失败回退。这个参数会把整个问题绕过去,代价是放弃 IPv6 支持,内网环境通常可以接受。
随机数阻塞。现象是启动极慢但最终能起来,jstack看到线程卡在SecureRandom初始化上。老版本 JDK 默认从/dev/random读熵,容器环境熵池小,就会阻塞。新版本 JDK 已经改用/dev/urandom,但如果你用的是较老的运行时,可以显式指定:
java -Djava.security.egd=file:/dev/urandom -jar app.jar注意路径写成/dev/urandom而不是/dev/./urandom,后者是老 JDK 上绕开某些检查的历史写法,新版本不需要了。
最后分享一个小习惯:我在基础镜像里会预留一个JAVA_OPTS环境变量的注入位置,业务镜像通过ENV JAVA_OPTS="..."覆盖,CMD写成sh -c "exec java $JAVA_OPTS -jar app.jar"。但更推荐的做法是让 CMD 保持 exec 数组形式,JVM 参数直接在数组里写死,避免 shell 展开带来的信号处理问题和参数注入风险。这点取舍看你团队对配置灵活性的需求有多强,我个人的偏好是基础镜像保持干净,参数在业务镜像里显式写清楚,出了事好定位。