Spring Boot 项目在本地跑得飞起,IDEA 里一点运行按钮,控制台秒出 “Tomcat started on port 8080”,这种感觉确实爽。但等你真正要把这个项目丢到一台 Ubuntu 服务器上,让它在脱离你电脑的情况下稳定运行,很多人才发现坑比想象中多:要么打出来的 jar 起不来,要么起来了第二天挂了,要么日志刷得飞快却不知道程序在干嘛。这篇内容就是围绕“Java 进阶部署”这个场景,把我在 Ubuntu 上部署 Spring Boot 应用踩过的坑、总结的一套还算顺手的流程,从头到尾捋一遍。适合刚接触服务器部署的 Java 开发,也适合那些已经在部署但总觉得哪里不够规范的兄弟参考。
先说清楚这篇文章能帮你解决什么问题:第一,学会打一个干净、可用的 Spring Boot 生产包;第二,搞清楚 Ubuntu 服务器端需要准备哪些环境和用户策略;第三,掌握用 systemd 托管 Java 进程,做到开机自启、崩溃自动拉起;第四,把配置外置、日志管理、Nginx 反代这些生产环境绕不开的细节处理好。整个过程我会尽量讲清楚每一步背后的为什么,不只是给你一份“复制粘贴就能用”的命令清单,因为命令总会过时,思路不会。
1. 部署前的整体设计:从开发到生产的思维切换
1.1 你以为部署就是把 jar 丢上去就完事了吗
很多新手第一次部署 Spring Boot,思路特别朴素:本地mvn package打出一个 jar,scp 上传到服务器,java -jar app.jar,完事儿。听上去没毛病,但实际上这种操作方式在生产环境里活不过三天。
原因很简单。第一,本地打包的 jar 可能携带了本机环境的配置信息,比如application-dev.yml里的本地数据库地址、调试日志级别。第二,直接前台运行 Java 进程,SSH 窗口一关,进程就跟着没了。第三,没有日志轮转,没有进程守护,一旦 JVM 崩溃,没有任何机制把它拉起来。第四,服务器上的环境变量、内存配置、时区设置都没人管,应用跑起来表现就很诡异。
部署从本质上讲不是“把文件复制过去”,而是“把应用从开发环境平滑迁移到运行环境”的过程。这个过程需要你提前想清楚三件事:运行环境是什么规格、应用配置怎么和代码分离、进程怎么被操作系统托管。想清楚这三件事,部署就成功了一半。
1.2 环境选型:JDK 版本、构建工具、服务器规格怎么定
先聊 JDK。Spring Boot 2.x 默认支持 Java 8 到 Java 17,Spring Boot 3.x 则强制要求 Java 17 以上。如果你现在还在用 Java 8,同时项目里又有 javax 包,那大概率走的是 Spring Boot 2.x 的老路;如果你是从头建的新项目,直接用 Java 17 + Spring Boot 3.x 更省心。服务器上安装 JDK,我推荐用 OpenJDK 而不是 Oracle JDK,两者在绝大多数场景下没差异,但 OpenJDK 的更新和安装方便程度在 Linux 上明显更好。
构建工具方面,Maven 和 Gradle 二选一。我习惯 Maven,因为它在中大型团队里更通用,流水线里找现成模板容易。但这里有个小事必须提醒:最好在服务器上也装一个 Maven,不为了本地构建,而是为了在出问题时能快速执行mvn dependency:tree排查依赖冲突,或者跑一些官方诊断命令。
服务器规格这块给个参考,以单体 Spring Boot 应用为例,业务不复杂的话 2 核 4G 起步就够用了。JVM 留 2G 左右堆内存,系统留 2G 给操作系统和缓存,日常压力测试并发两三百基本能顶住。如果你们项目里集成了大量第三方 SDK、或者用了本地缓存框架,堆内存至少要按 50% 余量去放。
1.3 为什么选 Ubuntu Server 而不是其他发行版
CentOS 停更之后,很多团队把部署环境切到了 Ubuntu Server,这也是为什么网上搜“Ubuntu 部署”相关的内容越来越多。我自己的感受是,Ubuntu Server 有四个实实在在的优点:第一,apt 的软件源比 yum 的第三方源更省心,装 JDK、Nginx、Redis 基本都是apt install一条命令搞定;第二,Ubuntu 社区文档丰富,报错信息直接粘贴到搜索引擎,基本都能找到解决方案;第三,LTS 版本支持周期长,比如 20.04、22.04,服务器不用频繁升级;第四,默认的 systemd 体系管理服务非常方便,这正是我们部署 Java 进程要用到的核心工具。
系统安装完成之后,第一件事就是把 apt 源换成国内镜像,然后执行apt update && apt upgrade,把这些基础工作做到位再开始搞部署。我见过不少人省掉这一步,结果后面装什么软件都慢,甚至因为源版本太旧导致依赖冲突。
2. 本地构建:打出一个有底气的部署包
2.1 Maven 配置:跳过测试、定义产物名字
构建这一步看似简单,但里面有两个小细节值得认真对待。
第一个是多环境 Profile。标准做法是在src/main/resources下拆分application.yml、application-dev.yml、application-prod.yml,然后在pom.xml里设置<profiles>,也可以用 Spring Boot 原生的spring.profiles.active在启动时指定。我推荐用后者,因为部署时通过环境变量SPRING_PROFILES_ACTIVE=prod来决定激活哪个配置,根本不需要重新打包,灵活性高很多。就这一个改动,能避免“本地好好的,部署就炸”的一类经典问题。
第二个问题是产物命名。默认情况下 Maven 打出来的 jar 经常叫demo-0.0.1-SNAPSHOT.jar,这个带版本号的名字,在上传部署脚本里很容易写错。建议在pom.xml里加上这段配置:
<build> <finalName>${project.artifactId}</finalName> </build>这样打出文件名就是your-app.jar,路径固定,脚本不用改来改去。很多同学不重视这种命名细节,非要等到自动化部署时因为文件名对不上头发懵,才回来改这个,属实是走了弯路。
另外,打包命令我建议执行:
mvn clean package -DskipTests-DskipTests是跳过测试用例的执行,但会保留测试代码的编译。如果你连编译测试代码这一步也想省,用-Dmaven.test.skip=true。持续集成环境里,我通常是在流水线上专门跑一遍测试,然后再用跳过测试的方式去打部署包,两者兼顾。
2.2 验证 jar 包的结构和可启动性
打包完成之后,别急着传服务器,先在本地用两条命令快速验证一下。
第一条是看 jar 包内部结构:
jar tf target/your-app.jar | head -20正常情况你会看到BOOT-INF/classes/目录下有编译好的 class 文件,BOOT-INF/lib/下有各种依赖 jar。如果你发现整个包里面只有你自己的 class,没有那些 lib,那多半是没配spring-boot-maven-plugin的repackage功能,打出来的是一个普通 jar 而不是可执行的 fat jar。
第二条是直接本地启动测试:
java -jar target/your-app.jar --spring.profiles.active=prod --server.port=8080如果这里就能看到 “Started Application in X seconds”,说明 jar 包本身的健壮性没问题,后续部署排查只需聚焦在服务器环境差异上。这一步相当于给部署流程加了一道保险丝,能省掉后面很多排查时间。
2.3 连接外部配置:把变量从代码里抠出来
构建之前,还有一个容易被忽略的设计问题:application-prod.yml里的数据库密码、Redis 密码、第三方密钥,到底要不要写死在文件里?我的答案是:写死的配置都是安全隐患,而且代码仓库里的变动历史会暴露密钥。
更好的方案是用环境变量覆盖。Spring Boot 对配置有天然的优先级体系,环境变量的优先级高于application.yml文件。比如你在 yml 里写了:
spring: datasource: url: jdbc:mysql://localhost:3306/app_db username: app_user password: ${DB_PASSWORD}部署时在 systemd 里注入DB_PASSWORD这个环境变量,就能在不改代码、不改构建产物的前提下,完成生产环境密码的配置。这个技巧贯穿整篇部署流程,后面讲 systemd 时还会再看到它。
3. Ubuntu 端环境准备:别让脏环境坑了你
3.1 建一个专用用户,别用 root 跑 Java
很多新手登录服务器后,直接把自己当成 root,java -jar app.jar一把梭。这样做最大的问题是:一旦 JVM 被攻击者利用,他们拿到的就是 root 权限。而且 root 用户下管理的服务,如果同时还有别的操作,日志和文件归属会非常乱。
正确做法是新建一个专门的应用用户:
sudo useradd -m -s /bin/bash appuser sudo mkdir -p /opt/your-app sudo chown -R appuser:appuser /opt/your-app后续把 jar 包放到/opt/your-app下,服务的启动、日志写入、配置读取都归appuser管。权限边界划清楚之后,以后在服务器上排查问题时也会清爽很多——不是说 root 不能用,而是生产环境要尽量减少不必要的风险暴露。
3.2 JDK 安装:别装错版本,别忘验证位数
Ubuntu 上装 JDK 表面看很简单,sudo apt install openjdk-17-jdk就完了。但这里常见的坑是:有些云镜像默认自带的是 OpenJDK 11 或者 JRE,不是完整 JDK,结果 Spring Boot 项目用到某些编译期特性时直接报错。反正我每次部署前都会先跑一遍:
java -version javac -version两条命令输出里都要有17.0.x字样才行。如果你只看到java没有javac,说明装的是 JRE,需要补装 JDK。
另外有个环境变量容易被忽略,就是JAVA_HOME。很多第三方工具,包括后面要讲的 systemd 脚本,会显式引用JAVA_HOME去定位 Java 路径。Ubuntu 上可以通过update-alternatives --config java查看安装路径,然后在/etc/environment里配置:
JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"设置完成后执行source /etc/environment使其生效。这个变量配置虽然不起眼,但能避免很多工具链找不到 Java 的诡异问题。
3.3 目录规划:jar 包、日志、配置分离
部署目录我习惯遵循下面的结构,你可以直接照抄:
/opt/your-app/ ├── app.jar ├── config/ │ └── application-prod.yml └── logs/ ├── app.log └── app.log.1.gzjar 包放到固定位置,外部配置文件放config/,日志文件独立目录。Spring Boot 默认会优先读取./config/目录下的配置文件,所以你把application-prod.yml放到config/下,优先级比 jar 包内置的同名配置要高。这样一来,改服务器上的配置不需要重新打包,改完重启一下服务就行。日志目录独立出来是为了方便做轮转和采集,后面在 systemd 和 logrotate 里都会用到。
我第一次部署时图省事,日志让 Spring Boot 默认输出到控制台,想着反正临时用。结果服务跑了三个月,想排查历史问题才发现没有任何日志文件,那一瞬间真的很崩溃。所以目录规划这件事,再小也要按规范来。
4. 程序上云:上传、启动、守护三步走
4.1 上传 jar 包的几种方式与推荐场景
上传文件到服务器的方式有好几种,各有适用的场景。
最简单的就是scp,适合一次性手动上传:
scp target/your-app.jar appuser@服务器IP:/opt/your-app/如果你是在 Windows 上操作,用scp命令通常需要安装 OpenSSH 客户端,或者直接用 WinSCP、FinalShell 这类图形化工具,也能达到同样的效果。
如果你用的是云服务器,还可以用云平台自带的管理终端直接把文件拖拽上去,但这种方式在自动化场景下不好使。所以我对团队的建议是:手动部署用scp,自动化部署用流水线生成制品后直接分发。
上传过程中有个细节,上传完之后最好用md5sum或sha256sum比对一下本地和远程的 jar 包哈希值,防止网络中断导致文件残缺。我遇到过不止一次因为网络波动传了一个半个包,启动时各种报错,最后定位半天才发现是文件损坏。
4.2 手动启动:验证环境是否 OK 的第一道关
放到服务器上之后,第一件事不要直接写 systemd 脚本,先用前台方式跑一次:
cd /opt/your-app sudo -u appuser java -jar app.jar --spring.profiles.active=prod以appuser身份启动,能有效避免权限问题。注意观察启动日志的完整输出,看到 “Started Application in X.XXX seconds” 后,再另开一个窗口访问一下接口:
curl http://localhost:8080/actuator/health如果返回{"status":"UP"},说明应用本身没问题。这一步验证通过,再进入 systemd 托管阶段,心里才踏实。
如果启动直接报错,常见的几类原因我放在第六节详细展开,这里先说一个最常踩的坑:端口被占用。如果你本机已经跑了一个 Spring Boot,再去启动另一个同样监听 8080 的进程,一定会报Port 8080 was already in use。这时候先别忙着改端口,查一下是谁在占用:
sudo lsof -i:8080确认之后再决定是停掉旧进程还是改端口。
4.3 用 systemd 把 Java 进程从“野孩子”变成“正规军”
systemd 是现代 Linux 发行版标准的管理工具,Ubuntu 16.04 之后默认就是它。用 systemd 管理 Java 服务,最大的好处有三个:开机自启、崩溃自动重启、日志统一走 journald。
在/etc/systemd/system/your-app.service创建服务文件,内容可以参考:
[Unit] Description=Your Spring Boot Application After=network.target [Service] User=appuser WorkingDirectory=/opt/your-app EnvironmentFile=/opt/your-app/env.conf ExecStart=/usr/bin/java -Xms512m -Xmx2g -jar /opt/your-app/app.jar SuccessExitStatus=143 Restart=always RestartSec=10 StandardOutput=append:/opt/your-app/logs/app.log StandardError=append:/opt/your-app/logs/app-error.log [Install] WantedBy=multi-user.target这里面有几个关键行,我逐个解释一下。
After=network.target确保系统网络服务准备好之后才启动我们应用,免得启动时连接外部服务失败。User=appuser指定以非 root 身份运行。EnvironmentFile指定了一个外部环境变量文件,实际部署时你在这个文件里写数据库密码之类的敏感信息,避免直接暴露在 service 文件里。SuccessExitStatus=143是因为 Java 应用收到 SIGTERM 信号时退出码是 143,这里标记为正常退出,避免 systemd 误判。Restart=always配合RestartSec=10是指进程挂掉后 10 秒自动拉起,这就是“守护”的核心。日志输出直接重定向到指定文件,运维采集也方便。
配置写好之后,执行:
sudo systemctl daemon-reload sudo systemctl enable your-app sudo systemctl start your-appenable是设置开机自启,start是立即启动。启动之后一定要看状态:
sudo systemctl status your-app sudo journalctl -u your-app -fjournalctl -f是流式查看 journal 日志,排查启动问题基本靠它了。如果你只想看今天的日志,加上--since today参数就能精准过滤。
4.4 环境变量配置:把密码放到 service 之外
上面提到了EnvironmentFile=/opt/your-app/env.conf,这个文件的内容长这样:
SPRING_PROFILES_ACTIVE=prod DB_PASSWORD=your_strong_password_here REDIS_PASSWORD=another_password文件的权限要收紧:
sudo chown root:appuser /opt/your-app/env.conf sudo chmod 640 /opt/your-app/env.conf这样设置后,只有 root 和 appuser 组能读,其他用户看不到密码明文。这一步对安全意识强的团队来说很重要,很多内部项目数据库泄露就是因为配置文件权限没管住,生产环境密码躺在 world-readable 文件里,谁登录服务器都能直接看。
5. 生产环境必须处理的几个细节
5.1 配置外置:改配置不重新打包
前面在目录规划时提到config/目录的优先级比较高,这里再展开一下具体用法。
比如你的application-prod.yml里面需要改数据库连接串,直接从/opt/your-app/config/application-prod.yml改,改完重启服务就行,不需要动 jar 包。这个方案的核心是利用 Spring Boot 的配置加载规则:./config/目录的优先级高于 classpath 内的application.yml。
我实际项目的做法是,application.yml里只留应用名、编码方式等基本配置,所有环境相关的内容都通过 profile 文件加环境变量注入。代码里不出现生产环境的具体地址和密钥,本地开发用本地application-dev.yml,线上用外置config/application-prod.yml,两边互不干扰。这套方案我已经用了好几年,几乎没有因为配置问题导致部署事故。
5.2 数据库连接与连接池调优
Spring Boot 默认用的是 HikariCP 连接池,性能和稳定性在 Java 生态里属于第一梯队。但默认配置不一定适合生产,尤其是数据库连接数这个参数。
连接池的核心逻辑是:池里的连接数并非越大越好,因为每条连接都要占用数据库端的内存和资源。常规的单体应用,maximum-pool-size设为 10 到 20 之间就够用了,并发量特别高的场景再往上调。稍微做一点保守的估算:假设你接口平均耗时 100ms,那么一个连接每秒能处理 10 个请求,20 个连接就是每秒 200 个请求,对大多数内部系统来说已经是天花板了。
HikariCP 的配置在application-prod.yml里看起:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000max-lifetime设为 30 分钟,比数据库端wait_timeout时间要短,这样能避免连接被数据库端清理掉后客户端还在傻等响应。这个细节不算深奥,但踩过坑的人都懂,那种“服务跑着跑着接口突然卡死,过一会又自己恢复”的症状,大概率就是连接泄漏或者连接被服务端断开引起的。
5.3 Nginx 反向代理:把 8080 藏起来
Spring Boot 默认监听 8080 端口,但生产环境通常不会直接把 8080 暴露给用户,而是前面挂一个 Nginx 做反向代理和静态资源处理。这样做有三个好处:第一,用户只访问 80/443 端口,看不到后端应用的实际端口,安全性更高;第二,Nginx 处理静态资源、HTTPS 证书、Gzip 压缩等任务更擅长,能让 Java 进程专注于业务逻辑;第三,以后如果要做负载均衡,Nginx 天然支持 upstream 配置,扩展起来非常平滑。
一个最小化的 Nginx 配置片段:
server { listen 80; server_name your-domain.com; 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; } }配好之后重载 Nginx:
sudo nginx -t sudo systemctl reload nginx如果应用里用到 HTTPS,你可以在 Nginx 层面终结 SSL,之后以 HTTP 协议转发给内网 Java 进程,这样就避免了在 Java 层处理证书链和 HTTPS 的复杂逻辑。
5.4 日志轮转:日志文件不能无脑增长
Java 应用在生产环境跑个半年,日志文件轻松上 GB。如果不做轮转,磁盘空间迟早被打满。Linux 系统最常用的工具是logrotate,Ubuntu 默认装了,只需要配一下规则。
在/etc/logrotate.d/your-app创建配置文件:
/opt/your-app/logs/*.log { daily rotate 7 compress delaycompress missingok notifempty copytruncate }daily表示每天切分一次日志,rotate 7保留最近 7 份,compress对历史日志做 gzip 压缩,copytruncate是在不重启应用的前提下把当前日志文件复制一份再清空。最后一条对 Java 应用特别重要,因为 Java 进程通常持有文件句柄,如果你直接删除旧日志文件,进程会继续往那个已被删除的 inode 里写数据,白白浪费磁盘空间。
6. 常见问题与排查技巧实录
6.1 端口被占用、内存不够、时区不对
部署过程中最频繁遇到的问题,我整理了一张速查表,照着排查效率会高很多。
| 异常现象 | 典型原因 | 快速排查命令 | 解决办法 |
|---|---|---|---|
启动报Port 8080 was already in use | 端口被其他进程占用 | sudo lsof -i:8080 | 停掉占用进程,或改SERVER_PORT环境变量 |
启动失败,日志里有OutOfMemoryError | 堆内存设置过大或系统内存不足 | free -h | 调小-Xmx,或者升级服务器内存 |
| 应用启动成功但接口调用很慢 | 数据库连接池耗尽 | show processlist; | 增大连接池或优化慢 SQL |
| 服务器时区不对,日志时间差 8 小时 | 系统时区未设置 | timedatectl | 执行sudo timedatectl set-timezone Asia/Shanghai |
| 访问接口报 404 或 403 | Nginx 反代路径配置不对 | 检查 Nginx 的 access log | 调整location路径规则 |
| systemd 服务频繁重启 | 启动时异常退出 | journalctl -u your-app -f | 根据日志定位具体异常,先修问题再加Restart |
这里特别想提一下时区问题。很多初次部署的同学会发现,应用打印出来的日志时间跟本地时间对不上,总觉得是 Spring Boot 的配置问题。其实大多数情况下是服务器系统时区没设置成中国标准时间。Spring Boot 里可以通过spring.jackson.time-zone设置,但更底层的思路还是让操作系统时区正确,这样 JVM、数据库、Nginx 各个层面的时间戳都是统一标准,排查问题时逻辑会清楚很多。
6.2 启动失败后,如何从日志里快速定位真凶
日志是部署排查最重要的抓手,但 Spring Boot 的日志如果配置不当,很容易被无关紧要的信息刷屏。生产环境建议至少把根日志级别设为INFO,第三方包设为WARN,你自己的业务包可以设为DEBUG方便追踪问题。
在application-prod.yml里可以这样配置:
logging: level: root: INFO org.springframework: WARN com.yourcompany: DEBUG file: name: /opt/your-app/logs/app.log当服务起不来时,第一步是看app-error.log或执行journalctl -u your-app -n 200查看最近 200 行日志。定位的关键是先分清楚是“启动即挂”还是“启动后运行一段时间才挂”。
启动即挂,优先看是不是环境问题,比如数据库连不上、端口被占、依赖的外部组件(Redis、MQ)没起来。这类问题的报错信息通常会明确指向某个连接失败或地址不可达。
运行一段时间才挂,优先看是不是资源问题,比如内存泄漏、连接池耗尽、磁盘写满。这类问题要结合监控数据判断,单纯看日志文本往往只能看到表象,比如各种TimeoutException、Connection reset,但这些通常只是链条尾部,真正的原因可能藏在 GC 日志或系统监控里。
6.3 排查心法:先看环境,再看代码,最后怀疑框架
这是我这几年部署踩坑沉淀出的一个排查顺序,分享出来供你参考。
第一步永远先确认环境状态。systemctl status your-app看服务有没有在跑;free -h看内存够不够;df -h看磁盘满没满;curl你的服务端口看通不通。环境层面的问题占部署失败的七成以上,先把这步做牢,大多数问题都能浮出水面。
第二步才去看应用日志。Spring Boot 的日志其实算是写得挺友好的,启动阶段如果有错误,基本都能看到异常堆栈。你先定位到第一行出现的ERROR,而不是被最后一大段堆栈带着跑。第一行错误才往往是根因,后续的堆栈可能只是连锁反应。
第三步才是怀疑代码和框架。尤其是那些“昨天还好好的,今天就不行了”的场景,大概率不是代码变了,而是环境变了:服务器重启了、磁盘空间满了、外部服务的密钥过期了。先把这些可能排除干净,再去翻 Git 历史看最近有没有提交值得怀疑的改动。
6.4 升级部署:新旧版本切换不想停机怎么办
单体应用部署最尴尬的时刻就是:你在替换 jar 包那一瞬间,服务必须停下来,用户那边如果正在用系统,就会出现几秒钟的 502 或者连接中断。要解决这个问题,最轻量的方案是用 Nginx 做无间断切换。
思路是这样的:服务器上部署两个目录,/opt/your-app-1和/opt/your-app-2,两个目录里各有一份当前运行版本的副本。升级时新版本部署到非活跃目录,然后切换 systemd 服务的WorkingDirectory和ExecStart里对应的 jar 路径,重启服务;Nginx 那边完全无感,因为它反向代理到内网端口,只要端口上的服务本身正常,切换步骤对用户来说就是透明的。
这个方案不引入复杂的注册中心和容器编排,纯粹靠目录切换加 systemd 的daemon-reload完成升级,对单体应用来说已经足够优雅。当然,如果你们团队的规模已经大到需要滚动升级、自动扩缩容,那说明单体架构本身也到了该拆微服务或者引入容器化的时候了。
7. Docker 方式来部署,跟 systemd 相比怎么选
7.1 Docker 部署的典型玩法
聊到这里,肯定有人会问:现在不都流行 Docker 部署吗?你怎么还在讲 systemd?
我的看法是,Docker 部署和 systemd 部署并不是二选一的敌对关系,而是不同阶段的选择。Docker 部署的典型路径是:先写一个 Dockerfile,把 JDK 和 Spring Boot jar 打包进镜像,然后在服务器上docker run启动容器。举个例子:
FROM openjdk:17-jdk-slim WORKDIR /app COPY target/your-app.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-XX:+UseG1GC", "-Xms512m", "-Xmx2g", "-jar", "app.jar"]构建并启动:
docker build -t your-app:1.0.0 . docker run -d --name your-app \ -p 8080:8080 \ -e SPRING_PROFILES_ACTIVE=prod \ -e DB_PASSWORD=your_password \ -v /opt/your-app/logs:/app/logs \ --restart=always \ your-app:1.0.0用 Docker 的好处是环境隔离彻底,镜像内自带 JDK 和运行依赖,换一台服务器也能保证行为一致。而且--restart=always本身也提供了进程守护能力。
7.2 两种方式的优缺点对比
| 维度 | systemd 直跑 | Docker 容器 |
|---|---|---|
| 学习成本 | 低,会 Linux 命令就行 | 中,需要理解镜像和容器 |
| 资源占用 | 低,原生进程无额外开销 | 中等,运行时本身有少量开销 |
| 环境一致性 | 依赖宿主机 JDK | 镜像内自包含,一致性高 |
| 版本回滚 | 靠目录切换 | 镜像 tag 切换,更简单 |
| 运维工具链 | 一般 | 有 Docker Compose、K8s 等生态 |
| 适合场景 | 单体、小团队、单机 | 微服务、云原生、多环境 |
如果你是从零开始的新项目,团队又有一定容器基础,我建议直接上 Docker,因为未来的扩展空间更大。但如果你只是为了把一个单体应用稳定跑起来,团队也没有专职的运维,那 systemd 直跑反而更省心——没有镜像构建、没有仓库管理、没有容器网络那一堆概念,一个 service 文件搞定所有事。我在很多中小团队里做技术咨询,看到反例:项目本身只有一个 Spring Boot 应用,却硬要上 Docker+K8s,结果光折腾基础设施就花了大半个月,业务一点没推进。
7.3 我的建议:小步快跑,别为了容器而容器
如果你问我现在新建项目会选哪种方式,我的回答是:先用 systemd 跑起来,等确实到了需要多实例、快速扩容、或者多个服务之间需要复杂网络编排的阶段,再从容地迁移到 Docker Compose 乃至 K8s。
原因很简单,部署方式的核心目标是稳定可运维,而不是追逐技术时尚。Spring Boot + systemd 这套组合已经足够支撑单机环境下绝大多数业务场景,资源开销更低,排查问题更直观,而且不需要额外学习容器相关的概念。等业务规模真到了单机撑不住的时候,再引入容器化反而是一次有前瞻性的架构演进,因为你已经明确知道自己的痛点在哪里,而不是一上来就为了用 Docker 而用 Docker。
8. 从部署到稳定的最后一公里
8.1 设置 JVM 参数:堆内存与垃圾回收器的选择
很多人部署 Spring Boot 时都是java -jar一把梭,完全不管 JVM 参数,这在生产环境是很危险的。JVM 默认堆内存大小是物理内存的四分之一,如果你的服务器是 4G,默认堆只有 1G,可能刚够用,但偶尔一次高峰流量就能把堆撑爆,然后发生频密的 Full GC,接口延迟飙升。
我的经验是,手动设置-Xms(初始堆)和-Xmx(最大堆)为相同值,比如 2G,让 JVM 在启动时直接把堆内存分配到位,避免运行期动态扩容带来的性能抖动。可以参考这样的启动参数:
java -Xms2g -Xmx2g -XX:+UseG1GC -jar app.jarG1 垃圾收集器是 JDK 9 之后的默认选项,适合大堆、低停顿场景。如果你用的是 JDK 8,也可以在启动命令里显式加上-XX:+UseG1GC,能明显减少 GC 停顿时间。
还有个参数容易被忽略:-Dfile.encoding=UTF-8。服务器上如果默认字符集不是 UTF-8,中文日志会乱码,接口返回的中文也可能出问题。启动命令加上这个参数,能少很多无谓的折腾。
8.2 健康检查:让系统自己告诉你它还行不行
应用部署完后,健康检查应该成为标配。Spring Boot 的 Actuator 组件提供了/actuator/health端点,返回 JSON 格式的健康状态。在pom.xml加依赖后:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>启动后访问http://localhost:8080/actuator/health,正常返回:
{"status":"UP"}这个端点在手工验证时有用,更重要的是可以配置到云监控或自建监控系统里,比如让curl定时请求健康端点,连续失败三次就触发告警。
systemd 配合健康检查也能做到更精准的自动拉起。比如设置Restart=on-failure只在异常退出时重启,但如果 JVM 还活着而内部线程池已经全卡死,这种“活死人”状态 systemd 是感知不到的。这时需要写一个监控脚本,定期curl健康端点,请求失败就主动systemctl restart your-app,相当于给服务加了一个业务层面的“心电监护仪”。
8.3 回滚预案:升级失败怎么快速还原
升级部署最怕的是新版本有严重 bug,上线后被用户投诉。这时候你需要的不是紧急写代码修复,而是一键回滚到上一个稳定版本。
如果用 systemd 加目录切换方案,回滚就非常直观:直接把 symlink 指回上一个版本,重启服务。推荐在/opt/your-app下做一个软链接:
ln -s /opt/releases/your-app-2.0.0.jar /opt/your-app/app.jar发布新版本时先更新软链接,再systemctl restart your-app。如果新版本有问题,几秒钟就能把软链接切回旧版本并重启。这套方案没有引入额外组件,纯靠文件系统的软链接机制,但非常可靠。
我见过一些团队,升级后发现问题,手忙脚乱找旧包,发现旧包已经被覆盖了,只能临时从 Git 仓库重新拉代码打包,搞得所有人神经紧绷。提前做好回滚预案,这种状况就完全不会出现。
8.4 与开发工具的协同:从 IDE 到服务器的通路
最后回归到本文开头那个困惑:为什么我本地跑好好的,部署到服务器就各种出问题?除了环境差异,还有一个重要原因是“本地的成功是经过 IDE 伪装过的”。在 IDEA 里你点击运行,它会自动加载 classpath 配置、自动设置工作目录、自动注入虚拟机选项,这些默认值把很多问题掩盖了。而部署到服务器,你面对的是裸奔的java -jar,所有参数都要自己给定清楚。
所以我在压测环境出问题时的保守做法是:先在本地用命令行方式启动一次,不借助 IDE,验证 jar 包本身的健壮性。如果命令行起得来,再排查服务器环境;如果命令行都起不来,说明你的项目配置文件或依赖存在和 IDE 运行环境的隐式耦合,这时候优先检查application.yml里的路径、字符编码、环境变量引用,把这些隐式依赖显式化,才是治本的办法。
写到这里,我其实想表达的核心就一个:部署不应该是开发流程的终点,而应该是从开发到运维这条链路中最关键的一段润滑剂。Spring Boot 帮我们屏蔽了很多底层复杂度,但部署环节该有的基本功,比如用户隔离、进程守护、配置外置、日志轮转、健康检查、回滚预案,一样都不能少。这些东西没有太多高深的技术,却决定了你的应用能否真正稳定服务用户。希望这篇文章里踩坑总结出的经验,能让你在 Ubuntu 上折腾部署时少走一些弯路。