1. 为什么我坚持用 Docker 镜像来交付 SpringBoot 项目
刚入行那几年,我交付一个 SpringBoot 项目,流程基本是这样的:本地mvn clean package打出 jar 包,用 scp 传到服务器,写一个nohup java -jar xxx.jar &的启动脚本,再配一个 systemd 或者 supervisor 守护进程。这套流程能跑,但每次换服务器、换 JDK 版本、换环境变量,就得重新折腾一遍。最要命的是"在我机器上是好的"这句话,几乎成了团队里的口头禅。
后来我把交付方式统一改成 Docker 镜像,情况完全变了。SpringBoot 项目打包为 Docker 镜像,本质上是把"应用代码 + 运行时环境 + 启动配置"这三样东西冻结成一个不可变的交付单元。服务器上只要有 Docker 引擎,docker run一条命令就能起来,JDK 版本、时区、字符集、JVM 参数全部在镜像里定死,不再依赖宿主机环境。这对中小团队尤其友好——没有 K8s 那么重,但又比裸 jar 包规范得多。
这篇文章我想聊的不是"Docker 是什么"这种入门概念,而是我这些年把 SpringBoot 打成镜像时踩过的坑、做过的取舍,以及一套可以直接抄作业的落地方案。内容包括:镜像构建的几种思路怎么选、Dockerfile 每一行到底在干什么、分层缓存怎么让构建从 3 分钟降到 20 秒、镜像体积怎么从 500MB 压到 80MB、时区和字体这些"看起来没事、上线就出事"的细节怎么处理。适合已经会写 SpringBoot、但对容器化交付还不够熟练的开发者,也适合想把现有部署流程规范化的运维同学。
我下面给出的所有 Dockerfile、命令、参数,都是我在实际项目里反复验证过的,你可以直接拿去改改就用。涉及具体数值的地方,我会把计算过程也写出来,方便你按自己的项目调整。
2. 镜像构建方案选型:三种思路的取舍
在动手写 Dockerfile 之前,先想清楚一件事:你的镜像要"装"什么。SpringBoot 的产物是一个可执行 jar,理论上只要有 JRE 就能跑。但"只要有 JRE"这句话背后,藏着三种截然不同的构建思路,选错了后面全是麻烦。
2.1 方案一:单阶段构建,基础镜像直接用 JDK
这是最直觉的做法:找一个openjdk:17-jdk或者eclipse-temurin:17-jdk作为基础镜像,把 jar 拷进去,ENTRYPOINT里java -jar启动。优点是简单,五行 Dockerfile 搞定;缺点是镜像里塞了一整套 JDK(编译器、javac、jshell 全在里面),而运行时根本用不到。一个eclipse-temurin:17-jdk镜像光基础层就 400MB 出头,加上你的 jar,轻松突破 500MB。
我早期项目就是这么干的,直到有一次在带宽受限的环境里推送镜像,一个 500MB 的镜像推了十几分钟,才下决心优化。JDK 和 JRE 的区别在这里很关键:JDK 是开发工具包,JRE 是运行时环境,生产环境只需要 JRE。不过从 Java 11 开始,Oracle 和各家发行版不再单独提供 JRE 镜像了,所以实际做法是用jlink裁剪,或者直接用更小的基础镜像。
2.2 方案二:多阶段构建,构建与运行分离
多阶段构建是我现在的主力方案。思路是:第一阶段用完整的 JDK + Maven 镜像来编译打包,第二阶段只用一个精简的 JRE 基础镜像来运行。两个阶段在同一个 Dockerfile 里,但只有第二阶段的产物会进入最终镜像。
# 第一阶段:构建 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests -B # 第二阶段:运行 FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=builder /build/target/*.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar"]这个方案的好处是构建环境(Maven、JDK)和运行环境(JRE)彻底解耦,最终镜像里没有 Maven、没有源码、没有编译缓存。eclipse-temurin:17-jre-alpine大概 160MB 左右,加上 jar 通常能控制在 200MB 以内。相比方案一,体积直接砍掉一半以上。
注意:Alpine 用的是 musl libc 而不是 glibc,绝大多数纯 Java 应用没问题,但如果你的项目依赖了 JNI 本地库(比如某些图像处理、加密库),可能会在 Alpine 上跑不起来。这种情况换成
eclipse-temurin:17-jre-jammy(基于 Ubuntu)更稳妥,代价是体积大一些。
2.3 方案三:分层 jar + 分层镜像,把缓存用到极致
SpringBoot 2.3 之后,官方提供了一个很实用的能力:把 fat jar 拆成四层——dependencies、spring-boot-loader、snapshot-dependencies、application。为什么要拆?因为 Docker 镜像的每一层都是独立缓存的,如果整个 jar 是一层,那你改一行代码,整个 jar 的哈希就变了,这一层缓存全部失效,每次构建都要重新拷贝几十 MB。
拆层之后,依赖层(通常占 jar 体积的 80% 以上)只要pom.xml没变就不会失效,只有最上层的application层(你自己的代码,通常几百 KB)会重新构建。实测下来,一个中等规模项目的镜像构建时间能从 2-3 分钟降到 20-30 秒。
要启用分层,需要在pom.xml里配置spring-boot-maven-plugin:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layers> <enabled>true</enabled> </layers> </configuration> </plugin>打包后用java -Djarmode=layertools -jar app.jar list可以看到分层结构。然后在 Dockerfile 里用layertools extract把各层分别拷进去:
FROM eclipse-temurin:17-jre-jammy AS runtime WORKDIR /app COPY --from=builder /build/target/*.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY --from=runtime /app/dependencies/ ./ COPY --from=runtime /app/spring-boot-loader/ ./ COPY --from=runtime /app/snapshot-dependencies/ ./ COPY --from=runtime /app/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]这里有个细节要注意:SpringBoot 3.2 之后,JarLauncher的包路径从org.springframework.boot.loader.JarLauncher变成了org.springframework.boot.loader.launch.JarLauncher。如果你从旧项目复制 Dockerfile,启动时会报ClassNotFoundException,这个坑我踩过,排查了半天才反应过来是版本差异。
2.4 三种方案的对比与选择建议
| 方案 | 镜像体积 | 构建速度 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 单阶段 JDK | 500MB+ | 快 | 低 | 本地测试、临时验证 |
| 多阶段 JRE | 150-250MB | 中 | 中 | 绝大多数生产项目 |
| 分层 jar | 150-250MB | 快(增量) | 高 | 频繁迭代、CI/CD 流水线 |
我的建议是:新项目直接上方案二,等 CI/CD 流水线跑起来、构建频率高了,再升级到方案三。方案一除非是临时验证,否则不要用于生产。
3. Dockerfile 逐行拆解与关键参数计算
选好方案之后,Dockerfile 里每一行怎么写,直接决定了镜像的质量和运行时的稳定性。这一节我把关键行拆开讲,顺便把参数计算的过程也交代清楚。
3.1 基础镜像怎么选:发行版、JDK 版本、架构
基础镜像的选择要考虑三个维度:发行版、JDK 版本、CPU 架构。
发行版方面,Alpine 最小(约 160MB),但 musl libc 有兼容性风险;Ubuntu(jammy)约 250MB,兼容性最好;Debian slim 介于两者之间。我的经验是:纯 SpringBoot Web 应用用 Alpine 没问题,涉及本地库、字体渲染、图像处理的,老老实实用 Ubuntu。
JDK 版本要和你的pom.xml里maven.compiler.source保持一致。比如你编译用的是 Java 17,运行镜像就必须是 17 或更高。这里有个容易忽略的点:编译版本和运行版本不一致可能导致UnsupportedClassVersionError。我见过有人用 JDK 21 编译,运行镜像却用了 17,启动直接崩。
CPU 架构方面,现在很多开发机是 ARM(比如 M 系列芯片的 Mac),而服务器是 x86。如果你在 Mac 上构建镜像再推到 x86 服务器,需要指定--platform linux/amd64,否则镜像架构不匹配,运行时报exec format error。构建命令:
docker buildx build --platform linux/amd64 -t myapp:1.0.0 .3.2 工作目录、时区、字符集:三个"上线才出事"的细节
WORKDIR看似无关紧要,但它决定了后续所有相对路径的基准。我习惯用/app,简洁明了。不要用/或者/root,前者太乱,后者在某些基础镜像里权限有问题。
时区是重灾区。容器默认时区是 UTC,而国内业务通常需要Asia/Shanghai。如果不设置,日志时间会比北京时间晚 8 小时,排查问题时对不上时间线。设置方式有两种:
# 方式一:设置环境变量(推荐,最轻量) ENV TZ=Asia/Shanghai # 方式二:安装 tzdata 并软链(Alpine 需要) RUN apk add --no-cache tzdata \ && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone方式一在大多数 JVM 场景下够用,因为 Java 会读TZ环境变量。但如果你的应用里有依赖系统时区的本地库,就得用方式二。
字符集同样关键。容器默认 locale 往往是POSIX或C,中文会显示成乱码。JVM 层面可以通过-Dfile.encoding=UTF-8解决,但更彻底的做法是设置LANG:
ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8提示:
C.UTF-8比en_US.UTF-8更省事,因为不需要额外生成 locale,Alpine 和 Ubuntu 都原生支持。
3.3 JVM 参数怎么配:内存、GC、容器感知
容器里跑 JVM,最容易出问题的就是内存。早期 JVM 不认识 cgroup 限制,会按宿主机的物理内存来设置堆大小,结果容器被 OOM Killer 干掉。Java 10 之后有了UseContainerSupport(默认开启),JVM 能正确读取容器内存限制,这个问题基本解决了。但仍有几个参数值得显式配置。
堆内存:-Xmx和-Xms建议设成一样,避免运行时动态扩缩带来的抖动。设多少?一般按容器内存限制的 60%-75%。比如容器限制 1GB,堆设-Xmx768m比较合适,剩下的留给元空间、线程栈、直接内存。
GC 选择:Java 17 默认是 G1,对大多数 Web 应用够用。如果堆小于 2GB 且追求低延迟,可以考虑-XX:+UseSerialGC(小堆下反而更快);如果堆很大(8GB+)且对停顿敏感,考虑 ZGC。
容器感知:显式加上-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0,让 JVM 按容器内存的百分比来算堆,比写死-Xmx更灵活。
综合起来,我的JAVA_OPTS通常长这样:
ENV JAVA_OPTS="-XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/app/logs \ -Dfile.encoding=UTF-8 \ -Duser.timezone=Asia/Shanghai"HeapDumpOnOutOfMemoryError这个参数强烈建议加上,OOM 时自动 dump 堆快照,事后分析有据可查。dump 路径要挂载到宿主机卷,否则容器一销毁就没了。
3.4 启动脚本与 ENTRYPOINT 的写法
ENTRYPOINT有两种写法:exec 形式和 shell 形式。一定要用 exec 形式(JSON 数组),因为 shell 形式会让你的 Java 进程变成 shell 的子进程,收不到SIGTERM信号,docker stop时无法优雅停机,只能等超时被SIGKILL强杀。
# 正确:exec 形式,Java 进程是 PID 1,能收到信号 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] # 错误:shell 形式,Java 是子进程,信号收不到 ENTRYPOINT java $JAVA_OPTS -jar app.jar注意上面用了sh -c来展开$JAVA_OPTS环境变量。如果直接写["java", "$JAVA_OPTS", "-jar", "app.jar"],$JAVA_OPTS不会被展开,会被当成一个完整的参数传给 java,直接报错。用sh -c的代价是多了一层 shell,但sh会把信号转发给子进程,所以优雅停机仍然有效。
如果你想要更精细的控制(比如启动前做健康检查、等待依赖服务),可以写一个entrypoint.sh:
#!/bin/sh set -e echo "Starting application with JAVA_OPTS: $JAVA_OPTS" exec java $JAVA_OPTS -jar /app/app.jar关键是最后一行用exec,让 java 替换掉 shell 进程,成为 PID 1。
4. 完整实操流程:从零到镜像跑起来
前面讲了原理和参数,这一节我把完整流程走一遍,你可以跟着一步步操作。
4.1 项目准备与 pom.xml 配置
假设你有一个标准的 SpringBoot 项目,先确认pom.xml里的打包插件配置正确:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layers> <enabled>true</enabled> </layers> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build>excludes里排除 Lombok 是个好习惯,它只在编译期起作用,运行时不需要,排除后能省几 MB。
4.2 编写生产级 Dockerfile
把下面这份 Dockerfile 放到项目根目录,我加了详细注释:
# ========== 构建阶段 ========== FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build # 先拷 pom.xml 单独跑一次依赖下载,利用 Docker 层缓存 # 只要 pom.xml 没变,这一层就不会重新执行 COPY pom.xml . RUN mvn dependency:go-offline -B # 再拷源码编译 COPY src ./src RUN mvn clean package -DskipTests -B # 用 layertools 把 jar 拆层 RUN java -Djarmode=layertools -jar target/*.jar extract --destination /build/extracted # ========== 运行阶段 ========== FROM eclipse-temurin:17-jre-jammy WORKDIR /app # 时区、字符集 ENV TZ=Asia/Shanghai ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8 # JVM 参数 ENV JAVA_OPTS="-XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/app/logs \ -Dfile.encoding=UTF-8 \ -Duser.timezone=Asia/Shanghai" # 创建非 root 用户运行应用(安全加固) RUN groupadd -r appuser && useradd -r -g appuser appuser \ && mkdir -p /app/logs && chown -R appuser:appuser /app # 按层拷贝,最大化缓存命中 COPY --from=builder --chown=appuser:appuser /build/extracted/dependencies/ ./ COPY --from=builder --chown=appuser:appuser /build/extracted/spring-boot-loader/ ./ COPY --from=builder --chown=appuser:appuser /build/extracted/snapshot-dependencies/ ./ COPY --from=builder --chown=appuser:appuser /build/extracted/application/ ./ USER appuser EXPOSE 8080 ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]这份 Dockerfile 里有几个点值得单独说。非 root 用户运行是安全加固的基本要求,容器里用 root 跑应用,一旦被攻破,攻击者拿到的就是容器内的 root 权限,配合内核漏洞可能逃逸到宿主机。创建appuser并USER appuser之后,即使应用被攻破,影响范围也被限制在普通用户权限内。
--chown参数是为了让拷贝进来的文件属于appuser,否则文件属主是 root,appuser可能没有读写权限。
4.3 构建镜像与体积优化验证
构建命令:
docker build -t myapp:1.0.0 .构建完成后看体积:
docker images myapp:1.0.0如果体积超过预期,用docker history看每一层的大小:
docker history myapp:1.0.0 --no-trunc --format "{{.Size}}\t{{.CreatedBy}}"常见的体积问题来源:基础镜像选太大(换成 Alpine 或 slim)、构建缓存被拷进最终镜像(多阶段构建能避免)、日志文件被打进镜像(.dockerignore里排除)。
.dockerignore文件很重要,至少包含这些:
target/ .git/ .idea/ *.iml *.log logs/ node_modules/4.4 运行容器与参数验证
启动容器,限制内存 1GB,映射端口,挂载日志目录:
docker run -d \ --name myapp \ --memory=1g \ --cpus=1.5 \ -p 8080:8080 \ -v /host/logs:/app/logs \ -e JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -Dspring.profiles.active=prod" \ myapp:1.0.0注意这里通过-e JAVA_OPTS覆盖了镜像里的默认值,把堆比例调到 70%,并激活了 prod 配置。这种"镜像给默认值、运行时按环境覆盖"的模式很实用,同一个镜像可以在不同环境用不同参数启动。
启动后验证 JVM 是否感知到容器内存限制:
docker exec myapp java -XX:+PrintFlagsFinal -version | grep -i maxheap输出里MaxHeapSize应该约等于 1GB 的 70%,也就是 700MB 左右。如果显示的是宿主机内存(比如 16GB),说明容器感知没生效,检查UseContainerSupport是否被禁用。
5. 常见问题与排查技巧实录
这一节是我这些年踩坑攒下来的经验,按问题类型整理,方便你遇到时快速定位。
5.1 启动失败类问题速查
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
UnsupportedClassVersionError | 编译 JDK 版本高于运行 JRE | docker run --rm 镜像 java -version | 运行镜像 JDK 版本 ≥ 编译版本 |
exec format error | 镜像架构与宿主机不匹配 | docker inspect 镜像 | grep Architecture | 构建时加--platform |
ClassNotFoundException: JarLauncher | SpringBoot 3.2+ 包路径变了 | 看异常里的完整类名 | 改用loader.launch.JarLauncher |
no main manifest attribute | jar 不是可执行 fat jar | unzip -p app.jar META-INF/MANIFEST.MF | 检查 spring-boot-maven-plugin 配置 |
| 启动后立即退出 | 主线程结束或配置错误 | docker logs 容器名 | 看日志定位,常见是端口占用或配置缺失 |
UnsupportedClassVersionError这个我特别想强调。它的报错信息里会写"class file version 61.0,this version only recognizes up to 55.0"之类的,61 对应 Java 17,55 对应 Java 11。看到这个数字对照表,就能快速判断版本差多少。
5.2 内存与 OOM 问题排查
容器里 OOM 有两种:JVM 堆 OOM和容器 OOM Killer。前者会抛OutOfMemoryError并生成堆 dump,后者是容器直接被内核杀掉,docker logs里看不到任何 Java 异常,只有docker inspect里的OOMKilled: true。
判断方法:
docker inspect 容器名 --format '{{.State.OOMKilled}}'返回true就是被容器 OOM Killer 干掉的。这种情况通常是堆设太大,加上元空间、线程栈、直接内存后超过了容器限制。解决办法是把MaxRAMPercentage调低,比如从 75% 降到 60%,给非堆内存留足空间。
我遇到过一个典型案例:容器限制 512MB,MaxRAMPercentage=75,堆就是 384MB,看起来没问题。但应用用了 Netty,直接内存(Direct Memory)默认上限等于堆大小,又是 384MB,加起来 768MB 直接超限。后来把-XX:MaxDirectMemorySize=128m显式限制,问题解决。堆内存 + 直接内存 + 元空间 + 线程栈,这四项加起来才是真实内存占用,配参数时不能只盯着堆。
5.3 时区、编码、字体三类"隐形"问题
时区问题前面讲过,这里补充一个排查技巧:进容器执行date看系统时间,再执行java -XshowSettings:properties -version 2>&1 | grep timezone看 JVM 时区,两者要一致。
编码问题表现为中文乱码,排查时看locale命令输出。如果LANG是空的或者POSIX,中文必乱。JVM 层面用-Dfile.encoding=UTF-8兜底,但系统层面最好也设上。
字体问题比较隐蔽,通常在做验证码、PDF 导出、图片水印时暴露。报错是java.lang.NullPointerException或者Fontconfig head is null,本质是容器里没装字体。Alpine 和 slim 镜像默认都不带字体,需要手动装:
RUN apt-get update && apt-get install -y --no-install-recommends \ fontconfig \ fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/*fonts-dejavu-core提供了基础字体,能覆盖大部分场景。如果业务需要中文字体,得额外装fonts-noto-cjk,体积会大几十 MB,按需取舍。
5.4 镜像构建慢的优化技巧
构建慢通常卡在依赖下载。mvn dependency:go-offline这一层如果每次都重新执行,说明pom.xml的哈希变了。检查是不是pom.xml里混入了会变的内容(比如build.number之类的动态版本)。
另一个技巧是用 Maven 本地仓库缓存。在 CI 环境里,可以把~/.m2挂载成缓存卷,或者用 BuildKit 的 cache mount:
RUN --mount=type=cache,target=/root/.m2 \ mvn clean package -DskipTests -BBuildKit 的 cache mount 不会把缓存打进镜像层,但能在多次构建间复用,效果很好。启用 BuildKit 需要设置DOCKER_BUILDKIT=1或者用docker buildx build。
提示:cache mount 里的内容在构建结束后不保留,所以它只加速构建,不影响镜像体积。这是它和普通
RUN层的本质区别。
5.5 优雅停机与健康检查
docker stop默认发SIGTERM,等 10 秒后发SIGKILL。SpringBoot 从 2.3 开始支持优雅停机,配置:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s配合前面说的 exec 形式 ENTRYPOINT,SIGTERM能正确传到 Java 进程,应用会停止接收新请求、等待进行中的请求处理完再退出。如果docker stop总是等满 10 秒才停,八成是信号没传到位,检查 ENTRYPOINT 写法。
健康检查用HEALTHCHECK指令:
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1start-period给应用启动留足时间,避免启动期间被误判为不健康。这个参数要根据你应用的启动耗时来定,我一般设成实际启动时间的 1.5 倍。
6. 我个人的几条实战心得
聊了这么多技术细节,最后分享几条我在实际项目里总结的经验,都是文档里不太会写、但确实能省事的东西。
第一条:镜像 tag 一定要有语义。我见过太多人用latest部署生产,结果回滚时不知道上一个版本是哪个。我的习惯是应用名:版本号-构建号,比如myapp:1.0.0-20240115,版本号对应代码分支,构建号对应 CI 流水线编号。这样出问题能精确定位到某次构建。
第二条:配置文件不要打进镜像。镜像应该是环境无关的,配置通过环境变量或挂载卷注入。我见过把application-prod.yml打进镜像的做法,结果测试环境和生产环境用了同一个镜像,配置却不一样,非常混乱。正确做法是镜像里只放application.yml默认配置,环境相关的用-e SPRING_PROFILES_ACTIVE=prod加挂载的配置目录。
第三条:日志一定要挂出来。容器是无状态的,容器一删日志就没了。用-v /host/logs:/app/logs把日志目录挂到宿主机,或者配置 logback 直接输出到 stdout 由 Docker 的日志驱动收集。我倾向于后者,配合docker logs和日志聚合工具,排查更方便。
第四条:定期清理无用镜像。开发机上跑久了,docker images里会堆一堆<none>的悬空镜像,占几十 GB 空间。定期执行docker image prune -a清理,但注意-a会删掉所有没被容器引用的镜像,生产环境慎用。
第五条:镜像安全扫描别偷懒。基础镜像里的系统库可能有已知漏洞,用docker scout或者trivy扫一下,心里有数。我一般把扫描集成到 CI 里,发现高危漏洞就阻断构建。这不是形式主义,真出过事就知道值了。
这套流程我从单体项目用到微服务,从几 MB 的小工具用到几百 MB 的大应用,基本都能覆盖。核心思路就一句话:把环境差异关进镜像里,让交付变成一条命令的事。剩下的优化,都是在这个基础上做减法和提速。