首页 / 资讯中心 / 文章详情

Spring项目打包部署全攻略:从Maven构建到Linux systemd实战

Spring项目打包部署全攻略:从Maven构建到Linux systemd实战 ★ FEATURED ARTICLE
Spring 项目打包部署这事说难不难说简单也真不简单。我见过不少开发在本地跑得飞起一提交部署就被环境拷打要么 jar 包启动没反应、要么端口被占、要么配置读不到、要么时区乱掉折腾到深夜才怀疑人生。这篇文章就把 Spring 项目从打包到部署的完整链路捋清楚结合我实际踩过的坑、调过的参数、看过的日志把每一步该做什么、为什么这么做、踩坑了怎么排查一次性讲透。内容覆盖 Maven 打包配置、JAR/WAR 选择、Linux 环境部署、systemd 服务脚本、配置外部化、日志处理、常见故障排查适合刚接触部署的开发者也适合团队里负责发版的同学做参考清单。1. 打包部署前的整体设计与思路拆解很多人拿到 Spring 项目就习惯性mvn package然后传到服务器java -jar启动成功就觉得完事了。实际上部署是一个需要提前规划的系统工程从打包方式选型到服务器环境准备每个环节都影响线上稳定性。1.1 JAR 包还是 WAR 包先想清楚运行形态Spring Boot 项目默认打出来的包是 JAR内置了 Tomcat 等 Web 容器直接java -jar就能启动不需要额外装容器这是我个人强烈推荐的方式。它的好处非常明显环境一致性高服务器上只需要有 JDK不需要单独维护 Tomcat 版本启动简单一条命令的事情没有容器管理成本资源隔离好每个应用独立进程互不影响但如果你所在公司有统一的 Tomcat 管理规范或者需要和别的非 Spring Boot 应用共享同一个容器那可以考虑打 WAR 包放到外部 Tomcat 的 webapps 目录下。这种情况需要注意pom.xml 中packagingwar/packaging启动类继承SpringBootServletInitializer外部 Tomcat 版本要和项目依赖兼容比如 Spring Boot 2.x 用 Tomcat 9Spring Boot 3.x 用 Tomcat 10我遇到过一个团队为了省资源把多个应用塞到一个 Tomcat结果一个应用内存泄漏带崩了整个容器。所以如果条件允许还是各打各的 JAR各占各的端口省心又安全。1.2 构建工具的选择Maven 还是 Gradle我自己的主力工具是 Maven它生态成熟、插件丰富、资料多适合大多数 Spring 项目也是本文讲解的重点。Gradle 的优势在于构建速度快、脚本更灵活适合大型多模块项目或者有定制构建逻辑的场景。判断标准很简单项目里已经有什么就用什么不要为了追求新东西在部署前临时换构建工具没有任何收益还增加风险。不管用哪种工具构建结果都是产物JAR/WAR构建过程都会涉及依赖下载、编译、测试、打包这几个阶段。理解这一点后面所有操作思路就统一了。1.3 环境准备不只是装个 JDK 那么简单服务器上的 JDK 版本必须和本地开发版本保持一致。如果本地用 JDK 8 开发服务器装了 JDK 17Spring Boot 2.x 一般还能跑但如果用了 JDK 8 的javax包、JSP 或者一些老库极可能启动报ClassNotFoundException或者NoSuchMethodError。Spring Boot 3.x 要求 JDK 17 起JDK 8 根本跑不起来这点要特别注意。除了 JDK服务器上还要确认几件事系统时间和时区中国的服务器建议统一设置为Asia/Shanghai不然定时任务、日志时间戳都会错乱字符集需要支持 UTF-8很多应用中文乱码的根源就是服务器默认字符集不是 UTF-8磁盘空间至少预留 jar 包本身 5 倍以上的空间日志增长很快内存看应用启动参数和 JVM 配置别让物理机内存不够导致 OOM我在一台 2G 内存的云服务器上部署过一个内存占用 1.8G 的应用再加上系统服务和 SSH直接卡到无法操作最后只能加内存。部署前先看资源能省很多麻烦。2. Maven 打包核心配置与容易踩的坑打包是部署的第一道关口打包配置出错、打包质量不达标后面部署再做得好也是白搭。这一节把 Maven 打包相关的关键配置和容易出问题的地方讲清楚。2.1 spring-boot-maven-plugin 的关键作用如果你用过普通 Java 项目打出来的 JAR 通常是个不完整的包直接java -jar会报no main manifest attribute, in app.jarSpring Boot 项目之所以不会出这个问题是因为 pom.xml 里配置了spring-boot-maven-plugin。这个插件在package阶段做了两件事重新打包把工程编译产物和所有依赖的第三方库全部塞进一个胖 JAR生成启动信息在 MANIFEST 里写入Main-Class和Start-Class让java -jar能知道从哪个类启动pom.xml 里最简配置长这样build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build注意这个插件要放在plugins区域别放错位置。我还见过有人把多个 Spring Boot 模块放在同一个父工程下结果某个子模块没有引入这个插件打出来的包怎么java -jar都启动不了最后检查发现是 MANIFEST 缺少启动类信息。所以在父 pom 统一管理插件版本的基础上每个可执行模块都要有明确的插件声明。2.2 多环境打包测试和生产用一个包还是分两个包最常见的做法是把环境相关配置放到application-{profile}.yml里比如application-dev.yml开发环境application-test.yml测试环境application-prod.yml生产环境主配置application.yml里只放公共配置然后用spring.profiles.active指定当前环境。打包的时候有两种策略策略一一套包到处跑。JAR 包里包含所有 profile 的配置文件部署时通过启动参数--spring.profiles.activeprod指定或者通过环境变量SPRING_PROFILES_ACTIVEprod指定。优点是只打一次包发版灵活缺点是包里带着测试环境配置如果忘记指定 profile可能启动到默认开发环境存在一定安全隐患。策略二按环境打包。比如mvn clean install -DskipTests -Pprod配合 pom.xml 或 application 配置文件里的 profile 逻辑让不同环境打包时只带入对应配置。这样发版时包内容更干净但每换一个环境就要重新构建一次。我个人偏向策略一加外部配置文件下面一节细讲因为部署的核心是包不变配置可变减少因为环境重新打包带来的产物不一致风险。多环境管理的原则只有一个生产环境的数据库地址、密码、密钥这类敏感信息绝对不能写死在 JAR 包里必须放到服务器上的外部配置或环境变量里。2.3 打包命令的常用形态与参数说明最常用的打包命令是mvn clean package -DskipTests这里说一下几个参数含义clean清理 target 目录避免旧产物残留package执行到 package 阶段生成最终产物-DskipTests跳过测试执行但会编译测试代码-Dmaven.test.skiptrue跳过测试编译和执行打包更快但测试代码的编译错误不会被发现如果用了自定义环境 profile还需要加-P{profile}比如mvn clean package -DskipTests -Pprod如果机器上本地仓库缺依赖可以用-U强制拉取最新快照版依赖mvn clean package -DskipTests -U打包完成后在 target 目录下找到类似xxx-0.0.1-SNAPSHOT.jar的文件这就是你要部署的产物。Spring Boot 还会生成一个.jar.original那是 repackage 之前的原始 jar不要用它部署。提示发布前用java -jar xxx.jar --debug在本地跑一次确认能正常启动再传服务器。很多人跳过这步结果在服务器上发现启动类扫描不到 Controller排查半天才发现是本地依赖版本不对。3. Linux 环境部署实操从启动到自愈打包完成后真正见真章的是部署环节。这一节我把一套经过多次验证的 Linux 部署流程完整分享出来包括目录规划、配置外部化、启动脚本、systemd 管理以及为什么每一步要这么设计。3.1 部署目录结构规划别一股脑全放根目录很多新手喜欢把 jar 包直接丢在/root或者/home下再把日志重定向到nohup.out这样的后果是文件越堆越多分不清哪个是哪个。我建议每个应用建一套统一目录例如/opt/app/myapp ├── bin # 启动脚本 ├── conf # 外部配置文件 ├── lib # jar 包 ├── logs # 运行日志 └── back # 历史版本备份路径规划的好处是发布升级时只替换 lib 下的 jar配置和日志不受影响排查问题能快速定位文件位置后续写 systemd 服务、日志切割、备份脚本都方便配套我习惯用一个启动用户比如appuser。不要用 root 直接运行应用万一应用有安全漏洞root 权限被利用后果很严重。用普通用户启动配合 systemd 管理权限清晰也方便排查。3.2 配置文件外部化让 JAR 包与环境解耦Spring Boot 对配置文件有自己的一套加载顺序其中非常重要的一条是JAR 包所在目录的/config子目录下以及 JAR 包同级目录下的application.yml和application-{profile}.yml优先级高于 JAR 包内部的配置文件。利用这个特性我们把生产环境的配置放到外部目录例如/opt/app/myapp/conf/application.yml /opt/app/myapp/conf/application-prod.yml这样每次升级只需要替换 jar 包配置文件不动环境相关的东西全部沉淀在服务器上。这避免了每次发版还要重新修改 JAR 包内的配置也防止了数据库密码之类的信息被打进包里流传出去。启动命令里显式指定配置目录更稳妥java -jar /opt/app/myapp/lib/myapp.jar --spring.config.additional-location/opt/app/myapp/conf/--spring.config.additional-location会在默认加载顺序基础上额外加载指定目录的配置。还有一个细节Spring Boot 2.4 之后配置加载规则有调整但核心的外部配置覆盖内嵌配置这件事没有变。如果你在服务器上改了配置却没生效第一反应就查一下启动命令里的additional-location参数是不是写对了以及配置文件是不是真的在这个目录里。3.3 JVM 参数启动前一定要调的几项写启动脚本之前先把 JVM 参数想清楚。默认的 JVM 堆内存是物理内存的 1/4这在大多数生产环境都不合适。我习惯的一种配置是java -Xms512m -Xmx1024m -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -jar app.jar参数含义-Xms初始堆大小-Xmx最大堆大小-XX:MetaspaceSize元空间初始大小Spring 项目类很多太小会导致频繁 Full GC-XX:MaxMetaspaceSize元空间最大大小设个上限防止类加载过多撑爆内存-Xms和-Xmx最好设成一样避免 JVM 动态扩容带来的性能抖动。如果服务器是 4G 内存可以-Xms2048m -Xmx2048m留一半给系统和其他进程。另外如果服务器 CPU 核数多可以加-XX:UseG1GCJDK 8 后期版本默认就是 G1JDK 11 不用显式指定。一个教训曾经有个项目只设置了-Xmx没设-Xms请求一上来堆内存不断扩容然后在扩容过程中触发频繁 GC接口响应时间从 80ms 涨到 3s。调整成初始堆和最大堆一致后响应时间立刻稳定下来。JVM 参数不是想起来才加而是部署前就要想清楚。3.4 启动脚本与 systemd 服务管理我强烈建议用 systemd 管理 Spring Boot 进程而不是用nohuptail这一套。systemd 的好处是开机自启、进程守护、崩溃自动拉起、日志统一管理这些在生产环境太重要了。先看传统启动脚本start.sh的写法#!/bin/bash APP_NAMEmyapp APP_HOME/opt/app/myapp JAR_FILE$APP_HOME/lib/$APP_NAME.jar LOG_FILE$APP_HOME/logs/run.log JAVA_OPTS-Xms512m -Xmx1024m -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseG1GC PID$(ps -ef | grep $JAR_FILE | grep -v grep | awk {print $2}) if [ -n $PID ]; then echo Application already running, PID: $PID exit 1 fi nohup java $JAVA_OPTS \ -jar $JAR_FILE \ --spring.config.additional-location$APP_HOME/conf/ \ --spring.profiles.activeprod \ /dev/null 21 echo Application started, PID: $!这份脚本做了几件事检查进程是否已在运行、设定 JVM 参数、指定外部配置、后台启动。日志没有输出到 nohup.out 是因为 Spring Boot 自身会写日志后面讲日志配置。再看 systemd 服务文件路径在/etc/systemd/system/myapp.service[Unit] DescriptionMy Spring Boot Application Afternetwork.target [Service] Userappuser WorkingDirectory/opt/app/myapp ExecStart/usr/bin/java -Xms512m -Xmx1024m -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -jar /opt/app/myapp/lib/myapp.jar --spring.config.additional-location/opt/app/myapp/conf/ SuccessExitStatus143 Restartalways RestartSec10 [Install] WantedBymulti-user.target说明几个关键配置Userappuser指定运行用户避免 root 运行Restartalways进程异常退出时自动拉起这是 systemd 相比 nohup 最大的优势RestartSec10重启前的等待秒数SuccessExitStatus143143 是应用收到 SIGTERM 的正常退出码如果不加这一行systemd 可能认为正常停止是异常写好后执行systemctl daemon-reload systemctl enable myapp.service systemctl start myapp.service systemctl status myapp.service常用运维命令systemctl restart myapp # 重启 systemctl stop myapp # 停止 journalctl -u myapp -f # 实时查看 systemd 管理的应用日志 tail -f /opt/app/myapp/logs/run.log # 按业务日志输出有一说一用 systemd 之后我再也没碰过裸 nohup 部署方式。守护进程这件事人工盯是盯不过来的交给 systemd 省心太多。3.5 日志配置别让日志吃掉磁盘Spring Boot 默认只输出到控制台如果直接用nohup重定向日志时间久了文件会非常大。我建议在application.yml里配置 logback 输出logging: file: name: /opt/app/myapp/logs/myapp.log logback: rollingpolicy: max-file-size: 100MB max-history: 15 total-size-cap: 2GB这样日志按大小滚动保留最近 15 个文件总容量不超过 2GB基本不会出现磁盘被日志塞满的情况。如果你在代码里自定义了 logback-spring.xml就以该文件配置为准这里的logging配置会作为兜底。日志格式建议加上时间、线程、日志级别、logger 名称和消息方便排查%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n排查线上问题时没有日志等于蒙着眼睛干活。所以部署前一定先确认日志路径存在且有写权限别等出了问题才发现日志没输出。4. 部署后的验证、升级与回滚应用能启动只是第一步部署完成后还要验证服务是否真正可用、思考下一次升级怎么平滑操作、以及万一变更失败怎么回滚。这些事看着不紧迫但遇到一次故障就值回票价。4.1 启动后的基本健康检查启动命令敲下去不要急着欢呼。我每次都会按这个顺序检查第一步查进程systemctl status myapp.service ps -ef | grep myapp第二步查端口是不是在监听netstat -tlnp | grep 8080 lsof -i:8080第三步查日志有没有报错tail -n 100 /opt/app/myapp/logs/myapp.log第四步请求一下接口看实际响应curl -X GET http://127.0.0.1:8080/api/health这几个步骤下来部署成没成基本心里有数。如果你在项目里用了 Actuator还可以通过/actuator/health查看应用的详细健康状态。我一般会让运维在负载均衡层配置健康检查直接请求这个端点比 TCP 端口检查准确得多。4.2 升级发布替换 JAR 包的规范流程升级最忌讳的是直接替换 JAR 然后重启一旦新版本有问题想回退都找不回旧包。我用的规范流程如下# 第一步备份当前版本 cp /opt/app/myapp/lib/myapp.jar /opt/app/myapp/back/myapp-$(date %Y%m%d%H%M%S).jar # 第二步上传新版本 mv /tmp/myapp.jar /opt/app/myapp/lib/myapp.jar # 第三步重启服务 systemctl restart myapp.service # 第四步健康检查 sleep 10 systemctl status myapp.service curl -X GET http://127.0.0.1:8080/api/health如果新版本启动失败回滚命令就是systemctl stop myapp.service cp /opt/app/myapp/back/myapp-20240101120000.jar /opt/app/myapp/lib/myapp.jar systemctl start myapp.service这里有个容易被忽略的细节cp xxx.jar myapp.jar之后最好确认一下文件权限和属主没有变化。如果之前是appuser用户上传启动突然换成了 root 上传可能导致应用读取配置时权限不足。统一用同一个用户操作或者在部署脚本里加一句chown appuser:appuser myapp.jar。4.3 多实例与负载均衡下的部署注意点当应用部署在多台机器或同一台机器部署多实例时请特别注意端口和内存的隔离。多个实例意味着多个端口比如8080、8081各跑一个JVM 参数里的内存也要按实例数量重新分配不能让两台实例都申请 2G 堆而物理机只有 2G 内存。多实例部署还有一个优雅性问题不要直接 kill 掉旧进程再启动新进程那样会有短暂的请求空窗。正确做法是让负载均衡先把这台机器的流量摘掉应用处理完存量请求后再退出新实例启动完成并通过健康检查后再把流量拉进来。这一步虽然是运维配合做的事但开发心里得有这根弦发布是协同操作不是一发命令就完事。5. 常见问题排查实录把部署路上的坑先踩平这一节整理我在部署 Spring 项目时遇到过的高频问题按现象、原因、排查步骤、解决方案的顺序给出来方便直接查阅。5.1 端口启动报错Web server failed to start这是最经典的问题日志里通常有APPLICATION FAILED TO START Description: Web server failed to start. Port 8080 was already in use.排查netstat -tlnp | grep 8080 lsof -i:8080如果是自己项目的其他进程占用要么停掉旧进程要么改新项目的端口。如果是未知进程占用ps -ef | grep 对应 PID看是哪个应用确认后再处理。这里有个自制教训有一次线上端口被一个测试环境没关干净的 Java 进程占了排查花了一个小时。后来我给所有应用都统一加 JVM 参数-DAPP_NAMEmyapp进程列表里一眼就能认出是哪个应用排查效率提升很多。5.2 JAR 包启动报“没有主清单属性”日志里出现no main manifest attribute, in /opt/app/myapp/lib/myapp.jar基本可以断定这个包不是 Spring Boot 的 fat jar或者spring-boot-maven-plugin没有生效。解决方案是回到 pom.xml确认 build 区域引入了插件然后重新执行mvn clean package -DskipTests还有一种情况项目是多模块结构你打的只是某个公共模块的包这个模块本身没有启动类当然不会有主清单。确认你打的包对应的模块里确实有标注SpringBootApplication的启动类。5.3 本地能启动服务器启动不了这个问题的可能性很多按概率从高到低排查JDK 版本不一致查看java -version服务器磁盘空间不足执行df -h服务器内存不足执行free -m权限问题appuser对 conf、logs 目录没有写权限字符集问题日志里中文全部乱码或者出现sun.io.ByteOrderMark相关异常常见的一种是 JDK 版本问题本地用的 JDK 17 Spring Boot 3.x服务器还是 JDK 8启动直接报UnsupportedClassVersionError。所以我在部署文档开头第一项就写先对比 JDK 版本版本不一致直接跳过其他排查。5.4 配置文件不生效改了配置重启后还是旧值这种问题十有八九是配置加载顺序不对。Spring Boot 内部配置、外部配置、命令行参数、环境变量优先级从低到高。如果项目里既有application.yml又在启动命令里指定了--spring.config.additional-location要注意外部配置和内部配置是互补还是覆盖关系。排查分三步第一步确认启动命令里有没有配置additional-location路径拼写是否正确。 第二步确认外部配置文件路径下确有对应文件且权限能读到。 第三步在配置里临时加一个server.port8082重启看端口是否变化用于测试外部配置是否生效。生效说明配置加载没问题再聚焦到具体配置项上。5.5 应用启动成功但请求返回 404如果/actuator/health能通业务接口却 404先检查是不是 context-path 配置导致请求路径前缀不对server: servlet: context-path: /api那么请求地址应该是http://127.0.0.1:8080/api/xxx如果你直接请求http://127.0.0.1:8080/xxx当然 404。另外Spring Boot 3.x 默认把静态资源路径等规则调整过如果你的 Controller 类没有在启动类所在包的同级或子包下面可能导致Controller扫描不到。确认启动类里SpringBootApplication默认扫描的包范围。5.6 启动慢到无法接受Spring Boot 启动期间要扫描大量类、初始化上下文、连接数据库和中间件。如果启动特别慢重点查几个方面数据库连接池初始化是否失败重试看看配置的连接地址对不对Redis、MQ 等中间件是否正常是否引入了大型第三方包类扫描范围过大JVM 堆设置太小导致频繁 Full GC对策有几个方向排除不需要的自动配置类用spring.autoconfigure.exclude排除无关模块合理配置连接池超时参数换更合适的 GC 策略。启动慢不是功能问题但影响发布效率和故障恢复速度值得优化。5.7 定时任务跑了两遍这个问题在集群部署时特别常见。多实例部署同一个应用Scheduled定时任务在每个实例上都会执行于是出现重复执行的脏数据问题。解决方案有几种引入分布式任务调度框架统一控制任务分发基于数据库做任务锁保证同一时刻只有一个实例执行如果任务只在一台机器上跑可以专门部署一个单实例任务执行器不提供唯一解是因为这取决于团队规模和基础设施能力。但如果你的场景允许最省钱的做法是用 SQL 做分布式锁比如一张任务表加唯一索引任务执行时先尝试插入锁定记录执行完删除。虽然简单但在大多数内部系统里完全够用。6. 部署流程的优化建议与工程化思考如果把一次手工部署的时间控制在 10 分钟内通过脚本和规范把重复操作沉淀到团队是一件投入产出比很高的事。下面几条是我在实际团队里用过的优化手段不涉及重型方案适合中小团队逐步落地。6.1 版本管理构建产物和源码版本一一对应构建出的 JAR 包一定要能对应到代码版本。最简单的方式是 Maven 里配置${revison}或用 Git 版本号作为 jar 包名后缀。我一般会在 pom.xml 里加version1.0.0-${maven.build.timestamp}/version或者在流水线里把 git commit 写入配置文件。部署时把版本号记录到部署文档回滚时能快速找到对应版本的备份这是线上故障应急的生命线。6.2 环境变量管理敏感信息不落地配置数据库密码、Redis 密码、第三方密钥这些敏感信息我强烈建议不要落在application.yml里哪怕是外部配置文件也应该从环境变量或密钥管理服务读取。Spring Boot 里可以用spring: datasource: password: ${DB_PASSWORD}然后在服务器的 systemd 服务文件里配置[Service] EnvironmentDB_PASSWORDxxxx这样日志、备份文件、代码仓库都不会出现明文密码。如果有人拿到你的配置备份也不会直接泄露生产数据库密码。这个话题往大了说可以延伸出机密管理方案但即使只用环境变量这个简单手段安全性也比明文配置文件好得多。6.3 部署脚本模板沉淀成团队文档部署这件事人多手杂没有文档约束就会出现每个人部署方式都不一样。我建议团队里准备一份标准部署步骤文档内容包括前置检查JDK 版本、磁盘、内存、端口打包命令文件上传和备份步骤启动命令和检查清单回滚方案常见问题快速排查表这些内容看着琐碎真出了故障它就是团队救命的操作手册。而且新同学上手部署拿着文档走一遍基本不会出大错。部署是一个积累型工作每踩一个坑就为团队沉淀一条经验。希望这篇文章能帮你少走一些弯路把 Spring 项目稳稳当当跑在服务器上。
阅读完成 · 觉得有帮助?
咨询建站