在实际部署过程中,最容易被忽视的往往不是SpringBoot代码本身,而是服务器上的运行环境、进程托管方式、日志治理这些"看不见"的部分。恰好前两天帮一位朋友排查一个"应用在本地跑得好好的,上服务器就间歇性超时"的问题,最后定位到是Nginx代理超时设置和数据库连接池配置不匹配导致的。顺着这个事,我干脆把在Ubuntu上部署SpringBoot应用的完整过程重新梳理了一遍——从环境准备、构建上传、进程守护到反向代理和日志治理,把每一步背后的原因也说清楚。这篇内容适合那些已经能独立开发SpringBoot接口,但对Linux服务器部署还比较陌生,或者曾经"照着网上的命令敲完了却不知道为什么不生效"的Java开发者。
1. 部署前先想清楚这几件事:包形式、环境版本与目录规划
很多人在部署SpringBoot应用时第一个纠结的问题就是:到底打JAR包还是WAR包?我见过不少团队至今还在用WAR包——把应用塞进外部Tomcat的webapps目录里,然后去改Tomcat的server.xml。这种做法的历史原因是早期Java Web应用普遍依赖Servlet容器管理,但SpringBoot的出现已经彻底改变了这个局面。
SpringBoot内置了Tomcat、Jetty或Undertow,JAR包本身就是"应用+容器"的合体。打个不太恰当的比方:WAR包像是把厨房设备都搬进别人家的中央食堂,你得配合食堂的管理规则;而JAR包像是一个移动餐车,自带炉灶、水电和排风系统,给块空地就能开业。SpringBoot官方也是默认推荐JAR包形式的,spring-boot-maven-plugin这个插件就是为JAR包的可执行性而生的。
选定了JAR包之后,还有一个问题需要正视:SpringBoot版本对应的JDK版本到底匹配不匹配。例如Spring Boot 3.x要求JDK 17以上,Spring Boot 2.7则最低需要JDK 8。服务器上装JDK不是"装上就行",版本不匹配的情况在部署阶段的报错往往不是"版本不对"那么直接,而是启动时抛出各种奇怪的类加载异常或UnsupportedClassVersionError。所以搭建环境之前务必确认java -version的实际输出。
除了Java版本,有几个基础设施信息最好提前确认:
- Ubuntu系统版本(18.04、20.04、22.04、24.04的行为差异主要在systemd和网络工具上)
- 是否需要反向代理(通常需要,见第5部分)
- 数据库版本和连接方式(注意MySQL 8+与Spring Boot默认驱动的兼容性)
- 服务器开放哪些端口(sshd、HTTP、数据库等)
目录规划同样关键。我见过有人在root家目录下解压应用部署的,也有把应用放在/tmp下的——重启一次服务器应用就没了。比较推荐的做法是在/opt下按应用名建立一个专属目录:
/opt/myapp ├── app.jar ├── config/ │ └── application-prod.yml └── logs/把配置文件和JAR包分开放是有好处的:升级应用时只需要替换app.jar,配置文件不受影响;日志单独放一个目录,后续做日志轮转和采集也更加方便。
2. 服务器基础环境配置:专用用户、安装JDK、时区与编码
Ubuntu上部署Java应用,第一步不是上传JAR包,而是把服务器环境"打扫干净"。这里说的干净包括三个层面:运行用户的隔离、JDK的安装方式、系统时区与字符集。
2.1 创建专用运行账号
直接用root跑应用是生产环境的大忌。不是说一定出事,而是万一应用被入侵、或者因为误操作改了系统文件,root权限会让影响范围成倍扩大。建议为每个应用创建一个独立的系统用户:
sudo useradd -M -s /usr/sbin/nologin appuser其中-M表示不创建home目录,-s /usr/sbin/nologin表示禁止该用户登录shell——应用以这个身份运行,即使被攻破也无法直接拿到交互式shell。应用启动后涉及目录的读写权限:
sudo mkdir -p /opt/myapp/{config,logs} sudo chown -R appuser:appuser /opt/myapp sudo chmod -R 750 /opt/myapp2.2 安装OpenJDK的两种方式
Ubuntu上装JDK主要有两条路线:直接用apt装发行版仓库里的OpenJDK,或者用SDKMAN这类版本管理工具。生产服务器我建议用apt安装,因为走的是系统源,后续apt upgrade可以自动打安全补丁:
sudo apt update sudo apt install -y openjdk-17-jdk-headless java -version-headless版本不包含图形相关库,服务器上用不到GUI相关能力,少装东西也意味着攻击面更小。如果多个应用需要不同JDK版本共存,再用SDKMAN也不迟,单应用场景下apt就足够了。
2.3 时区、字符集和文件描述符
时区不统一的问题往往在日志和数据库时间上才会暴露。如果应用里有定时任务依赖本地时间,会直接看到任务提前或延后数小时执行。统一设置:
sudo timedatectl set-timezone Asia/Shanghai华为云、阿里云等各类云厂商提供的Ubuntu镜像默认可能又是UTC时区,这点很subtle,但上线后所有日志时间戳都会和实际时间差8个小时,排错时非常费劲。再顺手确认一下系统字符集:
locale如果输出不是LANG=en_US.UTF-8或zh_CN.UTF-8这种UTF-8编码,后续读取含中文的文件可能出现乱码。SpringBoot读取application.yml里若有中文注释或字符串,就会碰到编码问题。
最后调整文件描述符上限。SpringBoot应用在较高并发下文件句柄消耗很快,默认的1024根本不够用。虽然systemd服务文件里可以单独配置LimitNOFILE,但最好系统层面也放开:
ulimit -n 65535这条命令只影响当前会话,持久化修改需要在/etc/security/limits.conf中追加条目,或者依赖第4部分systemd服务脚本中的LimitNOFILE=65535设置。
3. 构建与上传:Maven打包细节、校验文件完整性
环境准备好后,接下来是本机开发环境中的构建工作。这一步看似简单——mvn clean package回车就完事——但实际构建时有一些细节会影响最终部署的成败。
3.1 配置生产环境的打包方式
构建前确认pom.xml中包含了SpringBoot的Maven插件:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>这个插件的作用是把应用打成一个"可执行JAR",内部通过特殊的Spring-Boot-ClassLoader来加载BOOT-INF/classes下的类和BOOT-INF/lib下的依赖JAR。没有这个插件,mvn package打出来的JAR只是一个普通JAR,依赖不会包含进去,在服务器上运行时铁定报ClassNotFoundException。
对于多环境配置的情况,我习惯在构建时显式激活生产配置:
mvn clean package -DskipTests -Dspring.profiles.active=prod不过更常见的做法是构建时不绑定profile,而是在服务器上通过环境变量或启动参数指定,这样同一个JAR可以灵活部署到不同环境。JAR内和JAR外配置的优先级关系往后靠一靠,部署参数部分再详细讲。
3.2 上传与完整性校验
构建完成后,target目录下会生成两个文件:一个myapp-0.0.1-SNAPSHOT.jar,一个以.original结尾的原始JAR。部署时使用的是前者。很多新手分不清两个文件的区别——前者是SpringBoot插件改造过、能直接java -jar运行的;后者是Maven默认打的原始JAR包,没有内嵌依赖,不能直接运行。
上传方式上,小文件用scp就够了:
scp target/myapp.jar appuser@服务器地址:/opt/myapp/app.jar但生产环境我建议先传到临时目录,做校验之后再移动到应用目录。这样能避免"传了一半文件被应用读到导致启动失败"的问题。校验最简单的方式是比对MD5:
md5sum app.jar # 本机执行,记录值 md5sum /tmp/app.jar # 服务器上传后执行,检查是否一致另外提醒一句:不要在目标服务器上用vim直接编辑JAR包里面的配置文件——JAR是zip压缩格式,直接编辑会破坏结构导致无法启动。
3.3 外部配置优先:application.yml与Config Data
SpringBoot的外部化配置有一个优先级顺序,命令行参数大于SPRING_APPLICATION_JSON环境变量,大于application-{profile}.yml,最后才是JAR包内的application.yml。这个特性非常实用,也是我推荐"JAR里不带生产配置"的原因。
生产环境的数据库连接、Redis地址这类敏感信息,最稳妥的放置方式是放到服务器上独立的配置文件里,用启动参数显式指定:
java -jar app.jar \ --spring.config.additional-location=file:/opt/myapp/config/application-prod.yml这样JAR包本身可以在不同环境间复用,服务器上的配置文件也便于纳管和审计,换一台机器不需要重新构建。
4. 用systemd托管服务:进程挂掉自动拉起与开机自启
Java进程跑起来不难,难的是"服务器重启之后应用别消失""进程意外退出之后能自动拉起来"。这两件事如果在Windows上用服务,在Linux上最标准的答案就是systemd。
4.1 为什么要用systemd而不是nohup
我看到网上很多教程还在用类似nohup java -jar app.jar > /dev/null 2>&1 &的方式。这种方式的缺点很明显:进程没有监督机制,挂了就是挂了;开机不会自动启动;日志管理混乱。systemd作为Linux主流系统的服务管理器,提供了进程守护、自动重启、开机自启、日志统一收集这些能力,生产级部署没有理由不用它。
4.2 编写service文件
在/etc/systemd/system/下新建myapp.service:
[Unit] Description=My Spring Boot Application After=network.target mysql.service Wants=mysql.service [Service] User=appuser Group=appuser WorkingDirectory=/opt/myapp Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" Environment="SPRING_PROFILES_ACTIVE=prod" Environment="SERVER_PORT=8080" ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/myapp/app.jar Restart=on-failure RestartSec=10 SuccessExitStatus=143 StandardOutput=journal StandardError=journal LimitNOFILE=65535 [Install] WantedBy=multi-user.target逐项解释一下关键点:
After=network.target mysql.service:声明应用应该在网络服务和MySQL服务启动后再启动。MySQL服务名在不同Ubuntu版本上可能叫mysql.service或mysqld.service,根据实际情况调整。Restart=on-failure:非正常退出(退出码非0)时自动重启。某些情况下应用主动退出且退出码为0时,on-failure不会触发重启,这通常是符合预期的。SuccessExitStatus=143:143是SIGTERM信号对应的退出码,systemd执行stop操作时会发SIGTERM,如果SpringBoot正常处理优雅退出返回143,会被systemd视为正常退出,不触发Restart逻辑。LimitNOFILE=65535:等价于替这个服务设置了ulimit,不需要依赖limits.conf,这是systemd服务单独隔离的优势。StandardOutput=journal:应用stdout和stderr输出重定向到journald,统一日志管理基础。
一个常见的疑问是ExecStart里/usr/bin/java这个路径。实际上配置JDK后也可以直接用java命令,但systemd服务默认的PATH可能没有包含JDK目录,所以在service文件里写完整路径是最稳妥的。想知道Java安装路径,执行readlink -f $(which java)即可。
4.3 systemd服务启用与常用操作
编写完成后依次执行:
sudo systemctl daemon-reload sudo systemctl enable myapp sudo systemctl start myappenable的作用是创建开机启动的符号链接。如果立即需要查看应用启动日志(相当于以前的tail -f),用:
sudo journalctl -u myapp -f平时需要查看服务状态的:
sudo systemctl status myapp如果想在服务还正常运行的情况下看某一次最新启动日志:
sudo journalctl -u myapp -n 100 --since "5 min ago"4.4 应用的新版本上线,如何平滑滚动发布
ssh在线发布时可以直接替换JAR包后重启服务:
sudo systemctl stop myapp # 上传新的app.jar并覆盖 sudo chown appuser:appuser /opt/myapp/app.jar sudo systemctl start myapp如果应用流量较大、不能允许较长停机时间,可以考虑设置Restart=always,配合systemctl restart来做到快速重启。但注意SpringBoot默认情况下停机是优雅的——会等处理中的请求完成再退出,因此重启耗时会比强杀长一些,但安全性更高。
5. Nginx反向代理与数据库连接:两个最容易踩坑的地方
SpringBoot内嵌的Tomcat默认只监听8080端口,直接把8080暴露给外部访问不是不可以,但有几个问题:一是没法同时托管多个域名和多个应用;二是不好处理HTTPS证书;三是应用层的请求日志和限流能力都比较弱。Nginx加一层反向代理是生产环境的标准姿势。
5.1 Nginx配置最小闭环
安装Nginx:
sudo apt install -y nginx在/etc/nginx/sites-available/下新建myapp.conf:
server { listen 80; server_name myapp.example.com; client_max_body_size 50m; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_read_timeout 60s; proxy_send_timeout 60s; } }几个容易出错的地方:
proxy_pass http://127.0.0.1:8080;结尾没有斜杠,与DSL方式proxy_pass http://127.0.0.1:8080/;行为有差别。带斜杠会把匹配到的location前缀从转发路径中去除,不带斜杠则保留原始URI。先搞清楚自己的URI设计再决定要不要加斜杠。server_name如果直接填IP访问测试,也可以用_作通配。client_max_body_size不设置的话默认1m,超过1MB的请求体直接413,很多人第一次上传文件时遇到的就是这个坑。- 如果应用通过
request.getRemoteAddr()获取客户端IP,会发现始终是127.0.0.1——因为后端看到的是代理服务器的连接。此时SpringBoot需要配合X-Forwarded-For处理,最简单的方法是在配置文件里设:
server: forward-headers-strategy: framework这样内嵌Tomcat会解析Nginx传入的header,正确设置真实客户端IP。
启用站点:
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/myapp.conf sudo nginx -t sudo systemctl reload nginx5.2 MySQL连接的时区与公钥问题
如果说Nginx配置是"入口的坑",那数据库连接就是"应用的坑"。最常见的两个报错场景:
The server time zone value 'CST' is unrecognized or represents more than one time zone。这是MySQL 8和JDBC驱动之间时区协商的经典问题。解决方案是在JDBC URL中明确指定时区:
jdbc:mysql://127.0.0.1:3306/mydb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=trueallowPublicKeyRetrieval=true单独说一下:MySQL 8默认使用caching_sha2_password认证,非SSL连接时客户端需要先拿到服务端公钥才能进行rsa加密密码传输。本地开发可能由于缓存公钥一直没报错,换个环境就抛Public Key Retrieval is not allowed异常。这个参数就是允许JDBC客户端主动向服务端请求公钥,开发环境可以开,生产环境如果走内网且不是严格安全域,问题不大;如果安全审计要求高,更推荐直接启用SSL连接。
- 连接池配置不合理导致间歇性超时。SpringBoot默认的
HikariCP连接池很优秀,但默认的maximumPoolSize=10可能偏小,而connectionTimeout=30000偏长。如果是低并发但多接口内多次查库的场景,连接池排队时间会被放大。我个人的经验值作为参考:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 idle-timeout: 30000 connection-timeout: 5000 max-lifetime: 1800000其中max-lifetime应该比数据库侧的wait_timeout略短,例如MySQLwait_timeout默认8小时,那么连接池max-lifetime建议不超过2小时(30分钟=1800000ms),这样能保证连接在数据库主动断开之前被池回收重建。
6. 日志治理与上线后第一周要盯着的事
服务起来了、Nginx转发正常了、数据库连上了——剩下的事情才是决定这个应用能不能长期稳定跑下去的关键:日志能不能找到、满了怎么办、挂了之后发生了什么。这些经验不是从官方文档里能直接学到的,更多是踩坑踩出来的。
6.1 SpringBoot日志落盘
SpringBoot默认只把日志打到控制台,配合systemd的journald可以看,但长期运行后日志文件管理和检索能力都很弱。更推荐在application-prod.yml中直接配置Logback输出到文件:
logging: file: name: /opt/myapp/logs/app.log logback: rollingpolicy: max-file-size: 100MB max-history: 15 total-size-cap: 2GB文件滚动策略的作用直观来说就是:单个日志到了100MB就滚动到下一个文件,保留最近15个文件,所有日志总量限制在2GB以内。不设置的话,日志文件可能无限增长,最终把磁盘撑爆。
6.2 磁盘空间告警
磁盘满是个非常隐蔽的故障源。应用可能不会立刻崩,但会出现很古怪的现象——写日志失败但接口仍在处理、上传文件报磁盘容量不足、MySQL突然拒写。建议上线后加一个简单的定时任务检查磁盘:
df -h /opt如果日志增长快、磁盘剩余告急,优先级最高的排查顺序是:
journalctl占用的日志量(systemd日志默认可能积累到GB级别)- 应用logback的滚动策略有没有生效
- 数据库自身的binlog、慢日志、错误日志大小
systemd的journal占用可以通过journalctl --disk-usage查看,再通过配置文件或journalctl --vacuum-size=500M清理。
6.3 上线后第一周必须盯的几个指标
部署完成不是结束,前一周的观察期里,这四类信号是最有价值的:
- 健康检查接口:SpringBoot Actuator如果引入了,直接访问
/actuator/health确认UP状态。没有引入的话,至少保证有一个简单接口可以做存活探测。 - 启动耗时:
ExecStart命令跑完后,journal里会看到Started My Spring Boot Application。如果启动时间超过30秒,检查是否网络依赖等待超时、连接池初始化太慢。 - 内存占用趋势:
systemctl show myapp -p MemoryCurrent可以看到当前使用内存。如果持续上涨不回落,大概率是代码里有内存泄漏,需要结合堆内存监控进一步排查。 - 线程池与连接池饱和:通过
/actuator/metrics可以看hikaricp连接池的使用情况,连接数持续逼近最大值时就要考虑调参或扩容。
6.4 一个常见的排错链路,供参考
假设应用上线后第二天发现接口偶尔500,你可以在服务器上按这个顺序排查:
- 看
journalctl -u myapp --since "30 min ago",查应用日志中是否有异常堆栈。 - 如果日志正常,再看
journalctl -u nginx --since "30 min ago",确认Nginx有没有报upstream timed out或upstream prematurely closed。 - 是upstream报错的话,检查数据库连接池和依赖的Redis等服务状态。
- 数据库连接池满了,则看当前active连接数,配合
SHOW PROCESSLIST确认是否有慢SQL占用连接不释放。 - 慢SQL的修复方向通常是加索引、优化查询或调整reduce连接占用时间。
这个链路从"症状"到"直接原因"到"根因",是一个标准的从现象到定位的思路。实际排查时大多数问题都能在第二步和第三步之间定位:要么应用本身抛异常,要么依赖的基础组件出问题,很少只停留在"不知道从哪里看起"的状态。
我在实际部署中最大的体会是:SpringBoot应用本身的部署难度其实不高,真正决定体验的是服务器侧的环境规划和进程治理是否到位。一个由systemd接管、Nginx前置、日志有滚动、磁盘有告警的部署形态,和"nohup挂起来就完事"的部署形态,在运行一个月后会产生巨大的维护成本差异。如果你要部署的应用会长期运行,值得把这些环节一次做对。