Spring Boot 应用 Docker 镜像最佳实践
把一个 Spring Boot 应用打成 Docker 镜像很简单,FROM eclipse-temurin:21-jre 加 COPY *.jar app.jar 就完了。但生产环境的镜像要小、要安全、要能被健康检查、要能在容器内存限制下正确工作。本文这套方案经过生产验证,逐条说明为什么这么做。
一、多阶段构建:构建产物不进运行镜像
构建用完整的 Maven + JDK 镜像,运行只用精简的 JRE 镜像,两个阶段互不干扰。最终镜像里只有 jar 和 JRE,没有源码、没有 .m2 缓存。
# ===== 阶段一:构建 =====
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
# 先只 COPY pom.xml,利用 Docker 层缓存
# 依赖没变时,这一层不重建,秒级复用
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
# ===== 阶段二:运行 =====
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
这里有个容易被忽略的细节:RUN mvn dependency:go-offline 在 COPY pom.xml 之后、COPY src 之前执行。这样只要依赖声明没变,Docker 构建就会复用这一层缓存,不会每次重新下载依赖——大项目的构建时间能差出好几分钟。
二、分层镜像:只更新变化的那一层
Spring Boot 3.x 的 maven 插件默认把 jar 拆成四层:依赖层、Spring Boot 层、应用层、资源层。开启分层后,把 jar 解包到镜像里,推送到镜像仓库时只有变化层需要传输。
<!-- pom.xml:开启分层 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers><enabled>true</enabled></layers>
</configuration>
</plugin>
# Dockerfile 运行阶段配合解包
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /build/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
效果:只改一行业务代码,重建镜像时依赖层、Spring Boot 层全部命中缓存,构建和推送都只动应用层,CI 速度提升明显。
三、非 root 用户运行
默认情况下容器里跑的是 root。一旦应用被攻破,攻击者拿到的是容器内最高权限,配合挂载的目录可能直接波及宿主机。生产镜像必须降权运行。
# Debian 系基础镜像(eclipse-temurin)
RUN groupadd -r app -g 1001 && useradd -r -u 1001 -g app -m -d /app app
USER app
# Alpine 系基础镜像则用
# RUN addgroup -S app && adduser -S -G app app
# USER app
Permission denied。比如日志目录:RUN mkdir -p /app/logs && chown -R app:app /app。这也是为什么数据卷的权限问题会追着容器跑——参考网络与数据卷管理一文里的权限处理方案。四、健康检查:让编排系统知道应用活没活
Spring Boot Actuator 暴露 /actuator/health,Docker 原生 HEALTHCHECK 定期探活。容器被标记为 unhealthy 后,Docker Compose 的 depends_on: condition: service_healthy 才能生效——依赖方不会在数据库还没就绪时启动。
# application.yml 只暴露 health,不暴露其他端点
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: never
# Dockerfile 追加
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD wget -q -O - http://127.0.0.1:8080/actuator/health || exit 1
# docker-compose.yml 中依赖关系
services:
app:
image: myapp:latest
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/actuator/health"]
interval: 30s
timeout: 3s
start_period: 40s
retries: 3
restart: always
nginx:
image: nginx:alpine
depends_on:
app:
condition: service_healthy
五、JVM 感知容器内存
默认 JVM 的堆大小按物理机内存的 1/4 计算。容器里如果限制了 512MB,JVM 却按宿主机 16GB 申请 4GB 堆,直接 OOM 被内核杀掉。用百分比参数让 JVM 跟着容器的 cgroup 限额走:
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-Xss512k", "-jar", "app.jar"]
- MaxRAMPercentage=75:堆上限为容器限额的 75%,给堆外内存(Metaspace、线程栈、DirectBuffer)留 25% 余量;
- Xss512k:默认线程栈 1MB,Java 应用线程数多时内存占用明显,压到 512k 常见且安全;
- JDK 8u191+ 已默认开启
UseContainerSupport,这里显式写百分比是为了精确控制。
配合 Compose 的 limits 一起用:
services:
app:
image: myapp:latest
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
六、时区与编码
容器默认 UTC 时区,Java 应用的时间戳、日志都会差八小时。运行时统一注入,不要每个 Dockerfile 各写一套:
# docker-compose.yml
services:
app:
environment:
- TZ=Asia/Shanghai
- JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai
七、验证构建结果
# 构建
docker build -t myapp:latest .
# 查看镜像体积(分层构建 + JRE 应该 200~300MB)
docker images myapp
# 本地试运行 + 健康检查
docker run --rm -p 8080:8080 --memory 512m myapp:latest
curl http://127.0.0.1:8080/actuator/health
# → {"status":"UP"}
延伸阅读:Spring Boot 3.x 多环境配置管理实战 · Docker Compose 网络与数据卷管理