SpringBoot 项目用 Docker 部署到发布环境:镜像、启动参数、日志、回滚,一次讲清楚
本地 docker run 一把梭,真正发到服务器就出事——时区差 8 小时、日志找不到、内存不受控、升级要停服、回滚靠重传 jar。这篇文章按发布流程梳理一套务实做法:镜像怎么打、启动参数怎么给、日志怎么落盘、如何优雅停机、怎么安全升级回滚,以及哪些场景其实不该用 Docker。
先泼一盆冷水:本地能跑和发布环境能跑是两码事
很多团队上 Docker 的第一步是"把 jar 包进镜像,docker run 一下,成功!"。然后发布到服务器,一连串问题就来了:
时区不对:日志时间比真实时间少 8 小时,一查,容器里默认是 UTC。
内存不受控:应用跑着跑着被 OOMKilled,容器内存只给了 1G,JVM 却按物理机内存算堆,直接超限。
日志消失:日志全在容器里,容器一删日志就没了,排障时一脸懵。
升级粗暴:升级就是 docker rm -f 强杀再起,用户那一下明显卡顿。
构建依赖服务器:服务器上没装 Maven/JDK,改个配置想重新构建都费劲。
这些问题都不是 Docker 的错,是"把 Docker 当成了换个方式跑 jar",没把它当发布流程来设计。下面按一条完整的发布链路来讲,每一步都是上面这些坑的解法。
第一步:镜像从哪来——别在发布服务器上构建
先说结论:构建(build)和发布(run)要分开。镜像应该在开发/构建机上打,推到镜像仓库,发布服务器只负责拉取和运行。理由很现实:
1. 服务器通常没有构建环境。发布服务器上没装 Maven 和 JDK 是常态,你在上面跑 mvn package 第一步就卡死。
2. 版本一致性。多台服务器部署时,每台各自构建一次,版本没法保证一致,排查问题时"这台和那台代码不一样"是最难定位的坑之一。
3. 服务器留源码是隐患。在服务器上构建会把源码、.git、target 目录都留在机器上,磁盘占用和泄露风险都是问题。
这一步的最小形态只有两种:有镜像仓库(Harbor 或自建 Registry)就把镜像推上去,服务器 docker pull;没有仓库、就一两台服务器,用 docker save 导出 tar 包、scp 过去再 docker load,也是真实团队大量在用的过渡方案——先跑起来,再补仓库。项目用的是 jar 还是 war 不改变这条链路:war 通常仍走外部 Tomcat 或转 jar 再容器化,jar 是最省事的形态。
最小可用的运行镜像,务实版本:
# 只跑,不编译
FROM eclipse-temurin:8-jre
# 时区,防止日志差 8 小时
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY app.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]
注意两件事:
基础镜像用 JRE 不用 JDK。运行不需要编译器,JDK 镜像大几百 MB,JRE 小一半。想再极致可以做多阶段构建让镜像只剩 JRE+jar,这块是另一篇文章(镜像瘦身)的范畴,本文不展开。
-XX:MaxRAMPercentage=75.0 这个参数必须给。原因见下一步。
第二步:JVM 内存,发布环境最容易翻车的点
在物理机上跑 Java,你习惯 -Xmx2g 手动指定。但进容器后这个习惯会害了你:
老版本 JDK 8(8u191 之前)不认识容器内存限制。它默认按物理机的内存来算堆大小。你 --memory=1g 限制容器,JVM 却以为机器有几十 G 内存,堆给你分配一大截,一压测直接 OOMKilled,docker ps 里能看到容器状态是 OOMKilled。
JDK 8u191+ 和 JDK 11+ 默认开了 UseContainerSupport。此时又容易走另一个极端:1G 容器分 1/4 给堆,其他内存(线程栈、Metaspace、直接内存)没留够,表现不是 OOMKilled,而是各种诡异的 OutOfMemoryError。
务实做法:在容器里别手写 -Xmx,用百分比。
-XX:MaxRAMPercentage=75.0
留 25% 给非堆内存(Metaspace、线程栈、GC 结构)。如果用了堆外内存比较重的组件(比如大量直接内存、本地缓存),比例再往下调。别追求一次调对,按 docker stats 观察真实占用再改。
第三步:日志——发布环境不能只靠 docker logs
容器跑起来后用 docker logs -f 容器名 能看,但发布环境必须想清楚日志最终去哪:
1. 至少保留一个控制台 appender。配 logback/log4j 时,保证至少有一个 appender 输出到 stdout(容器里就是控制台),否则 docker logs 什么都看不到。很多项目上线后发现"没有日志",八成是只配了按日期写文件,没配控制台输出。
2. 把日志目录挂载出来。容器删了重建,日志不能跟着消失。运行参数里挂载:
-v /opt/app/logs:/app/logs
3. 日志切割和保留策略要提前定。按天/按大小切割、保留 N 天,要在应用侧或宿主侧解决,否则 / 盘被日志打满是发布环境最常见的磁盘事故。日志盘单独分区分大一点,是最便宜的保险。
第四步:发布环境不能 run 完就完——restart 与 healthcheck
docker run 可以裸跑,但发布环境要加两样东西:
1. --restart=always。机器重启、docker 重启后容器自动拉起。不加的话,服务器一重启,你的服务就静默下线,等用户投诉才发现。
2. 健康检查。docker ps 里 STATUS 列会显示 (healthy),发布脚本和监控都靠它判断服务死活。
docker run -d --name blog-app \
--restart=always \
-p 8080:8080 \
-e TZ=Asia/Shanghai \
-e SPRING_PROFILES_ACTIVE=prod \
-v /opt/app/config:/config \
-v /opt/app/logs:/logs \
--health-cmd="wget -qO- http://127.0.0.1:8080/actuator/health || exit 1" \
--health-interval=30s \
--health-timeout=5s \
--health-retries=3 \
registry.example.com/blog/blog-app:20260904
一个真实的权衡点:Spring Boot 自带的 /actuator/health 是标准答案,但如果你没引 actuator,用项目自己的探测接口(登录页或某个公开接口)也行。健康检查探的是进程活着且能响应请求,不是探"我执行了 java -jar"。
第五步:升级发布与回滚——别再用 docker rm -f 硬来
发布 = 新版本容器替换旧版本容器。有两种做法:
做法 A:先启新的再停旧的(推荐,但要临时多一个端口)
docker pull registry.example.com/blog/blog-app:20260905
# 先以 8081 端口把新版本启起来
docker run -d --name blog-app-new -p 8081:8080 --restart=no ...新版本镜像...
# 验证 8081 正常后,再替换
docker stop blog-app && docker rm blog-app
docker rename blog-app-new blog-app
做法 B:单端口直接替换(简单,但有短暂断连)
docker stop blog-app && docker rm blog-app
docker run -d --name blog-app ...新版本镜像...
做法 B 是目前绝大多数后端项目的真实形态——停机窗口几十秒内能接受,就别为了"零停机"引入网关、负载均衡那套复杂度。反过来,如果服务是给外部用户 7×24 用的(比如开放 API、对外支付回调),做法 B 不可接受,必须上做法 A,或更前面的 Nginx 做流量切换。
回滚就一句话:镜像 tag 带版本号,回滚 = 用上一个 tag 重新 run 一遍。所以 tag 一定要带日期或构建号,别永远打 latest——latest 被覆盖后你根本不知道线上跑的是哪个版本。
还有一个后端程序员最容易漏的坑:回滚代码的同时要考虑数据库。如果新版本跑了 DDL(加了字段、改了表),回滚到旧版本,旧代码对着新表结构不一定跑得动。务实的缓解方式:应用升级前把要执行的 SQL 单独留档,升级失败回滚时,先人工判断要不要回退 SQL。没有银弹,但留档这个动作必须有。
第六步:优雅停机——让正在处理的请求跑完
docker stop 默认等 10 秒就发 SIGKILL 强杀。如果你什么都没配,用户正在提交的请求很可能被拦腰截断。Spring Boot 2.3+ 配两个东西:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
再配合容器侧把停止等待时间调长(docker-compose 或 docker run 的 stop-timeout):
docker run -d --name blog-app --stop-timeout 30 ...
两个时间要匹配:JVM 的优雅停机超时 <= 容器的停止等待时间,否则请求还没处理完就被强杀了,配了等于没配。
第七步:配置外置——镜像里别写死环境差异
同一个 jar,测试环境连测试库、生产连生产库。配置文件路径不要打进镜像。务实做法:
敏感配置走环境变量注入:SPRING_PROFILES_ACTIVE、数据库地址、密码,全部 -e 传。
非敏感但环境差异大的配置挂载外部文件:-v /opt/app/config:/config,然后 spring.config.additional-location 指过去。
镜像只放"代码 + 基础配置",环境相关的全部运行时注入。
这样做的直接好处是:同一个镜像测试/生产通用,回滚也简单——环境参数不变,只换镜像版本。
该不该用 Docker?说实话的边界
把话挑明,不是所有项目都该 Docker:
单机单服务、没人管运维:systemd 直接跑 jar 反而更简单,日志归 journald,自动重启配 Restart=always,没有 Docker 的学习和维护成本。
一台服务器要部署几个服务、且彼此版本/依赖容易冲突:Docker 隔离的价值就出来了,MySQL、Redis、应用各一个容器互不污染。
要横向扩展、滚动发布:那是编排系统(K8s/Swarm)的领域,先别急着上——单机用 docker-compose.yml 把服务和依赖编排好,够用很久。
务实原则是:Docker 解决的是"部署一致性和隔离"问题,不是"我用上了 Docker 就专业"问题。项目就一个小服务,硬上一套镜像仓库加编排,是拿复杂度换虚荣心,不值。
小结
发布环境跑 Docker,抓住四个关键字:
分离——构建在构建机,发布机只拉只跑;
参数化——时区、JVM 内存、环境配置全部显式传,不靠镜像里写死;
可观测——日志挂载出容器、healthcheck 挂上,出事能查;
可回滚——tag 带版本、升级有脚本、数据库变更留档。
这套东西不需要 K8s,不需要 CI/CD 平台,一台服务器加一个脚本就能落地,也是绝大多数后端团队最真实的形态。真到要上 K8s 的那天,前面这些"跑得起来、挂了能查、坏了能回"的基本功,照样是地基。