容器化部署这件事绕不开的一个基础动作就是自己构建镜像。尤其是跑 Java 服务官方基础镜像要么体积大得离谱要么缺东少西最后还得自己动手。这次我就拿“Dockerfile 构建包含 JDK 的 Ubuntu 镜像”这个最典型也最实用的场景把整个思路、参数选择、踩坑记录完整拆一遍。不管你是刚接触容器的新手还是已经写过几个 Dockerfile 的老手这篇内容都能让你少走弯路——我会从镜像分层原理讲到 JDK 版本选型从构建缓存优化讲到生产环境的瘦身技巧把每一步“为什么这么做”都讲透。1. 整体设计思路与方案选型1.1 为什么不用现成的 openjdk 官方镜像很多人第一反应是直接FROM openjdk:17省事。但我实测下来这条路在真实项目里问题不少。官方 openjdk 镜像基于 Debian 或 Alpine前者体积动辄 400MB 起步后者虽然小但用的是 musl libc某些依赖 glibc 的本地库比如部分加密库、图像处理库会直接报错排查起来非常折磨人。自己基于 Ubuntu 构建的好处在于基础系统可控、依赖库齐全、团队熟悉度高。Ubuntu 的 apt 生态成熟装什么工具都方便出了问题查资料也容易。代价是镜像体积会偏大但这个问题可以通过多阶段构建和清理缓存来解决后面会详细讲。另一个考量是版本锁定。官方镜像的 tag 会随上游更新而变化今天openjdk:17拉下来和三个月后拉下来可能不是同一个东西。自己写 Dockerfile 把 Ubuntu 版本和 JDK 版本都钉死构建结果可复现这在生产环境里是硬性要求。1.2 基础镜像版本怎么选Ubuntu 的 LTS 版本是首选非 LTS 支持周期只有 9 个月不适合做基础镜像。当前主流选择集中在 20.04 和 22.04 两个 LTS 上。我的建议是版本优势适用场景ubuntu:20.04生态最成熟老项目兼容性好遗留系统迁移、依赖老版本系统库ubuntu:22.04更新的工具链安全补丁更及时新项目、对安全性要求高的场景注意不要用ubuntu:latest这个 tag 会随上游漂移今天构建和明天构建结果可能不同。生产环境必须用具体版本号甚至加上 digest 摘要锁定。1.3 JDK 还是 JRE这是个问题标题说的是 JDK但实际部署时要想清楚运行 Java 应用只需要 JRE编译才需要 JDK。如果你是在镜像里跑构建比如 CI 环境那必须 JDK如果只是运行打包好的 jarJRE 就够了体积能省下 100MB 左右。不过现实情况是很多运维脚本、诊断工具比如 jstack、jmap都依赖 JDK 里的工具生产环境排查问题时没有这些工具会非常被动。所以我个人的做法是构建阶段用完整 JDK运行阶段如果追求极致体积可以用 jlink 裁剪出定制运行时。这个进阶玩法后面单独讲先把标准 JDK 镜像的构建流程走通。1.4 发行版选择OpenJDK 还是商业发行版Ubuntu 的 apt 源里自带 openjdk 包直接apt install openjdk-17-jdk就能装。但要注意Ubuntu 仓库里的 JDK 版本更新往往滞后而且不同 Ubuntu 版本对应的 JDK 大版本有限制。比如 20.04 默认源里最高到 JDK 17想装 JDK 21 就得手动加源或者下载压缩包。另一种做法是下载官方或商业发行版的 tar.gz 包手动解压。这种方式版本完全可控也不依赖 apt 源的状态缺点是下载链接需要自己维护构建时对网络环境有要求。我一般推荐在 Dockerfile 里用 apt 装简单可靠如果对 JDK 版本有严格要求再考虑手动下载的方式。2. Dockerfile 核心细节与实操要点2.1 一个能直接用的基础版本先给一个我日常用的最小可用版本后面再逐步优化FROM ubuntu:22.04 ENV DEBIAN_FRONTENDnoninteractive \ LANGC.UTF-8 \ JAVA_HOME/usr/lib/jvm/java-17-openjdk-amd64 \ PATH$JAVA_HOME/bin:$PATH RUN apt-get update \ apt-get install -y --no-install-recommends \ openjdk-17-jdk-headless \ ca-certificates \ tzdata \ rm -rf /var/lib/apt/lists/* WORKDIR /app CMD [bash]这个 Dockerfile 看着简单但每一行都有讲究我逐条拆解。2.2 ENV 里的三个关键变量DEBIAN_FRONTENDnoninteractive这行是必须的。apt 在安装某些包时会弹出交互式配置界面比如时区选择在 Docker 构建这种非交互环境里会卡住甚至报错。设成 noninteractive 让它自动用默认值构建才能顺利跑完。LANGC.UTF-8解决的是中文乱码问题。Ubuntu 基础镜像默认 locale 是 POSIXJava 应用里处理中文、日文等非 ASCII 字符时可能出现乱码。设成 UTF-8 是最稳妥的。这里有个细节只设 LANG 有时不够某些程序会读 LC_ALL保险起见可以两个都设。JAVA_HOME和PATH的配合是让系统能找到 java 命令。注意路径里的java-17-openjdk-amd64这个目录名是 Ubuntu 的约定不同架构arm64 等目录名会不同。如果你要在多架构环境构建这个路径写死会有问题后面讲多架构适配时再说。2.3 为什么选 headless 包openjdk-17-jdk-headless相比openjdk-17-jdk少了图形界面相关的依赖比如 X11 库。服务器环境根本不需要 GUI装完整版纯属浪费空间还会引入一堆用不上的库。headless 版本体积能小几十 MB依赖也更干净。但要注意headless 版本里字体相关的包是缺失的。如果你的 Java 应用涉及生成图片、PDF 里带文字、验证码这类功能可能会遇到字体找不到的报错。这种情况需要额外装fontconfig和字体包比如RUN apt-get install -y --no-install-recommends \ fontconfig \ fonts-dejavu-core这个坑我踩过当时一个生成报表的服务在本地跑得好好的进了容器就报Fontconfig head is null查了半天才发现是 headless 包缺字体。2.4 清理 apt 缓存的正确姿势rm -rf /var/lib/apt/lists/*这行必须和apt-get install写在同一个 RUN 指令里。原因涉及 Docker 的分层机制每条 RUN 指令产生一个镜像层如果你把安装和清理分成两条 RUN安装产生的缓存文件会留在上一层里清理只是在新层里“标记删除”镜像体积并不会真正减小。这是新手最容易犯的错误之一。我见过不少 Dockerfile 把apt-get update、apt-get install、rm -rf分成三条 RUN结果镜像白白大了几十 MB。记住原则同一批操作产生和清理的文件必须在同一个 RUN 里完成。2.5 时区设置不能忘tzdata这个包要显式装上并且建议设置时区ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone不设时区的话容器默认是 UTCJava 应用打出来的日志时间会比北京时间差 8 小时排查问题时对时间线非常痛苦。这个细节虽小但生产环境必须处理。3. 完整构建流程与优化实践3.1 构建命令与参数说明Dockerfile 写好后构建命令本身也有讲究docker build -t my-jdk-base:17-ubuntu22.04 .-t后面的 tag 命名建议带上 JDK 版本和基础系统版本比如17-ubuntu22.04这样团队里谁用都清楚这个镜像里是什么。别用latest多个版本并存时根本分不清。如果构建时网络慢可以配置构建参数走镜像加速但这里不展开重点讲 Dockerfile 本身的优化。3.2 多阶段构建瘦身前面说了完整 JDK 镜像体积偏大。如果你的场景是“构建时用 JDK运行时只要 JRE”可以用多阶段构建# 构建阶段 FROM ubuntu:22.04 AS builder ENV DEBIAN_FRONTENDnoninteractive RUN apt-get update \ apt-get install -y --no-install-recommends openjdk-17-jdk-headless \ rm -rf /var/lib/apt/lists/* WORKDIR /build COPY . . RUN ./gradlew build # 运行阶段 FROM ubuntu:22.04 ENV DEBIAN_FRONTENDnoninteractive \ JAVA_HOME/usr/lib/jvm/java-17-openjdk-amd64 \ PATH$JAVA_HOME/bin:$PATH RUN apt-get update \ apt-get install -y --no-install-recommends openjdk-17-jre-headless \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --frombuilder /build/build/libs/app.jar . CMD [java, -jar, app.jar]这样最终镜像里只有 JRE没有编译工具链体积能小一大截。注意运行阶段装的是jre-headless而不是jdk-headless这是关键区别。3.3 用 jlink 裁剪定制运行时进阶如果追求极致体积可以用 JDK 自带的 jlink 工具只保留应用实际用到的模块。比如一个纯 Web 服务可能只需要java.base、java.logging、java.sql这几个模块FROM ubuntu:22.04 AS jdk ENV DEBIAN_FRONTENDnoninteractive RUN apt-get update \ apt-get install -y --no-install-recommends openjdk-17-jdk-headless \ rm -rf /var/lib/apt/lists/* RUN jlink \ --add-modules java.base,java.logging,java.sql,java.naming \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress2 \ --output /custom-jre FROM ubuntu:22.04 COPY --fromjdk /custom-jre /opt/jre ENV JAVA_HOME/opt/jre \ PATH/opt/jre/bin:$PATH裁剪后的运行时可能只有 40-50MB比完整 JRE 小很多。但要注意模块列表必须覆盖应用所有依赖漏了某个模块运行时会报ClassNotFoundException或NoClassDefFoundError。建议先用jdeps分析应用的依赖模块再确定 jlink 的参数。这个方案适合对镜像体积敏感的场景普通项目用标准 JRE 就够了不必过度优化。3.4 构建缓存优化Docker 构建时会复用未变化的层。把变化频率低的操作放前面变化频率高的放后面能显著加快重复构建速度。比如# 先装系统依赖很少变 RUN apt-get update apt-get install -y ... rm -rf /var/lib/apt/lists/* # 再设环境变量很少变 ENV JAVA_HOME... # 最后拷应用代码经常变 COPY app.jar /app/这样改应用代码时前面的系统依赖层直接命中缓存不用重新装 JDK构建时间从几分钟降到几秒。3.5 验证镜像是否可用构建完别急着用先验证几件事# 检查 Java 版本 docker run --rm my-jdk-base:17-ubuntu22.04 java -version # 检查 JAVA_HOME 是否正确 docker run --rm my-jdk-base:17-ubuntu22.04 bash -c echo $JAVA_HOME # 检查时区 docker run --rm my-jdk-base:17-ubuntu22.04 date # 检查镜像体积 docker images my-jdk-base:17-ubuntu22.04java -version的输出要确认版本号符合预期date的输出要确认时区正确。这几步花不了一分钟但能避免后面部署时才发现问题。4. 常见问题排查与避坑实录4.1 构建时 apt 报错连不上源最常见的问题是apt-get update卡住或报Could not connect。原因通常是构建环境的网络策略限制或者 DNS 配置有问题。排查思路先确认宿主机能不能访问软件源再检查 Docker 的 DNS 配置。如果是企业内网环境可能需要配置内部镜像源。另一个隐蔽的坑是构建缓存导致的假成功。有时候apt-get update命中了旧缓存但源里的包索引已经过期install 时就会报找不到包。解决办法是在 update 时加--fix-missing或者干脆docker build --no-cache强制不用缓存重建一次确认是不是缓存问题。4.2 java 命令找不到明明装了 JDK运行java -version却报command not found。九成是JAVA_HOME路径写错了。Ubuntu 上不同 JDK 版本的安装路径不一样比如openjdk-11-jdk-headless→/usr/lib/jvm/java-11-openjdk-amd64openjdk-17-jdk-headless→/usr/lib/jvm/java-17-openjdk-amd64openjdk-21-jdk-headless→/usr/lib/jvm/java-21-openjdk-amd64而且 amd64 和 arm64 架构的目录名不同arm64 上是java-17-openjdk-arm64。写死路径在多架构环境会翻车。稳妥的做法是用update-alternatives或者动态查找RUN ln -s $(dirname $(dirname $(readlink -f $(which javac)))) /opt/java-home ENV JAVA_HOME/opt/java-home这样不管什么架构、什么版本都能正确指向 JDK 安装目录。4.3 中文乱码前面提过 LANG 设置但有时候设了还是乱码。检查两点一是 locale 是否真的生成了Ubuntu 最小镜像里可能没有 en_US.UTF-8 的 locale 数据需要locale-gen二是应用本身的编码配置比如 JVM 参数里的-Dfile.encodingUTF-8。容器层面设好 LANG 只是第一步应用层也要配合。4.4 镜像体积异常大如果构建出来的镜像比预期大很多按这个顺序排查现象可能原因解决比预期大几十 MBapt 缓存没清或清理在不同层合并到同一 RUN比预期大上百 MB装了完整 JDK 而非 headless换 headless 包持续增大构建产物、日志被 COPY 进去加 .dockerignore莫名很大基础镜像本身大换更小的基础镜像.dockerignore这个文件经常被忽略。构建时 Docker 会把整个上下文目录发给守护进程如果目录里有 node_modules、.git、日志文件不仅拖慢构建还可能被误 COPY 进镜像。建议至少包含.git *.log node_modules target build4.5 时区不生效设了TZ环境变量但date还是 UTC。原因是只设环境变量不够还要让系统时区文件指向正确位置。前面给的ln -snf命令就是干这个的。另外注意tzdata包必须装上否则/usr/share/zoneinfo目录不存在链接会失败。4.6 多架构构建的坑在 Apple Silicon 或 ARM 服务器上构建再推到 x86 环境运行会遇到架构不匹配。用docker buildx可以构建多架构镜像docker buildx build --platform linux/amd64,linux/arm64 -t my-jdk-base:17 .但要注意Dockerfile 里如果写死了amd64的路径比如 JAVA_HOME在 arm64 上就会出错。解决办法是用前面说的动态查找方式或者用构建参数区分ARG TARGETARCH ENV JAVA_HOME/usr/lib/jvm/java-17-openjdk-${TARGETARCH}TARGETARCH是 buildx 自动注入的变量amd64 架构下值是amd64arm64 下是arm64正好对上 Ubuntu 的目录命名。5. 生产环境加固与最佳实践5.1 用非 root 用户运行默认容器里是 root 用户这在生产环境是安全隐患。应该创建专用用户RUN groupadd -r appuser useradd -r -g appuser appuser USER appuser注意USER指令要放在装完系统依赖之后因为装包需要 root 权限。另外应用的工作目录权限要提前设好否则非 root 用户可能没权限写文件。5.2 固定镜像摘要前面说过 tag 会漂移更严格的做法是固定 digestFROM ubuntu:22.04sha256:具体摘要值摘要值可以通过docker inspect查看。这样即使上游重新推送了同名 tag你构建的镜像也不会变。代价是升级基础镜像时要手动改摘要适合对稳定性要求极高的场景。5.3 健康检查与信号处理Java 应用在容器里跑要注意两点一是 JVM 要能正确响应 SIGTERM 信号实现优雅停机二是配置健康检查让编排系统知道容器状态。前者需要在启动脚本里用exec java ...而不是java ...让 java 进程成为 PID 1 直接接收信号后者可以加HEALTHCHECK --interval30s --timeout3s \ CMD curl -f http://localhost:8080/health || exit 1不过健康检查依赖 curl如果镜像里没装就得补上或者用其他方式探测。5.4 安全扫描别省镜像构建完建议跑一次漏洞扫描看看基础系统和依赖包有没有已知问题。很多 CI 平台都集成了扫描工具本地也可以用命令行工具扫。扫描结果里基础镜像的漏洞往往占大头这也是为什么要定期更新基础镜像版本——不是构建完就一劳永逸了。我在实际项目里的体会是这个 JDK 基础镜像最好作为团队的公共基础镜像维护统一版本、统一配置各个业务镜像都FROM它。这样 JDK 升级、安全补丁只需要改一处所有业务镜像重新构建就能继承。比每个项目各写一份 Dockerfile 要省心得多也避免了版本碎片化。维护的时候把 Dockerfile 和构建脚本放进代码仓库配上 CI 自动构建推送团队里谁需要直接拉就行。
阅读完成 · 觉得有帮助?