资讯详情

资讯详情

用Dockerfile自制JDK 1.8镜像:环境统一与容器化部署实战

做 Java 开发的同学估计都碰到过同一个问题换台机器、部署到测试环境最怕的就是 JDK 版本对不上。与其让每台服务器各自装一套环境不如直接用 Docker 做一个“一次构建、处处运行”的 JDK 1.8 镜像。我平时最常用的方案就是写一个 Dockerfile从官方商业版 JDK 1.8 安装包开始做成一个干净、可控、带运行环境的镜像。这篇文章就是把这个过程从头到尾拆开聊包括基础镜像怎么选、安装包怎么处理、Dockerfile 每一行怎么取舍以及我实际踩过的坑。1. 为什么我选择用 Dockerfile 装 JDK 1.8而不是直接 pull1.1 开发环境不统一的痛点很多人第一反应是JDK 装起来多简单解压 tar 包配一下 PATH 就完事了。这句话放在十年前没错但放到现在容器化部署越来越普遍环境不统一带来的问题比装环境本身更麻烦。举个例子开发机上 JDK 8u202测试机上 JDK 8u351生产机上还跑着系统自带的 OpenJDK 1.8。日常开发没什么感觉一旦出现只在某一个环境才能复现的诡异问题所有人就只能围着那台机器来回折腾。用 Docker 把 JDK 环境固化成镜像等于把“安装过程”和“使用过程”彻底分开。你在本地把镜像构建好推送到镜像仓库之后不管到哪台机器pull 下来跑容器就是同一个运行环境。这一点对 Java 项目特别重要因为 Java 的运行时行为对 JDK 版本、补丁级别、字符集、系统时区都很敏感。从根源上消除环境差异比事后让运维去逐台对齐要靠谱得多。另外把 JDK 装进 Docker 还有一个隐藏好处宿主机本身不需要装任何 JDK。以前为了在 CI 机器上编译或者跑一个辅助脚本经常得在服务器上执行 apt install 或者 yum install装多了就有一堆残留的依赖和版本冲突。容器化之后宿主机始终保持干净JDK 相关的所有文件都在镜像里需要就起一个容器不需要就删掉没有任何系统污染。1.2 Oracle JDK 和 OpenJDK 镜像的相似与区别现在官方镜像仓库里有现成的 openjdk:8 系列很多人问何必自己写 Dockerfile。这里要先说清楚OpenJDK 和商业版 JDK 1.8 在大多数日常 API 上没有区别但有几个点仍然值得考虑。第一如果你所在的项目有明确的软件成分清单要求或者客户指定必须使用某个版本的官方 JDK那直接 pull 开放版镜像就不符合要求必须用官方安装包自己做镜像。第二部分老项目依赖一些第三方组件在开放版 JDK 和官方版 JDK 上的表现可能略有差异这种差异虽然概率低但出了问题排查成本很高。与其等到生产事故不如一开始就锁死目标版本。我并不是说不能直接用 openjdk 基础镜像。它是很好的选择体积小社区维护及时尤其是 openjdk:8-jre-alpine 这种镜像很多轻量服务都在用。但如果你明确要用官方商业版 JDK 1.8那就绕不开手动制作这一步。原因很简单官方二进制包的授权页面和下载方式没法直接被你写进一个无人值守的构建脚本里必须先准备一个本地安装包再通过 Dockerfile 把它COPY进镜像。1.3 为什么要“自制”而不是依赖现成镜像自己做镜像看起来很麻烦但它的价值集中在环境可控、版本可追溯、构建可离线复现这三件事上。我有几套内部服务的部署流程生产环境无法直接访问外网如果构建镜像时需要临时从公网拉取依赖整个构建就会卡住。自己准备的 JDK 安装包可以放到内网的构件仓库里Dockerfile 里只依赖本地文件构建过程完全离线也不会因为外网下载源抽风而失败。再一个就是补丁管理。JDK 1.8 的更新版本很多从 8u20 到 8u351 都有实际项目不一定跟最新而是跟着企业内部的合规版本走。自制镜像可以把版本号、构建时间、基础镜像信息都固化在镜像标签和 Dockerfile 注释里以后审计的时候一眼就能看到当前运行的是什么版本。直接 pull 现成镜像的另一个问题是你没法完全控制镜像底层的操作系统补丁不一定能通过某些安全扫描。所以自制镜像这件事不只是技术洁癖更是团队规范化的一部分。2. 准备工作先想清楚几个关键选择2.1 基础镜像Alpine、Ubuntu、CentOS 怎么选写 Dockerfile 之前第一步不是下载 JDK而是想清楚用哪个基础镜像。很多人潜意识里觉得 Alpines 最小就选它做底结果装完 JDK 一启动发现各种奇怪问题。JDK 1.8 官方二进制包默认依赖 glibc而 Alpine 用的却是 musl libc。我不否认在 Alpine 上装 JDK 1.8 是可行的不少人也这么干但代价是你得额外安装 gcompat、libc6-compat 这类兼容层还要处理时区、字体等一堆边角问题。在存量维护项目里我不建议为了省那几十兆镜像体积去冒险。Ubuntu Server 和 Debian 系是我使用多年下来最顺手的基础镜像。它们基于 glibcJDK 8 解压就能跑apt 包管理器里面也有 tzdata、fontconfig、ca-certificates 这些配套组件安装起来非常方便。我的习惯是用 Ubuntu 20.04 做 JDK 1.8 镜像的基础因为 20.04 还在生命周期内glibc 版本和 JDK 8 的兼容性也经过了大量验证。如果你确实在意体积个人觉得在不影响运行的前提下后续可以通过只拷贝 jre 子目录来做瘦身。CentOS 7 和 CentOS 8 现在已经逐渐退场除非项目本身有硬性的发行版要求否则我不建议在这上面继续投入。如果你所在的运维团队已经统一以某一种发行版作为内部标准比如以 Debian 系为主那就跟着团队标准走不必追求绝对一致。最重要的是在选型时把“JDK 8 能不能在这个基础镜像上原样运行”放在第一位把“体积小不小”放在后面。2.2 安装包从哪来以及授权和校验Dockerfile 里写一行RUN curl -O https://xxx/jdk-8u202-linux-x64.tar.gz就能下载很多教程是这样写实际操作里有几个问题。首先是授权问题。JDK 1.8 官方二进制包的下载页面需要同意许可协议直接给一个固定 URL 并不总是有效而且部分较新版本对商业使用有更严格的限制。不要把网络下载这种事硬塞进 Docker 构建流程更稳妥的做法是事先通过正规渠道拿到安装包放到你的构建目录或内部构件库里安装包允许的情况下再在 Dockerfile 里直接 COPY。拿到 tar.gz 之后先做 SHA-256 校验。有些人下载完不看哈希解压出来能用就闷头构建一旦安装包在传输过程中被篡改或者损坏后面排查问题会非常痛苦。校验的命令很简单在 Linux 下直接sha256sum jdk-8u202-linux-x64.tar.gz然后和官网给出的摘要对比。如果你要写进 Dockerfile也可以把期望的哈希值写死在 RUN 步骤里让构建过程自动校验内容不匹配就中断这样能够确保镜像内容完全可控。2.3 Dockerfile 的目录结构与路径规划建议单独开一个目录放着这次的构建文件不要和业务代码混在一起。我的习惯是建一个类似jdk8-image-builder的目录然后把 Dockerfile、.dockerignore 和 JDK tar 包放进去。目录结构长这样jdk8-image-builder/ ├── Dockerfile ├── .dockerignore └── jdk-8u202-linux-x64.tar.gz.dockerignore的作用很多人会忽略。Docker 构建时会把整个构建上下文发给守护进程如果你这个目录里有.git、日志、临时文件构建速度会明显变慢甚至把一些无关文件带到镜像里。一个基础版本的 .dockerignore 至少要排除掉.git、*.log、tmp/这样构建上下文始终保持精炼。至于安装路径我一般固定在/usr/local/jdk1.8.0_202然后通过环境变量 JAVA_HOME 指向它。路径定下来之后后面所有服务、脚本、启动命令都用 JAVA_HOME 和环境变量引用不写死具体 bin 路径避免以后升级 JDK 小版本时还要到处改配置。3. 正式编写 Dockerfile核心细节与步骤拆解3.1 一个可以直接上手的 Dockerfile这里给一份我目前在生产环境使用过的 Dockerfile思路很直接选 Ubuntu 20.04 作为基础镜像先装系统依赖再拷贝 JDK 安装包解压到指定目录清理临时文件最后创建一个普通用户来运行 Java 程序。FROM ubuntu:20.04 ENV DEBIAN_FRONTENDnoninteractive \ TZAsia/Shanghai \ LANGC.UTF-8 RUN apt-get update apt-get install -y --no-install-recommends \ tzdata \ ca-certificates \ curl \ fontconfig \ libx11-6 \ rm -rf /var/lib/apt/lists/* ENV JAVA_HOME/usr/local/jdk1.8.0_202 \ PATH/usr/local/jdk1.8.0_202/bin:$PATH COPY jdk-8u202-linux-x64.tar.gz /tmp/jdk-8u202-linux-x64.tar.gz RUN set -eux; \ echo 期望的sha256值 /tmp/jdk-8u202-linux-x64.tar.gz | sha256sum -c -; \ mkdir -p $JAVA_HOME; \ tar --strip-components1 -zxvf /tmp/jdk-8u202-linux-x64.tar.gz -C $JAVA_HOME; \ rm -f /tmp/jdk-8u202-linux-x64.tar.gz RUN groupadd --system app useradd --system --gid app app mkdir -p /app chown -R app:app /app USER app WORKDIR /app CMD [java, -version]这段 Dockerfile 不是最精简的但稳定性优先。里面设置noninteractive是为了避免 tzdata 在安装时弹交互提示设置TZAsia/Shanghai是为了容器内默认时区对齐LANGC.UTF-8是为了减少中文日志乱码概率。后面我会逐层解释为什么这样写。3.2 每一层为什么这么写这里要看懂前面这个 Dockerfile 的几处关键取舍。首先apt-get install 和清理动作放在同一个 RUN 里。原因是 Docker 的镜像层是基于文件系统快照的如果分两步跑先安装再清理清理掉的文件虽然不再存在但它们在镜像层里已经形成了一层“被删除的痕迹”镜像体积并不会变小。只有把安装和清理放同一个 RUN 步骤里最后生成的层才是一个干净状态。这就是很多人镜像里残留了大量 apt 缓存文件的原因。其次JDK 的安装包的 COPY、解压、清理同样合并成一个 RUN。我先用 COPY 把安装包从构建上下文移动到 /tmp然后 RUN 里做哈希校验、解压、删除压缩包。--strip-components1这个参数的作用是去掉 tar 包里的顶层目录因为jdk-8u202-linux-x64.tar.gz解压出来通常有一个jdk1.8.0_202顶层目录加了这个参数后直接把内容释放到$JAVA_HOME目录里避免出现/usr/local/jdk1.8.0_202/jdk1.8.0_202这种重复路径。关于ENV PATH有人问为什么不用RUN export PATH...。这其实是一个很关键的概念RUN export只对当前层有效不会成为最终镜像的环境变量。镜像启动后的进程如果本身不是通过 bash 登录式 shell 启动运行java -version时根本找不到 java 命令。Dockerfile 里的ENV会被写入镜像的配置在容器启动时保证环境变量已存在这就是环境固化。3.3 别忘了时区、字体、CA 证书这些“隐藏依赖”如果只是跑一个无界面的 Java 命令行工具那不用管字体。但不少 Java 应用会生成 PDF、图片验证码、报表图表这时候 Java 底层需要访问一些字体库和 native 库。基础镜像里默认不一定有运行时就可能报Unable to load font一类的错。所以我在基础依赖里预装了 fontconfig 和 libx11-6这两个包能解决大多数和字体、图形库相关的启动异常。时区是我最初容易忽略的问题。Java 应用的定时任务、日志时间戳如果默认走 UTC部署到国内之后会发现日志时间比本地时间慢 8 小时。Docker 容器默认继承宿主机的/etc/localtime但很多时候基础镜像里根本没有 localtime或者被设置为 UTC。装 tzdata 并设置TZAsia/Shanghai是最稳妥的方式它会把容器的本地时间固定到东八区不受宿主机影响。CA 证书也很重要。许多 Java 应用要请求 HTTPS 接口JVM 有自己的密钥库但系统层面的ca-certificates包提供的是根证书信息。如果基础镜像缺少这些证书哪怕目标接口是正规 HTTPS也会报 SSL 证书校验失败。所以这行ca-certificates不是可选项而是很多网络场景的必需品。4. 构建镜像与运行环境的验证4.1 构建参数和镜像定型Dockerfile 写完之后进入构建阶段。命令本身很简单但要给镜像一个清晰的标签。我建议不要只打一个 latest而是把 JDK 小版本号放在标签里比如app/jdk8:1.8.0_202再额外打一个app/jdk8:latest方便开发环境引用。构建执行docker build -t app/jdk8:1.8.0_202 . docker tag app/jdk8:1.8.0_202 app/jdk8:latest构建过程中如果报错先看是哪一层失败。Docker 的构建日志里会明确提示Step 4/8还是Step 6/8基本能快速定位。如果你想确认每个镜像层的体积和命令内容可以用docker history查看。我经常用这条命令检查镜像里有没有掺入多余的东西docker history app/jdk8:1.8.0_202 --no-trunc看到 RUN 命令里如果出现了大段的下载和安装就能意识到体积可能控制得不好。构建成功只是第一步真正要确认的还是容器里能否正常运行 Java。4.2 用一个小程序验证完整链路镜像构建好了先跑java -version是固定动作docker run --rm app/jdk8:1.8.0_202 java -version正常会看到如下输出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)看到这一行说明 JDK 主路径没问题。但还不够我习惯顺手验证一下 javac 和 jar 这两个工具是否可用因为有的瘦身镜像我测试时发现javac没了导致应用启动阶段做动态编译时会报错。简单执行docker run --rm app/jdk8:1.8.0_202 javac -version再进一步把宿主机上的 Hello.class 塞进容器跑一下。假设你在宿主机上放一个Hello.java内容只是打印 java.version那么先用容器里的 javac 编译。这里有个很实用的点即使宿主机没装 JDK你也能用刚构建的 JDK 8 镜像完成编译等于临时借用了容器作为编译环境。mkdir -p /tmp/javatest cat /tmp/javatest/Hello.java EOF public class Hello { public static void main(String[] args) { System.out.println(java.version System.getProperty(java.version)); } } EOF docker run --rm -v /tmp/javatest:/src app/jdk8:1.8.0_202 javac -d /src /src/Hello.java docker run --rm -v /tmp/javatest:/app app/jdk8:1.8.0_202 java -cp /app Hello这样验证的是最终运行时链路包括 JAVA_HOME、PATH、工作目录权限。如果USER app访问不了挂载目录这里就会暴露出来。4.3 体积瘦身和镜像版本管理很多人看到 Ubuntu 基础镜像第一反应还是会问能不能再小一点。能。最直接的思路是如果你确定只需要运行而不需要编译那就只拷贝 JDK 目录里的jre子目录不要整个 JDK。JDK 的 jre 目录包含了 java、keytool 等运行时核心命令体积能省不少。但要注意有些框架在启动时仍会寻找JAVA_HOME/bin下的命令甚至动态调用 javac所以这个方案并不适合所有项目。还有一种优化方式是把基础镜像换成更精简的 Debian 变体或者以openjdk:8-jre作为基础再做增量修改。但这会牺牲一些基础软件包的可定制性。我个人认为如果不是对接存储和体积要求特别严格不建议为了几十兆的体积去牺牲稳定性和维护成本。版本管理方面镜像标签里一定要带上完整的 JDK 补丁版本同时用 CI 的构建时间或提交号做辅助标记。比如app/jdk8:1.8.0_202-20240601一看就知道构建日期。这样做的好处是当团队里有多个镜像仓库、多个环境时不会出现“我知道是 JDK 8但不知道是哪个补丁版本”的模糊情况。5. 常见问题与排查技巧5.1 运行时常见的几类报错这里整理一下我在实际部署中遇到过的典型问题和排查方向很多坑并不是 JDK 本身的问题而是 Dockerfile 写法或者说基础镜像不完整导致的。报错表现常见原因排查与解决方向容器启动后立刻退出CMD 里的命令不存在或路径不对先用docker run --rm 镜像 命令行单独执行确认Error: Could not find or load main classCLASSPATH 未设置或 WORKDIR 不对检查-cp参数和 jar 包路径避免依赖宿主机的 CLASSPATHUnsupported major.minor version 52.0编译时用了更高版本的 JDK运行环境是 JDK 8统一编译用 JDK 8或者用javac -source 1.8 -target 1.8/lib64/libc.so.6: version GLIBC_2.33 not found基础镜像 glibc 过旧与 JDK 或应用依赖不匹配升级基础镜像版本比如从 18.04 升到 20.04Unable to load native library缺少系统 native 依赖比如 fontconfig、libx11在 Dockerfile 里补齐libx11-6、fontconfig容器内 HTTPS 请求失败SSL 证书错误基础镜像缺少 CA 证书安装ca-certificates必要时把证书导入 JVMkeytool 报证书时间无效容器时间不对或者系统时区未固定安装 tzdata设置 TZ 环境变量5.2 时区、编码与中文依赖问题时区问题是最隐蔽的坑。基础镜像默认是 UTC如果你只设置了ENV TZAsia/Shanghai但系统里没有装 tzdata这个环境变量在某些发行版上并不生效。JDK 应用读取时间时还会结合/etc/localtime来判断。所以正确的做法是先安装 tzdata再设置 TZ 环境变量这样容器内的 glibc 层和 JVM 层参数才会对齐。中文乱码分成两类。一类是控制台输出乱码通常是文件编码规则问题可以在启动参数上增加-Dfile.encodingUTF-8并在 Dockerfile 里设置ENV LANGC.UTF-8。另一类是图片上的中文变成方块这种不是编码问题而是系统缺少中文字体。处理方式是安装包含中文的字体包再加上 fontconfig让 Java 的图形渲染链路能找到字体文件。很多报表项目里的字体问题最后都是靠补一个字体包解决的。5.3 我踩过的 Docker 层缓存坑最后说一个很容易被忽略但实际影响很大的问题Docker 构建缓存的有效利用。一开始我写 Dockerfile 习惯把 COPY 放在前面后面才安装系统依赖。结果每次更新 JDK 补丁版本哪怕只改了压缩包内容前面的 COPY 层就失效了后面所有层包括 apt 安装层全部重新执行。每次构建都要重新下载系统包网络一慢就非常煎熬。后来把 Dockerfile 的顺序调成了“先装系统依赖再 COPY 安装包”因为系统依赖层很少变化COPY 层的内容一变只有后面的解压层会重新跑构建速度明显提升。另一个相关技巧是调试时不要轻易docker build --no-cache这会放弃所有层缓存。想强制重建某一段通常只需要改 Dockerfile 里对应步骤前的一个注释或者在构建命令里传一个额外的 build-arg 作为缓存标记。还有.dockerignore没写好会导致构建上下文巨大特别是把整个项目目录当构建上下文时本地提交记录、日志、临时文件全都会被打包传输到 Docker 守护进程构建速度肉眼可见地变慢。这个问题排查起来不复杂执行docker build时如果看到类似Sending build context to Docker daemon 1.023GB的输出基本就是上下文太大了。回头补好.dockerignore再构建一次上下文能缩到几十兆。对我来说一个可靠的 JDK 1.8 镜像不只是简单地把安装包装进去它本质上是一个包含系统依赖、时区、字符集、用户权限和缓存策略的小型基础平台。我个人的习惯是每升级一次补丁版本就完整跑一遍构建、版本验证、Hello 程序加载三件套确认无误后再推送到内部镜像仓库。这套流程看似繁琐但它能保证我在任何新环境里都能用最短的时间拉起一个行为一致的 Java 运行底座少踩很多环境相关的坑。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →