如果把一个在本地跑得好好的SpringBoot服务,挪到一台Ubuntu服务器上,要求它“开机自动启动、挂了能自动拉起来、日志随时可查、发版还能快速回滚”——这里面每一环,都藏着不少坑。我最初接手这类任务时,以为就是把jar包扔上去然后java -jar完事,结果被现实来回教了好几轮:防火墙拦了端口、JDK版本对不上、进程半夜自己没了、日志把磁盘塞满、升级一次直接把线上干挂半小时。
这篇文章就围绕“在Ubuntu上部署SpringBoot应用”这件事展开,从环境准备、systemd服务化、数据库连接,到发布回滚和故障排查,把我实测过的完整链路和教训写清楚。适合已经会写SpringBoot接口,但对Linux服务器不熟、或者第一次做生产部署的Java开发者参考。看完之后你会得到一套可以直接照搬的流程,而不是零零散散的命令拼凑。
1. 部署前先想清楚这三件事,比敲命令重要
1.1 你要部署的是应用,不是“能跑起来的代码”
SpringBoot应用和传统JavaWeb应用有个很大的区别:它内嵌了Tomcat,所有web容器相关的东西都被打进了jar里,所以不需要单独装Tomcat,也不需要额外部署war包。这个“自包含”的特性让部署变得很简单,但也让很多人产生了误解——以为随便一个Linux环境都能跑。
实际上,一个可执行jar能正常启动,至少需要满足几个条件:
- 操作系统架构和内核兼容性正常(x86_64的jar在ARM机器上要看是否有对应原生依赖);
- 已安装JDK版本满足Spring Boot 3.x的底线要求(17或更高);
- 应用依赖的资源(数据库、Redis、消息队列等)在启动时能连通;
- 进程有权限读取配置文件、写日志目录、绑定网络端口。
我在本地开发时经常用的是mvn spring-boot:run,这种启动方式其实屏蔽了很多生产环境的差异,比如JDK路径、运行用户权限、磁盘空间、防火墙之类的问题。一旦把这些jar放到Ubuntu服务器上,所有差异都会暴露出来。所以部署前的第一课,就是清楚知道:我们交付的是一个需要外部条件配合的Java进程,而不是一个双击就能跑的软件。
1.2 选JDK版本:17是主流底线,21按需升级
Spring Boot 3.0之后,Java版本底线就是17,所以除非你还在维护老项目,否则不要再装Java 8了。目前生产环境里两种选择最多:
| 场景 | 推荐JDK | 说明 |
|---|---|---|
| 新项目/多数老项目升级 | OpenJDK 17 | 稳定、兼容性广,G1回收器默认开启,适合绝大多数Web应用 |
| 需要虚拟线程/新语法特性 | OpenJDK 21 | 如果已经在用Spring Boot 3.2+,且代码里有意识地用了虚拟线程,可以选21 |
| 极端内存受限的微服务 | OpenJDK 17 + 精调GC参数 | 不是版本问题,而是参数问题,后面会讲 |
这里给一个实际的建议:如果项目没有强需求,直接上17就够了。不需要追求“总用最新版”,因为大多数Spring Boot应用的性能瓶颈在数据库和业务逻辑,而不是JDK版本差异。要追新,也应该是在本地做好完整回归测试后再切换,而不是借部署服务器的时候顺手升级。
另外,生产机上装JDK,我强烈建议只装openjdk-17-jre-headless,不要装带图形界面的完整JDK。原因很简单:少一个X Window依赖就少很多攻击面,JRE的体积更小,对只需要运行jar的服务器来说完全够用。如果你需要在这台机器上自己构建代码,那再装maven和JDK开发版也不迟。
1.3 部署形态:一个jar + systemd,别自找麻烦
部署SpringBoot应用,社区里有几种常见路线:直接nohup java -jar、用Docker容器跑、用systemd管理。我目前的结论是:对一台内存不大、服务数量不多的Ubuntu服务器,jar + systemd是最省心、最利于排查问题的方式。
为什么不用nohup?因为nohup只管“退出终端后进程不挂”,但它不会帮你做进程守护。进程崩溃了、机器重启了,它都不会自动恢复。而且nohup.out文件会一直膨胀,等你想看日志的时候,才发现有用的信息早就被冲掉了。
为什么不用Docker?Docker本身是好东西,但在小规模单机场景下,它引入了镜像构建、容器网络、数据卷、日志驱动等一系列额外复杂度。出了问题还要先查容器状态、再进容器内看日志,链路变长了。如果你团队里已经有成熟的容器平台和镜像流水线,那另说;否则,在单机部署阶段先用systemd把服务管起来,是最直接有效的路。
Systemd是Ubuntu自带的初始化系统,它负责管理服务的开机自启、异常重启、日志采集,还能通过systemctl、journalctl统一操作和排查。理解了“一个SpringBoot应用最终应该表现为一个受systemd管理的服务”,整个部署思路就清晰了。
2. Ubuntu运行时准备:JDK、构建产出与目录规范
2.1 装运行时的七条命令
在干净一点的Ubuntu服务器上,安装Java运行环境很简单:
sudo apt update sudo apt install -y openjdk-17-jre-headless java -version执行完java -version能看到类似openjdk version "17.0.x"的输出,就说明JRE装好了。如果这台机器还需要自己打包,那再装Maven:
sudo apt install -y maven mvn -v有一个细节我要特别提醒:如果项目用了比较新的Maven插件或依赖,系统自带的Maven版本可能偏老。遇到构建失败时,优先检查Maven版本,必要时用Maven的官方解压包替代,不要死磕apt源里的旧版本。
还有一件事是内存规划。很多云服务器的内存就2G或者4G,Java应用又向来“贪心”。如果服务器没有swap,物理内存一旦耗尽,系统可能会直接干掉进程(OOM Killer),你得在部署前评估要不要增加swap:
# 如果当前没有swap,可以创建一个2G的swap文件(示例) sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile我这里给的是临时方案。长期跑生产的机器,swap是保命用的,但别指望靠swap扛住性能压力,它只能避免进程瞬间被内核杀掉。
2.2 目录规划:用专用用户,别让root去跑业务
生产服务器上跑Java应用,一个很常见的错误是用root用户去启动。一旦应用被注入恶意代码,攻击者拿到的是整台机器的权限。正确的做法是创建一个专用系统用户,只给它能访问的目录授权:
sudo useradd -r -m -d /opt/app -s /usr/sbin/nologin app解释一下这个命令:-r表示创建系统账户,-m表示创建家目录,-s /usr/sbin/nologin表示禁止该用户登录终端,它只能作为服务运行身份。这样即使攻击者控制了JVM,也拿不到shell,权限被限制在应用目录内。
关于目录结构,我习惯的规范化方式是:
/opt/ └── apps/ ├── demo-api/ │ ├── current -> releases/20250115-1400 │ ├── releases/ │ │ ├── 20250115-1400/ │ │ │ ├── demo-api.jar │ │ │ └── config/ │ │ │ └── application-prod.yml │ └── logs/ │ └── app.log为什么把config放在jar的同级目录?因为Spring Boot默认的配置文件加载顺序是:jar包内部的application.yml→ 外部config/目录 → 当前目录。外部目录里的配置优先级更高,所以生产环境的数据库密码、密钥这类敏感配置,可以不打进jar,而是在发布的时候放到config里,单独保护权限。
目录授权的时候注意,/opt/apps/demo-api整体要chown给app用户,但是config目录可以只允许root和app读取,避免其他用户看到敏感信息。
2.3 构建产物校验与上传
小规模部署有两种构建方式:本地打包上传,或者服务器上拉代码构建。我推荐的做法是:在本地构建,上传jar,服务器只跑不建。因为服务器的价值应该用于稳定运行应用,而不是承担编译任务;而且构建时Maven要下载一堆依赖,很容易污染服务器网络环境。
本地打包时用:
mvn clean package -DskipTests生成的jar在target/下,之后可以scp或者用文件传输工具上传到服务器。上传完成后,我强烈建议校验一下文件的完整性,因为网络传输偶尔会出现文件损坏,而损坏的jar启动时会抛出各种莫名其妙的异常:
# 在本地生成sha256 sha256sum demo-api.jar # 在服务器上再次计算并比对 sha256sum /opt/app/releases/20250115-1400/demo-api.jar两次结果一致,再继续往下走。这一步多花十几秒,能避免后面排查两小时。
3. 从jar包到systemd服务:进程管理的核心一步
3.1 Systemd服务单元模板
打包上传完毕后,接下来是重头戏:让这个jar变成一个受系统管理的服务。我们先在/etc/systemd/system/下创建一个服务单元文件,比如叫demo-api.service:
[Unit] Description=Demo API Server After=network.target mysql.service redis-server.service [Service] User=app Group=app WorkingDirectory=/opt/app/demo-api/current EnvironmentFile=/opt/app/demo-api/env.conf ExecStart=/usr/bin/java -Xms512m -Xmx1024m -XX:+UseG1GC -jar /opt/app/demo-api/current/demo-api.jar SuccessExitStatus=143 Restart=always RestartSec=5 LimitNOFILE=65535 StandardOutput=append:/opt/app/demo-api/logs/stdout.log StandardError=append:/opt/app/demo-api/logs/stderr.log [Install] WantedBy=multi-user.target逐个解释关键配置:
After=network.target mysql.service redis-server.service:声明服务在网络和依赖服务之后启动。注意这只是“尽量靠后”,MySQL还没就绪时SpringBoot连库仍然可能失败,后面我会说兜底方案。User/Group=app:指定运行身份,对应我们前面创建的系统用户。WorkingDirectory:决定相对路径参照哪里。SpringBoot的外部config加载、日志文件相对路径都跟它有关。EnvironmentFile:从外部文件加载环境变量,我习惯把SPRING_PROFILES_ACTIVE=prod、DB_PASSWORD这类放这里,避免直接写在unit文件里。Restart=always:只要进程被异常终止就自动拉起,这是生产环境保命的配置。RestartSec=5:重启间隔5秒,防止进程陷入“崩溃-重启-崩溃”的疯狂循环。LimitNOFILE=65535:调大文件句柄数上限,如果应用涉及大量文件操作或高并发连接,这个值太小会报Too many open files。SuccessExitStatus=143:这段比较关键。JVM收到SIGTERM优雅退出时退出码是143,systemd默认把它视为异常,会导致Restart=always把正常停止的服务又拉起来。加上这行之后,手动systemctl stop才能真正停得下来。
3.2 环境变量与JVM内存参数的注入
先看EnvironmentFile指向的文件内容(/opt/app/demo-api/env.conf):
SPRING_PROFILES_ACTIVE=prod SERVER_PORT=8080 DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=demo_db DB_USERNAME=demo_user DB_PASSWORD=ChangeMe_StrongPassword REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD=ChangeMe_Redis然后在SpringBoot的application-prod.yml里使用占位符读取:
spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: ${DB_USERNAME} password: ${DB_PASSWORD} redis: host: ${REDIS_HOST} port: ${REDIS_PORT} password: ${REDIS_PASSWORD}这样做的好处是:密码不进入git仓库,不打进jar;运维修改密码时只需更新env.conf然后重启;每个环境(dev/test/prod)通过不同的SPRING_PROFILES_ACTIVE加载不同配置。
JVM参数方面,我的经验是:-Xms和-Xmx可以设为同一个值,避免运行期堆内存频繁扩容收缩。具体大小怎么定?如果是4G内存的服务器,应用独占且之外还要跑MySQL、Redis,Xmx给1.5G~2G比较合理,留下足够余量给操作系统文件缓存和其他进程。如果只有2G内存,Xmx给768m或1G是常见选择。
脚本示例里还加了-XX:+UseG1GC,这是JDK 17默认的GC,其实可以不加,但写上显式提醒自己用的是G1。真遇到内存瓶颈,后续可以做GC日志和堆dump分析,而不是盲目调大内存。
3.3 日志怎么配:journald和文件日志的关系
Systemd会把服务写到标准输出和标准错误的内容收进journald,执行journalctl -u demo-api -f可以实时看日志。但在生产环境,我不建议完全依赖journald:
- journald默认日志大小有限制,量大的服务几天就能撑满;
- 应用日志要按天归档、压缩,journald在这方面的机制不如传统日志文件直观。
所以最稳妥的组合是:应用自己用Logback写文件日志(logging.file.name=/opt/app/demo-api/logs/app.log),systemd只负责捕获stdout遗漏的早期启动日志。同时给日志目录配一个logrotate任务,按天切割:
sudo tee /etc/logrotate.d/demo-api <<'EOF' /opt/app/demo-api/logs/app.log { daily rotate 7 compress delaycompress missingok notifempty copytruncate } EOFcopytruncate是日志切割里比较重要的参数,因为Java进程一直在写文件,直接rename可能导致日志句柄失效,而copytruncate先复制再清空,对进程影响最小。配合这个策略,日志最多保留7天,磁盘不会莫名其妙被日志吃满。
4. 数据库和缓存服务的连通,部署中最容易绕的远路
4.1 配置外置与连接串细节
部署阶段出现最多的报错,就是数据库连接失败。排第一的原因不是地址写错,而是“数据库端不让你连”。我处理过不少自查案例,这里把最容易踩的三块单独拎出来。
先说连接串。以MySQL 8为例,我的生产连接串长这样:
spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4&connectTimeout=3000&socketTimeout=10000重点说两个参数:
useSSL=false:MySQL 8默认要求SSL握手,但很多内网环境并没有配置证书,为了快速连通可以先关掉。如果对安全有要求,应该在MySQL端配置好证书再开启SSL,而不要为了安全丢弃可用性。allowPublicKeyRetrieval=true:连接MySQL 8时,如果用户密码插件使用caching_sha2_password,而连接链路不放公钥,客户端会报“Public Key Retrieval is not allowed”。加上这个参数只是业务侧解锁,真正的做法是MySQL里用mysql_native_password或正确授权。connectTimeout=3000:单位是毫秒,3秒连不上就快速失败,避免应用启动时长时间阻塞在数据库连接上,拖慢整个服务的启动。
4.2 MySQL用户授权和外部访问限制
这是另一个高频坑。很多开发者喜欢用MySQL的root账号连数据库,开发环境无所谓,生产环境绝对不要这么做。创建专用账号并只授最小权限:
CREATE USER 'demo_user'@'127.0.0.1' IDENTIFIED BY 'ChangeMe_StrongPassword'; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON demo_db.* TO 'demo_user'@'127.0.0.1'; FLUSH PRIVILEGES;注意'demo_user'@'127.0.0.1'这个“主机”字段。它决定了MySQL服务器允许从哪里连接。如果SpringBoot和MySQL部署在同一台机器上,127.0.0.1就够了;如果跨机器,就要改成应用服务器内网IP。我见过最尴尬的情况是:应用和数据库在同一台机器,但连接串里填的是公网域名,云厂商安全组没放行3306,结果服务死活起不来。
4.3 Redis和其他依赖的连通性
SpringBoot连接Redis一般不会有太多调参数的空间,但有几个点值得注意,我直接列成表:
| 检查项 | 建议值/操作 | 原因 |
|---|---|---|
| Redis绑定地址 | 指向应用可访问的IP,默认127.0.0.1时外部连不上 | 最常见的连接地址错误 |
| 密码 | requirepass设置强密码,SpringBoot侧同密码 | Redis默认无密码,云环境暴露6379很危险 |
| 数据库索引 | spring.redis.database=0,如果项目用多个库要统一 | 避免连接串都对但数据对不上 |
| 连接超时 | 建议2-3秒 | 避免Redis挂掉时应用线程全部阻塞 |
除了配置,应用启动顺序也需要考虑:如果demo-api.service的After=mysql.service,而MySQL服务在systemd里存在,但实际还没完成初始化,SpringBoot启动时连库失败会抛异常,Restart=always会等待5秒后重启。此时数据库已经就绪了,第二次启动一般能成功。这是兜底机制的价值,也解释了为什么Restart=always在小项目里比任何高可用方案都实在。
5. 启动验证与故障排查:从systemctl status到日志定位
5.1 第一轮启动的完整检查清单
写完unit文件和配置文件之后,激动人心的一步来了——启动服务。按照下面的顺序来,不要乱:
# 1. 重新加载systemd配置 sudo systemctl daemon-reload # 2. 启用并启动服务 sudo systemctl enable --now demo-api # 3. 查看服务状态 sudo systemctl status demo-api如果看到Active: active (running),说明进程起来了。但“起来了”不等于“能正常提供服务”,还要做几项验证:
# 4. 确认端口监听正常 ss -lntp | grep 8080 # 5. 在服务器本机探活(如果集成了actuator) curl http://127.0.0.1:8080/actuator/healthcurl返回{"status":"UP"}才算真正健康。如果只是端口在监听,但接口一直超时,多半是数据库或下游依赖还没准备好,继续看日志:
# 6. 查看最近日志 sudo journalctl -u demo-api -n 100 --no-pagerjournalctl -f可以实时跟日志,重启服务后观察启动流程,定位慢点或报错。这个过程基本能覆盖90%的启动问题。
5.2 高频故障TOP 3和排查思路
我把部署时最常见的问题整理成一张表,方便你对照排查:
| 现象 | 可能原因 | 快速排查动作 |
|---|---|---|
systemctl status显示failed | 端口被占用、配置文件语法错误、jar损坏 | journalctl -u demo-api -n 50+ss -lntp看端口占用 |
| 进程在跑,端口不通 | 防火墙(ufw)放行了吗?云厂商安全组放行了吗?应用监听在0.0.0.0还是127.0.0.1 | sudo ufw status检查;登录云厂商控制台检查安全组;确认server.address配置 |
| 日志里报数据库连接超时 | MySQL未启动、账号权限不对、连接串地址错误 | 在应用服务器上执行telnet 127.0.0.1 3306或nc -vz 127.0.0.1 3306测试连通性 |
| 进程反复重启 | 应用启动失败但Restart=always在疯狂拉起 | `journalctl -u demo-api |
| 内存持续飙升后OOM被杀 | -Xmx设置过大或过小、系统内存不足 | `dmesg |
这里有一个反直觉的地方:服务器的防火墙(ufw)和云厂商安全组是两层独立的东西。即使你在Ubuntu上用ufw allow 8080放行了端口,如果云厂商控制台的安全组没放行,公网访问还是不通。很多新手只检查了一层,然后在网上找半天答案。我的排查习惯是:先本机curl 127.0.0.1确认应用没问题,再检查ufw,最后登录云厂商控制台看安全组,一步步缩小范围。
5.3 别忽视“看起来正常但不正常”的现象
有些故障不会让服务挂掉,但会像慢性病一样消耗性能。举几个我实测遇到的例子:
- 进程在跑,端口也在监听,但机器load average一直很高。这种情况如果不是业务高峰,大概率是GC频繁。可以用
jstat -gcutil <pid> 1000看一眼CPU采集中GC占比,如果Full GC次数增长很快,就要回查堆内存参数。 - 应用能启动,但过一段时间就无响应。可能是文件句柄被耗尽,通过
lsof -p <pid> | wc -l统计句柄数量,接近LimitNOFILE上限时果断重启应用并增大句柄限制。 - 日志里偶尔出现“Connection reset by peer”。这类报错在网络不稳定的环境很常见,先确认下游是MySQL还是Redis,再判断是否需要调大连接池的空闲检测时间。此类问题通常需要结合实际业务量观察,不要一上来就改代码。
6. 发布升级与回滚:让每一次上线都可控
6.1 软链方案实现原子切换
“部署”不只是第一次把服务跑起来,还包括后续每周、每两周的发版。我早期发版是直接覆盖jar再重启,某次上传途中网络断开,jar损坏,服务直接起不来——那次之后我改成了“releases目录 + 软链切换”的模式。
发布脚本的逻辑不复杂:
#!/usr/bin/env bash set -euo pipefail # 使用方式: ./deploy.sh /path/to/upload/demo-api.jar VERSION_TAG SOURCE_JAR="$1" VERSION_TAG="$2" APP_BASE="/opt/app/demo-api" RELEASE_DIR="$APP_BASE/releases/$VERSION_TAG" CURRENT_LINK="$APP_BASE/current" # 1. 创建新版本目录并拷贝jar和配置 mkdir -p "$RELEASE_DIR/config" cp "$SOURCE_JAR" "$RELEASE_DIR/demo-api.jar" cp /opt/app/demo-api/config/application-prod.yml "$RELEASE_DIR/config/" # 2. 校验文件完整性 cd "$RELEASE_DIR" sha256sum demo-api.jar # 3. 切换软链(原子操作) ln -sfn "$RELEASE_DIR" "$CURRENT_LINK" # 4. 重启服务 sudo systemctl restart demo-api关键就在ln -sfn这一步:软链替换是原子性的,不存在“jar写了一半”的状态。当前版本始终指向current,systemd服务的ExecStart用的是$CURRENT_LINK/demo-api.jar,所以切换操作本身不会破坏运行中的服务。重启动作放在软链切换之后,这样即使重启失败,旧版本软链内容还在,只需把它指回去。
6.2 回滚的两种策略
有了软链结构,回滚就变成一个命令的事情了:
# 假设上一个版本是 20250108-1600 sudo ln -sfn /opt/app/demo-api/releases/20250108-1600 /opt/app/demo-api/current sudo systemctl restart demo-api这种回滚方式适用于“代码逻辑有bug”“配置写错”“依赖版本不兼容”等情况,因为整体替换的是jar,应用内部状态会随着重启重建。
但有一种情况要特别谨慎:发布涉及数据库结构变更时,回滚不能只换jar。比如新版本执行了新增字段、删表等操作,旧版本jar连接的是一个已经被改过结构的库,回滚后往往会报Unknown column之类的错误。这类发版的回滚策略,应该在发布规划阶段就定:要么数据库变更设计成向前兼容,要么准备好配套的回滚SQL。这部分不是部署脚本能自动解决的,需要在需求评审时就考虑清楚。
6.3 发布完成后的验证动作
每次发版后,我至少花5分钟做三件事:
systemctl status demo-api确认服务处于active(running);curl http://127.0.0.1:8080/actuator/health确认健康检查通过;curl http://127.0.0.1:8080/业务请求或看日志,确认关键业务链路正常。
第一项和第二项很多人都会做,但第三项常常被忽略。有些服务接口在启动时是“看似正常”的,比如集成的某个第三方回调地址不对、缓存Key规则变更导致新接口读不到旧数据,这些只有真实请求才能暴露。所以我的习惯是,上线不是等“看起来跑起来了”就收工,而是至少跑通一个最小业务闭环再宣告完成。
经过这么多次部署和发版折腾,我的核心体会是:部署这件事,决定成败的往往不是技术多高深,而是流程是否规范、节奏是否可控、回滚是否顺畅。journald日志里那些“看起来不起眼”的报错,soft link软链设计里那份“多留一手”的安全感,数据库连接串里那几个参数,才是真正让一次部署从“碰运气”变成“确定性操作”的关键。
最后再分享一个小技巧:每次发版前,我都会先在本地执行一次mvn clean package -DskipTests,并记录jar的sha256sum;上传到服务器后再校验一次。这个习惯帮我挡掉过两次“明明代码没动,线上就是启动失败”的诡异问题——后来一查,果然都是上传时的文件损坏。别怕这些环节多,它们存下来,就是你在生产环境里的底气。