这周有同事把一个 Spring 项目打包部署到生产环境,结果从早上十点折腾到下午三点,光是启动不起来就排查了两轮。我们坐在一块儿复盘的时候,我突然意识到一个问题:Spring 项目打包部署这件事看着简单——mvn package一下,java -jar跑起来——但一碰上真实环境,坑全藏在那些“也没人教过我”的角落。今天这篇文章,我想把这次完整的打包部署过程、踩过的坑、以及我整理出来的检查清单,一次性写清楚。
不管你是刚接触 Spring Boot 的实习生,还是负责公司 Spring 服务发布的老手,只要工作里涉及“打包成 jar / war 然后部署到 Linux 服务器”这件事,这篇文章应该能帮你少折腾至少半天。我会尽量用大白话把原理讲透,把操作步骤写细,特别是那些容易忽略、但一踩就致命的细节。
1. 动手打包前,先决定形态:可执行 jar 还是传统 war
我见过太多的项目从一开始就埋了雷——团队里没人讨论过部署形态,默认按 Spring Boot 起始项目的默认配置走,结果到了部署阶段,选错了被测体、类加载和目录结构全乱成一锅粥。所以第一步绝不是mvn package,而是先回答一个问题:这个项目最终要跑在什么环境下?
1.1 三种容易混淆的“包”
很多人把 Spring 项目打包之后的东西统称为 jar,但实际市面上常见的成品有三种:
| 产物 | 典型结构 | 对应的部署方式 |
|---|---|---|
| 普通 jar | 只有自己写的 class 和资源配置 | 依赖外部容器(如 Tomcat)提供运行环境 |
| 可执行 jar(fat jar / uber jar) | 自带内嵌的 Tomcat / Jetty,第三方依赖全部打进包 | 用java -jar直接启动; |
| war | Web 归档,带有 WEB-INF 目录和 web.xml(或 Servlet 3.0 注解) | 丢到外置 Tomcat 的 webapps 目录,由容器部署 |
Spring Boot 默认打出来的是第二种,即内嵌容器可执行 jar。它的核心优势是部署环境极度简化:目标机器只需要有 JDK / JRE,连 Tomcat 都不用单独装。而传统 Spring MVC 项目(基于外置 Tomcat 部署)通常需要打包成 war,再由 Tomcat 解析。如果项目在 pom.xml 里明确写了<packaging>war</packaging>,或者依赖了spring-boot-starter-tomcat但又声明了provided作用域,那么它多半是需要外置容器的。
1.2 判断一个项目该走哪条路,结果其实写在 pom.xml 里
我自己判断项目的部署形态,一般按下面这个顺序检查:
- 打开根 pom.xml,看
<packaging>标签是jar还是war。默认 Spring Boot 父 POM 会帮我们设为 jar,但如果主工程是传统 Spring MVC 骨架,这里往往是 war。 - 查看是否引入了
spring-boot-maven-plugin。引入了,则默认会触发 repackage 目标,生成可执行 jar 的同时,也会在 target 目录留下.jar.original备份文件。 - 检查启动类:有
@SpringBootApplication且有main方法,并且依赖里没有排除内嵌 Tomcat,那基本就是可执行 jar 的类型。
提示:如果你不确定,快速验证的方式是解压 jar 包,看
BOOT-INF/lib下有没有内嵌的tomcat-embed-core-*.jar。有,就是胖 jar;没有,大概率是普通 jar 或需要外置容器。
1.3 为什么这个选择如此关键
我遇到过一位同事,项目 A 明明是可执行 jar,他为了“稳妥”拿外置 Tomcat 部署,把 jar 直接丢进了 webapps,然后 Tomcat 不加载它;他把 jar 改名为 war,结果又因为内嵌 Tomcat 和外部 Tomcat 的 Servlet 容器冲突,启动直接报Failed to start component,把一屋子人都看懵了。
反过来,如果是典型 war 项目跑java -jar,大概率会出现no main manifest attribute或者缺 Web 容器的报错。所以,这个形态判断早做彻底,后面所有操作才有意义。打包部署第一步不是执行命令,而是先看懂 pom 结构和最终产物类型。
2. 构建前的环境准备:本地能编过不代表服务器能跑
打包部署最隐蔽的坑,往往发生在“本地干干净净、服务器完全陌生的环境”里。你能在本机mvn package一遍过,只能说明构建工具和依赖能正常解析,不代表生产环境一定认这个产物。
2.1 JDK 版本对不上,是最常见的家庭矛盾
举个具体例子。假设你本地用的 JDK 17,项目也以 17 编译,但服务器只装了 JDK 8。理论上这跟打包过程没直接关系,因为编译目标已经写进了字节码,运行时需要高版本 JVM 才能解析。可如果一个项目用了 Lombok 或较新的字节码增强技术,运行时UnsupportedClassVersionError就会立刻爆出来,一行class file has wrong version 61.0, should be 55.0能让人反复确认“我明明打的是 17 啊”。
所以我的建议是:打包前就在 pom.xml 里把编译参数钉死:
<properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> <maven.compiler.release>11</maven.compiler.release> </properties>release参数会强制 javac 只使用 JDK 11 的 API 和语法,即使本地装的是 JDK 17,也不会误用 17 的新特性。这个细节非常容易被忽略,尤其当你用了source和target时,它们只能限制编译级别的写法,并不会限制--add-exports或新 API 的调用。实际编译产物可能依赖高版本 JDK 的内部实现。
2.2 构建机的网络环境和依赖仓库:私服三连
Spring 项目一般有一堆第三方依赖,默认从 Maven 中央仓库拉取。如果你的构建机在办公内网,通常需要配置公司私服(Nexus / Artifactory 之类)来代理中央仓库。如果私服没配好、或者私服里某几个包被清理掉了,那mvn package可能会卡在一个依赖上下载一天,有时候还报PKIX path building failed。
我整理了我自己常用的 settings.xml 核心思路:
mirror指向私服的 public 聚合仓库;server配置里写入用户名密码,方便把最后的构建产物上传到 release 仓库;- 本地
.m2/repository缓存尽量别清空,除非你明确想验证全新环境是否能解析依赖。
2.3 测试跳过类的操作:别盲目图快
打包时很多人习惯加-Dmaven.test.skip=true,我理解这是为了构建速度,但你要知道,这个参数是“跳过编译测试代码 + 跳过执行测试”的组合。如果项目里本来就有质量门槛,测试代码甚至承担了部分配置校验,彻底跳过会让一些低级错误悄悄流到部署阶段。
我更建议的做法是:
mvn clean package -DskipTests-DskipTests仍然会编译测试代码,只是不执行测试。至少保证测试代码本身语法正确。如果连编译测试代码都嫌慢,再用-Dmaven.test.skip=true也不迟,但要心里有数这属于风险操作。
2.4 先打空包、再逐步叠加,是我个人推荐的验证方式
如果你是第一次部署一个陌生项目,建议不要一次性信心满满地丢上去。我第一次部署某图像处理 Demo 时,就是先mvn package打包,然后直接远程java -jar,结果一个自定义过滤器类路径写错,启动时根本不会暴露,等第一个请求进来才从异常堆栈里发现。
所以我现在的习惯是:先打一个配置最简的产物,启动起来确认健康检查接口通了,再把配置文件、外部依赖一项一项加回来。这样即便出问题,你也有足够线索定位到究竟是谁起的副作用。
3. 打包命令和产物结构:从 target 目录说起
准备阶段做完,接下来的操作看似稀松平常,但里面有几个点值得掰开揉碎讲清楚。
3.1 看似平平无奇的 mvn package 到底发生了什么
以 Spring Boot 项目为例,执行mvn clean package时,Maven 生命周期会依次触发 validate、compile、test、package 等阶段。clean阶段负责删除旧的 target 目录,避免残留 class 影响排查;“package阶段本身只是把 class 文件和 resources 按 Maven 约定的结构打成 jar 或 war,但 Spring Boot 的spring-boot-maven-plugin在 package 之后还会额外执行repackage,把普通 jar 重组成可执行 jar。
这个 repackage 的逻辑很关键:它把项目自身的 class 放在BOOT-INF/classes,把第三方 jar 放在BOOT-INF/lib,再生成一个org/springframework/boot/loader/JarLauncher入口,保证可以直接用java -jar启动。如果你在最终目录里发现 jar 特别小、甚至连内嵌依赖都没打进去,那很可能是构建时没有配置这个插件,或者插件被 parent 覆盖了。
一个典型、可靠的 Spring Boot 构建片段长这样:
<build> <finalName>myapp</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>finalName不写的话,默认会用artifactId-version.jar,名字里带版本号在部署脚本里容易出错。用一个不带版本的固定名称,比如myapp.jar,后面写 systemd 服务、自动化脚本时都能省心不少。
3.2 打包产物在服务器上怎么放,是有讲究的
我见过不少同学把 jar 包直接放到/root/下跑,或者挤在/tmp里。不是不能用,而是不方便维护。生产实践上我建议按下面的目录结构安排:
/opt/app/myapp/ ├── myapp.jar # 最新产物 ├── config/ │ └── application-prod.yml # 外部化配置 ├── logs/ │ └── app.log └── bin/ └── start.sh # 启停脚本(可选)jar 包专门放一个目录,日志单独一个目录,和项目代码分开,排查问题时顺藤摸瓜非常快。上传工具上,我首推rsync,原因是它支持断点续传,而且增量传输很稳:
rsync -avz --progress target/myapp.jar user@server:/opt/app/myapp/如果带宽有限,也可以scp,但上传大 jar 卡住的时候没法续传。
3.3 启动命令的完整姿势
最简单的启动方式是:
cd /opt/app/myapp java -jar myapp.jar --spring.profiles.active=prod但直接这么跑有几个隐患:一是进程挂在当前 SSH 会话上,窗口一关服务就断;二是日志散落在控制台,没法统一收集;三是没有 PID 文件,后面想 stop 都费劲。
所以实际部署中,至少要学会用nohup:
nohup java -jar myapp.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > logs/app.log 2>&1 & echo $! > bin/app.pid其中$!是最近一个后台进程的 PID,保存下来方便后续 kill。2>&1保证错误输出也进同一个日志文件,省得你两个文件来回翻着看。
注意:nohup 只是临时方案,真正的生产环境建议直接上 systemd。后面专门开一节讲。
4. 配置文件外置:为什么我不建议把密码写进 jar 包
Spring Boot 项目打包部署时,最容易被忽略的一点就是配置文件的处理方式。很多人把application.yml一起打进 jar,部署后想改一个端口或数据库地址,就得重新打包上传一次,非常低效。而更严重的是:如果你的application-prod.yml在仓库里写了数据库密码、第三方密钥,这些信息会跟着 jar 传到服务器,甚至进入构建制品库,造成敏感信息泄露。
4.1 记住一个核心机制:外部配置文件优先级高于 jar 内部
Spring Boot 配置加载有几个常规顺序,优先级从高到低大致是:
- JVM 系统参数(
-D) - 当前目录下的
/config子目录 - 当前目录下的
application.yml - classpath 下的
/config包内配置 - classpath 根目录的配置
也就是说,只要在 jar 包同目录下放一个application.yml,或者在同目录的config/下放配置文件,jar 里的那份默认就会被覆盖。这就意味着:打包时只要用一套默认配置,部署时直接把生产配置放到 jar 旁边,修改配置完全不需要重新打包。
我强烈建议生产部署采用这种结构:
/opt/app/myapp/ ├── myapp.jar └── config/ └── application.yml # 生产环境的配置这样java -jar myapp.jar启动时,Spring Boot 会自动寻找config/application.yml,并且由于config/目录优先级更高,会覆盖 jar 内部同名配置项。
4.2 不同环境怎么切:profiles 和外部化配置的组合
如果你们公司有 dev / test / prod 多套环境,我更推荐在 jar 里保留一份不含敏感信息的application.yml,用--spring.profiles.active=prod激活对应环境的配置块。比如:
spring: config: activate: on-profile: prod datasource: url: jdbc:mysql://prod-host:3306/appdb username: prod_user password: ${DB_PASSWORD}这里密码不要写死,用环境变量占位符${DB_PASSWORD},部署时在 systemd 的 EnvironmentFile 里注入。这样做的好处是:即使配置文件被脱管,也拿不到真实密码。
这里有个小技巧:如果外部config/application.yml和 jar 内置的application.yml都定义了一堆属性,外部配置是整体性覆盖吗?不是。Spring Boot 的配置合并是“键级别”覆盖——外部配置只覆盖它自己出现的键,其他键继续用 jar 内部的。所以你可以把公共配置留在 jar 里,外部配置只写环境相关差异项,配置维护压力会小很多。
4.3 敏感信息管理:从“写死”到“环境变量 + 密钥库”
很多人问我:“配置中心是不是必须上?”我的观点是:如果你只是部署一两个 Spring 服务,引入一套配置中心反而增加运维复杂度。先尝试环境变量和外部化文件,等服务数量多到需要统一管控时,再考虑专业的配置中心或密钥管理方案,而在此之前,环境变量已经能解决 90% 的敏感信息泄露问题。
在 systemd 里注入环境变量的方法很简单:
[Service] Environment=DB_PASSWORD=xxxx EnvironmentFile=/opt/app/myapp/.env.env文件里写成DB_PASSWORD=xxxx即可,注意这个文件权限要严格收窄,建议chmod 600。
5. 把 jar 包真正管起来:推荐用 systemd 而不是裸 java -jar
前面提到了 nohup,它简单直观,但隐患很明显:如果进程意外崩溃,没有自动拉起能力;如果服务器重启,服务不会自动恢复。真正可控的部署,应该把 jar 包注册成一个系统服务,由 systemd 托管。这块我用真实踩坑经验展开。
5.1 一份能直接抄的 systemd Unit 文件
以我的部署习惯为例,项目名myapp,jar 在/opt/app/myapp/myapp.jar。Unit 文件放/etc/systemd/system/myapp.service:
[Unit] Description=My Spring Application After=network.target mysql.service Wants=mysql.service [Service] Type=simple User=appuser Group=appuser WorkingDirectory=/opt/app/myapp EnvironmentFile=/opt/app/myapp/.env ExecStart=/usr/bin/java -Xmx512m -Xms256m -jar /opt/app/myapp/myapp.jar --spring.profiles.active=prod SuccessExitStatus=143 Restart=on-failure RestartSec=5 StandardOutput=append:/opt/app/myapp/logs/stdout.log StandardError=append:/opt/app/myapp/logs/stderr.log [Install] WantedBy=multi-user.target逐项解释几个关键点:
Type=simple:默认类型,表示 ExecStart 启动的进程就是服务主进程,常见于前台运行的 jar 包。如果你在 ExecStart 里写了nohup或把进程放到后台,反而会让 systemd 认为主进程退出,容易误杀。Restart=on-failure:退出码非 0 时自动重启。Spring Boot 正常退出(比如执行System.exit(0))不会被拉起,但异常退出(OOM、端口被占、连接池爆掉)就能自动拉起。SuccessExitStatus=143:这个值得单独说。当你执行systemctl stop myapp时,systemd 会发 SIGTERM 给 Java 进程,Java 默认关闭钩子执行完会退出。有些版本返回的退出码是 143(128 + 15),如果你不把它配置进SuccessExitStatus,systemd 可能认为这是异常退出,从而反复拉起重启服务,造成“关不掉”的假象。User=appuser:不要用 root 跑 Spring 服务。单独建一个低权限用户,哪怕 jar 包只监听 8080,也减少了一点因反序列化漏洞导致的提权风险。
提示:JVM 参数可以写这里,但建议把 -Xmx 这类资源参数固定下来,免得不同环境跑出不同表现。比如内部接口偶尔 503,多半不是你代码问题,而是堆内存直接顶到上限触发了 Full GC。
5.2 systemd 服务管理常用命令
配置完成后,依次执行:
sudo systemctl daemon-reload sudo systemctl enable myapp # 设置开机自启 sudo systemctl start myapp sudo systemctl status myapp如果发现问题,看日志用 journalctl:
# 跟踪最近 200 行 journalctl -u myapp -n 200 -f # 查看从上次启动以来的完整日志 journalctl -u myapp --since "10 minutes ago"我实测下来,journalctl在本地开发机看日志特别好用,但是如果服务在往文件里同时写日志,就不建议再看 journald 了,重复且消耗性能。二者选其一。
5.3 常见 systemd 下的启动问题
问题一:服务启动后立刻退出,但 Restart=on-failure 一直拉不起来?
先执行/usr/bin/java -Xmx512m -jar /opt/app/myapp/myapp.jar --spring.profiles.active=prod在前台跑一遍,看具体报错。systemd 会把标准输出重定向到日志文件,但有一些启动早期的UnsupportedClassVersionError、NoClassDefFoundError,前台一跑就原形毕露。
问题二:我改了 jar 包,但 systemd 还在跑旧版本?
如果你上传的新 jar 是覆盖写的,systemd 不会自动感知。需要执行sudo systemctl restart myapp。更稳妥的方式是先停止再启动:
sudo systemctl stop myapp # 上传新 jar sudo systemctl start myapp问题三:启动线程卡住,journal 日志显示“Waiting for changelog lock...”?
这种情况多半不是 Spring 的问题,而是你用了 MySQL、Nacos 之类的外部组件,依赖超时迟迟连不上。systemd 默认没有启动超时限制,Type=simple 类型会一直等,但Restart=on-failure和超时相关参数TimeoutStartSec需要自己设置。如果外部依赖确实可能长时间无响应,建议加一个合理的TimeoutStartSec=90,避免卡死后无法接管。
6. 部署后的健康检查和验证:别急着宣布“上线成功”
我发现一个通病:部署服务后,很多人只看了一眼进程还在,就说“上线成功了”。然后第一个用户访问报 500,才开始紧张。这里分享一个健康的启动验证套路。
6.1 没有 actuator?先去系统层面查三层信号
如果项目没有引入 Spring Boot Actuator,我很依赖下面这三个信号组合:
- 端口是否监听起来:
ss -lntp | grep 8080这条能看到LISTEN状态,以及对应的进程 PID。
- 日志里有没有 “Started MyApplication in X seconds”:
如果日志里出现了这句,说明 Spring 容器启动完成,这是程序层面的最终确认。
- 用一个最简单的 HTTP 请求返回状态码:
curl -I http://127.0.0.1:8080/即使没有业务接口,只要连接能建立、能拿到 HTTP 响应(哪怕是 404 或 401),也说明 Web 容器和监听端口活着。
6.2 引入 actuator 后,健康检查更直接
更稳妥的方案是在 pom 里加 Spring Boot Actuator 依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>然后暴露健康端点:
management: endpoints: web: exposure: include: health,info endpoint: health: show-details: when_authorized配合 systemd 的 health check,可以做到自动拉起:
[Service] ExecStartPost=/bin/sh -c 'sleep 10 && curl -f http://127.0.0.1:8080/actuator/health'如果 10 秒后健康检查失败,systemd 会将该服务标记为启动失败,并进入 Restart 流程。注意curl -f会把 HTTP 4xx / 5xx 识别成错误,否则你以为服务挂得安安稳稳,实际业务早崩了。
6.3 上线后需要重点观察的数据
部署成功不等于部署完成,我通常在宣布上线后,继续观察至少 15 分钟:
- JVM 内存:
jstat -gc <pid>看 GC 频率,如果老年代快速膨胀,多半是某个并发池或缓存被生产流量打穿。 - 线程数:
jstack <pid> | grep "java.lang.Thread.State"粗略统计,如果线程数逼近系统上限,极可能有连接泄漏。 - 数据库连接池指标:如果有 druid 或 HikariCP 的监控,看 active / idle 比例。之前有个项目部署后莫名缓慢,查到最后是连接池 max 设成 10,生产并发一上来立即打满,等了 30 毫秒才排队拿到连接。
这些数据平时不记录,出问题回溯也只能瞎猜。建议每次发布都留一份基线数据,后面排障会有用得多。
7. 进阶一步:用 Docker 封装 Spring 服务的几种常见方式
在服务器上直接跑 jar 包,已经能满足大多数中小团队的需求,但如果你们环境的迁移、扩容比较频繁,那建议把 Spring 项目做成镜像。这里分享两种思路。
7.1 最省事的单阶段构建
直接把 jar 塞进基础镜像,适合项目少、不想改造的场景:
FROM eclipse-temurin:11-jre WORKDIR /app COPY target/myapp.jar /app/myapp.jar EXPOSE 8080 ENV JAVA_OPTS="" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/myapp.jar --spring.profiles.active=prod"]优点就一个字:省事。缺点也明显:镜像里带了一整层构建残留?其实并没有——单阶段构建只是把 jar 复制进去,本体还算干净。但如果基础镜像拉取慢,或者 Java 版本不尽相同,很容易在开发机里出现“我这儿跑得好好的,到你那里就崩”。
7.2 多阶段构建:构建镜像 + 运行镜像分离
我不太喜欢把 Maven 和运行环境混在一个镜像里,因为这会白白镜像体积增加几百 MB。更推荐多阶段构建:
FROM maven:3.9-eclipse-temurin-11 AS build WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:11-jre WORKDIR /app COPY --from=build /build/target/myapp.jar ./myapp.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]第一阶段用 Maven 官方镜像完成构建,第二阶段只保留 JRE 和 jar,构建产物很干净。第一次构建时dependency:go-offline会先把依赖全部拉下来并缓存,之后的构建会快不少。
7.3 docker-compose 编排:管理端口、数据卷和启动顺序
如果服务还依赖 MySQL、Redis 这些中间件,用 docker-compose 会比手动docker run清晰得多。大致示例:
services: app: image: myapp:1.0.0 ports: - "8080:8080" volumes: - ./config:/app/config - ./logs:/app/logs environment: - DB_PASSWORD=xxxx - SPRING_PROFILES_ACTIVE=prod这里有个细节:Spring Boot 在容器内识别到SPRING_PROFILES_ACTIVE环境变量,等价于命令行参数--spring.profiles.active=prod。用 docker-compose 管理多容器时,还要考虑中间件服务的健康检查,否则 app 容器启动时 MySQL 还没就绪,会连续重试一段时间才成功。
8. 排错实录:部署后进程在,但请求一直 502
这一节是这次实际排障经历的重点回放。上个星期同事部署的某个服务就是这样:进程活着、端口监听了、日志也没有报异常,一对外访问就是 502。当时我们的排查链路值得完整走一遍。
8.1 进程在、端口在,不代表服务真的健康
一开始我们直接curl http://127.0.0.1:8080/actuator/health,返回结果竟然是 503。这个状态码是 Spring Boot 健康检查机制搞的鬼:当某一个健康指示器(比如数据库连接、Redis 连接、磁盘空间)检查不通过时,整体聚合状态就是DOWN,HTTP 层面映射为 503。
所以第一步,先看健康详情:
curl http://127.0.0.1:8080/actuator/health如果返回:
{"status":"DOWN","components":{"db":{"status":"DOWN","details":{"error":"Cannot create PoolableConnectionFactory..."}}}}问题基本锁定在数据库连接上。这个服务不能用,不是 Tomcat 挂了,而是它依赖的后端资源失联。
8.2 数据库连接失败的几种原因排查
按以下顺序逐一验证:
- 网络连通性:
telnet dbhost 3306,不通就可能是安全组/防火墙,或数据库服务根本没起。这里要特别注意,生产环境安全组规则常常是“允许 3306 从内网网段进”,你怎么连都连不上。 - 账号密码:用系统里的 mysql 客户端手动连接一次,确认用户名密码是否正确。很多 502 假象源于密码错,但日志没有打出来,因为重试代码把错误吞了或者只打了 debug 级别。
- 连接池参数:如果库连接成功但池子满得快,检查 HikariCP 的
maximum-pool-size是否定得太小。
那次排障真正卡住我们的点非常荒唐:应用里配置的连接串指向的是测试库,但测试库的防火墙策略只放行测试网段,生产服务器被挡在外面。日志里只有一条Connection refused,初看更像数据库服务没启动。我们在生产网络里telnet dbhost 3306才发现直接被拒,而不是超时。
8.3 为什么健康检查 503 时,框架还在正常接受请求
这一点容易误解。Spring Boot 的shutdown和health分开控制:健康检查 503,并不代表应用拒绝所有请求,只要本身没有抛错,普通业务接口依然能响应。但一旦 Nginx 等网关开启了“后端健康检查不通过就不分配流量”,那从外部看就是 502。
所以部署后一定要确认两件事:应用自身的健康状态,以及网关是否根据健康状态摘除节点。有时候应用其实已经“亚健康”,但网关没察觉,流量照样进来,直到某个接口因为依赖不通直接 500,用户才开始投诉。
8.4 这次排障留下的复盘经验
服务能启动、端口能监听、进程还活着,这三件事只代表“虚活着”。真正上线前,必须把依赖项健康检查全过一遍。我后来给所有服务都养成了一个习惯:上线验证时把 actuator 的健康详情带出来,数据库、Redis、MQ 连接全部绿灯才算完。
9. 关于打包部署的五个容易忽视的细节
到了实操和排障之外,我还想聊几个不太显眼、但确实容易坑到人的细节。
9.1 时区问题
新装的服务器默认时区往往是 UTC,而业务库可能存的是北京时间。如果应用里到处用new Date(),日志时间和数据库时间对不上,排障非常痛苦。Spring Boot 启动时最好显式指定:
java -Duser.timezone=Asia/Shanghai -jar myapp.jar或者在 docker 环境里设置:
ENV TZ=Asia/Shanghai9.2 中文乱码问题
java -jar启动时默认字符集跟随系统。如果系统是传统 POSIX / C locale,日志里中文可能全部变成问号。建议在 Dockerfile 或 systemd 环境变量里设置LANG=en_US.UTF-8或LANG=zh_CN.UTF-8,同时给 JVM 加-Dfile.encoding=UTF-8。早期的 JDK 8 如果不特意设置,file.encoding 跟随操作系统 locale 走,乱码问题特别突出。
9.3 日志文件滚动
不配置 Logback 等日志框架的滚动策略,日志文件会无限增长,直到磁盘炸掉。Spring Boot 默认的日志配置在日志量较大时不会自动滚动,除非在application.yml里做了配置:
logging: file: name: /opt/app/myapp/logs/app.log logback: rollingpolicy: max-file-size: 100MB max-history: 30按天加大小双重滚动,配合定时清理,能避免磁盘写满导致服务假死。
9.4 JVM 参数不是越大越好
经常有人为了“性能好”给服务设置-Xmx8g,结果服务器总共只有 8GB 内存,操作系统被榨干,直接触发 OOM Killer。合理做法是预留系统内存 20%-30%,再根据服务实际负载逐步调整。如果并发不高,-Xmx512m对于大多数微服务已经足够。
9.5 不要忽略临时上传文件的目录权限
Spring Boot 应用在接收上传文件、生成临时表格时,经常往java.io.tmpdir写数据。如果这个目录权限不对,或空间不足,接口会莫名报No space left on device。部署时可以在 systemd 里把Environment=JAVA_TOOL_OPTIONS=-Djava.io.tmpdir=/opt/app/myapp/tmp指过去,并提前创建好目录。
10. 打包部署的“最后一公里”:从测试到生产 平滑过渡
到这里,基本的打包部署流程已经完整。再聊一点我个人的体会:如果你们团队发布频率不高,那手动打包部署完全够用;如果需要频繁发版,建议至少把构建和部署脚本自动化,实现“提交代码后自动打包、自动上传、自动重启”的效果。
10.1 一条可在本地旁路执行的部署脚本
我用过很多方式,最简单的是写一个 shell 脚本存到项目里:
#!/bin/bash set -e SERVER="部署服务器IP" APP_DIR="/opt/app/myapp" echo "==> 开始构建..." mvn clean package -DskipTests echo "==> 上传 jar 包..." rsync -avz --progress target/myapp.jar user@${SERVER}:${APP_DIR}/ echo "==> 远程重启服务..." ssh user@${SERVER} "sudo systemctl restart myapp && sleep 8 && curl -f http://127.0.0.1:8080/actuator/health"这里set -e保证了某一环节失败,后续不会再执行,避免“上传成功但重启失败,服务以为更新了”的分裂状态。
10.2 灰度发布思路
如果服务在网关后面多实例部署,可以先只重启一台实例,验证正常后再逐步滚动更新。手动操作时注意网关的权重配置。如果网关不支持按实例权重灰度,至少要先摘除一台,再更新该实例,最后重新挂回。这个流程听起来复杂,其实只要多做几次形成固定步骤,就自然顺了。
10.3 回滚预案
每次发布前保留上一版 jar 是成本最低的回滚方案:
cp /opt/app/myapp/myapp.jar /opt/app/myapp/myapp-$(date +%Y%m%d%H%M).jar出问题时不仅可以秒切回上一版本,还能对照两个 jar 排查差异。有些人喜欢用软链指向“当前版本”,也是非常好的做法。
写在最后
Spring 项目打包部署这件事,拆开看每一项都不难——跑一个 Maven 命令、写一段 systemd 配置、拖几个文件上去,但真正让部署稳定的是那些容易被忽视的边界条件:环境的 JDK 版本、配置文件的优先级、健康检查的策略、日志目录的权限。这篇文章里的很多经验,是我和同事在一次又一次“为什么本地好的,服务器上就是不行”的疑惑里攒出来的。如果你照着配置还是遇到奇怪问题,建议先从前台启动一遍拿到最原始的报错,再顺着依赖链逐步查——大多数部署问题的根源,都没有想象中那么神秘。