1. 先写一条能跑的Dockerfile:逐行拆给你看
大概一年前,帮一个朋友排查容器启动失败的问题。他的项目是常规的Spring Boot应用,Dockerfile也照着网上的模板写了,但容器就是起来以后立刻退出,日志里只有一句话:Unable to access jarfile app.jar。后来发现,他把COPY target/demo.jar app.jar写在了WORKDIR /app之前,而java -jar app.jar用的是相对路径,JVM在工作目录里根本找不到那个jar包。这个问题很小,却暴露了一个现实——很多Java开发者写Dockerfile,靠的是复制粘贴,而不是真正理解每一行在干什么。
所以这篇文章,我打算把Java程序打包成Docker镜像这件事,从最简单的一条Dockerfile开始,一路讲到多阶段构建、分层缓存、容器里的JVM参数、时区时区权限健康检查这些容易翻车的地方。每一条指令我都会说明为什么这么写,而不是只给你一个能跑的模板。
1.1 一条最小可用的Java镜像Dockerfile
先看一条最基础、也最完整的写法,这是所有Java项目镜像的起点:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/demo-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]先解释一下这条Dockerfile的每一行。
FROM eclipse-temurin:17-jre是基础镜像。为什么不用老的openjdk系列?因为openjdk官方镜像在Java 17之后基本不再更新维护,大家现在普遍使用Eclipse Temurin(源自Adoptium项目)的镜像。eclipse-temurin:17-jre只包含JRE运行环境,体积比包含完整JDK的镜像小不少。如果你的程序是JDK 11,就把17改成11;如果是JDK 8,可以找eclipse-temurin:8-jre这类标签,不同基础镜像厂商的标签规则略有差异,建议先去镜像仓库确认一下。
WORKDIR /app是设置工作目录。它有两个作用:一是后续的COPY、RUN、ENTRYPOINT都会默认在这个目录下执行;二是容器启动后,进程的工作目录也会是这里。建议每个项目都设置,别省这一行,否则你后面写路径时很容易出现"文件在但就是找不到"的问题。
COPY target/demo-0.0.1-SNAPSHOT.jar app.jar是把构建好的jar包从宿主机复制到镜像的/app目录下,并重命名为app.jar。注意源路径target/demo-0.0.1-SNAPSHOT.jar是相对于构建上下文(context)的,不是相对于Dockerfile所在目录的——这个区别很多人会踩坑,后面我会单独讲。
EXPOSE 8080只是声明容器内的应用监听8080端口,它不做什么实际操作,纯粹是文档性质的。真正把端口映射到宿主机,需要在docker run时加-p参数。
ENTRYPOINT ["java", "-jar", "app.jar"]是容器启动时执行的命令。这里是exec form,也就是JSON数组写法,它不会额外启动一个shell进程,而是直接以java进程作为容器的主进程(PID 1)。
1.2 ENTRYPOINT与CMD的区别,以及为什么让你用数组写法
网上很多旧教程会写CMD java -jar app.jar或者ENTRYPOINT java -jar app.jar,这两种写法是shell form,Docker会把它包装成/bin/sh -c "java -jar app.jar"来执行。问题在于:容器的主进程变成/bin/sh,而你实际的Java程序变成了它的子进程。
这在日常跑起来似乎没什么区别,但当你执行docker stop时,Docker会向PID 1发送SIGTERM信号。如果PID 1是shell,它不一定会把这个信号转发给Java子进程,结果就是Java进程收不到停止信号,不能优雅停机,只能等超时后被SIGKILL强杀。对于Spring Boot这类依赖优雅停机来释放数据库连接池、完成请求收尾的应用,这个差异是致命的。
另外还有一个很实际的原因:shell form在执行时会做变量替换,所以当你写ENTRYPOINT java $JAVA_OPTS -jar app.jar时,$JAVA_OPTS会被shell解析;但如果你写成ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "app.jar"],$JAVA_OPTS不会被解析,Docker会试图找一个名叫$JAVA_OPTS的文件,直接报错。
所以我的建议是:固定不变的命令用exec form数组写法,需要运行时注入环境变量的场景,用ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]这种显式shell包装,既保留了变量替换,又明确了意图。下表是这两种写法的对比:
| 写法 | 示例 | 是否启动shell | 能否做变量替换 | 信号传递 |
|---|---|---|---|---|
| exec form | ["java", "-jar", "app.jar"] | 否 | 否 | 直接,PID 1是Java |
| shell form | java -jar app.jar | 是 | 是 | 可能丢失,PID 1是shell |
| 混合写法 | ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] | 是 | 是 | shell需自行保证 |
2. 多阶段构建:把编译环境和运行环境彻底分开
很多人第一次写Java的Dockerfile,会在镜像里装Maven或者Gradle,然后RUN mvn package,再CMD java -jar。这条链路本身没问题,但它会带来两个实际问题:镜像体积惊人,以及构建产物、依赖、源码全部塞在一个镜像里,安全性和可维护性都很差。
2.1 为什么推荐多阶段构建
多阶段构建的核心思想,用一句话说就是:编译阶段用一个包含完整工具链的镜像,运行阶段只把编译好的jar包拷贝到一个小镜像里,最终交付的镜像只包含运行所需的最小环境。
# 第一阶段:构建 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 WORKDIR /app COPY --from=builder /build/target/demo-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]第一阶段做了三件事:先拷贝pom.xml,然后执行mvn dependency:go-offline把依赖提前下载好,最后拷贝源码并打包。注意mvn dependency:go-offline这一步的关键作用,是为了配合Docker的层缓存机制,我下一节细说。
第二阶段从第一个阶段构建出的镜像中,只把jar包拷贝过来,然后以纯JRE镜像作为最终镜像。COPY --from=builder就是从名为builder的构建阶段取文件。
用这种方式,最终镜像里只包含JRE和你的jar包,不会带着一整套Maven仓库和源码。我实测过,一个普通的Spring Boot项目,如果不加多阶段构建,镜像体积轻松超过500MB;用多阶段构建,通常在180MB左右。
2.2 进一步裁剪:用jlink生成最小运行时
如果你的项目对镜像体积有执念,或者需要部署到带宽受限、启动频繁的边缘环境,可以考虑用jlink来裁剪JRE。jlink是JDK 9开始提供的工具,它可以根据你实际用到的模块,生成一个最小的自定义运行时镜像。
FROM eclipse-temurin:17 AS jre-build WORKDIR /build COPY target/demo-0.0.1-SNAPSHOT.jar app.jar # 用jdeps分析jar包实际依赖的JDK模块 RUN jdeps --ignore-missing-deps --print-module-deps app.jar > /tmp/modules.txt # 用jlink裁剪最小运行时 RUN jlink \ --add-modules $(cat /tmp/modules.txt) \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress=2 \ --output /custom-jre FROM debian:bookworm-slim WORKDIR /app COPY --from=jre-build /custom-jre /custom-jre COPY --from=jre-build /build/app.jar app.jar ENV PATH="/custom-jre/bin:${PATH}" EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里有几个细节值得注意。jdeps --print-module-deps会扫描jar包的字节码,找出它真正依赖的JDK内置模块,输出结果像java.base,java.logging,java.sql这样的模块列表。理论上,如果程序只用到了一小部分JDK功能,裁剪后的运行时体积可以降到40~60MB左右,但前提是你的项目没有用到反射加载类、SPI等动态特性——这些场景下模块依赖分析可能不准,运行时你就会收到ClassNotFoundException。
另外,我把自定义jre放到debian:bookworm-slim上,而不是直接用temurin的jre镜像。这么做的好处是底包更干净、体积更小,坏处是你需要自己维护时区数据和CA证书。对于大多数团队来说,用temurin的jre镜像作为运行阶段就够了,jlink裁剪属于进阶玩法,不必一上来就追求极致。
3. 镜像分层缓存:先拷贝pom.xml而不是源码
如果只是日常构建,多阶段构建的写法已经够用了。但当你开始每天频繁出包、反复执行docker build时,构建速度会成为一个真实的痛点。Docker的构建过程是分层的,每一层都会尝试复用之前的缓存,而缓存命中的关键,在于你对COPY指令顺序的安排。
3.1 Docker镜像分层的原理
Docker镜像由一系列只读层堆叠而成。每一条RUN、COPY指令,都会生成一个新的层。当你再次执行docker build时,Docker会逐条检查指令:如果指令本身没变,且它依赖的上层也没有变化,就会直接复用上次构建生成的层缓存,而不是重新执行。
这个机制很像代码版本管理里的增量编译:唯一变化的那一层需要重做,其他层全部复用。
问题来了,如果我把Dockerfile写成:
COPY src ./src RUN mvn package那么只要src目录下有任何文件发生变化,COPY src这一层的缓存就会失效。紧接着RUN mvn package的输入变了,缓存也失效,于是Maven要把整个项目重新编译打包。哪怕你只是改了一个变量的值,也要重新下载依赖(虽然本地仓库可能还在,但Docker层重建会带来额外开销)、重新执行编译器。
3.2 正确的依赖缓存姿势
正确的思路,是把变化的频率作为COPY顺序的排序依据:最不容易变化的东西放在最前面,最频繁变化的东西放在最后面。
FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build # 最稳定的:pom.xml COPY pom.xml . # 先拉取所有依赖,这一层几乎不会失效 RUN mvn dependency:go-offline # 变化最频繁的:源码 COPY src ./src RUN mvn package -DskipTestsmvn dependency:go-offline会把项目所需的外部依赖全部下载到本地仓库。只要你的pom.xml没有变化,这一层缓存就会一直命中。之后你改Java代码、改配置文件,都只需要重跑COPY src和RUN mvn package这两层,免去了反复解析依赖、下载依赖的时间。我实际用下来,一个中等规模的Spring Boot项目,缓存命中后的增量构建只需要十几秒,而完全重新构建可能要三分钟以上,差距非常明显。
注意,mvn dependency:go-offline并不是Maven官方提供的标准生命周期目标,而是maven-dependency-plugin插件提供的能力。它会把项目声明的依赖以及插件依赖都拉取到本地仓库。如果遇到个别插件无法通过这个命令完整拉取,可以在构建镜像时额外执行一次RUN mvn package -DskipTests来补全,但通常go-offline已经够用。
3.3 别忘了.dockerignore
还有一个和构建速度直接相关的文件:.dockerignore。它的作用类似于.gitignore,告诉Docker在构建时不要把某些文件发送到构建上下文。
默认情况下,docker build .会把当前目录下的所有文件(除了被.dockerignore排除的)都打包发送给Docker守护进程。如果你不做排除,target/目录、node_modules/、.git/这些动辄几百MB的目录都会被上传,严重拖慢构建。如果构建上下文里包含大量无关文件,还可能因为体积膨胀导致构建超时。
一份典型的Java项目.dockerignore长这样:
target/ .git/ .idea/ *.iml *.log Dockerfile .dockerignore注意:如果Dockerfile本身对你最终的构建没有影响,是可以排除的;但如果你的Dockerfile里有用到COPY Dockerfile .这种操作,就不要排除。.dockerignore里写的规则也支持*和**通配符,需要更复杂的排除规则时可以参考官方文档。
4. 容器里的JVM:别再写死-Xmx512m了
这个问题是我见过最多、也最容易被忽视的。很多Java开发者写Dockerfile,习惯性地把JVM参数固化在启动命令里,比如ENTRYPOINT ["java", "-Xmx512m", "-jar", "app.jar"]。在物理机上这么做没什么问题,但在容器环境里,这可能导致严重的资源浪费,甚至OOM。
4.1 为什么-Xmx在容器里不安全
容器的核心资源限制是cgroup提供的。docker run -m 2g给容器设定了2GB的内存上限,但这并不意味着JVM能感知到。旧版本的JVM只读取宿主机的物理内存,它按照宿主机内存来设置默认堆大小。
举个例子,你的宿主机有64GB内存,容器限了2GB。如果不在启动时显式指定堆大小,JVM默认会把堆大小设置成宿主机的四分之一,也就是16GB。这已经超出了容器的2GB上限。当应用运行到内存峰值时,进程会触发OOM,而这个OOM可能不一定是Java堆OOM,而是容器级别的OOM Killer直接把容器杀掉。
反过来,如果写死-Xmx512m,在容器内存调整到4GB时,JVM最多也只能用512MB,造成了大量资源闲置。写死参数的另一个问题是,你没办法在不重新构建镜像的情况下对不同环境做差异化调优。
4.2 使用容器感知参数
从JDK 8u131开始,JVM对容器有了初步感知能力;JDK 8u191之后,-XX:+UseContainerSupport默认开启;JDK 10及以上版本默认就支持cgroup内存限制。所以如果你用的是现代JDK,正确的做法是让JVM根据容器限制自动计算堆大小。
推荐的做法是使用百分比参数:
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:MinRAMPercentage=25.0" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]MaxRAMPercentage=75.0表示JVM最多使用容器内存上限的75%。为什么要留25%?因为堆之外还有元空间、线程栈、直接内存(DirectByteBuffer)、JVM自身开销以及可能的系统缓存。如果你的Spring Boot应用大量使用ByteBuffer或Netty,还需要进一步降低这个比例。
InitialRAMPercentage=50.0和MinRAMPercentage=25.0可以避免JVM启动时一次性申请过多内存,也让堆可以根据负载动态扩展。当然,如果你明确知道应用需要1GB堆,也可以直接-Xmx1g——关键区别在于,百分比参数让同一个镜像在不同规格的容器下都能自适应。
4.3 别忘了保留诊断现场
容器里的Java应用挂掉了,最痛苦的事情是连日志都没留下。所以我建议在JAVA_OPTS里固定加这两个参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof这样当Java抛出OOM时,会自动把堆快照写到/tmp/heapdump.hprof,方便事后用MAT或VisualVM分析。同时还可以加上-XX:+ExitOnOutOfMemoryError,让JVM在OOM时直接退出而不是继续半死不活地运行,方便编排系统自动重启容器。
这里补充一个排查技巧:当容器还在运行但表现异常时,用docker exec -it 容器名 jstat -gcutil 1 10可以直接在容器内查看GC情况,不需要进入容器再安装东西。如果容器里没有jstat(JRE镜像通常不带),可以用jcmd或者直接看Spring Boot Actuator暴露的/actuator/metrics数据,前提是你在应用里开启了对应端点。
5. 三个容易翻车的细节:时区、用户权限、健康检查
前几节讲的是"怎么把镜像构建出来",这一节讲的是"构建出来的镜像在真实生产环境里能不能站稳"。我见过不少镜像在本地跑得欢,一到服务器上就出各种幺蛾子,问题往往都出在这些不容易注意的小细节上。
5.1 时区问题:日志时间少了8小时
镜像里的默认时区通常是UTC,而国内服务器的业务日志、定时任务都期望使用北京时间。如果你不在镜像里设置时区,接到的第一个反馈必然是:xx系统的日志时间不对,差了8个小时。
在Debian系的镜像(temurin的jre镜像大多基于Ubuntu或Debian)里,可以这样设置:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone但如果你用的是Alpine系的基础镜像,/usr/share/zoneinfo默认不存在,需要先安装tzdata:
RUN apk add --no-cache tzdata \ && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone \ && apk del tzdata注意最后把tzdata卸载掉是为了减小镜像体积,但如果你后续还需要zoneinfo文件,就别卸载,否则时区又变回UTC。
也可以在启动命令里通过-e TZ=Asia/Shanghai传入环境变量,省去镜像内配置。但这样做的缺点是你的运行环境必须记住要带这个参数,容易遗漏。我更建议直接在镜像里固化时区配置,毕竟每个环境都加-e并不现实。
5.2 别用root用户跑Java进程
默认情况下,容器内的进程是以root身份运行的。这带来两个问题:镜像里任何一个漏洞被利用,攻击者直接获得容器内的最高权限;而容器内的root其实和宿主机root有剪不断理还乱的关系,一旦发生挂载卷的权限问题,宿主机上的文件权限也会被污染。
你应该创建一个普通用户,用它来运行应用:
FROM eclipse-temurin:17-jre RUN useradd -r -s /sbin/nologin -u 1001 appuser WORKDIR /app COPY --from=builder /build/target/demo-0.0.1-SNAPSHOT.jar app.jar # 让appuser拥有应用目录 RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里有两个细节。
第一,chown -R appuser:appuser /app不能省。因为COPY进来的文件默认所有者是root,切换到appuser后,如果应用需要写日志、写临时文件,就会遇到权限不足。如果你的应用根本不写文件,可以不chown,但为了保险起见,建议统一处理。
第二,EXPOSE 8080写在USER appuser之后没有影响,因为EXPOSE不涉及文件操作。但如果你后续还需要安装依赖、修改文件,这些操作必须在USER appuser之前完成,因为非root用户对系统目录没有写权限。
还有一个相关的点是:不要在容器里用spring-boot-maven-plugin自动生成的可执行jar包跑java -jar时依赖/tmp做解压。Spring Boot可执行jar在启动时会把内部jar解压到临时目录,而/tmp通常任何人都可写,所以问题不大;但如果你的基础镜像设置了严格的tmpfs挂载策略,就要格外留意。
5.3 HEALTHCHECK与容器生命周期
容器编排系统(比如Docker Swarm或Kubernetes)需要一种方式来判断容器里的应用是不是真的活着。进程活着不代表应用可用——JVM进程还在,但端口无响应、线程池打满,这种情况并不罕见。
Dockerfile里可以用HEALTHCHECK指令定义健康检查:
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ CMD wget -q -O - http://localhost:8080/actuator/health || exit 1这个检查会每30秒执行一次,启动后20秒才开始探活,连续3次失败才判定为不健康。它调用的是Spring Boot Actuator的/actuator/health端点,所以你的应用需要引入spring-boot-starter-actuator依赖。
注意镜像里不一定有wget,如果用的是裁剪过的小镜像,可能只有curl,或者两个都没有。我在实际使用中,更倾向于用Java本身来做这个健康检查,比如在JRE镜像里写一个很小的HealthCheck类,不依赖外部命令。但你也可以接受"镜像里自带一个wget或curl"作为折中方案,毕竟它们本身也是合法工具。
还有一个常被忽略的问题:优雅停机。docker stop会先发SIGTERM,Spring Boot默认注册了shutdown hook,会等待正在处理的请求完成后再退出。但如果你在Dockerfile里写了类似ENTRYPOINT java -jar app.jar &这种后台启动方式,Docker的PID 1就不是Java而是shell,stop信号会被shell吞掉,导致容器只能等超时被强杀。所以请坚持用前文推荐的ENTRYPOINT风格,不存在"后台启动"这个需求。
6. 从构建到发布:一条命令跑通完整流程
前面讲的都是Dockerfile本身的内容,这一节把视角拉高一点,从构建命令、运行参数到常见报错,串起一个完整的操作闭环。
6.1 docker build与docker run的完整操作
假设你的项目根目录下已经有了Dockerfile,那么一次完整的构建和运行过程如下:
# 构建镜像 docker build -t demo-app:1.0 . # 查看镜像信息 docker images demo-app # 运行容器,把宿主机8080映射到容器8080,后台运行,并设置JVM参数 docker run -d \ --name demo-app \ -p 8080:8080 \ -e JAVA_OPTS="-XX:MaxRAMPercentage=75.0" \ --memory="1g" \ demo-app:1.0 # 查看启动日志 docker logs -f demo-app # 进入容器检查运行状态 docker exec -it demo-app bash--memory="1g"给容器设内存上限,配合MaxRAMPercentage,JVM就能自动计算出合理的堆大小。如果你有多个环境需要不同的堆设置,用-e JAVA_OPTS传入即可,不需要为每个环境单独构建镜像。
如果你在构建时需要指定平台(比如在Apple Silicon上构建amd64的镜像),加上平台参数:
docker build --platform linux/amd64 -t demo-app:1.0 .6.2 常见报错排查表
我把实际中见过的高频报错整理成一份速查表:
| 报错现象 | 常见原因 | 解决办法 |
|---|---|---|
COPY failed: no such file or directory | jar包路径不在构建上下文内,或尚未执行Maven打包 | 检查target/下有没有jar包;把mvn package放到Dockerfile里或先在本机执行;调整.dockerignore |
exec: "java": executable file not found in $PATH | 基础镜像里没有JRE,或使用了非标准路径 | 检查FROM是否选对了镜像标签;不要在运行时去PATH里找不存在的命令 |
容器启动即退出,Unable to access jarfile | 拷贝路径和工作目录不匹配 | 确认COPY的目标路径与ENTRYPOINT里的路径一致;尽量使用绝对路径或统一依赖WORKDIR |
/bin/sh: 1: java: not found | 基础镜像缺少Java运行时 | 确保运行阶段选用了jre镜像而不是纯操作系统镜像 |
以appuser启动后报Permission denied | 应用需要写文件但目录属主为root | 在USER appuser之前chown应用目录;或者把需要写的文件目录单独挂载并授权 |
容器反复重启,docker inspect显示OOMKilled为true | 内存上限设置过低或MaxRAMPercentage过高 | 调大--memory,或降低MaxRAMPercentage;检查是否有堆外内存使用 |
6.3 构建产物与镜像管理的小技巧
构建完成之后,还有几个日常运维经常用到的小操作。悬空镜像(dangling image)会随着多次构建越积越多,它们是没有被任何容器引用、也没有被打标签的镜像层,可以用下面命令清理:
docker image prune -f如果你想临时把某个镜像导出成tar包带走,可以:
docker save demo-app:1.0 | gzip > demo-app.tar.gz在另一台机器上导入:
docker load < demo-app.tar.gz如果是推到私有仓库,先打标签再推送:
docker tag demo-app:1.0 registry.example.com/demo-app:1.0 docker push registry.example.com/demo-app:1.07. 如果时间紧:Jib和Buildpacks是备选方案
最后说点个人经验。Dockerfile虽然很灵活,但对于一个“只想把Spring Boot应用跑起来”的团队来说,写Dockerfile本身还是有一定学习成本。如果你或者你的团队时间紧张,有两条替代路径可以参考。
一个是Google的Jib。它不需要Dockerfile,也不需要你本地装Docker,直接用Maven或Gradle插件把Java应用构建成镜像并推送到仓库。它的优势是构建过程与你已有的构建工具深度集成,把“镜像构建”变成了“打包”这个动作的一部分。缺点是它不生成Dockerfile,你失去了对镜像内容的精细控制——比如想在镜像里安装特定的调试工具,用Jib就不太方便。
另一个是Cloud Native Buildpacks。用pack build demo-app:1.0一条命令就能根据你的代码自动检测出这是一个Java项目,生成一个包含完整运行环境的镜像。Buildpacks自动帮你处理了JVM参数、安全补丁、用户权限等大量细节,适合标准化程度高、不希望维护Dockerfile的团队。
但我的态度是:这些工具可以救急,不能成为你不学Dockerfile的理由。生产环境中总有一些场景需要你手动调整镜像——比如往里面加一个自定义的证书、改时区、处理特殊的启动参数。到时候你连Dockerfile都看不懂,调试成本会非常高。
就我个人而言,现在写Java的Dockerfile已经形成了一套固定套路:多阶段构建加temurin基础镜像,pom.xml单独拷贝再go-offline,运行阶段用jre镜像、非root用户、固定时区,JVM参数走JAVA_OPTS并配合MaxRAMPercentage,最后加一个健康检查。这套组合拳用下来,无论是本地开发还是生产部署,都很少再被容器问题绊住。