多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

自建自定义JDK Docker镜像,内网离线分发实战指南

自建自定义JDK Docker镜像,内网离线分发实战指南 最近帮朋友搞定了一套内网项目的Java运行环境几十台服务器没有外网每台机器装的JDK版本还不统一。有人用系统自带的OpenJDK有人手动解压了Oracle JDK 17还有人直接把项目依赖一起拷过去整个环境像个大杂烩。折腾几天后我决定换个思路把所有JDK做成Docker镜像按版本打包导出一份到任何一台机器只要docker load进去就能用。这个方案不算新鲜但真正实践下来绕过的坑还挺多尤其是“自定义JDK版本”和“导出到局域网”两个环节细节比想象中容易翻车。这篇就把从Dockerfile编写到内网分发的完整流程写出来顺便把那些踩过的坑都标出来。1. 为什么要自建JDK镜像而不是直接拉官方镜像1.1 什么场景下需要自定义JDK镜像我见过最多的是三类需求。第一类是内网隔离环境服务器不允许出外网但又要统一Java运行环境。这种情况下不可能一台台去装JDK运维工作量太大而且每台机器往往还残留着不同的历史配置。Docker镜像可以事先在一个可联网的环境里构建好再通过离线方式分发出去一台机器导入完就能直接用环境完全一致。第二类是版本锚定。官方镜像虽然好用但版本滚动特别频繁今天构建出来是17.0.8过几个月再拉一次可能就变成17.0.12了。对做版本审计的团队来说镜像内容不可控变化是非常大的风险。自建镜像把JDK版本写死保证同一个镜像标签在任何时间、任何机器上构建或拉取内容完全一致。这正是镜像不可变性带来的好处。第三类是镜像定制。很多企业希望基础镜像里不只是JDK还要有字体库、时区、证书、监控agent甚至预置公司公共配置。把这些打包成一个JDK基座镜像业务团队拿过去就不需要关心底层JDK细节直接叠加自己的业务代码就行。1.2 直接使用官方镜像的坑先说明一下不是说官方Temurin或OpenJDK镜像不能用而是它和“自定义”的需求有间隙。首先是体积问题官方镜像为了保证兼容性基础系统往往做得比较完整压出来动辄四五百MB。离线环境用U盘拷还好但要通过HTTP分发给几十台机器网络压力很明显。精简到极致的Alpine镜像倒是小但musl libc和字体缺失会带来新的兼容性问题。我的建议是在“够用的精简”和“兼容性好”之间找一个平衡点不要一味追求那个几十MB的小镜像。其次是目录结构。官方镜像的JDK安装路径在不同tag、不同发行版之间经常变化JAVA_HOME的位置并不固定。自建镜像把JDK固定在明确目录下业务团队用起来才省心出了问题也容易排查。1.3 自建镜像的整体思路整个流程总结下来其实就三步选定JDK发行版和版本号编写Dockerfile把JDK和系统环境封装成镜像最后用docker save或私有仓库把镜像分发到局域网。后面每一步我都会给出实操命令并且把关键位置标注出来。这套方案同样适用于其他语言运行时比如Python、Node只是JDK在容器里的兼容性细节更典型一些。2. 构建前的准备基础镜像、JDK包与Dockerfile规划2.1 基础镜像怎么选这一步经常被跳过但实际影响很大。常见基础镜像有下面几类我用一张表说清楚适用场景。基础镜像体积Java运行兼容性维护状态适用场景Ubuntu 22.04 LTS较大最好仍在维护生产基线、依赖字体/图形库Debian 12 slim中好仍在维护通用Java服务CentOS 7较小好已停止维护存量系统兼容不推荐新项目Alpine很小需注意musl libc仍在维护追求极小的非关键业务OracleLinux中好仍在维护传统企业、Oracle生态我自己的偏好是新项目直接选Debian slim或者Ubuntu兼容性好遇到问题网上资料也多。老项目如果必须模拟物理机环境才考虑CentOS 7。虽说CentOS 7已经停止维护了但企业内部还有大量存量服务器构建一个自定义JDK镜像分发过去是迁移改造过程中的常规操作不用一上来就要求人家全部换系统。2.2 JDK版本与发行版的选择“自定义JDK版本”拆开看一个是版本号一个是发行版。版本号我强烈建议选LTS长期支持版本。JDK现在是半年一个大版本但只有LTS才有长期更新。8仍是存量项目霸主11是分水岭17目前最主流21是较新的LTS。新项目我会优先用17或21老项目保留8也没问题。下面这套流程从8到21全部通用。发行版比版本号更容易被忽略。Oracle JDK运行时完整但许可条款比较麻烦团队做开源合规审计时经常要解释。开源的OpenJDK发行版里我比较常用这几个Eclipse Temurin也就是Adoptium项目社区主流免费商用友好优先推荐。Azul Zulu长期支持版本很全从JDK8到21都有对老硬件平台兼容性好。Amazon CorrettoAWS维护性能稳定下载源分布广。阿里Dragonwell和华为毕昇国内团队维护在信创和国产化场景里很常见。确定发行版之后再根据CPU架构选压缩包。现在服务器CPU主要分x86_64和aarch64两种JDK包和基础镜像的架构必须一致否则构建时没问题运行时直接报exec format error。这块后面会专门讲排查方法。2.3 Dockerfile结构的提前规划动手写Dockerfile之前先约定好目录和变量。我见过太多同学在宿主机上随便解压一个JDK再COPY进镜像到了容器里JAVA_HOME指向不知道哪里。现在定这几条规则JDK统一安装到/opt/java下用版本号带子目录比如/opt/java/jdk-17.0.8这样多个版本可以共存。环境变量用ENV JAVA_HOME和ENV PATH设置写在Dockerfile靠底部的位置避免被后续指令覆盖。JDK版本号用ARG传参不写死在代码里同一个Dockerfile要构建不同版本时直接换构建参数就行。下载好的JDK压缩包不拷进最终镜像解压后删除临时文件镜像能小不少。时区、字体、locale这类系统配置在同一份Dockerfile里一次性处理好。提前把变量规划好最大的好处是可复用。以后团队新增一个新版本时你只需要准备对应的JDK压缩包再重新执行一次构建命令就不需要改Dockerfile本体。3. 编写Dockerfile构建自定义JDK镜像3.1 一个可以直接跑的Dockerfile我用Debian 12 slim做基础镜像演示完整写法。假设JDK的tar.gz包已经下载到构建目录的soft目录下文件名类似jdk-17_linux-x64_bin.tar.gz。# syntaxdocker/dockerfile:1 FROM debian:12-slim AS base ARG JDK_VERSION17.0.8 ARG TARBALLsoft/jdk-17_linux-x64_bin.tar.gz ARG INSTALL_DIR/opt/java/jdk-${JDK_VERSION} COPY ${TARBALL} /tmp/jdk.tar.gz RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ ca-certificates \ tzdata \ fontconfig \ locales \ curl \ ; \ rm -rf /var/lib/apt/lists/* RUN mkdir -p ${INSTALL_DIR} \ tar -xzf /tmp/jdk.tar.gz -C ${INSTALL_DIR} --strip-components1 \ rm -f /tmp/jdk.tar.gz ENV JAVA_HOME${INSTALL_DIR} ENV PATH${JAVA_HOME}/bin:${PATH} RUN ln -sf ${JAVA_HOME}/bin/java /usr/bin/java \ ln -sf ${JAVA_HOME}/bin/javac /usr/bin/javac ENV LANGC.UTF-8 \ TZAsia/Shanghai CMD [java, -version]几个关键点拆开讲。--strip-components1的作用是去掉JDK压缩包里的第一层目录比如压缩包解出来是jdk-17.0.8/加上这个参数就直接把内容释放到${INSTALL_DIR}下面不需要再手动移动。fontconfig很多人会省掉但在跑Excel导出、验证码生成、图片处理类Java程序时没有字体库会频繁抛异常装一次后面省很多事。curl不是必须的但如果打算在容器里做健康检查或者调试先留着比较方便。3.2 多个JDK版本镜像是怎么构建的同一个Dockerfile要构建多个JDK版本只需要覆盖构建参数。前提是压缩包文件名和版本号对应好建议统一在soft目录下管理。命令这样写docker build -f Dockerfile \ --build-arg JDK_VERSION8u412 \ --build-arg TARBALLsoft/jdk-8u412-linux-x64.tar.gz \ -t local/jdk:8u412 . docker build -f Dockerfile \ --build-arg JDK_VERSION21.0.3 \ --build-arg TARBALLsoft/jdk-21_linux-x64_bin.tar.gz \ -t local/jdk:21.0.3 .构建完成后先看一眼镜像内容确认目录结构正确docker run --rm local/jdk:21.0.3 ls -l $JAVA_HOME/bin/java有个缓存优化思路要提一下JDK压缩包的COPY尽量放在apt-get安装之后。apt安装属于系统层只要基础镜像不变它就不会失效而JDK文件经常换版本放在后面意味着修改JDK版本时不需要重新跑apt安装构建速度会明显快不少。不过如果每个JDK版本都是独立tag缓存影响不大这个优化主要是给业务镜像反复改文件时用的。3.3 JDK8和古早项目要额外处理哪些细节JDK8在容器里有两个高频坑。第一个是时区和字符集设置不好日志时间差8小时中文乱码。所以Dockerfile里TZAsia/Shanghai和LANGC.UTF-8一定不能删。第二个是生成随机数的阻塞问题旧版镜像在启动时可能会卡很久新版JDK基本没有了。如果只是应用依赖低版本JDK可以在启动命令里加-Djava.security.egdfile:/dev/urandom这个参数绝大多数场景下能缓解启动卡顿。另外JDK8的安装包有的是目录方式拷贝过来的比如物理机上的jdk1.8.0_xxx目录直接压成tar包再COPY进镜像虽然能用但还是建议下载官方tar.gz保证文件权限和完整性。内部审计时也说得清楚。4. 把自定义镜像导出并分发到局域网4.1 docker save与docker load的正确用法构建好镜像之后最直接的分发方式就是docker save。强调一句保存的是镜像不是容器。很多人一开始会把docker commit和docker save混在一起导出又大又不可控。docker commit是把当前容器打包成新镜像适合紧急修复现场正式交付镜像请一定用docker save。导出前先给镜像打一个带内网语义的标签目标机器导入后能直接识别归属。我习惯带仓库地址前缀docker tag local/jdk:17.0.8 192.168.100.10:5000/devbase/jdk:17.0.8 docker save -o jdk-17.0.8.tar 192.168.100.10:5000/devbase/jdk:17.0.8不压的情况下JDK镜像大概三四百MB传内网机器时我会先压缩一次gzip -9 jdk-17.0.8.tar文件会变成jdk-17.0.8.tar.gz体积能降一半左右。到目标机器上先解压再导入或者直接让docker load读取tar文件gzip -dk jdk-17.0.8.tar.gz docker load -i jdk-17.0.8.tar导入之后建议立刻验证状态不要等到运行时才发现问题docker images | grep 17.0.8 docker run --rm local/jdk:17.0.8 sh -c java -version head -n 1 /etc/os-release如果局域网里的机器数量非常多一台台scp再docker load虽然可行效率太低。那就要用私有镜像仓库方案。4.2 局域网内搭建私有镜像仓库私有仓库组件很多企业生产环境一般用Harbor只是为了分发自定义JDK镜像用registry就够了。在有条件的一台机器上启动docker run -d \ -p 5000:5000 \ --name registry \ -v /opt/registry-data:/var/lib/registry \ --restartalways \ registry:2仓库起来后要在所有需要拉取镜像的机器上配置/etc/docker/daemon.json。这里有个很隐蔽的坑registry默认是HTTP协议而Docker客户端默认要求HTTPS不额外配置的话推镜像时直接报server gave HTTP response to HTTPS client。解决办法是加一行insecure-registries{ insecure-registries: [192.168.100.10:5000] }改完daemon.json必须重启Docker才生效一般执行sudo systemctl restart docker。重启Docker会让容器全停高负载机器操作前要提前通知相关方。这个小点经常被忽略很多人改完配置忘了重启推了半天一直报HTTPS错误。推镜像命令不复杂docker push 192.168.100.10:5000/devbase/jdk:17.0.8其他机器拉取时用完整地址docker pull 192.168.100.10:5000/devbase/jdk:17.0.8如果只有一台局域网服务器需要保存镜像又不想搭仓库直接把registry:2镜像也打成tar包带过去在这台机器上随便用个数据卷启动它就行效果完全一样。4.3 无外网环境下的整套部署步骤帮朋友部署时我总结出一套顺序适合完全没有外网的环境照着做基本不会乱第1步在联网的构建机上构建好所有需要版本的JDK镜像逐一验证java -version正常。第2步docker save保存成tar文件再用gzip -9压缩。第3步把registry:2镜像也save出来方便离线环境快速搭私有仓库。第4步将tar包通过U盘、内网共享盘或scp发送到局域网内任意一台有Docker的服务器。第5步这台服务器docker load后启动registry容器再加载用到的JDK镜像。第6步其他服务器配置insecure-registries并重启Docker然后docker pull。如果内网连系统包源都隔离了这套流程还要再往前一步把Linux系统本身的依赖也准备出来或者直接用已经包含完整运行环境的JDK基础镜像。没有统一答案按现场情况来。总的原则是能提前tar到本地、能提前save成镜像的都提前做好不要在离线现场临时想办法。5. 常见问题与排查技巧实录5.1 镜像导入后启动报exec format errordocker load后镜像信息看着没问题一运行就报exec format error基本可以断定架构不匹配比如一台aarch64机器上导入了amd64架构的镜像。先用命令查一下docker inspect local/jdk:17.0.8 | grep -E Architecture|Os输出里如果是amd64而宿主机是aarch64方案就是重新下载对应架构的JDK包和基础镜像再构建一次没有捷径。更稳妥的做法是构建前就写个脚本判断宿主机架构把平台选择写进交付文档防止构建机架构和目标机器不一致。5.2 容器里的JAVA_HOME不生效这个问题的根源多半是Dockerfile里PATH变量覆盖顺序出问题。有些基础镜像预先设置了ENV PATH如果你的Java相关环境变量写在Dockerfile中部后面又有其他指令重新设置了PATH那JAVA_HOME可能就失效了。解决办法是把环境变量统一放在Dockerfile底部。还有一个小坑进入容器手动检查时如果用docker exec -it 容器 bash有些精简镜像默认不加载/etc/profile此时看不到Java命令不代表镜像内环境变量没配好。最稳的办法是docker inspect看Config.Env那里记录的是容器真正生效的环境变量。5.3 镜像文件太大传输不了一点JDK镜像动辄几百MB压缩后能好很多。gzip -9通常能把tar文件压到原来的40%左右。如果还嫌大可以考虑换更小的基础镜像或者用jlink定制一个精简版JRE。但jlink裁剪要非常小心很多Java应用会用到动态加载模块过度裁剪后在业务运行阶段突然报找不到类排查起来比体积问题痛苦得多。我个人不太建议为了几十MB去赌兼容性内网传输几百MB不是问题别让网络成为架构决策的瓶颈。5.4 Windows下用Docker Desktop构建的坑如果构建机是WindowsDocker Desktop经常报virtualization support not detected之类的错误。这个和镜像本身关系不大主要是Windows的Docker Desktop依赖WSL2或Hyper-V。排查思路是先在BIOS里确认虚拟化开启再安装并启用WSL2执行wsl --update然后到Docker Desktop设置里确认WSL2后端已打开。机器老旧不满足WSL2要求的话建议改用虚拟机里的Linux去构建镜像反而少踩很多坑。Windows上的docker CLI和Linux上的命令基本一致save和load跨平台没有兼容问题。唯一要留意的是gzip命令在Windows下不方便可以在PowerShell里用wsl gzip -9 jdk-17.0.8.tar借道Linux环境执行省得再装一堆工具。5.5 时区、字体、编码三件套最后集中说中国开发者最容易踩的坑。Java程序跑起来日志时间比本地时间少8小时大概率时区没设置导出Excel或生成验证码时出现字体相关异常大概率容器缺字体库日志中文乱码多半是locale没设置好。这三件套在我给的Dockerfile里已经包含了但如果是拿别人的基础JDK镜像一定要检查有没有TZAsia/Shanghai、fontconfig和LANGC.UTF-8。排查起来不难但在离线环境里每补一次都要重新导出重新传一次镜像来回就是半小时起步。做镜像这件事最难的不是把Dockerfile写出来而是把版本、时区、字体这些坑提前填平。我自己现在的新项目已经习惯先搭一个JDK基座镜像再在它上面做业务镜像JDK版本全部用ARG控制各环境直接复用。实测下来几台机器的交付时间从以前的一两个小时压缩到十几分钟。如果你也正为内网Java环境发愁建议按上面的流程走一遍把自定义JDK镜像固化下来后续加服务器、加模块就不用再重装环境了。
返回列表