ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java容器化实战:Dockerfile编写、多阶段构建与JVM调优指南

Java容器化实战:Dockerfile编写、多阶段构建与JVM调优指南

1. 为什么要把 Java 程序打包成镜像

这年头要是还没把 Java 服务容器化,出去跟同行聊天都有点抬不起头。但说实话,光看那些网上的 Docker 教程,把一个大项目塞进镜像里那几个命令看着挺简单,真到自己动手的时候,一个时区问题、一个 JVM 内存参数,就能折腾掉你一整天。这篇文章要聊的就是 Dockerfile 的编写,以及怎么把一个 Java 程序打包成镜像,从最基础的指令讲到多阶段构建,再讲点生产环境才会踩到的坑。

先说清楚这东西到底解决什么问题。传统部署 Java 应用,流程通常是:打包出 Jar 包,拷到服务器上,装个 JDK,然后 java -jar 跑起来。听起来没什么问题,但一换服务器就得重新装环境,一换 JDK 版本可能就出兼容性问题,别人接手你的项目还得先配半天环境。Docker 镜像把这些全打包到一起了,代码、运行时、依赖,全在一个镜像里,拿到任何装 Docker 的机器上都能直接跑,一次构建,随处运行。这才是容器化最实在的价值。

这篇内容适合谁看?刚接触容器化、第一次自己写 Dockerfile 打包 Java 服务的开发者,以及在本地能跑但一上 Docker 就到处是 Bug,想搞清楚为什么的人。我默认你有一些 Java 和 Maven 的基础,知道 Spring Boot 大概是个什么东西,但对 Docker 本身不求甚解,只想快速把项目容器化跑起来。如果你已经是老手,也可以跳着看后面的避坑部分,那些内容大概率能帮上忙。

我得先提醒一句,Docker 入门不难,难的是理解它的设计思想。同样的一个 Jar 包,有人打包出来镜像 800 多兆,跑起来内存动不动就爆;有人打包出来一百多兆,稳定运行几个月都不出问题。这中间的差距,不是命令记不记得住,而是你对 Dockerfile 每一行的含义到底有没有真正理解。

2. Dockerfile 核心指令逐行拆解

2.1 基础选择:FROM 并不是越新越好

所有 Dockerfile 的第一行基本都是 FROM,它决定了你的镜像是站在哪个巨人肩膀上。对于 Java 程序来说,最常见的两个选择:一个是直接用带 JDK 的基础镜像,比如 openjdk:8-jdk-alpine、eclipse-temurin:8-jdk-alpine;另一个是用 Maven 镜像来构建,再用一个精简的 JRE 镜像来运行。

很多新手喜欢用 openjdk:latest,图省事。但我一般不推荐这么干,除非你就是本地做着玩。原因有几个:latest 标签的镜像体积大不说,它对应的底层 Linux 发行版变化也比较快,今天拉下来是一个版本,过几个月再拉可能就是另一个版本了,同样的代码跑出来的行为可能出现细微差异。生产环境讲究的是可重复,镜像的 tag 一定要精确到具体版本号,比如 openjdk:8u212-jdk-alpine,这样无论什么时候构建,拿到的底层系统是一致的。

另外要注意一个点,openjdk 官方镜像已经比较久没更新了,现在社区更多推荐 Eclipse Temurin 的镜像,这是 Adoptium 项目维护的,带 TCK 认证的 JDK 构建版本。如果你在用 JDK 11 或 JDK 17,直接搜 eclipse-temurin 系列的镜像,通常能找到带 -jdk 和 -jre 的不同版本。生产环境我会优先选 JDK 本身,因为有些 Java 程序运行时会动态生成字节码,或者用了一些需要编译期的工具库,只装 JRE 会踩到 ClassNotFoundException 的坑。

2.2 WORKDIR 和 COPY:你在镜像里的落脚点

WORKDIR 指令是很多教程一笔带过但实际很重要的配置,它指定了容器的工作目录。写过 Java 项目的人应该对相对路径有感觉,容器里跑的程序如果不固定工作目录,到了不同机器上,相对路径引用的文件可能就找不到了。用 WORKDIR /app 先把应用的根目录定死,后面所有操作都在这个目录下进行,包括 COPY 的路径解析、RUN 命令的默认执行位置、ENTRYPOINT 启动时的当前路径,全部统一了。

WORKDIR 还有个容易被忽略的特性,它会在目录不存在时自动创建,不需要先 RUN mkdir。这个设计省掉了不少事,但副作用是如果你的 Dockerfile 里把 WORKDIR 写错了层级,比如多写了一层嵌套目录,容器里会静默创建这个目录,你还不容易察觉,最后发现文件 COPY 到了错误位置,排查起来还挺费劲的。

COPY 指令在这个阶段就是把本地的文件或目录复制到镜像里。语法是 COPY [源路径] [目标路径],源路径只能是构建上下文里的内容,这一点要特别记住。你在 Dockerfile 里写 COPY ../something /app,构建的时候会直接报错,理由是 COPY 不能引用上下文之外的文件。这个我后面会单独展开讲,因为构建上下文的理解直接决定了你的构建速度快慢和镜像是否臃肿。

2.3 RUN 指令:构建时执行,不是运行时执行

RUN 是在构建镜像的过程中执行的命令,每一条 RUN 都会生成一个新的镜像层。这也是很多新手一开始搞不清楚的地方,分不清 RUN 和 CMD/ENTRYPOINT。简单说,RUN 做的是构建时的事情,比如装依赖、下工具、修改文件权限;CMD 和 ENTRYPOINT 做的是容器启动时的事情,比如执行 java -jar。一个镜像的构建过程,本质就是不断叠加层的过程,每一条指令产生的变化都作为一层写入镜像。

这里就引出了 Dockerfile 优化的第一个核心原则:尽量减少 RUN 的使用次数,合并相关命令。因为每一层都对应镜像的体积增量,层数越多,镜像越大,构建越慢。实际操作中我常用的做法是,把需要执行的多条 shell 命令用 && 连接起来,合并成一条 RUN。另外还有一个反直觉的点,RUN 里用 rm 删除文件并不能帮镜像减重,因为删除动作是在这一层发生的,而前面一层里那个文件还存在于历史层中。真正能让镜像瘦身的,是后面要讲的多阶段构建。

RUN 指令的执行方式也值得注意,它默认通过 /bin/sh -c 来执行,所以在不同基础镜像里对 shell 语法的支持可能不一样。比如 alpine 镜像默认的 shell 是 sh,不是 bash,某些你在 Ubuntu 上写的 bash 语法,比如数组操作,搬过来可能就报错了。这个问题容易出现在用 alpine 做基础镜像的场景,后面会单独聊。

2.4 ENV 与 ARG:构建参数别搞混

ARG 和 ENV 表面上看都是在 Dockerfile 里设置变量的,区别却非常实际:ARG 是构建时的参数,只存在于构建过程,容器运行时就没了;ENV 是环境变量,不仅构建时生效,容器运行时也生效。两者可以配合使用,给 ENV 赋默认值,同时用 ARG 允许外部覆盖。

Java 程序对 ENV 的需求尤其明显,最常见的场景是 JVM 内存参数。很多人把 -Xmx 参数直接写死在 ENTRYPOINT 里,结果容器一到不同环境就跑不起来。比如你本地开发机器内存大,写了 -Xmx2g,到了生产容器里内存限制只有 1g,JVM 启动会直接报错,或者频繁触发 Full GC。更合理的做法是,在 Dockerfile 里用 ENV 把 JVM 参数声明成可覆盖的变量,然后在启动命令里引用这个变量,运行时再用 -e 参数覆盖。这样同一个镜像,在不同规格的机器上都能自适应,运维也省心。

ARG 的另一个典型用途是传递版本号。比如你在一个多环境交付的场景里,同一个项目需要构建多个不同版本的服务镜像,可以让构建脚本通过 --build-arg VERSION=xxx 传入版本,Dockerfile 里用 ARG VERSION 接收,然后在 COPY 或 RUN 里引用。这种方式配合 CI 流水线,能做到一份 Dockerfile 多处复用,不用为每个环境单独维护一份。

2.5 EXPOSE 和 HEALTHCHECK:别把端口声明当端口映射

EXPOSE 是一个经常被误解的指令,它的含义很简单,只是声明这个镜像运行时可能会使用的端口,属于文档性质的说明,并不会真的把宿主机的端口映射到容器里。实际要让外部访问到容器内的服务,必须在 docker run 时用 -p 参数做端口映射,或者用 --network host 让容器直接使用宿主机网络。这一点新手特别容易卡住,在 Dockerfile 里写了 EXPOSE 8080,然后 docker run 之后发现外面访问不到,就开始怀疑是不是 EXPOSE 写错了。

从规范角度讲,EXPOSE 仍然是推荐要写的,它是镜像的使用说明书,可以让拿到镜像的人一眼看出这个服务默认跑在哪个端口上。在 Docker 的元数据里也会记录这个信息,某些容器平台会自动读取并给出端口提示,只是不承担实际的端口转发功能。

HEALTHCHECK 是我建议有条件就加上的一条指令。生产环境里镜像的存活不等于服务的健康,比如你的容器进程还活着,但内部的线程池已经耗尽,或者数据库连接断了,这时候从外部看端口还在监听,负载均衡就不会把流量摘掉,用户请求一路超时。有了 HEALTHCHECK,容器平台就能周期性地探测真实业务状态,自动把不健康的实例摘除或重启。Java 服务常用的健康检查方式是访问一个检测端点,或者在容器里执行命令检查进程状态。对 Spring Boot 应用,一般用 Actuator 的 health 端点做 HTTP 探测,设置好 interval 和 timeout 参数后,能在调度平台里更智能地管理实例。

3. 构建前后端分离的现实问题:JVM 在容器里的表现

3.1 Java 程序在容器里的默认行为

很多人第一次把 Java 程序容器化后都会遇到同一个怪现象:本地跑得好好的,内存占用也就几百兆,进了容器直接 OOM 被杀,或者频繁 Full GC 卡到超时。这不是代码的问题,是 JVM 在容器里的默认行为和老版本 JDK 的已知问题叠加的结果。

在 JDK 8u191 之前的版本里,JVM 并不认容器给的内存和 CPU 限制,它只看宿主机上的真实配置。也就是说,你在宿主机上限制了容器只能用 512MB 内存,但 JVM 启动时默认按宿主机 64GB 内存来算堆大小,直接把 MaxHeap 设置成了物理内存的四分之一,也就是 16GB。结果可想而知,容器内存一超限,直接被内核 OOM Killer 干掉,而你的程序完整日志还没打出来几行,就是一句 exited: signal: 9 或者类似信息。

这也是为什么很多人升级到 JDK 8u191 或者使用 JDK 9 以上之后,发现容器 OOM 的现象自动消失了。从那个版本开始,JVM 默认启用了容器感知特性,能自动读取 cgroup 的配额,根据容器限制的内存合理调整堆大小。但这不代表你可以完全不设参数,自动计算出的堆大小往往只是保证不 crash,不一定是最优值。实际的线上经验是,建议显式设置 -XX:MaxRAMPercentage 这类比例参数,让堆上限跟随容器配额动态伸缩,而不是写死一个 -Xmx 绝对值。

3.2 内存参数设置的黄金配比

这里我要给出一个实际验证过的经验配置。假设你的服务是常规 Web 应用,没有本地缓存大量数据的需求,在容器里分配 512MB 到 1GB 是常见的配置。在启动参数里,我会这样设置:

java -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=25.0 -XX:MaxMetaspaceSize=128m -jar app.jar

MaxRAMPercentage 设置为 75 而不是 100,是因为 JVM 占用的内存不仅仅是堆,还有 Metaspace、线程栈、JIT 编译器、GC 相关的数据结构等等。堆外的这些开销同样要计入容器的配额里。如果堆设成 100%,堆外内存一涨,容器照样 OOM。留出四分之一给堆外,是我在多次生产事故之后总结出来的安全线。

另外一个容易被人忽略的是 Metaspace 的配置。默认情况下 Metaspace 没有上限,如果加载的类特别多,或者有频繁的动态代理生成,Metaspace 可能涨得远超预期。配一个 MaxMetaspaceSize 至少有兜底的作用。

关于容器内的 CPU 限制,要留意的一点是 JVM 在容器里看到的 CPU 数量和容器的实际配额有关。如果你限制了容器只能用 1 个 CPU,JVM 默认 GC 线程数可能还是会按宿主机物理核数来算,导致频繁的线程上下文切换。常见的做法是显式设置 -XX:ActiveProcessorCount=2 这样的参数,让 GC 线程数匹配容器限额。这个参数在 JDK 8u192 之后才有,老版本没有,需要额外注意。

3.3 时区问题:Java 程序容器化的经典坑

时区这个问题,本地开发几乎碰不到,一到容器里就集体翻车。原因很简单,很多精简基础镜像(尤其是 alpine 系列)默认的时区是 UTC,而国内业务普遍用北京时间。如果一个 Java 服务在容器里打印时间日志,或者处理定时任务,经常出现日志时间和本地时间差 8 个小时的情况。数据库插入的时间也会跟着错乱,你以为存的是中午 12 点,实际落库成了凌晨 4 点,排查业务数据异常的时候极难联想到时区上面去。

解决方式是在 Dockerfile 里明确设置时区,不依赖基础镜像的默认值。对于基于 Debian 的镜像,可以在 RUN 里执行安装 tzdata 包并设置环境变量,对于 alpine 镜像,可以通过安装 tzdata 包或者直接设置 TZ 环境变量的方式处理。最省事的方法是在 Dockerfile 里加一行 ENV TZ=Asia/Shanghai,然后在启动命令里配合 date 校验一下容器内时间。如果镜像里已经带了 /usr/share/zoneinfo 文件,这个环境变量就够用;如果没有,需要先装 tzdata。

时间问题看起来小,但在定时任务密集的服务里特别容易出事。我见过线上跑批任务因为时区差导致的前后两批数据重叠覆盖事故,最后定位到根因就是容器时区没有设置。在这里放一句实在话:容器化之后,时间问题不是小问题,请务必在早期就把它处理掉。

4. 镜像瘦身实践:多阶段构建

4.1 为什么你的镜像几百兆,别人只有一百多兆

把 jar 包直接 COPY 进 JDK 镜像里,镜像体积看着不大,也就三四百兆。但如果你的构建过程是把整个项目目录复制进去,然后 Maven 下载依赖、编译、打包,一条命令走到底,那镜像里就残留了完整的 Maven 仓库、编译中间产物、源码文件,体积直接飙升到 1GB 以上。解决这个问题的思路叫做多阶段构建。

多阶段构建的核心思想,是在一个 Dockerfile 里分多个 FROM 段,每个阶段使用不同的基础镜像,只有最后一个阶段的文件才会出现在最终镜像里。前面阶段产生的东西,比如编译好的 class 文件、包装好的 jar 包,可以通过 COPY --from 指令复制到最终阶段。构建工具和依赖包,只存在于中间阶段的临时容器里,构建结束后这些数据就销毁了。

这一步是镜像瘦身的最大杀器。下面是适用于 Maven 项目的多阶段构建实践思路:

# 第一阶段:依赖下载与编译打包 FROM maven:3.8-openjdk-11 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B # 第二阶段:仅保留运行所需文件 FROM eclipse-temurin:11-jre WORKDIR /app COPY --from=builder /build/target/app.jar ./app.jar ENV TZ=Asia/Shanghai EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]

这样最终镜像里只有 JRE 和 app.jar,体积可以控制在 150MB 左右,而如果直接用一个含 Maven 和完整仓库的阶段来运行,体积轻松超过 800MB。对分发镜像或者离线部署的场景,这个差距是致命的。

4.2 依赖缓存优化:构建速度比别人快一倍

多阶段构建解决体积问题,但依赖下载速度是另一个痛点。每次从零构建,Maven 都要把所有依赖重新下载一遍,尤其是一个大型项目几百个依赖的时候,这一下可能就要几分钟甚至十几分钟。解决办法是把依赖下载和源码编译拆成两个 RUN。

第一步只把 pom.xml COPY 进构建阶段,然后执行 mvn dependency:go-offline,把项目依赖一次性拉取到本地仓库。这一步生成的层是相对稳定的,只要 pom.xml 不变,这个层就不变,Docker 构建时可以直接命中缓存,不用重新下载。第二步再把 src 目录 COPY 进去,执行 mvn package,那一步因为依赖已经就位,运行很快,而且每次代码改动也只会重新执行这一步。

这种方式对 CI 流水线的收益非常明显。假设你一天要构建二十次代码,每次省下七八分钟下载依赖的时间,累积省下接近三个小时。构建缓存的原理本质上是 Docker 层的复用机制,理解了这一层,Dockerfile 的优化就不是死记配置,而是合理利用层缓存。

4.3 构建上下文:为什么你的 Docker build 总是慢得离谱

还有一个被广泛忽略的性能问题,是构建上下文太大。docker build 执行时,会把指定的上下文目录整个打包发送给 Docker 守护进程,即使 Dockerfile 里根本没用到那些文件,传输过程仍然会发生。很多项目仓库里有 node_modules、target、.git 这类体积大户,新手不管三七二十一,直接 docker build .,几次下来就发现构建过程卡到哭。

解决办法是维护一个 .dockerignore 文件,语法和 .gitignore 类似,明确排除不需要的文件和目录。下面是一份比较常见的 Java 项目 .dockerignore 参考:

.git target .idea *.iml *.log node_modules

这样构建时 Docker 就不会加载这些垃圾文件,不仅构建速度快了,也避免了一些项目里的敏感文件被意外打进镜像层的风险。构建上下文的优化,带来的体验提升是立竿见影的,我建议所有项目创建初期就维护一份 .dockerignore,别等构建卡了再来补。

5. 完整实操:从项目文件到成功运行的镜像

5.1 准备一个可运行的 Spring Boot 项目

为了演示完整流程,我用一个最普通的 Spring Boot 项目来说明。假设项目结构如下:

my-app/ ├── pom.xml ├── .dockerignore └── src/ └── main/ ├── java/com/example/Application.java └── resources/application.yml

打包前先在本地跑一次 mvn clean package,确认能正常生成 target/my-app-1.0.0.jar,然后启动一下,确保代码本身没问题。这一步看似多余,但能帮你在后续排查问题时把 Docker 相关变量压缩到最小。很多朋友在容器里遇到问题,第一反应是怪 Dockerfile,最后查来查去发现是代码本身在某种环境下的配置文件有问题,白白浪费调试时间。

application.yml 里如果想验证容器内时区和环境变量这种细节,可以预埋两个测试项,比如把日志时间和某个配置项打出来,这样容器启动后一眼就能判断配置是否生效。当然实际开发里更多是用配置中心来做这些,这里为了演示图省事就这么处理。

5.2 编写完整的 Dockerfile

下面是一份我在实际项目里验证过的 Dockerfile,覆盖了上面讲到的多阶段构建、时区设置、内存参数、健康检查,适合 Spring Boot 项目直接改。这是完整的文件内容:

FROM maven:3.8-openjdk-11 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B FROM eclipse-temurin:11-jre WORKDIR /app RUN useradd -r -u 1001 appuser COPY --from=builder /build/target/my-app-1.0.0.jar ./app.jar ENV TZ=Asia/Shanghai \ JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=25.0 -XX:MaxMetaspaceSize=128m" USER appuser EXPOSE 8080 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

关于这份 Dockerfile,有几个细节需要特别解释。USER appuser 这一步,是为了避免容器以 root 身份运行,虽然本地开发无所谓,但在安全合规要求高的环境里,这几乎是硬性要求。用户 ID 固定为 1001,是为了配合容器编排平台的任意用户运行机制,后面运维统一管控权限的时候不会因为 UID 不确定而踩坑。

ENTRYPOINT 里使用 sh -c 是为了让 JAVA_OPTS 作为环境变量被正确展开。有人会纠结为什么不用 exec 形式的 ENTRYPOINT,因为 ENTRYPOINT 是 exec 形式时,不会通过 shell 扩展环境变量,$JAVA_OPTS 会被原样当成参数传给 java,直接导致启动失败。这个细节是新手最容易踩到的,在这里要特别标记出来。

5.3 构建镜像与启动容器

Dockerfile 准备好之后,在项目根目录执行:

docker build -t my-app:1.0.0 .

-t 参数给镜像打上名字和标签,格式是 名字:标签。构建过程会依次执行各个阶段,你会看到每个步骤的输出。首次构建会比较慢,因为要拉取基础镜像和下载依赖,后续再次构建因为有缓存会快得多。构建完成后用 docker images 确认镜像存在,体积大约是 150MB 左右,和直接一条 RUN 把所有东西塞进去的镜像相比,差距是很直观的。

启动容器的命令如下:

docker run -d --name my-app-instance -p 8080:8080 --memory=512m my-app:1.0.0

--memory=512m 限制容器最大内存为 512MB,配合镜像里的 MaxRAMPercentage=75,JVM 堆上限大约是 384MB,堆外留 128MB,整体刚好在限额内安全运行。如果不设置内存限制,JVM 的容器感知会去读宿主机的配额,行为可能不稳定。这又是一个生产环境和本地开发表现不一致的典型原因。

启动完成后执行 docker logs my-app-instance 查看启动日志,确认应用成功监听 8080 端口,再执行 curl http://localhost:8080 验证接口调通。如果一切正常,你的 Java 服务就已经成功容器化,而且是一个体积合理、参数可调的规范化镜像。

6. 从零到一调试容器:常见问题与排查技巧实录

6.1 容器启动后马上退出,日志只有一句进程结束

这是最高频的问题,做法是先把 run 方式改一下,加 -it 参数并且覆盖入口命令,用一个能保持前台运行的命令替换掉原始的启动命令:

docker run -it --rm my-app:1.0.0 /bin/sh

这样能进入容器内部看看文件是否 COPY 到位、环境变量是否设置正确、启动脚本能不能正常执行。很多时候容器秒退是因为 Jar 包路径不对,或者 WORKDIR 和 COPY 路径不匹配,进入容器后执行 ls 看一眼,问题马上水落石出。

如果进程确实启动了但端口没监听,再用 docker logs my-app-instance 查详细报错。一个清晰的排查路径是:先确认进程存活,再确认端口监听,最后确认业务接口可访问,一步一步定位。

6.2 OOMKilled 状态:不是代码问题,不是镜像问题,是 JVM 和容器的适配问题

前面已经重点聊过 JVM 的容器感知问题和参数设置方案,这里补充一个排查技巧。当你看到 docker inspect 结果里有某个容器处于 OOMKilled 状态时,不要急着调大容器的内存限制,先查一下 JVM 实际用了多少。执行 docker stats 实时观察内存曲线,如果发现容器配额一大半都被 Metaspace 或堆外占了,说明 MaxRAMPercentage 留的余量不够,需要调整参数。如果堆外一直稳定但整体还是超限,可以尝试显式调低 MaxRAMPercentage。

还有一种情况容易被忽略,是 spring-boot 项目里内嵌的 Tomcat 线程池。默认的线程数是 200,每一个线程默认栈大小大约 1MB,200 个线程就是 200MB 左右的额外开销。如果你把 MaxRAMPercentage 设置到 75%,这个线程池开销加上堆,很容易把内存顶到 99%。解决方式有两个方向,要么调低 MaxRAMPercentage至 50 到 60 之间,给线程池留空间;要么在应用配置里显式调小 server.tomcat.threads.max 的值。这里的取舍看你的业务类型,并发不高的小服务,线程数调到 50 足够,内存压力大幅下降。

6.3 容器里时间总是差 8 小时

排查过程很简单,exec 进入容器执行 date 命令,看到输出是 UTC 就能确认问题。修复方式上面讲过了,分为两类基础镜像分别处理。值得提醒的是,时区对日志系统的影响特别大,如果你的容器日志采集链路是按时间字段做索引的,时区不一致会导致日志查询时对不上时间轴,排查问题的时候会造成极大的困扰。

6.4 基础镜像选择:为什么有时候 alpine 并不好用

alpine 镜像以体积小著称,但 Java 在上面有一些先天障碍。最典型的是 glibc 的问题,alpine 默认使用 musl libc,而部分 Java 原生库(比如某些底层网络库或加密库)依赖 glibc 的特定行为,在这些镜像是可能出现兼容性 bug。虽然 OpenJDK 官方现在对 alpine 的支持在逐步改善,但生产环境如果求稳,我更推荐基于 Ubuntu 或 UBI 的基础镜像。

另一个使用 alpine 常见的问题是排查工具缺失,bash、curl、procps 这些常用工具默认都没有,调试起来很别扭,你得先 apk add 装一堆东西。镜像体积省了 50MB,调试体验掉一半,这个取舍要自己权衡。如果项目对镜像体积有极致要求,建议配合多阶段构建把最终运行阶段拿到最精简,实测下来很多 Java 应用用 eclipse-temurin 的 jre 镜像已经足够,也不差 alpine 那一点体积优势。

6.5 常见问题速查表

为了便于日常排查,把前面遇到过的典型问题整理成一张速查表:

现象根因解决方案
容器秒退,无明确错误Jar 路径不对或启动命令有误exec 进入容器确认文件路径,检查 ENTRYPOINT 写法
内存超限被杀(OOMKilled)JVM 按宿主机配额计算堆大小设置 MaxRAMPercentage,限制容器内存
日志时间差 8 小时基础镜像默认 UTC 时区设置 ENV TZ 并安装 tzdata
ENTRYPOINT 中 $JAVA_OPTS 不生效exec 形式不经过 shell 转义改为 ["sh","-c","java $JAVA_OPTS -jar app.jar"]
构建时 COPY 报错,不能引用外部路径构建上下文范围外的文件不可用调整构建上下文目录,或把所需文件放入其中
外部访问不到容器端口EXPOSE 只是声明,没有端口映射docker run 加 -p 参数做映射
构建每次都重新下载依赖没有合理利用层缓存pom 单独 COPY 并执行 dependency:go-offline

这张表是我平时带同事排查问题时最常用的一份清单,覆盖了大概百分之八十的容器化启动问题。情况对不上号的时候,再从日志和事件倒推,通常也能很快收敛。

7. 最后的实操心得

Dockerfile 编写本身没什么神秘的,把指令语法背熟只是第一步。真正有价值的是理解每条指令背后影响运行时的那些细节:JVM 和容器配额的关系、层缓存对构建速度的影响、精简镜像和调试体验之间的取舍。这些东西光看文档学不到,靠的是反复构建、反复排查、反复踩坑积累出来的。

我个人在实际操作中有一个习惯,每写一份 Dockerfile 都会顺手把基础镜像的精确版本号写在文件注释里。这样过三个月回来看,还能准确知道当时基于什么环境构建的,不至于猜测。另一个小技巧是构建完成后执行一遍 docker history 查看镜像的分层情况,每一层的大小变化一目了然,如果发现某几层体积明显异常,立刻能知道是哪一步出了问题。

如果你现在手上的项目还没有容器化,建议就从今天开始动手,把 Dockerfile 写起来,哪怕只是把一个最简单的 Spring Boot 服务跑起来,你的收获也会远超想象。容器化不是云原生转型的宏大叙事,它就是从一个可靠的 Dockerfile 开始的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进