ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java应用容器化:从Dockerfile到多阶段构建与JVM参数最佳实践

Java应用容器化:从Dockerfile到多阶段构建与JVM参数最佳实践

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 formjava -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 -DskipTests

mvn 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 directoryjar包路径不在构建上下文内,或尚未执行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.0

7. 如果时间紧: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,最后加一个健康检查。这套组合拳用下来,无论是本地开发还是生产部署,都很少再被容器问题绊住。

RELATED READING

延伸阅读

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