
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打出一个 jarscp 上传到服务器java -jar app.jar完事儿。听上去没毛病但实际上这种操作方式在生产环境里活不过三天。原因很简单。第一本地打包的 jar 可能携带了本机环境的配置信息比如application-dev.yml里的本地数据库地址、调试日志级别。第二直接前台运行 Java 进程SSH 窗口一关进程就跟着没了。第三没有日志轮转没有进程守护一旦 JVM 崩溃没有任何机制把它拉起来。第四服务器上的环境变量、内存配置、时区设置都没人管应用跑起来表现就很诡异。部署从本质上讲不是“把文件复制过去”而是“把应用从开发环境平滑迁移到运行环境”的过程。这个过程需要你提前想清楚三件事运行环境是什么规格、应用配置怎么和代码分离、进程怎么被操作系统托管。想清楚这三件事部署就成功了一半。1.2 环境选型JDK 版本、构建工具、服务器规格怎么定先聊 JDK。Spring Boot 2.x 默认支持 Java 8 到 Java 17Spring 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_ACTIVEprod来决定激活哪个配置根本不需要重新打包灵活性高很多。就这一个改动能避免“本地好好的部署就炸”的一类经典问题。第二个问题是产物命名。默认情况下 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.skiptrue。持续集成环境里我通常是在流水线上专门跑一遍测试然后再用跳过测试的方式去打部署包两者兼顾。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.activeprod --server.port8080如果这里就能看到 “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很多新手登录服务器后直接把自己当成 rootjava -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.activeprod以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] DescriptionYour Spring Boot Application Afternetwork.target [Service] Userappuser WorkingDirectory/opt/your-app EnvironmentFile/opt/your-app/env.conf ExecStart/usr/bin/java -Xms512m -Xmx2g -jar /opt/your-app/app.jar SuccessExitStatus143 Restartalways RestartSec10 StandardOutputappend:/opt/your-app/logs/app.log StandardErrorappend:/opt/your-app/logs/app-error.log [Install] WantedBymulti-user.target这里面有几个关键行我逐个解释一下。Afternetwork.target确保系统网络服务准备好之后才启动我们应用免得启动时连接外部服务失败。Userappuser指定以非 root 身份运行。EnvironmentFile指定了一个外部环境变量文件实际部署时你在这个文件里写数据库密码之类的敏感信息避免直接暴露在 service 文件里。SuccessExitStatus143是因为 Java 应用收到 SIGTERM 信号时退出码是 143这里标记为正常退出避免 systemd 误判。Restartalways配合RestartSec10是指进程挂掉后 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_ACTIVEprod DB_PASSWORDyour_strong_password_here REDIS_PASSWORDanother_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; } }配好之后重载 Nginxsudo nginx -t sudo systemctl reload nginx如果应用里用到 HTTPS你可以在 Nginx 层面终结 SSL之后以 HTTP 协议转发给内网 Java 进程这样就避免了在 Java 层处理证书链和 HTTPS 的复杂逻辑。5.4 日志轮转日志文件不能无脑增长Java 应用在生产环境跑个半年日志文件轻松上 GB。如果不做轮转磁盘空间迟早被打满。Linux 系统最常用的工具是logrotateUbuntu 默认装了只需要配一下规则。在/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 或 403Nginx 反代路径配置不对检查 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_ACTIVEprod \ -e DB_PASSWORDyour_password \ -v /opt/your-app/logs:/app/logs \ --restartalways \ your-app:1.0.0用 Docker 的好处是环境隔离彻底镜像内自带 JDK 和运行依赖换一台服务器也能保证行为一致。而且--restartalways本身也提供了进程守护能力。7.2 两种方式的优缺点对比维度systemd 直跑Docker 容器学习成本低会 Linux 命令就行中需要理解镜像和容器资源占用低原生进程无额外开销中等运行时本身有少量开销环境一致性依赖宿主机 JDK镜像内自包含一致性高版本回滚靠目录切换镜像 tag 切换更简单运维工具链一般有 Docker Compose、K8s 等生态适合场景单体、小团队、单机微服务、云原生、多环境如果你是从零开始的新项目团队又有一定容器基础我建议直接上 Docker因为未来的扩展空间更大。但如果你只是为了把一个单体应用稳定跑起来团队也没有专职的运维那 systemd 直跑反而更省心——没有镜像构建、没有仓库管理、没有容器网络那一堆概念一个 service 文件搞定所有事。我在很多中小团队里做技术咨询看到反例项目本身只有一个 Spring Boot 应用却硬要上 DockerK8s结果光折腾基础设施就花了大半个月业务一点没推进。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.encodingUTF-8。服务器上如果默认字符集不是 UTF-8中文日志会乱码接口返回的中文也可能出问题。启动命令加上这个参数能少很多无谓的折腾。8.2 健康检查让系统自己告诉你它还行不行应用部署完后健康检查应该成为标配。Spring Boot 的 Actuator 组件提供了/actuator/health端点返回 JSON 格式的健康状态。在pom.xml加依赖后dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency启动后访问http://localhost:8080/actuator/health正常返回{status:UP}这个端点在手工验证时有用更重要的是可以配置到云监控或自建监控系统里比如让curl定时请求健康端点连续失败三次就触发告警。systemd 配合健康检查也能做到更精准的自动拉起。比如设置Restarton-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 上折腾部署时少走一些弯路。