多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot应用Ubuntu生产部署:打包、systemd守护与日志治理

SpringBoot应用Ubuntu生产部署:打包、systemd守护与日志治理 Java进阶-在Ubuntu上部署SpringBoot应用最近一直在折腾 Spring Boot 的生产环境部署踩了不少坑也总结了一些能直接抄作业的套路。不少朋友问我本地开发跑得好好的项目一到 Linux 服务器上就各种幺蛾子启动慢、端口起不来、内存动不动就飙高、日志找不到、程序一重启就丢配置……这些问题我基本都遇到过。这篇文章就以 Ubuntu 为主战场把 Spring Boot 应用从打包到上线、从进程守护到日志治理的完整链路捋一遍。先说清楚这篇东西解决什么问题你在本地用 IDEA 把 Spring Boot 项目写完了想要部署到一台 Ubuntu 服务器上让它稳定跑起来并能像成熟产品那样做到后台运行、开机自启、优雅停服、反向代理、日志轮转。适合谁看刚接触 Linux 部署的 Java 新人以及已经会基本部署但想优化架构细节的初中级工程师。核心要点就是两句话进程要托管给 systemd配置要外部化JVM 参数要在生产环境单独调。1. 部署前的全局规划与核心决策1.1 生产环境选型为什么是 Ubuntu Spring BootSpring Boot 本身是跨平台的最终运行的是 JVM 字节码理论上 Windows、Linux、macOS 都能跑。但生产环境选 Ubuntu 不是拍脑袋的决定背后有几个实打实的理由。首先是稳定性。Ubuntu 的 LTS 版本比如 20.04、22.04、24.04有长达五年的安全更新支持这意味着部署上去之后不需要频繁升级大版本操作系统层面的坑比较少。我在生产环境用 Ubuntu Server 22.04 LTS 比较多跑了两年多没出过系统层面的问题。其次是生态适配。Linux 环境下 systemd 直接管理 Java 进程非常顺手不像 Windows 下要借助 NSSM 或者计划任务这些第三方工具。而且 Ubuntu 的 apt 包管理器安装 Nginx、MySQL、Redis 这些基础设施非常省心一个命令就能装好依赖自动解决。还有一个容易忽略的点内存表现。同样的 Spring Boot 应用在 Linux 上跑的内存开销通常比 Windows 上更可控。因为 Windows 图形界面和相关服务本身就占了不少资源而 Ubuntu Server 最小化安装完系统本身占用可能只有几百 MB余下的资源都给了你的 Java 应用。我试过同一个应用在 Windows 上跑出 800MB 的 RSS在 Ubuntu 上同样配置只要 600MB 出头差距明显。如果你是初学者我建议直接拿一台 Ubuntu 22.04 LTS 或 24.04 LTS 的机器练手。不用装桌面环境不需要图形界面SSH 连上去就能干所有事。1.2 JDK 版本与构建工具的选择思路选 JDK 版本是部署前必须定的事不然后面一堆兼容性问题。先说结论Spring Boot 3.x 要求 JDK 17 起步Spring Boot 2.7 及以下可以用 JDK 8 或 JDK 11。如果你现在还在用 JDK 8那大概率只能停留在 Spring Boot 2.x 系列。这本身没什么问题很多企业还在跑 2.x稳定压倒一切。但如果你是新项目我建议直接 JDK 17 Spring Boot 3.x因为 2.x 的开源维护已经逐步收窄而 17 是 LTS 版本生态支持最好。这个选择直接决定了你在 Ubuntu 上装哪个 JDK。我个人的偏好是使用 Eclipse TemurinAdoptium 项目的 JDK因为它是 TCK 认证的标准 JDK 构建兼容性成熟更新节奏稳定。当然你也可以用 OpenJDK 官方构建、Amazon Corretto 或者 Zulu本质上差别不大选一个长期维护的即可。再补充一个实际经验Ubuntu 的 apt 仓库里自带的default-jdk在系统版本更新后会跟着升级比如从 17 升到 21这在生产环境里是一个隐患——你明明部署的是 JDK 17 编译的包系统却给你换了个运行时有些老项目就会出问题。所以生产环境我建议手动安装特定版本的 JDK不用 apt 自带版本这样版本始终可控。构建工具方面Maven 和 Gradle 二选一。Maven 更普及Spring Initializr 默认生成的就是 Maven 结构新手友好。Gradle 的构建脚本更灵活增量构建也更快适合多模块大项目。不管用哪个最终部署时拿到的产物都是可执行的 fat JAR区别只在于打包命令。2. Spring Boot 应用构建与产物准备2.1 Maven 打包与多环境 Profile 配置一个常见错误是把项目打包成 war 丢到外部 Tomcat 里跑。我见过不少团队这么干理由是“这样可以改 webapps 下的类文件直接替换”听着方便实则后患无穷。Spring Boot 官方推荐的方式是打成可执行 JAR内嵌 Tomcat用java -jar直接跑。这样依赖不冲突版本统一启停就是一个进程的事无论本地还是线上行为完全一致。Maven 项目打包命令很简单mvn clean package -DskipTests如果你是 Gradle 项目./gradlew clean bootJar -x test打包出来的 JAR 在target/或build/libs/下名字一般带版本号。这个 JAR 就是个自包含的运行时java -jar就能启动。但这里有个关键细节打包时用的配置文件和运行时的配置文件要分离。开发环境的数据库地址、Redis 密码跟生产环境完全不同你不能把生产密码写死在application.properties里一起打进 JAR不然开发同事人人都能看到而且换环境就得重新打包太蠢了。正确做法是利用 Spring Boot 的多 Profile 机制在代码仓库里维护application-dev.yml和application-prod.yml然后通过启动参数--spring.profiles.activeprod指定运行哪个环境。打包产物里两个 Profile 都存在但运行时的输入决定用哪一份。还有一种更严格的做法把生产环境的配置完全外部化放在服务器上的/etc/yourapp/目录下启动时通过--spring.config.additional-location/etc/yourapp/覆盖 JAR 内部配置。这样生产配置根本不进代码仓库敏感信息只在服务器上存在安全性又上一个台阶。2.2 配置文件外部化与敏感信息处理我看到太多团队把数据库密码、短信密钥写死在 properties 文件里然后整个仓库推送到 GitLab/GitHub。如果是私有仓库还凑合一旦仓库权限设置不当密码直接泄露。更稳妥的做法是配置外部化加环境变量注入。Spring Boot 的配置优先级从高到低大概是命令行参数 Java 系统属性 环境变量 application.yml 外部文件 application.yml 内部文件。利用这个机制敏感信息可以通过环境变量传进去。比如在 application-prod.yml 里写spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicodetruecharacterEncodingutf8 username: ${DB_USER} password: ${DB_PASSWORD}然后在 systemd 服务文件里通过Environment指定这些变量EnvironmentDB_HOST10.0.0.10 EnvironmentDB_PORT3306 EnvironmentDB_NAMEorder_system EnvironmentDB_USERapp_user EnvironmentDB_PASSWORDyour-strong-password这样代码仓库里只有变量占位符没有真实值。服务器上的/etc/systemd/system/文件默认 root 才能读安全性有保障。我把这个习惯带进了所有项目后来几次团队人员变动也没出现过密码外泄的事故。顺便提一个踩坑点Spring Boot 在解析${DB_HOST}这种占位符时如果环境变量没设置启动会直接报错提示 Could not resolve placeholder。第一次遇到的人容易懵其实就是在告诉你环境变量没传进去去检查 systemd unit 文件或 shell 环境就行。我建议所有外部配置项都用这种占位符方式运行时报错比运行时静默用错值强一万倍。3. Ubuntu 部署环境的初始化配置3.1 创建专用运行用户与目录规划很多人把应用直接丢在 root 用户下跑图省事。这在生产环境是有风险的万一应用有漏洞被利用攻击者直接拿到 root 权限整个服务器就沦陷了。正确姿势是创建一个权限受限的专用账户只让它能读写自己目录下的文件。我的标准操作是这样的sudo useradd -r -s /usr/sbin/nologin appuser-r表示创建系统账户-s /usr/sbin/nologin表示禁止用来登录 shell。这样即使被攻击也无法通过这个账户拿到 shell 交互环境,有效降低破坏面。目录规划我强推一套固定结构/opt/ └── yourapp/ ├── app.jar # 应用 JAR 包 ├── config/ # 外部配置文件目录 ├── logs/ # 日志输出目录 └── bin/ # 可选的启停脚本为什么放在/opt这是 Linux 文件系统层级标准里约定俗称的第三方软件安装目录团队协作时一看/opt下的目录名就能猜出有哪些应用省去各种“应用装哪儿了”的沟通成本。JAR 包直接叫app.jar而不是带版本号的名字是因为部署脚本里不用记住具体版本号下次更新直接覆盖同名文件即可配合软链接做版本回退也更方便。当然如果要保留历史版本可以做成app-1.0.0.jar加一个app.jar - app-1.0.0.jar的软链接部署脚本只认app.jar需要回退时改一下软链接就行。创建完目录后要设置所有权sudo chown -R appuser:appuser /opt/yourapp sudo chmod -R 750 /opt/yourapp这样只有appuser和 root 有权限读写其他用户连进来也看不到应用配置里的敏感信息。3.2 手动安装 JDK 与环境变量配置细节我前面反复强调不要用 apt 自带的 JDK这里给出手动安装的具体步骤。先去 Adoptium 官网下载 JDK 17 的 Linux x64 压缩包.tar.gz然后执行sudo mkdir -p /opt/jdk sudo tar -zxvf jdk-17.0.10_linux-x64_bin.tar.gz -C /opt/jdk/ sudo mv /opt/jdk/jdk-17.0.10 /opt/jdk/jdk17接着配置系统级环境变量。之前说过ubuntu环境变量配置错误这个问题很常见很多同学把环境变量写在/etc/profile里改坏了导致所有用户登录出问题。实际上给所有用户配置 JDK 路径更安全的做法是在/etc/profile.d/下新建一个独立文件sudo tee /etc/profile.d/jdk17.sh EOF export JAVA_HOME/opt/jdk/jdk17 export PATH$JAVA_HOME/bin:$PATH EOF这样做的妙处在于即使这个文件写错了也只需要删除这个文件就能恢复不会影响系统的其他环境变量。然后source /etc/profile.d/jdk17.sh验证一下java -version如果看到类似openjdk version 17.0.10说明 JDK 装好了。还有一个细节容易踩坑如果服务器上原本装过 OpenJDK 8 或 11java命令可能指向旧版本的路径。执行which java看看路径如果指向/usr/bin/java说明系统里还有旧版本在抢占用sudo update-alternatives --config java可以切换默认 Java 版本。我在一开始部署新环境时经常被这个坑到测试了半天最后发现命令调用的根本不是想用的 JDK。4. Systemd 服务化部署与开机自启4.1 编写 Systemd 服务单元文件部署 Spring Boot 应用最优雅、最省心的方式就是交给 systemd 管理。systemd 是 Ubuntu 默认的 init 系统所有服务的启停、开机自启、崩溃重启都能通过编写一个.service文件搞定不用自己写一堆 shell 脚本。在/etc/systemd/system/下创建一个服务文件比如yourapp.service[Unit] DescriptionYour Spring Boot Application Afternetwork.target mysql.service redis.service [Service] Typesimple Userappuser Groupappuser WorkingDirectory/opt/yourapp EnvironmentFile/etc/yourapp/env.conf ExecStart/opt/jdk/jdk17/bin/java \ -Xms512m -Xmx1024m \ -jar /opt/yourapp/app.jar \ --spring.profiles.activeprod \ --server.port8080 ExecStop/bin/kill -s SIGTERM $MAINPID SuccessExitStatus143 Restartalways RestartSec10 StandardOutputappend:/opt/yourapp/logs/stdout.log StandardErrorappend:/opt/yourapp/logs/stderr.log [Install] WantedBymulti-user.target拆开来说几个关键点。After表示这个服务在网络和数据库之后启动。如果你把 MySQL、Redis 也装在同一台机器上并写成了服务这样 Spring Boot 启动时数据库通常已经就绪能避免很多启动失败。Typesimple表示ExecStart启动的进程就是主进程systemd 不会 fork直接监控它。这是 Spring Boot 应用的标准配置。User和Group指定进程运行身份对应就是刚才创建的appuser这保证了应用以最小权限运行。EnvironmentFile指向一个专门存放环境变量的文件。这个文件里可以写密钥、URL 等信息比直接写在 service 文件里更灵活修改环境变量不需要重启 systemd daemon。ExecStop使用 SIGTERM 信号优雅停机。Spring Boot 默认处理 SIGTERM 时会触发PreDestroy钩子完成线程池收尾、连接池释放、未完成请求处理后再退出。这非常重要直接 kill -9 会丢请求、断数据库连接甚至损坏本地缓存文件。Restartalways配RestartSec10表示进程异常退出或被杀后10 秒内自动重启。这是生产环境稳定性的底线保障应用挂了你不在电脑前它也能自己爬起来。操作命令sudo systemctl daemon-reload sudo systemctl enable yourapp sudo systemctl start yourappenable负责开机自启start立即启动。之后日常维护只需要systemctl restart yourapp或systemctl status yourapp非常顺手。4.2 JVM 参数调优与 GC 选型经验JVM 参数是部署里最容易被人忽略的部分很多人直接java -jar app.jar跑默认堆大小是物理内存的四分之一。比如服务器有 8G 内存JVM 默认最大堆就是 2G但这未必是你想要的——也许你希望堆更大充分利用内存也许希望堆小一点给其他进程留余地。我一般遵循一个原则让 JVM 的堆大小跟容器内存匹配而不是让 JVM 自己猜。比如服务器 4G 内存系统占 500MBNginx 和监控占 300MB那 JVM 堆可以给 2G留 1G 余量给元空间、线程栈、GC 开销。直接用-Xms2g -Xmx2g锁死堆大小避免频繁动态扩容。-Xms和-Xmx设置相等的好处是启动时就把堆分配到位运行期间不会因为堆扩容发生 stop-the-world 停顿。缺点是启动稍慢一点内存占用从进程启动就比较高。如果你在意内存弹性比如同一台机器上跑多个应用可以设-Xms512m -Xmx2g让堆慢慢长大。GC 选型方面Stack Overflow 和各类博客上都有人争论。我的经验直接给出建议值JDK 8 且堆小于 4G用默认的 Parallel GC 就行吞吐量优先简单可靠。JDK 11如果响应时间要求高考虑 G1它是默认选择。JDK 17 上如果你追求低延迟且服务器核心数足够可以试 ZGC但它的 CPU 开销稍大得实测对比。我自己的项目使用的就是-XX:UseG1GC -XX:MaxGCPauseMillis200。G1 目标是控制 GC 停顿在 200ms 以内对大多数业务接口来说体感无影响。还有一个新手一定会踩的坑拿到一台内存很小的云主机比如 1G跑 Spring Boot 默认配置直接 OOM。如果你必须在这种环境跑可以把堆压到-Xmx256m同时加-XX:ExitOnOutOfMemoryError至少在 OOM 时进程能及时退出让 systemd 重启而不是卡在半死不活的状态。虽然谈不上性能好但至少能跑起来然后该升配就升配。5. Nginx 反向代理与日志治理5.1 Nginx 配置要点与动静分离Spring Boot 内嵌的 Tomcat 是应用容器不是 Web 服务器。生产环境我前面必定加一层 Nginx理由有三个一是端口优雅应用走内网端口比如 8080Nginx 占 80/443 对外二是静态资源处理效率高Nginx 对静态文件的响应能力远超 Tomcat三是集中处理 HTTPS 证书、请求限制、日志不用应用代码去操心这些事。安装 Nginxsudo apt update sudo apt install nginx -y配置/etc/nginx/sites-available/yourappserver { 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; } location /static/ { alias /var/www/yourapp/static/; expires 7d; access_log off; } }这里proxy_pass http://127.0.0.1:8080把请求转发给本地 Spring Boot。注意proxy_set_header这几个头不能少尤其X-Forwarded-For和X-Forwarded-Proto——它们是应用拿到客户端真实 IP 和协议的依据。如果少了应用里用request.getRemoteAddr()拿到的就是 Nginx 的地址而不是用户真实 IP在审计和风控上会出大问题。如果你用 Spring Boot 内置逻辑做路径判断可能还需要在应用配置里打开server.forward-headers-strategyframework这样 Spring Boot 才会信任 Nginx 传递的X-Forwarded-*请求头。启用配置并重载sudo ln -s /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginxnginx -t是必须的先测试配置语法再重载。我见过有人改完配置直接systemctl restart nginx结果配置写错导致服务起不来线上直接 502。用reload的好处是无损重载旧连接不中断新连接走新配置。5.2 应用日志轮转与监控告警日志是排查问题的第一手资料。Systemd 的StandardOutputappend:能把应用输出写到固定文件但这还不够——日志文件会无限增长几个月下来可能几十个 GB把磁盘塞满然后又引发一串奇怪的问题。标准解法是引入 logrotate。Ubuntu 自带 logrotate你只需要写一个配置文件让它按天或按大小轮转日志。sudo tee /etc/logrotate.d/yourapp EOF /opt/yourapp/logs/*.log { daily rotate 14 compress delaycompress missingok notifempty copytruncate } EOF解释几个参数rotate 14保留 14 份日志也就是两周的历史。compress轮转后的旧日志用 gzip 压缩省空间。delaycompress延迟一天压缩因为应用还可能在写当前日志文件需要留一个缓冲。copytruncate复制内容并把原文件清空这样应用不需要感知文件被替换症状就是日志不中断。Spring Boot 的框架日志包括 Spring、Tomcat、业务逻辑通过 Logback/Log4j2 输出的默认打到 stdout。你也可以用logging.file.name/opt/yourapp/logs/app.log指定独立文件再针对它做 logrotate。两条路都行关键是要有轮转不能裸奔。监控告警这块我不展开说太深但至少做一个进程存活检测。最简单的手段是 systemd 的Restartalways已经覆盖了进程崩溃自动拉起。如果你想更主动一点用systemctl is-active yourapp写个 crontab 定时检查或者直接部署一个 Uptime Kuma / AlertManager对 8080 端口做 HTTP 探活。这个可以后面再扩展。6. 上线后的问题排查实战6.1 启动失败与端口冲突的根本原因分析部署完你会发现事情并不会一帆风顺。常见的启动失败原因和排查思路我列一下顺便讲清楚底层逻辑。端口被占用Spring Boot 默认 8080如果服务器上已经有别的进程占了启动会报Port already in use。先查占用情况sudo lsof -i :8080或者sudo ss -tlnp | grep 8080查到占用进程后要么把它停掉要么改 Spring Boot 端口。这里有个经验如果启动日志里报错用的是Web server failed to start. Port 8080 was already in use.说明只是端口问题如果日志里出现一堆类找不到的异常那才是 JAR 本身的问题。先分清楚类别再动手避免瞎折腾。数据库连不上Spring Boot 启动时 DataSource 初始化失败常见报错是Cannot create PoolableConnectionFactory。这时先检查数据库服务是否在运行再检查防火墙。Ubuntu 的 ufw 防火墙默认可能是关闭的但如果你开过记得放行数据库端口或者直接限制数据库只监听内网。排查命令sudo systemctl status mysql sudo ufw status telnet 127.0.0.1 3306JAR 包损坏或不完整部署时覆盖了旧包或者 FTP/SFTP 传输过程中断导致 JAR 不完整。启动时报Error: Invalid or corrupt jarfile。验证方法很简单jar tf /opt/yourapp/app.jar | head能列出内容说明 JAR 结构正常列不出来说明文件坏了重新上传即可。在这个环节我最推荐的排查路径是先看日志。Spring Boot 的日志输出非常完善启动失败时基本都会明说原因。journalctl -u yourapp -n 100或直接看 stdout.log 尾部大部分问题的答案就在最后几十行里。6.2 内存溢出与 CPU 飙高的诊断方法线上运行一段时间后内存问题开始显现。常见的有两类。堆内存溢出OutOfMemoryError: Java heap space多为并发高峰或内存泄漏。诊断步骤是拿到堆转储文件分析。在 systemd 服务里加 JVM 参数-XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/opt/yourapp/logs/这样 OOM 时会自动生成.hprof文件。然后你可以在本地用 Eclipse MAT 或 JProfiler 打开看一下大对象和 Dominator Tree定位是哪段业务代码在堆积对象。如果实在不会用工具就直接查哪些接口内存增长明显或用jmap -dump手动快照。非堆内存问题这个更隐蔽通常是 Metaspace 溢出或 JVM 线程数暴涨导致进程整体内存飙升。看到的是java.lang.OutOfMemoryError: Metaspace或直接 OOM Killer 把进程杀了。前者可能是反射/代理用太多元空间不够加-XX:MaxMetaspaceSize512m控制上限后者要查线程数用top -Hp pid看看哪个线程吃 CPU 最狠再jstack pid threaddump.txt分析线程栈。CPU 飙高的问题经典场景是死循环或者 GC 频繁。先top看 CPU 占用率拿到进程 PID再用top -Hp拿到线程 ID把线程 ID 转成十六进制printf %x\n tid然后jstack pid | grep -A 20 十六进制线程号你就能看到对应线程的栈了。检查是否有死循环、Lock 等待或者 GC 线程在疯狂回收。这类问题不复杂熟练之后 10 分钟能定位到具体代码。服务假死进程还在但请求没响应。先看 CPU 和内存再看 GC 日志如果频繁 Full GC 且回收不掉基本就是内存泄漏赶紧做堆转储。如果 CPU 不高但请求卡住可能卡在数据库连接池——连池满了所有线程在等待连接jstack能看到大量WAITING状态的线程等着Connection。我给团队建的排查顺序是systemctl status确认进程状态 →journalctl -u yourapp -n 200看异常日志 →top -Hp看线程 CPU →jstack取线程栈 → 必要时jmap拿堆快照。这条链路走一遍90% 的问题都能定位。6.3 版本升级与回滚的平滑策略最后一个实际场景分享我自己的一个项目上线第一周因为业务需求变了改了三次版本。第一次直接systemctl stop yourapp然后覆盖 JAR 再start服务中断了快一分钟虽然是小团队内部用无所谓但那次之后我就开始想平滑升级的方案。参考之前说的软链接方案每次部署可以这样# 假设新版本是 app-1.2.0.jar cp app-1.2.0.jar /opt/yourapp/ ln -sfn app-1.2.0.jar app.jar systemctl restart yourapp旧版本 JAR 还在/opt/yourapp/下没有删如果新版本有问题一条命令回滚ln -sfn app-1.0.0.jar app.jar systemctl restart yourapp这样回滚只花了软链接更新和进程重启的时间基本是秒级。而且因为你用 systemd 管理重启之后服务自动拉起、自动注册完全不用手动干预。部署脚本我通常会做成一个简单的 bash 脚本包含上传校验、软链接切换、重启、健康检查四个步骤。health check 可以用 curl 打一个/actuator/health接口如果引入了 Actuator返回 UP 才算成功。这一点强烈建议在 Spring Boot 项目里加 Actuator 依赖不需要额外开发就把健康检查、线程快照、堆转储的 HTTP 接口白送给你调试起来省很多事。最后分享一个我这些年来最深刻的心得部署的本质不是把代码放到服务器上而是让应用在无人值守时也能持续、稳定、可预期地提供服务。所以 process 托管、配置外置、日志可观测、优雅启停这四个环节每一个都比花里胡哨的业务代码更值得你花时间打磨。对 Spring Boot 应用来说systemd 外部配置 容器化部署这套组合拳是我目前在 Ubuntu 生产环境里最信任的方案。如果你项目里还没把部署做到这个程度可以照着这篇文章整理一遍很快你就会发现运维突发事件明显变少了。
返回列表