前阵子帮一个合作方接手老项目,对方环境里必须用 Oracle JDK 1.8 跑一套好几年前的中间件。当时第一反应是找现成的镜像,结果翻了一圈没找到既干净又能装进公司私有仓库的 Oracle JDK 组合方案——openjdk 镜像倒是多,但老中间件里有些加密相关的库和字符集行为,在 OpenJDK 和 Oracle JDK 上表现就是不一样。最后只能老老实实用 Dockerfile 自己拼一个运行环境。
这篇文章就把整个搭建过程梳理一下:从 Oracle JDK 1.8 的获取、基础镜像选型,到 Dockerfile 逐行怎么写、构建验证怎么做,再到容器里跑 JDK 最常见的时区、乱码、瘦身这些坑。内容适合刚接触 Docker 的 Java 开发者,也适合被老项目绑定在 JDK 8、又不想在每台机器上手动折腾环境的人。
1. 为什么我坚持用 Docker + Oracle JDK 1.8 这套组合
1.1 环境一致性带来的好处远超想象
先把最直接的好处说透:同一套 Dockerfile 构建出来的镜像,在开发机、测试机、生产服务器上跑出来的 JDK 环境几乎完全一致。以前在物理机或虚拟机上装 JDK,最怕的就是“我这能跑、你那不行”——有人装的是 jdk-8u191,有人装的是 8u202,有人系统里有自带的 OpenJDK 把 PATH 优先级改了,光排查这些差异就能耗掉半天。
用 Docker 之后,JAVA_HOME、PATH、版本号、时区、字符集这些全部固化在镜像里。后面做交付时,我只需要把镜像导到目标服务器,run 起来就是一个可复现的 Java 运行环境,省去了“现场装 JDK + 配置环境变量 + 改系统参数”一整条链路。
1.2 Oracle JDK 1.8 和 OpenJDK 镜像的实际差异
很多人会问:不就用个 Java 嘛,openjdk:8-jdk-alpine 拉下来就是,何必自己写 Dockerfile?这话在一般场景下没错,但遇到老项目就有差别了。
我这次要跑的中间件,部署文档里明确写着“基于 Oracle JDK 1.8 验证”。实测下来主要有两个差异点:一是旧版中间件会扫描 JDK 安装目录里的特定文件结构,OpenJDK 在某些发行版里的路径和文件布局不完全一样;二是 Oracle JDK 自带的加密策略文件、字体相关的组件,在 OpenJDK 的瘦身镜像里经常没有,导致程序启动时不报错、运行到某个功能才抛异常。
当然,如果你是全新项目,没有历史包袱,直接用 openjdk 官方镜像或者更现代的发行版没有任何问题。自己写 Dockerfile 是在“必须锁定 Oracle JDK 1.8”这个前提下才推荐的做法。
1.3 什么情况下不需要自己造轮子
这里也说句公道话,不是所有场景都需要自己从零写 Dockerfile。如果你只是给普通 Spring Boot 应用做容器化,用一个长期维护的基础镜像,或者用平台提供的构建包,都比自己维护一个 JDK 镜像要省心。自己构建 JDK 镜像的价值,主要体现在老项目兼容性要求高、需要把特定小版本固化进企业镜像仓库、或者要同时预装字体、时区、监控 Agent 等额外组件这些场景。
我自己判断要不要自己写 Dockerfile,就看一条:项目里有没有官方镜像覆盖不了的“固定要求”。有,就自己构建;没有,用现成的更香。
2. 动手写 Dockerfile 之前:Oracle JDK 的获取、版本和基础镜像选型
2.1 官方下载入口和授权边界
Oracle JDK 8 的安装包不能直接用 wget 裸拉,官网下载页需要先勾选“接受许可协议”才会给真实链接。早期版本的文件名通常会在这里看到两种格式:
- jdk-8u202-linux-x64.rpm
- jdk-8u202-linux-x64.tar.gz
这里需要提醒一句:Oracle JDK 8 在 2019 年 1 月之后就不再提供免费商用更新,8u202 是很多人默认的“最后免费版本”。生产环境商用前,建议确认清楚授权边界。技术上讲,这是构建镜像绕不开的第一步。
下载时优先从官网入口操作,把安装包放到一个单独的目录,比如./jdk/,方便 Docker 构建时拷贝。不要图省事从第三方博客、网盘拉安装包,那些经过二次打包的版本里可能夹带不明文件,安全风险很高。
2.2 选 rpm 还是 tar.gz:决定了 Dockerfile 的安装方式
第一次写这类 Dockerfile 的人,最容易在三分钟内做两个决定:基础镜像用哪个、JDK 安装包选哪种格式。这两件事是绑定的。
如果你选 CentOS/Rocky 这类 RHEL 系镜像,用 rpm 安装包最顺,一条yum localinstall或者rpm -ivh就完事,装完的路径规范,还自动处理了 alternatives。如果你选 Debian/Ubuntu 系镜像,系统里没有 rpm 命令,就需要用 tar.gz 解压方式,整个环境变量也更灵活。
我自己实际更倾向于 tar.gz 方案:不依赖系统包管理器,后续无论换成哪个基础镜像,Dockerfile 里主体逻辑都能复用。
2.3 基础镜像选 CentOS 还是 Debian
官方 openjdk 镜像已经很精简了,但自己做 JDK 基础镜像时,我不建议直接拿centos:7这种大而全的镜像,也不建议一开始就上 alpine——JDK 8 官方包没有原生支持 alpine 的 musl 版本,硬塞会额外引入兼容问题。
| 选型 | 优点 | 需要注意 |
|---|---|---|
| CentOS 7 系 | 国内企业环境熟悉,yum 装字体、时区方便 | 系统包偏旧,基础镜像体积偏大 |
| Debian 系 | 镜像小,包管理灵活,Docker 生态主流 | 没有 rpm,JDK 要用 tar.gz 方式 |
| alpine | 体积极小 | JDK 8 兼容性折腾,不建议新手尝试 |
我个人现在用得比较多的是 Debian 系(比如debian:bullseye-slim)。原因是体积小、社区资料多、apt安装字体和时区数据也直观。如果你所在公司已经有统一维护的基础镜像,优先复用内部的,不要为了“新”而引入没人维护的新镜像。
3. Dockerfile 逐行拆解:从拷贝安装包到固化环境变量
3.1 tar.gz 解压方式的完整 Dockerfile
下面这个 Dockerfile 是我在一套模拟项目里实际用过的,目标是构建一个具备 Oracle JDK 1.8 运行环境的基础镜像。文件名叫Dockerfile.jdk8,和jdk/目录平级。
# 基础镜像:选一个体积适中、包管理顺手的系统镜像 FROM debian:bullseye-slim # 安装必要系统工具:tzdata 用于时区,fontconfig 用于中文字体 RUN apt-get update \ && apt-get install -y --no-install-recommends tzdata fontconfig \ && rm -rf /var/lib/apt/lists/* # 拷贝 JDK 安装包到镜像内 # 这里用 COPY 而不是 ADD:ADD 对 tar.gz 会自动解压,行为隐式,出现问题时不好排查 COPY jdk/jdk-8u202-linux-x64.tar.gz /tmp/ # 解压到 /usr/local/java 目录 RUN mkdir -p /usr/local/java \ && tar -xzf /tmp/jdk-8u202-linux-x64.tar.gz -C /usr/local/java \ && rm -f /tmp/jdk-8u202-linux-x64.tar.gz # 配置环境变量 ENV JAVA_HOME=/usr/local/java/jdk1.8.0_202 ENV PATH=$PATH:$JAVA_HOME/bin ENV LANG=C.UTF-8 # 默认执行 java -version,方便构建后快速验证 CMD ["java", "-version"]这个 Dockerfile 的关键点在于:每一行 RUN 都尽量把安装和清理放在同一个层里,避免安装包残留在镜像层中导致体积膨胀。如果你不放心解压后的目录名,可以先构建一次镜像,再用docker run --rm <镜像名> ls /usr/local/java确认实际目录名是不是jdk1.8.0_202——不同小版本的目录名会有细微差别。
3.2 rpm 安装方式的版本:CentOS 系的基础镜像
如果公司内部统一用 CentOS/Rocky 系镜像,也可以用另一种方式。思路基本相同,区别在安装命令:
FROM centos:7 RUN yum install -y tar \ && yum clean all COPY jdk/jdk-8u202-linux-x64.tar.gz /tmp/ RUN mkdir -p /usr/local/java \ && tar -xzf /tmp/jdk-8u202-linux-x64.tar.gz -C /usr/local/java \ && rm -f /tmp/jdk-8u202-linux-x64.tar.gz ENV JAVA_HOME=/usr/local/java/jdk1.8.0_202 ENV PATH=$PATH:$JAVA_HOME/bin CMD ["java", "-version"]如果你拿的是 rpm 包,也可以这样:
COPY jdk/jdk-8u202-linux-x64.rpm /tmp/ RUN yum localinstall -y /tmp/jdk-8u202-linux-x64.rpm \ && rm -f /tmp/jdk-8u202-linux-x64.rpm \ && yum clean allrpm 方式装完,JDK 默认安装在/usr/java/下,JAVA_HOME就要改成对应路径,例如/usr/java/jdk1.8.0_202-amd64。注意yum localinstall会走一遍依赖解析,比直接rpm -ivh更稳妥,尤其是 JDK 偶尔会依赖libgcc、glibc这些基础库。
3.3 JAVA_HOME、PATH、CLASSPATH 到底该怎么设
这里专门说一下环境变量,因为经常有人照着网上一顿抄,把 CLASSPATH 设成自定义值,结果程序跑起来 ClassNotFound。
- JAVA_HOME:必须指向实际 JDK 所在目录,给 Maven、Gradle、Tomcat 等外部工具识别。
- PATH:追加
$JAVA_HOME/bin,让java、javac命令可用。 - CLASSPATH:JDK 1.8 里默认就能定位到
lib下的核心包,一般不用手写。老项目如果依赖tools.jar,多数中间件也会自动拼接。手动设置反而容易覆盖掉默认行为。
所以在 Dockerfile 里,我通常只设置 JAVA_HOME 和 PATH,不主动设置 CLASSPATH,非要设就保持.开头:
ENV CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar如果项目里所有 Java 进程都依赖同一个 CLASSPATH,那放这里没问题;否则更推荐在每个服务的启动脚本里单独管理,别固化进基础镜像。
3.4 CMD 和 ENTRYPOINT 的差别:验证镜像时最容易困惑的点
基础镜像我一般用CMD ["java", "-version"],这样直接docker run <镜像名>就能看到 JDK 版本。但如果后续有人用这个镜像做基础镜像,继承的 CMD 会被子镜像覆盖,不影响使用。
对于业务镜像,则更适合用ENTRYPOINT固定启动命令,比如:
ENTRYPOINT ["java", "-jar", "/app/app.jar"]区别在于docker run后面追加的参数:CMD 会被追加的参数替换,ENTRYPOINT 会把追加部分作为参数传进去。基础镜像阶段建议先别纠结,用 CMD 验证版本最直接。
4. 构建、验证与二次复用:把 JDK 镜像用起来
4.1 构建命令和常见参数
构建时我习惯给镜像带上明确的标签,不要用默认 latest 到了生产环境搞不清版本:
cd 项目目录 docker build -t jdk8-oracle:1.8.0_202 -f Dockerfile.jdk8 .如果你把 JDK 安装包放在了.gitignore或.dockerignore之外的地方,容易把几个 GB 的无关文件作为构建上下文传进来。建议在项目根目录加一个.dockerignore文件:
jdk/*.tar.gz jdk/*.rpm .git target *.md这里要说明一下:我把整个jdk目录里的压缩包忽略,是因为它们已经被复制进镜像或作为外部文件管理,没必要再进构建上下文,否则每构建一次都要打包传输一次上百兆的文件,浪费时间也影响 CI 速度。
4.2 运行容器验证 JDK
构建完成后,第一时间验证:
docker run --rm jdk8-oracle:1.8.0_202正常会输出类似:
java version "1.8.0_202" Java(TM) SE Runtime Environment (build 1.8.0_202-b08) Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)如果输出不对,比如弹出 “not found” 或者 “JAVA_HOME is not defined correctly”,先不要重写 Dockerfile,进容器里手动排查:
docker run -it --rm jdk8-oracle:1.8.0_202 bash echo $JAVA_HOME ls /usr/local/java/ java -version这种交互式排查比反复改 Dockerfile 快得多。我第一次写的时候,就是解压后目录名带了个-amd64后缀,JAVA_HOME 配错,进容器一看目录名就明白了。
4.3 基于这个 JDK 镜像再构建业务镜像
基础镜像验证没问题后,业务镜像就很简单了。比如一个模拟的 Spring Boot 应用,我通常会加一层时区配置和启动参数:
FROM jdk8-oracle:1.8.0_202 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /app COPY myapp.jar /app/ EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]如果是用 Maven 多阶段构建出来的 jar,也可以把编译阶段独立出来。这里只写一个最简单的多阶段示例,前提是前面已经构建好了jdk8-oracle:1.8.0_202基础镜像:
# 第一阶段:编译 FROM maven:3.8-jdk-8 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 第二阶段:取编译产物打包 FROM jdk8-oracle:1.8.0_202 WORKDIR /app COPY --from=builder /build/target/myapp.jar . ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]这里多阶段构建的本质是让最终镜像只包含运行环境和 jar,不携带编译工具链。如果你们项目已经有统一构建流程,可以不套用这一步。
4.4 版本锁定:不要用浮动标签
自己在 Dockerfile 里写定基础镜像时,最忌讳用FROM centos:latest、ENV JAVA_VERSION=8u202这种“看着也行”的写法。基础镜像一旦更新,可能底层 glibc 版本变了、包管理源变了,下次docker build --no-cache构建出来的镜像行为和上次不一样。
所以我通常会在 Dockerfile 注释里写明三个信息:JDK 小版本、基础镜像的精确版本、构建日期。类似:
# JDK: Oracle JDK 1.8.0_202 # Base: debian:bullseye-slim # Build: 2025-xx-xx这个习惯在排查问题时价值很大。后期镜像出了问题,一眼就知道该找哪一层的问题。
5. 容器里跑 JDK 最容易踩的四个坑
5.1 时区:默认 UTC,日志时间差 8 小时
Java 应用默认读取系统时区,而 Docker 基础镜像默认一般是 UTC。你本地跑得好好的,一容器化日志时间全变了。解决方式有两种:
第一种,在基础镜像里固化时区:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone第二种,在启动参数里指定 JVM 时区。适合不方便改镜像的情况:
java -Duser.timezone=Asia/Shanghai -jar app.jar我实际操作中也会直接给容器加环境变量跑:
docker run -e TZ=Asia/Shanghai -d myapp但这要求基础镜像里已经装了 tzdata,否则时区数据文件都不存在,设置 TZ 也不生效。所以更可靠的做法是在基础镜像里一步装好。
5.2 中文乱码和字体问题
JDK 容器里跑报表、验证码、导出 PDF 等需要图形字体的功能时,经常会遇到两种情况:
- 日志中文乱码:多半是系统 locale 不是 UTF-8。
- 图形字体报错:比如
java.lang.Throwable: Problem creating image,多半是缺少 fontconfig 和字体包。
有效做法是在 Dockerfile 里统一处理:
ENV LANG=C.UTF-8 RUN apt-get update \ && apt-get install -y --no-install-recommends fontconfig \ && rm -rf /var/lib/apt/lists/*如果业务应用对字符集非常敏感,也可以在启动参数里显式指定:
java -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 -jar app.jar实际经验是:基础镜像提前装好 fontconfig,比每个业务镜像都去补要省事得多。有次我在某图像处理 Demo 里生成图片,弹了 “fontconfig error”,查了半天发现基础镜像里根本没装字体配置组件。
5.3 glibc 和动态库缺失:不要轻易上 alpine
JDK 8 官方包针对的是 glibc 体系,而 alpine 用的 musl libc。有些人为了镜像体积小,强行在 alpine 上装 glibc 兼容层,短期能跑,但真实场景里可能遇到 DNS 解析异常、加密库加载失败等奇怪问题。
如果你不是有精力长期维护这套兼容层的团队,老老实实留在 Debian/CentOS 系的 glibc 环境。镜像大几十兆不是大问题,稳定运行比省这点空间重要得多。
5.4 镜像瘦身:安装包要随装随删
第一次构建完镜像,我习惯docker history看一下每层大小,排查有没有无效的缓存文件留在镜像里。对 JDK 镜像影响最大的动作就两个:
- 安装包必须在同一个 RUN 里删除,否则即使后面写了
rm -f,删除动作发生在新层,前一层的安装包已经固化进历史层。 apt/yum的缓存列表也要清理,一般 Dockerfile 里加一句rm -rf /var/lib/apt/lists/*或yum clean all。
我习惯把解压、删除安装包、清理包管理器缓存放进同一个 RUN 指令,让这一层成为一次性的临时操作,避免把本地安装包带进下一层。
6. 补一个实用技巧:用启动脚本替代硬编码参数
最后补充一个我在实际业务里的小经验:如果你的 JDK 基础镜像可能被多个服务复用,别把全部启动参数固化在 Dockerfile 里。更好的做法是放一个简单的启动脚本,例如:
#!/bin/bash export JAVA_HOME=/usr/local/java/jdk1.8.0_202 export PATH=$JAVA_HOME/bin:$PATH JAVA_OPTS="${JAVA_OPTS:-}" exec java $JAVA_OPTS -jar "$@"镜像里保留脚本,业务方通过环境变量或命令行传参覆盖。这样基础镜像保持稳定,不同服务的 JVM 参数不需要重新构建镜像,一个环境变量就能切换。
这套做法我用了很久,最大的体会是:写 Dockerfile 安装 JDK 并不难,难的是把“每次都能稳定复现”这件事用文件固化下来。如果你也在维护老项目,建议把 JDK 安装包、Dockerfile、构建命令都放进同一个仓库,哪天需要重新出镜像,一条命令就能跑出来,不用再翻聊天记录找安装包在哪个网盘。