从"能跑"到"跑得好"
把 Spring Boot 应用放进 Docker,最简单的 Dockerfile 是三行:拉一个 JDK 镜像、把 jar 拷进去、java -jar 启动。能跑,但问题不少:镜像动辄七八百 MB,每次改一行代码都要重新下载依赖,构建一次几分钟;容器内存超限被 kill,日志里只有 OOMKilled;用 root 运行,安全扫描一跑一堆告警。
这篇文章给一份可以直接用的多阶段 Dockerfile,重点解决三件事:构建缓存、镜像体积、运行时配置。
多阶段构建
构建和运行分开,构建阶段用 Maven 镜像,运行阶段只保留 JRE:
# 第一阶段:构建
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src ./src
RUN mvn -B -q clean package -DskipTests
# 第二阶段:解压分层
FROM eclipse-temurin:17-jre-jammy AS layers
WORKDIR /app
COPY --from=build /build/target/app.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# 第三阶段:运行
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
RUN groupadd -r app && useradd -r -g app app
COPY --from=layers /app/dependencies/ ./
COPY --from=layers /app/spring-boot-loader/ ./
COPY --from=layers /app/application/ ./
RUN chown -R app:app /app
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-Duser.timezone=Asia/Shanghai", "org.springframework.boot.loader.JarLauncher"]
配合两个小配置。pom.xml 里固定 jar 名称,避免 target 下出现多个 jar:
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
再加一个 .dockerignore,别把 target、.git、IDE 配置传给构建上下文:
target/
.git/
.idea/
*.iml
*.log
为什么要分层
Spring Boot 的 fat jar 里,依赖占了绝大部分体积,业务代码只有几百 KB。如果每次构建都把整个 jar 作为一个层,改一行代码也会让这一层失效,后面所有层都要重建。
layertools 把 jar 拆成四部分:dependencies(第三方依赖)、spring-boot-loader(启动器)、snapshot-dependencies(SNAPSHOT 依赖)、application(业务类和配置)。按这个顺序 COPY,依赖层很少变,能命中缓存;只有 application 层每次重建,镜像推送量也从几百 MB 降到几 MB。
注意 snapshot-dependencies 目录只有在项目依赖 SNAPSHOT 版本时才存在。没有这个目录时写 COPY,构建会直接失败,所以示例里没有加这一行。需要的话再补:
COPY --from=layers /app/snapshot-dependencies/ ./
分层之后,启动方式也要跟着变:不能再 java -jar app.jar,因为 jar 已经被拆开,要用 JarLauncher 从解压后的目录启动。这一点在改造老项目时经常被漏掉,容器起来就报 no main manifest attribute。
常见坑
1. COPY target/*.jar 拷贝到多个文件
Spring Boot 重新打包后,target 下会同时存在 app.jar 和 app.jar.original。用通配符 COPY 会匹配到两个文件,报 when using COPY with more than one source file, destination must be a directory。固定 finalName 后写明确文件名。
2. 每次构建都重新下载依赖
先 COPY pom.xml 再 dependency:go-offline,最后才 COPY src。这样只要 pom.xml 没变,依赖层就能复用。如果直接把整个项目 COPY 进去再 mvn package,任何代码改动都会触发全量下载。
3. 构建上下文太大
docker build . 会把当前目录整个发给 Docker daemon,包括 target、node_modules、.git。上下文几百 MB,构建光传输就要很久。用 .dockerignore 排除掉。
4. 镜像架构不匹配
在 Apple Silicon 或 ARM 服务器上构建的镜像,推到 amd64 机器上跑不起来,报 exec format error。构建时用 --platform linux/amd64 明确目标架构,或者在 CI 里统一构建架构。
5. JVM 不感知容器内存限制
老版本 JDK 只认宿主机内存,容器限制 1GB,JVM 可能按 16GB 去设置堆,最后被 OOMKilled。Java 10 以后支持容器感知,JDK 17 默认开启。再加 -XX:MaxRAMPercentage=75.0,让堆不超过容器上限的 75%,给元空间、线程栈、堆外内存留空间。
6. 用 root 运行
默认的 root 用户权限太大,容器被攻破后影响面也大。创建普通用户并 USER app,同时确保挂载目录对这个用户可写。
7. ENTRYPOINT 和 CMD 用混
ENTRYPOINT 是容器启动的固定命令,CMD 是默认参数,docker run 后面的参数会覆盖 CMD。写反了会导致启动参数被意外替换。Java 应用一般用 ENTRYPOINT ["java", ...] 固定启动方式。
8. 时区和编码
基础镜像默认 UTC,日志时间差 8 小时;默认编码在部分镜像里不是 UTF-8,中文会乱码。启动参数里加 -Duser.timezone=Asia/Shanghai 和 -Dfile.encoding=UTF-8。
9. 没有健康检查
容器进程活着不代表应用可用。可以加 HEALTHCHECK,或者交给 Kubernetes 的 readiness/liveness 探针。Spring Boot 项目配合 actuator 的 /actuator/health 最方便。
排查步骤
镜像构建或启动失败时:
# 看完整构建日志,定位是哪一步失败
docker build --progress=plain -t app:1.0 .
# 用运行镜像进容器检查文件结构
docker run --rm -it app:1.0 sh
ls -l /app
# 看镜像分层和体积
docker history app:1.0
# 启动后确认退出原因
docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' app
如果容器反复重启,先看 OOMKilled 是否为 true,再看应用日志里有没有启动异常。镜像体积异常大时,用 docker history 找到最大的那一层,通常是构建工具、缓存或者没清理的临时文件。把构建产物和运行环境分开,体积自然就下来了。
评论