1. 为什么放着现成的镜像不用非要自己折腾如果你平时除了写业务代码还要被叫去管理几台服务器的部署环境那你大概率早就遇到过这种情况项目A还停留在JDK8项目B已经切到JDK17两边都要上容器办公室的Windows笔记本装了Docker Desktop机房却有四台没法访问公网、只走局域网的老服务器。这时候你不能再指望每次部署都到公网拉镜像也不能让每个环境都用不同版本的官方openjdk镜像凑合。使用Docker构建自己的镜像支持自定义JDK版本导出镜像到局域网这件事解决的就是上面这一连串麻烦。我在实际项目中用一套自定义JDK基础镜像统一解决了多版本、离线分发和团队协作问题。这篇文章适合谁看如果你需要给团队做基础镜像、在局域网内搭建镜像仓库、或者正在为JDK版本不统一而发愁可以直接照着抄。1.1 官方JDK镜像在日常使用中的几个现实问题官方openjdk镜像最大的问题是版本和发行版推进得比较乱。早年Docker Hub上的openjdk官方镜像虽然方便但JDK 8之后官方长期停留在带有老版本号的包上而且openjdk:8这类镜像很多只有JRE精简版缺少完整JDK里的jcmd、jmap、jstack这些排查工具。你一旦碰上内存泄漏或者CPU飙升想在容器里执行jstack看线程栈时容器里根本没有这些命令只能重新构建一个带完整JDK的镜像。另一个问题是Java官方现在把OpenJDK的二进制包交给Eclipse基金会维护也就是现在大家经常挂在嘴边的TemurinAdoptium项目。Docker Hub上的openjdk镜像很久没更新不少团队还在拉映像仓库里不知道多少年前的旧标签或者拉到一个和现场操作系统架构不匹配的镜像跑起来才报错。这些问题的根源都在于你没有把JDK版本、基础系统、运行参数这些变量掌握在自己手里。再说实际的多版本需求。一个中型团队往往同时存在多个微服务数据库工具类项目用JDK8新上的Spring Boot 3项目必须用JDK17再加上某些老旧中间件只兼容某个小版本。如果每个人都在自己电脑上装好几个JDK再靠IDE切换换一台机器就全部失效。而在Docker里做自定义JDK镜像每个版本就是一个标签拉哪个就用哪个误差直接消失。1.2 自定义JDK镜像到底解决了什么事一句话概括自定义JDK镜像就是由你决定基础系统、JDK包来源、环境变量、时区、字体库、常用工具链再把这些固化成镜像模板。我给自己团队定的镜像规范是一个基础镜像里只放操作系统和JDK不塞任何业务代码业务项目再从这个基础镜像派生出去。这样有几个明显好处。第一所有项目共用同样的JDK基础层磁盘占用省了镜像仓库里同一层只需要存一份。第二修复安全漏洞或升级补丁时只要基础镜像更新业务镜像重新构建一次就能同步。第三离线环境也好办把基础镜像导入局域网之后所有机器都从局域网拉不依赖外网。1.3 这篇文章里要完整走通的流程我下面会按实际操作顺序讲先把JDK发行版和基础镜像选好再写一份支持JDK版本参数化的Dockerfile接着介绍构建后的验证、瘦身和JVM参数注意事项最后是两种局域网分发方式一种适合临时拷文件一种适合搭正规仓库。全篇都基于我自己在CentOS和Windows Docker Desktop两种环境下跑通的命令不是从文档里抄的。2. 材料准备JDK发行版与基础镜像怎么搭很多第一次自己构建JDK镜像的人卡在第一步不是不会写Dockerfile而是不知道JDK从哪来、基础镜像到底该选哪个。我先把这个讲透。2.1 选JDK发行版的几条经验现阶段主流的JDK发行版无非是OpenJDK上游源码构建出来的几个主要分支Eclipse TemurinAdoptium项目、Oracle OpenJDK、Liberica、Microsoft Build of OpenJDK。还有一个容易混淆的是OpenJ9那是Eclipse的另一个JVM实现不是HotSpot很多企业应用没验证过不建议乱用。我的原则很简单首选Temurin的HotSpot版本。理由一是它社区维护最积极版本更新及时二是它在多平台发布时提供完整tar包解压就能用适合咱们在Dockerfile里COPY进去。下载时认准官网的tar.gz包不要下那些安装版、msi或者deb包Docker构建时需要的是可直接解压运行的目录结构。如果你所在项目用的厂商定制版JDK比如某些国产中间件配套的JDK也完全可以走同样的路子只要拿到Linux x64的tar.gz包即可。关键在于保证解压之后目录里存在bin/java、lib、conf这些标准目录。拿到后先执行一下./bin/java -version确认它能在目标架构上运行。小版本上我建议只固定到具体构建版本比如17.0.127不要用“最新的17”这种表述。因为每次重新构建镜像拉到的包版本会变导致相同标签构建出不同内容的镜像这等于把不确定带入生产。把版本号写死到Dockerfile的ARG里这样任何人任何时间构建得到的东西都是确定性的。2.2 基础镜像的取舍glibc、musl和镜像体积基础镜像的选择直接影响JDK跑不跑得起来。大家常听到的体积最小是alpine但它基于musl libc而绝大多数官方JDK tar包是编译在glibc环境上的。直接往alpine里COPY进去很可能启动时报找不到libjvm.so或者各种undefined symbol。为此可以给alpine装gcompat兼容层但我在实践中发现gcompat解决了一部分问题却不一定保证某些本地方法、加密库稳定属于“能跑但有玄学风险”。除非你非常清楚自己在做什么否则自建JDK镜像别用alpine。现实中最稳的是ubuntu:22.04或debian:12-slim。它们都自带glibc通常还内置基础证书库在线安装依赖也很方便。别为了少几十MB给线上留隐患。至于老掉牙的centos:7不少老项目组还在用因为要跟已有系统完全保持一致。但CentOS 7官方已经停止维护仓库源逐渐失效每次yum install都可能出问题。我的建议是能换就尽早换到Debian系。如果你的生产环境确实只能基于CentOS也要提前把需要的rpm包和CentOS-Base.repo离线准备齐全别到构建时才匆忙找源。2.3 先把JDK压缩包和目录结构理清楚动手写Dockerfile前在构建机某个目录下准备好JDK压缩包。比如我常用的目录结构是这样/opt/docker-build/jdk/ ├── jdk-17.0.12_linux_x64.tar.gz ├── jdk-8u412_linux_x64.tar.gz └── Dockerfile观察解压之后的目录名这点非常重要。很多JDK包解压出来目录名带着完整版本号比如jdk-17.0.127或者jdk-8u412-linux-x64。后面Dockerfile里做软链接时就要依赖这个目录名。我自己踩过的坑是不同发行版的目录命名规则不一样有的叫jdk-17.0.127有的叫temurin-17.0.127直接写死某个名字会导致软链接失败。所以建议先在构建机解压一次确认真实目录名再把它作为构建参数。JDK压缩包下载好后最好也做一次SHA256校验。这一步很多人嫌麻烦但镜像是要反复被别人拉去使用的如果包被破坏或者下载不完全后续所有基于它构建的镜像都废了。能在分发前发现的问题就不要留到线上。3. Dockerfile里最关键的几个部分照着写就行这里直接给出我实际使用的Dockerfile它的核心思路是通过构建参数指定JDK版本让同一个Dockerfile能构建出不同JDK版本的镜像。3.1 一个支持JDK版本参数化的Dockerfile# 基础镜像固定版本不要用latest FROM ubuntu:22.04 # 构建参数JDK版本号以及对应的tar包文件名 ARG JDK_VERSION17.0.12 ARG JDK_TAR_FILEjdk-17.0.12_linux_x64.tar.gz # 解压后的目录名注意不同发行版命名可能不同 ARG JDK_DIR_NAMEjdk-17.0.127 # 元信息 LABEL org.opencontainers.image.titlecustom-jdk LABEL org.opencontainers.image.version${JDK_VERSION} LABEL org.opencontainers.image.descriptionCustom JDK base image for internal use # 安装基础依赖tzdata用于时区ca-certificates用于HTTPS证书 # fontconfig用于Java图形和字体渲染 RUN apt-get update \ apt-get install -y --no-install-recommends \ tzdata \ ca-certificates \ fontconfig \ curl \ rm -rf /var/lib/apt/lists/* # 拷贝JDK压缩包进去并解压到/opt/java目录 COPY ${JDK_TAR_FILE} /tmp/${JDK_TAR_FILE} RUN mkdir -p /opt/java \ tar -xzf /tmp/${JDK_TAR_FILE} -C /opt/java \ ln -s /opt/java/${JDK_DIR_NAME} /opt/java/jdk \ rm -f /tmp/${JDK_TAR_FILE} # 设置JAVA_HOME和PATH ENV JAVA_HOME/opt/java/jdk ENV PATH${JAVA_HOME}/bin:${PATH} # 统一设置时区为中国标准时间 ENV TZAsia/Shanghai # 创建非root运行用户关键 RUN groupadd -r appuser useradd -r -g appuser -s /sbin/nologin appuser # 切换用户 USER appuser # 默认工作目录 WORKDIR /app # 验证 RUN java -version javac -version CMD [java, -version]为什么要把JDK_VERSION和JDK_DIR_NAME分成两个参数因为版本号和解压目录名不一定完全一致。有的发行版tar包文件名和内部目录名看起来相关但细节不同分开设置更灵活。ARG写在FROM后面很重要如果写在FROM之前那它只在解析FROM时有效进入构建阶段后就没了。COPY那一步会把JDK压缩包作为一个镜像层。如果你在build目录里同时放了JDK8和JDK17两个包而Dockerfile里用ARG指定不同文件名那么只有指定那个文件会被纳入构建上下文影响不会把所有文件都塞进最终镜像。但要注意构建上下文里的文件都会先发送给Docker守护进程所以构建目录不要放无关的大文件。3.2 环境变量和非root用户基础镜像该有的底线我在Dockerfile里特意加了几处看起来“多余”的配置它们在实际使用中回报极高。第一是tzdata和TZAsia/Shanghai。不配置时区的话容器内默认是UTC时间应用日志里记录的时间和宿主机差8个小时。排查问题时最容易让人抓狂。你可以在启动容器时用-e TZAsia/Shanghai覆盖但基础镜像里直接写死业务镜像就没机会犯这个错。第二是ca-certificates。Java应用经常需要访问HTTPS接口比如调用外部网关或者云服务。基础镜像不带证书库Java会报unable to find valid certification path。Ubuntu镜像安装了它JDK的自签名库才能正常使用系统证书链。这个依赖在容器构建时不显眼跑起业务才暴露。第三是非root用户。容器安全不能指望运行时再去--user参数更合理的是基础镜像就默认非root用户业务镜像继承这个约束。我用useradd -r -s /sbin/nologin创建系统用户不允许它登录shell只用来运行Java进程。要注意那些需要写文件或监听低端口的业务可以在业务镜像里把对应目录chown给appuser或者在构建最后RUN chown -R appuser:appuser /app。这一点属于“我吃过亏才补上”的教训以前基础镜像用root后来生产环境收到安全扫描报告全部跑了一遍整改。还有字体库fontconfig这是被低估的一步。只要Java服务生成图片验证码、导出PDF、画报表图表就必须依赖系统字体。没有fontconfig运行时可能遇到java.lang.UnsatisfiedLinkError或者中文全部变成方块。基础镜像统一装上业务镜像就不用各自去折腾。3.3 构建命令与版本标签的管理习惯Dockerfile写好后构建命令反而要立好规矩。我建议所有构建都用-t打双标签一个是具体版本号一个是主版本号例如docker build \ --build-arg JDK_VERSION17.0.12 \ --build-arg JDK_TAR_FILEjdk-17.0.12_linux_x64.tar.gz \ --build-arg JDK_DIR_NAMEjdk-17.0.127 \ -t internal/jdk:17.0.12 \ -t internal/jdk:17 \ -f Dockerfile /opt/docker-build/jdk/internal/jdk:17这个标签是可变的指向当前用的最新17版本internal/jdk:17.0.12是不可变的用来精确定位。业务镜像的Dockerfile里可以写FROM internal/jdk:17未来版本升级只在基础镜像上完成业务镜像不用改代码。如果版本回退就重新完成internal/jdk:17标签影响面可控。使用不同版本时反复切换ARG重新构建会因为Docker层缓存机制产生一个现象第一次构建JDK17镜像后再构建JDK8时COPY的tar包变了缓存失效后面层会重建但之前的系统包安装层可能仍命中缓存。这没问题但镜像仓库里如果同时保留多个历史构建会占用不少存储。建议定期执行docker image prune清理悬空镜像别让构建机变成垃圾场。4. 构建出来之后先别急着分发验证和瘦身不能省镜像构建完成不代表能用。我见过不少同事构建完就docker push结果业务镜像一跑就崩。验证这一步绝对不能省。4.1 快速验证镜像里的Java环境是否正常构建完成先启动一个临时容器执行基础检查docker run --rm internal/jdk:17 java -version docker run --rm internal/jdk:17 javac -version docker run --rm --entrypoint sh internal/jdk:17 -c echo $TZ date第三步是验证时区是否生效date应该显示东八区时间。如果项目用到了keytool、jstack这些工具也可以顺手跑一下确认从JDK tar包解压的完整JDK没缺文件。我通常还会跑一次curl -I https://www.baidu.com来确认证书链正常毕竟是基础镜像后面所有应用的网络访问都基于它。接下来可以用一个真实业务包做冒烟测试。比如在业务目录里准备一个Spring Boot jar写一个最简单的业务DockerfileFROM internal/jdk:17 WORKDIR /app COPY app.jar app.jar ENV JAVA_OPTS-Xms256m -Xmx256m EXPOSE 8080 ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar app.jar]启动后访问健康检查接口确认进程正常、日志时间正确、内存符合预期。如果这步过了基础镜像才算真正可用之后推仓库或者导出到局域网才有意义。4.2 镜像层从哪里来的怎么把没用的东西赶出去很多新手不知道自己构建的镜像为什么这么大。JDK tar包解压后通常在200MB左右加上Ubuntu基础镜像最终镜像体积200多MB很正常。想瘦身可以做几件事。一是确认不需要的东西没被放进镜像。构建时用的apt-get install我带了--no-install-recommends避免装一堆无关推荐包。rm -rf /var/lib/apt/lists/*是清掉apt源索引缓存不然那部分也会留在镜像层里。二是可以把JDK替换成JRE如果你的业务只是跑Java应用不需要编译和诊断工具。同样是Temurinjre版本的tar包比完整JDK小100MB左右。基础镜像分两个标签一个-jdk给构建场景用一个-jre给运行场景用。但要注意容器里跑Spring Boot应用经常也需要看线程栈、堆信息如果删掉jcmd、jmap这些诊断工具线上排障会很难受。我的做法是留下完整JDK换来排障工具齐全。三是多阶段构建。适用于不是你直接发布基础镜像而是把所有步骤打包进一个Dockerfile的场景。比如你要一个带Maven的JDK镜像可以用一个阶段安装Maven进行依赖下载最终只把Maven输出和JDK基础镜像合成。这能有效缩短最终体积。但如果你只做JDK基础镜像就没有太大必要强行多阶段。4.3 JVM在容器里的参数设置尽量在构建期就想清楚JVM参数我建议不要用-Xmx1g这种写死的方式。容器是动态资源分配的宿主机内存变了写死的内存参数很容易导致EMT即实际分配超过容器限制被操作系统杀掉。JDK 8u191及之后版本默认开启UseContainerSupportJVM会自己识别容器cgroup限制。所以在基础镜像Dockerfile里不写死内存而是用环境变量占位ENV JAVA_OPTS-XX:UseContainerSupport -XX:MaxRAMPercentage75.0 -Dfile.encodingUTF-8这里MaxRAMPercentage的意思是让JVM最多使用容器可用内存的75%给堆外内存、线程栈、GC留足余地。别设成100%除非你想制造OOM。JDK 8早期版本不识别cgroup如果你必须用特别老的8u版本就只能通过启动脚本读取/sys/fs/cgroup内存限制来手动计算-Xmx比较麻烦。条件允许就直接升级到受支持的安全版本。另外不建议在基础镜像里用JAVA_TOOL_OPTIONS写死-Xmx因为业务镜像启动时这个变量会被直接带进去业务无法自行覆盖容易踩到冲突。用我们自己的JAVA_OPTS占位符业务就可以在启动时自由注入才是灵活的做法。5. 把镜像导出到局域网两种办法分场景用镜像构建好之后真正要解决的问题是把“构建好的镜像”变成“局域网里每台机器都能用的镜像”。这里两条路按场景选。5.1 临时共享docker save配合ssh/scp直接灌到目标机如果你的目标机器就三两台或者只是临时给同事的电脑用不需要维护一堆版本最简单的办法是docker save导出成单个文件再复制过去docker load。# 构建机上导出 docker save internal/jdk:17 | gzip internal-jdk-17.tar.gz # 通过scp传过去或者放共享目录 scp internal-jdk-17.tar.gz user192.168.1.20:/opt/images/ # 目标机器上加载 docker load -i /opt/images/internal-jdk-17.tar.gz为什么要加gzipdocker save出来的tar不压缩一个300MB的镜像导出来可能也是300MB而JDK包里面可压缩率很高gzip之后经常能压缩到一半以下。适合局域网传输。还有一个更省事的做法在“构建机到目标机”这条链路能通的情况下直接用管道传输不在中间生成文件docker save internal/jdk:17 | gzip | ssh user192.168.1.20 gunzip | docker load这条命令会的坑是如果没有配置SSH免密会阻塞等待密码。你要是远程机器多建议配置SSH密钥一条命令把镜像批量灌给几十台机器都行。Windows环境Docker Desktop的SSH通常需要额外配置这时我建议直接用文件传输不要再折腾管道。目标机docker load之后用docker images | grep internal/jdk确认标签还在。只要load进去的是带标签镜像FROM internal/jdk:17的Dockerfile就能直接复用不用改任何东西。5.2 长期使用搭一个轻量Registry做局域网镜像仓库机器超过五台还隔三差五更新镜像再用save/load传文件就太原始了。每台机器都手动load版本还容易错。正式一点的做法是局域网里跑一个registry:2容器所有机器都往这个仓库拉取镜像。搭建仓库本身很简单# 在局域网某台服务器上执行 docker run -d \ --name registry \ --restartalways \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ registry:2要注意-v挂载目录不能省。registry容器如果挂了没有数据盘挂在宿主上镜像数据会随容器一起消失。这里我挂载到/data/registry这是服务器上的一块独立数据盘目录。多台机器共享镜像存储会持续增长建议构建机上定期清理旧版本。然后构建机给镜像打上局域网仓库的地址标签再推送docker tag internal/jdk:17 192.168.1.100:5000/internal/jdk:17 docker push 192.168.1.100:5000/internal/jdk:17目标机器不需要额外操作只要能够访问192.168.1.100:5000就可以直接拉取docker pull 192.168.1.100:5000/internal/jdk:17业务Dockerfile里改一行FROM 192.168.1.100:5000/internal/jdk:17这样整个局域网内的构建和部署就统一到这个仓库地址。配合一个简单的制品版本策略基本能达到企业里镜像中心的感觉。5.3 完整分发实战与客户端配置这里提前说一个最常见的问题你在目标机执行docker pull 192.168.1.100:5000/internal/jdk:17时会报类似http: server gave HTTP response to HTTPS client的错误。原因很直白默认情况下Docker只允许连HTTPS的镜像仓库而registry:2容器如果没配证书就是纯HTTP。解决办法是为该地址明确标注为不安全的仓库。在客户端机器的Docker配置里添加insecure-registries。Linux下通常编辑/etc/docker/daemon.jsonWindows Docker Desktop则修改C:\ProgramData\docker\config\daemon.json或者在桌面版设置界面的Docker Engine直接编辑。{ insecure-registries: [ 192.168.1.100:5000 ] }保存后重启Docker服务再执行docker pull就正常了。如果目标机器比较多不要把IP写错写错一个整条链路都拉不通。如果在意传输安全可以给registry配置自签名证书在daemon.json里配置registry-mirrors和证书目录但这是另一个话题。局域网内部可以接受HTTP方案但真要上生产环境建议还是用一套正规的HTTPS证书至少在nginx层做一下TLS终结把HTTP请求重定向到HTTPS。还有一个细节目标机拉取镜像时仓库地址里的IP或主机名必须和daemon.json里配置的完全一致。如果你在推送时用192.168.1.100:5000拉取时就必须也是192.168.1.100:5000不能用机器名也不能用localhost。这个不一致的问题会让你怀疑人生。6. 这些坑我在局域网分发和版本切换时踩过最后说几个我自己趟过的坑都是真实环境里遇到的问题。如果你按前面的步骤做大概率能绕开。6.1 alpine镜像装自编译/官方tar包JDK的兼容性问题这是我在早期追求镜像体积时踩过的。当时想用alpine:3.20做基础镜像把Temurin的tar包直接COPY进去结果容器一启动就报错java命令根本无法执行提示找不到某些共享库。原因是alpine底层是musl libcJDK二进制是为glibc编译的。网上有方案是apk add gcompat我也试过部分情况下能跑起来但后续某些依赖JNI的功能还是偶发异常。用这种方案做基础镜像等于把不确定性传给下游所有业务。后来我干脆放弃了alpine选择了ubuntu:22.04。牺牲一点体积换来稳定这才是基础镜像该有的姿态。6.2 时区、字体、随机数源这些“小配置”影响比想象大日志时间差8小时的问题前面已经说过这是最显眼的。还有两个比较隐形的配置。一个是java.security.egd。老版本JDK在Linux上初始化SecureRandom时如果/dev/urandom不可用或配置不当启动过程可能卡住几十秒甚至几分钟。在JVM参数里加-Djava.security.egdfile:/dev/./urandom可以缓解这个启动阻塞。有些资料还会写file:/dev/urandom但一定要注意必须带中间那个./不然老版本JDK不认依然会卡。另一个是字体。前面Dockerfile里装了fontconfig但有的业务还需要中文字体文件。如果你们有特殊字体要求可以在基础镜像里把字体目录打进/usr/share/fonts然后执行fc-cache -f。没有这一步以后验证码、导出报表出乱码排查成本比装字体高得多。6.3 跨架构镜像与save/load的关联问题现在ARM服务器越来越普及尤其在信创和办公场景里。你在一台x86机器上构建出的JDK镜像docker save导出到ARM机器上docker load能成功但一运行就报exec format error。这不是镜像损坏而是架构不匹配。Docker的save/load默认只针对当前机器架构它不会自动包含多架构清单。想要一个镜像同时支持x86和ARM常规做法是用docker buildx构建多架构镜像然后推送到registry。docker buildx build --platform linux/amd64,linux/arm64 -t 仓库地址/镜像:版本 --push .。注意buildx构建多架构镜像时需要把--push一起带上因为多架构manifest必须放在远程仓库无法用docker save导出单个tar包。所以结论是如果你面临多架构环境别依赖save/load一定要用registry并且在构建时用buildx明确声明platform。如果没有registry也只能分别在每种架构的机器上构建镜像再分别导出。6.4 私有仓库HTTPS和daemon.json的坑最后再说一个和前面章节关联的常见翻车点。很多人在客户端配好了insecure-registries重启Docker后仍然拉取失败。可能原因有两个。第一配置文件格式错误。daemon.json是严格的JSON多个配置项之间必须有逗号最后一个不能带逗号。配置里顺手留了个尾逗号整个配置文件就解析失败Docker服务直接起不来。改完配置务必先用docker version确认守护进程正常。第二重启方式不对。systemctl restart docker是Linux下的标准做法但如果你用的是Docker Desktop必须通过托盘图标重启或者执行Restart-Docker插件否则配置不生效。改完配置后重启完再执行docker info查看Insecure Registries部分是否列出了你配置的地址这比盲目重试有效得多。这些坑单看都挺小串在一起足以让你一次分发流程忙活一整天。我自己把它们全部写进团队规范里后来新同事照着做基本没再翻过车。如果你现在也正被多版本JDK和离线环境折磨可以直接从前面那份参数化Dockerfile开始。先用一个版本在测试环境完整走到局域网拉取确认流程通了再铺开其他版本。构建基础镜像这件事一旦把第一版啃下来后面就是纯粹的复制粘贴和版本管理了。