ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Docker构建自定义JDK镜像并实现内网离线分发

用Docker构建自定义JDK镜像并实现内网离线分发 最近正好在帮组里搭一套基础的 Java 运行环境镜像踩了不少坑也总结出了一套相对顺手的方法。核心诉求其实很简单用 Docker 构建自己的镜像JDK 版本能自定义构建完还能导出去给局域网里的其他机器用。这篇就围绕这三件事把整个流程、背后的思路以及实际遇到的坑完整写一遍。先说清楚这套方案解决什么问题。很多团队初期都是直接从 Docker Hub 拉官方openjdk或eclipse-temurin镜像图省事。但用一段时间就会发现几个绕不开的痛点官方镜像的版本更新节奏你控制不了有些旧项目要固定 JDK 8 的某个小版本官方早就下架了更麻烦的是生产环境往往是内网隔离的外网拉取镜像这个动作本身就很难实现。自己构建镜像本质上是把“依赖外部镜像源”变成“依赖自己手里的构建产物”配合导出和导入就能完全摆脱外网限制把镜像像安装包一样在内网传播。这篇文章适合三类人一是研发团队里负责基础环境搭建的运维或全栈工程师二是需要在离线环境部署 Java 应用的实施人员三是刚接触 Docker、想彻底搞懂镜像构建原理的开发者。内容会从选型原理讲到实操命令再讲到局域网分发的几种方式和对应的坑尽量做到看完整篇就能照着落地。1. 为什么要自己构建 JDK 镜像三个真实需求1.1 官方镜像的不可控性直接用openjdk:8-jdk-alpine或者eclipse-temurin:17-jdk表面省事实际埋雷。我经历过的典型场景是这样的某天安全扫描报告出来说某个基础镜像里带的 OpenSSL 版本有漏洞需要升级基础镜像。结果一查官方镜像已经更新了 tag重新拉下来重新构建发现应用的某个老依赖在新 JDK 小版本下行为变了或者是时区、字体、glibc 这些细节和原来不一致导致线上出问题。这种不可控性在正式环境里代价很高。自己构建则完全不同。你用一个固定版本的 CentOS 7 或 Ubuntu 作为基础镜像把 JDK 以你确认过的版本放进去构建一次产物就固定下来了。之后无论官方怎么更新、旧 tag 怎么下架你的镜像和线上运行环境始终保持一致。这一点对有等保、合规要求的项目尤其重要。1.2 自定义版本与精简系统的诉求官方镜像大多比较“重”一个 openjdk 镜像动辄几百 MB包含了很多用不到的组件。自建镜像时可以主动裁剪只保留 JDK 和必要的运行库、时区配置、基础工具把镜像体积控制在合理范围内。另外不同项目组可能用不同 JDK 版本有的要 8有的要 11有的要 17 甚至 21。如果有一种方式能做出版本参数化的构建模板那就很方便了。这其实就是“自定义 JDK 版本”的核心价值同一个 Dockerfile通过参数切换 JDK 版本一条命令构建出不同标签的镜像而不是每个版本写一套不同的配置。1.3 内网分发的刚需很多企业的服务器在隔离网络内没法直连外网。Docker Hub 对于他们来说几乎是不可达的。这时候构建好的镜像文件就成了唯一的“快递包裹”。把镜像导出成 tar 包拷到内网机器上再导入是最直接的方法。如果机器数量多还可以在局域网内部署一个私有镜像仓库做到像外网一样docker pull只是速度完全取决于内网带宽。后面我会细讲这些方案。2. 构建前的准备基础镜像与 JDK 版本的选型策略2.1 基础镜像选 CentOS 还是 Ubuntu 还是 Alpine基础镜像的选型直接决定了最终镜像的兼容性和体积需要想清楚。CentOS 7和很多老牌生产服务器的系统一致glibc 版本稳定跑老应用兼容性最好。但 CentOS 7 官方已停止维护软件源逐渐失效构建时需要留意yum源配置。Ubuntu 20.04 / 22.04软件源维护活跃安装工具方便glibc 版本较新适合大多数现代 Java 应用。如果你的应用依赖较新的系统库优先考虑 Ubuntu。Alpine体积极小基础镜像只有几 MB但用的是 musl libc和标准的 glibc 有差异。Java 本身能跑但某些用到了 JNI 原生库比如一些加密组件的应用可能会出问题。另外docker exec进容器里想用bash还得手动装。如果在生产环境用我会保持谨慎态度。我的默认选择是CentOS 7 或 Ubuntu 22.04除非确认应用无原生依赖才考虑 Alpine。对于老项目稳妥比体积更重要。2.2 JDK 版本怎么选发行版和版本策略JDK 这件事说起来有个背景Oracle JDK 的发行协议限制了商用场景所以现在主流的做法是采用OpenJDK 发行版。常用的几个来源包括发行版特点适用场景Eclipse Temurin (Adoptium)社区活跃质量好免费通用 Java 应用最推荐OpenJDK (Oracle 官方构建)官方发布但更新节奏相对固定对合规要求高的场景Zulu (Azul)支持平台极广长期支持版本多老系统兼容Liberica (BellSoft)带 JavaFX 等组件桌面/特殊应用龙井 (Alibaba Dragonwell)阿里维护针对生产环境优化大规模 Java 服务的场景版本策略方面我的建议是Java 81.8.0_xxx存量老系统的绝对主力很多老框架在 Java 11 以上会有兼容问题如果有这类系统就需要定在JDK 8 的某个安全更新版本。Java 11过渡版本适合部分升级上来的项目稳定性不错。Java 17目前的主流长期支持版本LTS新项目直接上 17性能和特性都够用了。Java 21更新的 LTS未来趋势不过周边生态各种中间件、框架的兼容性仍需实测确认。选择策略很简单新项目默认 17老项目保持 8别有执念去追最新版本稳定压倒一切。2.3 JDK 文件怎么获取两种路线构建镜像时把 JDK 放进镜像一共有两种方式各有适用场景。第一种提前在宿主机下载好 JDK 压缩包通过 Dockerfile 的 COPY 指令放进去。这种方式优点是构建过程不依赖网络速度快、稳定适合内网环境。缺点是宿主机上要预先准备对应版本的 tar.gz 文件。第二种构建时自动下载。在 Dockerfile 中用curl或wget从 JDK 下载源获取配合ARG参数动态拼接下载地址。这种方式更灵活但构建时必须有外网而且下载源偶尔会变踩过坑的人都知道有多痛。我建议优先采用第一种方式也就是宿主机先下载、再 COPY。这样整个构建过程是可控的下载 JDK 这个变量被移出了构建环节出问题更容易排查。注意下载 JDK 时一定要确认下载的是tar.gz 或 tar.xz 压缩包而不是 rpm、deb 安装包或 dmg。我们要做的是解压后放进镜像不是安装。3. Dockerfile 编写与镜像构建实战3.1 一个可直接套用的 Dockerfile下面这个 Dockerfile 是我实际在用的模板以 CentOS 7 为例支持构建时指定 JDK 版本。# 基础镜像CentOS 7 FROM centos:7.9.2009 # 定义 JDK 版本参数构建时可通过 --build-arg 覆盖 ARG JDK_VERSION17 ARG JDK_FILEjdk-${JDK_VERSION}_linux-x64_bin.tar.gz # 环境变量 ENV JAVA_HOME/opt/jdk-${JDK_VERSION} ENV PATH$JAVA_HOME/bin:$PATH # 设置时区避免容器内时间差 8 小时 RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone # 安装基础工具可选根据实际需要裁剪 RUN yum install -y curl wget tzdata fontconfig \ yum clean all # 将宿主机上预先下载的 JDK 压缩包拷贝进镜像 COPY ${JDK_FILE} /tmp/${JDK_FILE} # 解压到 /opt 并重命名为 jdk-${JDK_VERSION} RUN tar -xzf /tmp/${JDK_FILE} -C /opt \ rm -f /tmp/${JDK_FILE} # 验证 Java 是否可用 RUN java -version javac -version # 默认工作目录 WORKDIR /app # 供容器启动时执行的命令通常先留一个可覆盖的默认值 CMD [java, -version]构建这条镜像的命令是docker build --build-arg JDK_VERSION8 -t my-jdk:8 . docker build --build-arg JDK_VERSION11 -t my-jdk:11 . docker build --build-arg JDK_VERSION17 -t my-jdk:17 .你家宿主机上需要提前准备好对应的jdk-8_linux-x64_bin.tar.gz、jdk-11_linux-x64_bin.tar.gz、jdk-17_linux-x64_bin.tar.gz文件放置在 Dockerfile 同一个目录下。注意这些文件可能体积不小100 到 200 MBCOPY 进镜像时没问题但别把它作为常驻文件留在构建上下文里可以用.dockerignore来规避。3.2 Dockerfile 里的几个关键细节有几个细节比命令本身更重要值得单独提一下。ARG 和 ENV 的区别ARG JDK_VERSION17只在构建阶段有效容器运行时看不到ENV JAVA_HOME/opt/jdk-${JDK_VERSION}会写进镜像的元数据容器启动后环境变量是存在的。我的写法用ARG控制版本用ENV输出运行时变量两者配合正好。容器内时区问题官方镜像默认时区是 UTCJava 应用打印出来的日志时间会比北京时间慢 8 小时。很多团队是在启动脚本里加-Duser.timezoneAsia/Shanghai这是可行的但如果能在镜像层面就统一肯定更省事。在RUN里做了 timezone 和 time zone 的配置这样所有基于这个镜像的容器都不会有时区问题。fontconfig 为什么值得装Java 应用如果做图片验证码、导出 Excel、生成 PDF大概率需要加载系统字体没有 fontconfig 会有一些和字体相关的报错。基础镜像里先装好后续省不少事。CMD 用java -version作为默认命令这只是为了验证镜像可用。实际项目里应该在 Dockerfile 中把 CMD 替换成你的应用启动命令或者用脚本例如CMD [sh, -c, java $JAVA_OPTS -jar /app/app.jar]。3.3 构建时的两个小技巧分层缓存与多阶段构建Dockerfile 的每一行RUN、COPY都会生成一个镜像层。Docker 的缓存机制是按层缓存的也就是说如果某一行及其前面的内容没有变化重建时会直接使用缓存。因此建议把不常变动的步骤放前面比如安装基础工具、设置时区把根据ARG变化的步骤放后面加快多次构建的速度。比如 JDK 版本参数变了前面几层缓存依然可用只有 COPY 和后续层会重建。如果你的项目需要编译 Java 代码还可以用多阶段构建第一阶段用带 Maven 或 Gradle 的构建镜像把代码编译成 jar 包第二阶段再把 jar 包放进只有 JDK 的运行镜像。这样做的好处是最终镜像不包含编译器、依赖源码等冗余内容体积可以大幅缩小。# 第一阶段编译 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 第二阶段运行 FROM my-jdk:17 WORKDIR /app COPY --frombuilder /build/target/myapp.jar . CMD [java, -jar, myapp.jar]这种方式属于进阶玩法但对于工程化落地很实用。第一个阶段用的maven镜像只存在于构建环境最终交付的镜像只包含运行环境和 jar 包。4. 导出镜像到局域网三条可行路径与对比4.1 方案一docker save 和 docker load 离线传输这是最基础、最直接的方式。在能构建镜像的机器上执行# 导出镜像为 tar 包 docker save my-jdk:17 -o my-jdk-17.tar # 压缩减少拷贝体积 gzip my-jdk-17.tar # 传输到目标机器以 scp 为例 scp my-jdk-17.tar.gz user192.168.1.100:/opt/images/ # 在目标机器上解压并导入 gunzip my-jdk-17.tar.gz docker load -i my-jdk-17.tar需要注意的细节docker save会完整保留镜像的历史层和元数据docker load加载后镜像名称和 tag 不会丢失。这个方案适用于单台或几台机器的分发场景。如果需要批量发给几十台机器逐台docker load比较费劲可以先传到局域网里的共享存储再写脚本批量执行导入。压缩时建议用gzip就够了xz压缩率更高但慢JDK 镜像本身就几百 MB压缩后的产物不会太夸张。这个方案的局限是只能整机分发如果你构建了多个版本8/11/17每个版本都要单独压缩、传输、导入管理成本变高。4.2 方案二在局域网搭建 Docker Registry私有仓库机器数量超过 5 台建议直接上 Registry也就是 Docker 私有镜像仓库。部署一个registry容器其实很简单# 在局域网的一台机器上运行 registry docker run -d \ --name local-registry \ -p 5000:5000 \ --restartalways \ -v /data/registry:/var/lib/registry \ registry:2然后在每台需要拉取镜像的机器上配置/etc/docker/daemon.jsonWindows 版 Docker Desktop 是在 Settings 里配置{ insecure-registries: [192.168.1.50:5000] }接着重启 Docker。之后就可以像用 Docker Hub 一样操作了# 在构建机上打标签并推送 docker tag my-jdk:17 192.168.1.50:5000/my-jdk:17 docker push 192.168.1.50:5000/my-jdk:17 # 在目标机器上拉取 docker pull 192.168.1.50:5000/my-jdk:17insecure-registries这个配置是在告诉 Docker“这个地址用的是 HTTP 协议我可以信任它”和任何代理、加速器无关只是内网服务常见的部署方式。如果配置不当docker push时会报http: server gave HTTP response to HTTPS client这个经典错误这也是很多人第一次搭私有库时最容易遇见的问题。Registry 方案的优势是集中管理、按需拉取适合持续集成交付的场景。缺点是它本身也是一个容器需要一台稳定的服务器承载且数据要定期备份。4.3 方案三借助 HTTP 共享快速分发 tar 包还有一个取巧但很实用的办法不用 docker save 把镜像导成文件而是直接把镜像文件放在一台内网 HTTP 服务器上其他机器通过wget或curl下载后再docker load。举个例子在构建机上用 Python 起一个临时的 HTTP 服务cd /data/images python3 -m http.server 8080其他机器上执行wget http://192.168.1.50:8080/my-jdk-17.tar.gz gunzip my-jdk-17.tar.gz docker load -i my-jdk-17.tar这个方案比 scp 更简单特别适合临时分发不用配置密钥也不用装额外客户端。当然它没有 Registry 那样正式的版本管理能力用一次两次可以长期使用还是上 Registry 更规范。4.4 三种方式如何选场景推荐方式说明临时给 1-2 台机器装镜像docker save scp最直接链路最短10 台以下偶尔分发HTTP 共享 docker load无需额外服务方便常态化批量分发、配合 CI/CD私有 Registry版本管理规范、自动化程度高我的经验是起步阶段用方案一形成固定需求后直接跳到方案二。方案三可以作为一种备用应急手段谁也不能保证某台机器 Docker 配置一定改对了但docker load永远不会被网络策略拦住。5. 常见问题排查与避坑实录5.1 Docker Desktop 启动失败或虚拟化未开启用 Windows 装 Docker Desktop 时常见报错是Virtualization support not detected或启动后一直转圈。这类问题一般出现在未开启 BIOS 虚拟化的电脑上排查步骤是打开任务管理器 - 性能 - CPU检查“虚拟化”状态是否为“已启用”。如果没有启用重启进 BIOS找到 Intel VT-x或 AMD SVM选项开启后保存重启。开启后如果还启动失败检查 Windows 的“Hyper-V”或“虚拟机平台”功能是否打开。另外提醒一下Windows 的 Docker Desktop 需要 WSL 2 后端在 PowerShell 里执行wsl --status可以看到当前 WSL 版本。如果 WSL 没升级到 2可能会遇到内核版本过旧的报错。5.2 JDK 环境变量配置失败在容器里用java -version没反应首先要确认JAVA_HOME和PATH两个变量是否都已正确设置。进入容器检查docker exec -it 容器名 bash echo $JAVA_HOME echo $PATH如果JAVA_HOME是空的检查 Dockerfile 中ENV写法是否正确如果PATH里没有$JAVA_HOME/bin检查是否拼写错误。大多数所谓“配置失败”其实就是解压后的目录名和你写进环境变量里的目录名不一致。比如解压得到的目录是jdk-17.0.119而你在 Dockerfile 里写的是/opt/jdk-17那必然找不到。我的做法是解压后立刻用mv把目录改名为预期的短名称再进行验证。5.3 Dockerfile 中 COPY JDK 文件后构建报错COPY jdk-17_linux-x64_bin.tar.gz /tmp/报错no such file or directory十有八九是当前目录下没有这个文件。对着官方文档查了半天最后发现构建上下文放错了位置。这里有两个做法可以避免一是用.dockerignore把 JDK 压缩包排除免得打进镜像上下文二是在 Dockerfile 里使用相对路径时确认docker build是在 Dockerfile 所在的目录执行的。另外如果 JDK 包很大docker build传输上下文会比较慢可以尝试用.dockerignore限制上下文大小或者把 JDK 包放到其他目录后用COPY --from配合多阶段构建。5.4 docker save 后的 tar 包在目标机器 load 不成功这个报错通常是Error processing tar file或者unexpected EOF。原因大概率是 tar 包在传输过程中损坏或未完整写入。解决方法是校验 md5# 源机器上计算校验值 md5sum my-jdk-17.tar # 目标机器上核对 md5sum my-jdk-17.tar如果校验值不一致重新传一遍同时注意磁盘空间——docker load需要足够的空间存放展开后的镜像数据目标机器磁盘满也会导致 load 失败。5.5 导出后导入时镜像名和 tag 丢失docker save和docker load会保留镜像名和 tag但docker export和docker import不会。很多新人容易踩这个坑用docker export导出的是容器的文件系统快照把它用docker import重新导入时镜像是没有 tag 的需要手动指定。我建议直接使用docker save/docker load这两者的语义就是“镜像打包传输”最贴合镜像分发场景。只有当你只想保留文件系统、不要任何历史层时才考虑docker export。5.6 局域网内 docker pull 报 HTTP 错误前面提到过私有 Registry 用 HTTP 协议时需要在客户端配置insecure-registries。如果没配会报Error response from daemon: Get http://xxx:5000/v2/: http: server gave HTTP response to HTTPS client解决办法是在/etc/docker/daemon.json里加入该地址并重启 Docker。注意修改 daemon.json 后重启 Docker 是必须的这个操作在一些机器上会提示加载配置失败特别是有大量运行容器时——重启前先确认没有正在运行的关键容器。5.7 镜像时区不对构建完成后运行容器发现 Java 进程打印的日志时间和宿主机差了 8 小时。这就是我没在 Dockerfile 里配置时区的后果。虽然可以在启动命令里加-Duser.timezoneAsia/Shanghai但最佳方式还是在镜像层面统一。构建镜像时用ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime和echo Asia/Shanghai /etc/timezone处理就能一劳永逸。5.8 基础镜像源失效的应对CentOS 7 停止维护后yum install经常报Could not resolve host: mirrorlist.centos.org。建议换用可用的镜像源比如阿里云或网易的开源镜像站。具体做法是修改/etc/yum.repos.d/CentOS-Base.repo中的baseurl配置。这些操作在 Dockerfile 的 RUN 阶段完成即可例如RUN sed -i s|mirrorlist|#mirrorlist|g; s|#baseurlhttp://mirror.centos.org|baseurlhttp://mirrors.aliyun.com|g /etc/yum.repos.d/CentOS-Base.repo \ yum clean all \ yum makecache不过要注意如果构建机器本身也无法访问外网那连yum都用不了。这种情况下建议直接用现成的精简基础镜像作为 base而不是先装一个完整系统再裁剪。6. 几个从实战里沉淀的经验最后分享几条纯个人体会不成系统但每条都是从实际项目里踩出来的。第一镜像构建环境要固定。我建议团队里指定一台“构建机”所有镜像都在同一台机器上构建。这样做的好处是基础镜像层缓存能共享JDK 压缩包能提前下载好放在统一目录后续审计时也知道镜像来源。多人各自在自己电脑上构建容易产出差异很大的镜像反而增加一致性风险。第二镜像的 tag 要有规则。建议格式是项目名-jdk版本-构建日期例如my-jdk-17-20250115配合私有 Registry 使用时还能加上环境前缀如prod/my-jdk:17-20250115。tag 规则一旦定了就坚持别今天17明天latest——latest在交付场景里是最不靠谱的因为你根本不知道它指代哪个具体构建。第三每次构建完先做一轮冒烟验证。构建出镜像后第一件事不是导出而是用这个镜像起一个容器跑一下java -version再跑一个最简单的 Spring Boot 或普通 Java 程序确认 JDK 可用、时区正确、基础依赖齐全。这步排查的成本远低于把镜像铺到所有生产机器上再发现问题的代价。第四导出的 tar 包建议保留一段时间。在 Docker Registry 普及之前tar 包就是最原始的备份。我在本地保留了一个images目录存放每个版本的 tar 包命名规范、附上构建说明文件。真到了需要快速恢复到某个历史版本的时候这个目录能救命。第五理解镜像和容器的区别是基本功。docker save保存的是镜像docker export导出的却是容器。很多人混淆导致导出导入后镜像结构不完整。我自己的记忆方法是镜像像“源代码”容器像“运行中的进程”带历史层的镜像适合分发纯文件系统快照只适合迁移数据或调试现场。这整套流程跑通了之后会发现维护一组自定义 JDK 镜像一点都不复杂。真正花时间的反而是最开始的设计阶段——把基础镜像、JDK 发行版、版本命名规则、分发方式这些想清楚后续就是一条稳定的重复工作。现在的做法基本是本地改 Dockerfile、构建、push 到内网 Registry、应用机器 pull全流程几分钟搞定再也不受外网波动影响了。希望这篇文章能帮你把这条链路也跑通少踩几个我已经踩过的坑。
返回列表