把Java应用打包成镜像跑在Docker里,看似只是选择FROM加上COPY和ENTRYPOINT,但真正上生产时会暴露出一系列问题:镜像体积超过1GB、容器内存限制不生效、日志时间比宿主机慢8小时,甚至JVM进程收到SIGTERM后不会优雅退出。出现这些现象的根源通常不在业务代码,而在于基础镜像特性和JVM参数没有根据容器运行环境做适配。下面围绕镜像选择、Dockerfile构建和容器化JVM配置三个层面来说明。

一、Java基础镜像怎么选
很多团队以前习惯直接使用Docker Hub上的openjdk官方镜像,但这个项目已经停止维护,继续使用会积累安全漏洞。目前更推荐使用Eclipse Temurin,它由Adoptium维护,发布节奏稳定,覆盖8、11、17、21等常见LTS版本。Temurin镜像同时提供Ubuntu/Jammy、Debian和Alpine等不同系统变体,选择合适的底层系统可以明显控制镜像体积。
如果业务依赖较多原生库,例如用到了JNI、嵌入式数据库或者需要glibc的底层能力,建议选择Debian Slim版本,例如eclipse-temurin:17-jre-jammy。它的体积通常在两三百MB左右,比完整Ubuntu小很多,同时保留glibc兼容性。Alpine版本虽然能压缩到150MB甚至更低,但Alpine使用musl libc,个别Java库在musl环境下可能出现不兼容或性能下降。因此不要把Alpine当作默认选择,尤其是处理大量并发网络IO时,glibc的DNS解析和内存分配行为更稳定。
另一个关键点是区分JDK与JRE镜像。运行时容器只需要JRE就够了,不需要携带javac、调试工具和源码包。构建阶段可以单独使用带JDK的镜像,运行阶段切到JRE镜像,这样能把最终镜像减少上百MB。标签尽量写完整小版本,避免使用latest,因为latest会随着新版本发布而漂移,构建结果不可重复。
| 镜像 | 基础系统 | 典型体积 | 适用场景 |
|---|---|---|---|
| eclipse-temurin:17-jre-alpine | Alpine | 约150MB | 追求小体积、无JNI依赖 |
| eclipse-temurin:17-jre-jammy | Ubuntu | 约250MB | 需要glibc或通用兼容 |
| eclipse-temurin:17-jdk-alpine | Alpine | 约300MB | 仅用于构建阶段 |
二、编写适合容器运行Java应用的Dockerfile
单阶段构建会把Maven依赖、源码和编译产物全部打进镜像,最终体积可能超过1GB。多阶段构建的思路是:第一阶段使用带JDK和Maven的镜像完成编译打包,第二阶段只把生成的Jar复制到精简JRE镜像中。这样既保留完整构建能力,又不会让构建工具污染运行时环境。
# 构建阶段 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 eclipse-temurin:17-jre-alpine RUN addgroup -S app && adduser -S app -G app WORKDIR /app COPY --from=builder /build/target/demo-app.jar app.jar USER app EXPOSE 8080 ENTRYPOINT ["java","-jar","/app/app.jar"]
上面的Dockerfile中,COPY pom.xml和RUN mvn dependency:go-offline放在COPY src之前,是为了利用Docker层缓存。只要pom.xml没有变化,依赖下载层就可以复用,日常改业务代码后构建速度会快很多。运行阶段使用addgroup和adduser创建了非root用户,并通过USER app切换过去。容器以root用户运行Java进程风险较高,一旦应用被攻破,攻击者可能获得容器内完整管理权限。
ENTRYPOINT采用JSON数组形式,它对应Linux中的exec调用,Java进程会直接作为PID 1运行,能正确接收docker stop发来的SIGTERM信号。如果写成shell形式ENTRYPOINT java -jar app.jar,实际启动的是/bin/sh -c,信号会被shell拦截,应用无法执行优雅关闭钩子,可能导致请求处理到一半被强制杀死。容器端口声明EXPOSE 8080只是文档用途,真正对外提供服务还需要在docker run时使用-p映射。
三、JVM容器化内存与CPU参数适配
现代JDK从10开始默认开启了UseContainerSupport,能够读取cgroup的内存和CPU限制,不再按照宿主机物理内存计算堆默认值。但在Docker限制内存较小时,如果不显式设置比例,JVM默认最大堆可能仍接近容器上限,导致没有空间留给Metaspace、线程栈和JIT编译产生的外部内存,最终被OOM Killer杀掉。更合理的做法是使用MaxRAMPercentage和InitialRAMPercentage,让堆大小随容器内存动态计算。
java -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -jar app.jar
例如容器限制为512MB时,75%意味着堆最大约384MB,剩余内存留给JVM自身结构和操作系统页缓存。不要继续使用传统的-Xmx固定值,因为一旦容器配额调整,固定堆不会自动适配。CPU方面一般不需要额外参数,JVM会读取cgroup的CPU配额来决定默认的GC线程数和公共线程池大小,但可以使用ActiveProcessorCount进行调试确认。
如果应用使用Spring Boot,还可以把JAVA_OPTS或JDK_JAVA_OPTIONS环境变量传入容器,避免把参数写死在Jar包里。比如在docker run或编排文件中设置JDK_JAVA_OPTIONS环境变量,JVM启动时会自动读取该变量并追加参数。
docker run -d --name app \ --memory=512m \ --cpus=1.0 \ -e JDK_JAVA_OPTIONS="-XX:MaxRAMPercentage=75.0" \ -p 8080:8080 \ my-java-app:1.0
四、时区、文件权限与信号关闭的踩坑点
Java日志时间不一致是容器化后最常见的问题之一。Alpine和Debian基础镜像默认使用UTC时区,如果应用没有显式设置时区,打印出来的时间会比北京时间慢8小时。可以在Dockerfile中安装tzdata并设置TZ环境变量,或者在启动参数中添加-Duser.timezone=Asia/Shanghai。推荐在镜像构建阶段安装tzdata,因为运行容器通常以非root用户运行,无法临时安装系统包。
FROM eclipse-temurin:17-jre-alpine RUN apk add --no-cache tzdata ENV TZ=Asia/Shanghai
挂载目录权限也是容易忽略的地方。假设宿主机上的数据目录属主是uid为1000的用户,容器内切换到了uid为10001的非root用户,应用尝试写入挂载目录时就会出现Permission denied。解决办法是让容器用户uid与宿主机挂载目录属主保持一致,或者在启动脚本中通过gosu改变身份。对于只读取配置的场景,可以提前把文件复制到镜像内部,避免挂载权限带来的问题。
最后还要关注优雅关闭。除了ENTRYPOINT使用exec形式外,Java应用本身也需要注册ShutdownHook,或者使用Spring Boot的优雅停机配置。docker stop默认会先发SIGTERM并等待10秒,如果应用没有及时退出,Docker会发送SIGKILL。对于处理长任务的应用,可以适当调大stop等待时间,例如docker stop -t 30。