SpringBoot应用Ubuntu生产部署:systemd服务化与回滚实战

发布时间:2026/10/20 15:28:42
SpringBoot应用Ubuntu生产部署:systemd服务化与回滚实战 如果把一个在本地跑得好好的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文件会一直膨胀等你想看日志的时候才发现有用的信息早就被冲掉了。为什么不用DockerDocker本身是好东西但在小规模单机场景下它引入了镜像构建、容器网络、数据卷、日志驱动等一系列额外复杂度。出了问题还要先查容器状态、再进容器内看日志链路变长了。如果你团队里已经有成熟的容器平台和镜像流水线那另说否则在单机部署阶段先用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装好了。如果这台机器还需要自己打包那再装Mavensudo apt install -y maven mvn -v有一个细节我要特别提醒如果项目用了比较新的Maven插件或依赖系统自带的Maven版本可能偏老。遇到构建失败时优先检查Maven版本必要时用Maven的官方解压包替代不要死磕apt源里的旧版本。还有一件事是内存规划。很多云服务器的内存就2G或者4GJava应用又向来“贪心”。如果服务器没有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] DescriptionDemo API Server Afternetwork.target mysql.service redis-server.service [Service] Userapp Groupapp 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 SuccessExitStatus143 Restartalways RestartSec5 LimitNOFILE65535 StandardOutputappend:/opt/app/demo-api/logs/stdout.log StandardErrorappend:/opt/app/demo-api/logs/stderr.log [Install] WantedBymulti-user.target逐个解释关键配置Afternetwork.target mysql.service redis-server.service声明服务在网络和依赖服务之后启动。注意这只是“尽量靠后”MySQL还没就绪时SpringBoot连库仍然可能失败后面我会说兜底方案。User/Groupapp指定运行身份对应我们前面创建的系统用户。WorkingDirectory决定相对路径参照哪里。SpringBoot的外部config加载、日志文件相对路径都跟它有关。EnvironmentFile从外部文件加载环境变量我习惯把SPRING_PROFILES_ACTIVEprod、DB_PASSWORD这类放这里避免直接写在unit文件里。Restartalways只要进程被异常终止就自动拉起这是生产环境保命的配置。RestartSec5重启间隔5秒防止进程陷入“崩溃-重启-崩溃”的疯狂循环。LimitNOFILE65535调大文件句柄数上限如果应用涉及大量文件操作或高并发连接这个值太小会报Too many open files。SuccessExitStatus143这段比较关键。JVM收到SIGTERM优雅退出时退出码是143systemd默认把它视为异常会导致Restartalways把正常停止的服务又拉起来。加上这行之后手动systemctl stop才能真正停得下来。3.2 环境变量与JVM内存参数的注入先看EnvironmentFile指向的文件内容/opt/app/demo-api/env.confSPRING_PROFILES_ACTIVEprod SERVER_PORT8080 DB_HOST127.0.0.1 DB_PORT3306 DB_NAMEdemo_db DB_USERNAMEdemo_user DB_PASSWORDChangeMe_StrongPassword REDIS_HOST127.0.0.1 REDIS_PORT6379 REDIS_PASSWORDChangeMe_Redis然后在SpringBoot的application-prod.yml里使用占位符读取spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse 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、RedisXmx给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可以实时看日志。但在生产环境我不建议完全依赖journaldjournald默认日志大小有限制量大的服务几天就能撑满应用日志要按天归档、压缩journald在这方面的机制不如传统日志文件直观。所以最稳妥的组合是应用自己用Logback写文件日志logging.file.name/opt/app/demo-api/logs/app.logsystemd只负责捕获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}?useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4connectTimeout3000socketTimeout10000重点说两个参数useSSLfalseMySQL 8默认要求SSL握手但很多内网环境并没有配置证书为了快速连通可以先关掉。如果对安全有要求应该在MySQL端配置好证书再开启SSL而不要为了安全丢弃可用性。allowPublicKeyRetrievaltrue连接MySQL 8时如果用户密码插件使用caching_sha2_password而连接链路不放公钥客户端会报“Public Key Retrieval is not allowed”。加上这个参数只是业务侧解锁真正的做法是MySQL里用mysql_native_password或正确授权。connectTimeout3000单位是毫秒3秒连不上就快速失败避免应用启动时长时间阻塞在数据库连接上拖慢整个服务的启动。4.2 MySQL用户授权和外部访问限制这是另一个高频坑。很多开发者喜欢用MySQL的root账号连数据库开发环境无所谓生产环境绝对不要这么做。创建专用账号并只授最小权限CREATE USER demo_user127.0.0.1 IDENTIFIED BY ChangeMe_StrongPassword; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON demo_db.* TO demo_user127.0.0.1; FLUSH PRIVILEGES;注意demo_user127.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.database0如果项目用多个库要统一避免连接串都对但数据对不上连接超时建议2-3秒避免Redis挂掉时应用线程全部阻塞除了配置应用启动顺序也需要考虑如果demo-api.service的Aftermysql.service而MySQL服务在systemd里存在但实际还没完成初始化SpringBoot启动时连库失败会抛异常Restartalways会等待5秒后重启。此时数据库已经就绪了第二次启动一般能成功。这是兜底机制的价值也解释了为什么Restartalways在小项目里比任何高可用方案都实在。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 50ss -lntp看端口占用进程在跑端口不通防火墙ufw放行了吗云厂商安全组放行了吗应用监听在0.0.0.0还是127.0.0.1sudo ufw status检查登录云厂商控制台检查安全组确认server.address配置日志里报数据库连接超时MySQL未启动、账号权限不对、连接串地址错误在应用服务器上执行telnet 127.0.0.1 3306或nc -vz 127.0.0.1 3306测试连通性进程反复重启应用启动失败但Restartalways在疯狂拉起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写了一半”的状态。当前版本始终指向currentsystemd服务的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确认服务处于activerunningcurl 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上传到服务器后再校验一次。这个习惯帮我挡掉过两次“明明代码没动线上就是启动失败”的诡异问题——后来一查果然都是上传时的文件损坏。别怕这些环节多它们存下来就是你在生产环境里的底气。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询