周一早上,负责上线的同事在群里发了条消息:「镜像 800MB,拉取花了 3 分钟,测试环境全阻塞了。」
底下没人接话。因为所有人都知道问题出在哪:Spring Boot 应用被打进 Docker 镜像时,大多数人只是把 JDK 和 fat jar 粗暴地塞在一起。
这篇文章不讲 Docker 入门,只讲 Spring Boot 容器化部署里真正影响生产的东西:镜像体积、构建速度、JVM 内存、优雅停机、健康检查。每一条都是线上踩出来的。
一、先看大多数人怎么做的
FROM openjdk:8-jdk COPY app.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]三行,能跑。问题也都在:
- openjdk:8-jdk 基础镜像自带完整 JDK,含编译器、调试器,体积 500MB 起步
- fat jar 里塞着所有依赖,每次改一行代码,整个 jar 重新打包,构建缓存全部失效
- JVM 默认按宿主机内存比例分配堆,容器内存限制形同虚设,OOM 是常态
- 容器停止时,Spring Boot 来不及优雅收尾,消息丢失、连接池残留
三行代码,五个坑。
二、多阶段构建:把构建环境和运行环境分开
核心思路:构建阶段用完整的 Maven + JDK 镜像完成编译打包,运行阶段只保留 JRE 和产物。
FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=build /build/target/app.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]一个 Dockerfile 两段,构建环境里的 Maven、源码、编译中间产物都不会进入最终镜像。基础镜像从 openjdk:8-jdk(约 500MB)换成 eclipse-temurin:17-jre(约 200MB),体积直接砍掉一大半。
这里还有个关键细节:RUN mvn dependency:go-offline单独执行。它把依赖下载和源码编译拆成两层,只要 pom.xml 没变,Docker 构建缓存就不会失效,二次构建从几分钟降到十几秒。
三、分层镜像:让缓存命中率翻倍
Spring Boot 2.3 之后,官方插件支持分层打包。jar 里的依赖、Spring Boot 框架层、应用代码层被拆开,层与层之间相互独立。
spring:boot:build-image:{}或者更直接的方式,在 pom.xml 里显式开启:
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><layers><enabled>true</enabled></layers></configuration></plugin>打包后用java -Djarmode=tools -jar app.jar extract解出分层目录,Dockerfile 按层 COPY:
FROM eclipse-temurin:17-jre AS builder WORKDIR /extract COPY --from=build /build/target/app.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /extract/extracted/dependencies/ ./ COPY --from=builder /extract/extracted/spring-boot-loader/ ./ COPY --from=builder /extract/extracted/snapshot-dependencies/ ./ COPY --from=builder /extract/extracted/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]依赖层和快照依赖层在最前面,应用代码层在最后。改业务代码只重建最后一层,拉镜像时其余层全部命中本地缓存。团队人多、发版频繁时,这个收益非常明显。
四、JVM 必须感知容器内存
这是容器化最容易翻车的一环。JDK 8u191 之前的版本,JVM 不认识 cgroup 限制,默认按宿主机物理内存的 1/4 分配堆。宿主机 64GB,容器限制 512MB,JVM 一启动就想吃 16GB,容器直接被 OOM Killer 干掉。
现代 JDK(8u191+ / 11+)默认开启-XX:+UseContainerSupport,会读取 cgroup 配额。但只认配额还不够,推荐显式指定比例参数:
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:MinRAMPercentage=50.0" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]MaxRAMPercentage=75的意思是:堆最大占容器内存的 75%,给 Metaspace、线程栈、Direct Memory 留出余量。这个参数必须写在 ENTRYPOINT 里通过环境变量注入,这样运维不用改镜像也能调内存,k8s 里按 Deployment 的 resources.limits 调整即可。
不要再用-Xmx2g这种硬编码。容器规格一变,镜像就得重发。
五、时区、用户、编码,三个小坑一次填平
生产环境最常见的三连问:日志时间为什么差 8 小时?容器里为什么是 root?中文为什么乱码?
ENV TZ=Asia/Shanghai \ LANG=C.UTF-8 RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime RUN useradd -r -s /sbin/nologin app USER app- TZ 环境变量解决容器默认 UTC 导致的日志时间偏移
- 非 root 运行是安全基线,容器逃逸后 root 权限等于宿主权限
- LANG 解决 JVM 读文件时的编码推断问题
六、优雅停机:别让流量断在重启时
Docker stop 默认发 SIGTERM,15 秒后强制 SIGKILL。Spring Boot 2.3+ 的优雅停机需要显式开启:
server:shutdown:gracefulspring:lifecycle:timeout-per-shutdown-phase:30s开启后,收到 SIGTERM 时 Spring Boot 会停止接收新请求,等待在处理请求完成,再销毁 Bean、释放连接。配合 k8s 的 preStop hook 和 Readiness 探针,滚动发布可以实现零中断。
注意 timeout-per-shutdown-phase 要小于 Docker 的 stop-timeout 和 k8s 的 terminationGracePeriodSeconds,否则请求还没处理完就被 SIGKILL 了。
七、健康检查:让编排系统知道应用活着
Dockerfile 加一行,Docker/K8s 才能判断容器是否真的可用:
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD curl -fs http://localhost:8080/actuator/health || exit 1配合 Actuator 的 health 端点,除了进程存活,还能覆盖数据库连接、Redis、消息队列的可用性。基础镜像里没有 curl 时,用 wget 或 Java 的 HttpURLConnection 替代。
八、完整示例:一份可以直接上生产的 Dockerfile
FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B FROM eclipse-temurin:17-jre ENV TZ=Asia/Shanghai \ LANG=C.UTF-8 \ JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0" RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && useradd -r -s /sbin/nologin app WORKDIR /app COPY --from=build /build/target/app.jar app.jar USER app EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD wget -q -O- http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]构建:
dockerbuild-tdemo-app:1.0.0.dockerrun-d-p8080:8080--memory=1g--cpus=1demo-app:1.0.0九、最后对一下账
这套方案做完,效果是可量化的:
- 镜像体积:约 500MB+ 降到约 200MB,拉取时间减少一半以上
- 二次构建:依赖层缓存命中后,从几分钟降到十几秒
- 内存:JVM 按容器配额分配,不再和宿主机抢资源
- 停机:优雅停机生效,滚动发布不再出现 5xx
- 安全:非 root 运行,健康检查接入编排系统
Spring Boot 容器化从来不是「写个 Dockerfile 就能跑」的事。镜像体积、构建缓存、内存感知、优雅停机,每一环都在生产环境等着你。
你现在的镜像多大?构建一次要多久?