
SpringBoot项目打包说难不难说简单也真有一堆坑等着你踩。我见过太多同事在IDEA里点个绿色三角按钮应用跑得欢天喜地一到要部署到服务器就傻眼了——打了半天包要么启动报错要么端口起不来要么干脆打出来一个几十MB的“空壳”jar。这套流程你要是没真正走一遍光靠看文档是记不住的今天我就拿一个实际的SpringBoot小案例把从创建项目、改配置、打包、到执行部署的完整流程拆开揉碎讲清楚。这篇东西适合刚接触SpringBoot部署的初学者也适合那些一直在IDE里跑、从没自己打过包的开发者。我会从最基础的jar包方式讲起再带上war包部署到外部Tomcat、Docker镜像打包这两种高频场景最后整理一份常见报错速查表。所有经验都来自我实际踩坑后的复盘照着走基本能少走一大半弯路。1. 环境准备与案例工程搭建1.1 版本选型为什么SpringBoot版本不能乱来先说一个很多人忽略的问题SpringBoot版本和JDK、Maven之间的兼容关系。别看SpringBoot用起来简单它的版本背后对应着严格的JDK基线。比如Spring Boot 2.7.x用Java 8或11都能跑但你要是拿JDK 17去编译Spring Boot 2.x项目大概率会碰到IllegalAccessError或者反射调用失败这类诡异问题反过来Spring Boot 3.x开始强制要求JDK 17以上你用JDK 8去构建直接就是编译失败。我建议你创建新项目时先确定一个组合JDK 8 Spring Boot 2.7.x Maven 3.6或者JDK 17 Spring Boot 3.2.x Maven 3.9。这两种组合经过了大量生产环境验证网上能搜到的资料也最多遇到问题最容易找到答案。别一上来就追最新版本Spring Boot 3.5这种刚发布的版本很多第三方starter还没适配你整合个中间件都费劲。创建项目直接用IDEA自带的Spring Initializr就行选好JDK版本、勾选依赖。依赖这块我建议一开始只加Spring Web就够了做一个最简接口来验证打包流程等流程跑通了再往上加东西。很多人一创建就勾一堆依赖打包报错了都分不清是代码问题还是配置问题排查起来非常痛苦。1.2 一个最小可用案例能跑通就成功一半这里我创建一个名叫demo的SpringBoot工程只保留一个Controller负责返回一段JSON。这样做的好处是打包后的产物足够小出问题定位也快而且能直观看到“打包后的应用确实能独立运行”这件事。RestController RequestMapping(/api) public class DemoController { GetMapping(/hello) public MapString, String hello() { MapString, String result new HashMap(); result.put(code, 200); result.put(message, hello spring boot package); return result; } }这个接口很简单但它是整个流程的“探针”。只要服务器上访问http://ip:8080/api/hello能返回这段JSON就说明打包、部署、执行链路全部通了。后面不管是排查端口问题、防火墙问题、还是环境变量问题都可以拿这个接口当测试点。项目根目录下的pom.xml需要确认几个关键配置。首先是SpringBoot父工程版本这是整个依赖管理的基石然后是spring-boot-starter-web依赖提供Web能力最后是spring-boot-maven-plugin插件这个插件是打可执行jar包的核心没有它打出来的jar就是个普通压缩包跑不起来。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build这段配置就是你所有SpringBoot项目打包的基础模板。java.version这里我写的8如果你用JDK 17就改成17。记住这个参数和本地JDK要一致不然编译时会报invalid target release之类的错误。Maven的安装不多说了下载解压配好环境变量就完事重点检查一下本地仓库有没有配阿里云镜像不然下载依赖能急死你后面打包更是慢得离谱。2. jar包方式打包最主流也是坑最多的方式2.1 搞清楚mvn package到底做了什么在项目根目录打开命令行执行mvn clean package -DskipTests很多人执行完看到BUILD SUCCESS就完事了从不关心target目录下生成了什么。这里我强烈建议你去看一眼target目录里面会有一个demo-0.0.1-SNAPSHOT.jar同时还有一个demo-0.0.1-SNAPSHOT.jar.original。这个.original文件是什么它是SpringBoot插件在执行repackage之前生成的原始jar包也就是你代码编译后的普通jar只有你自己项目的class文件没有依赖。而那个正式jar包是把.original里的内容连同所有依赖jar一起重新打包成的“fat jar”。打个比方.original就像一份菜谱记录了你的菜怎么做而正式的fat jar是一个完整的“半成品料理包”里面不止有菜谱还把所有的食材都处理好了拿回家只需要加热就行。这个fat jar的内部结构是三层BOOT-INF/classes存放你自己的class文件BOOT-INF/lib存放所有第三方依赖jarMETA-INF里有一个MANIFEST.MF清单文件它指定了Main-Class和Start-Class。SpringBoot的org.springframework.boot.loader.JarLauncher会根据这个清单启动你的应用所以这就是为什么直接用java -jar xxx.jar就能跑起来。如果你打包后想看jar包内部结构用解压软件打开就能看到不需要额外工具。看清楚这三层结构你后面遇到ClassNotFoundException或者NoSuchMethodError时排查思路就清晰多了——先看看BOOT-INF/lib里到底有没有那个类所在的jar包版本对不对。2.2 运行jar包的完整执行流程打包成功后在target目录下执行java -jar demo-0.0.1-SNAPSHOT.jar正常情况下你会看到Spring的banner然后是一堆启动日志最后出现Tomcat started on port(s): 8080就说明启动成功了。注意这里有个细节SpringBoot内嵌的Tomcat默认监听8080端口你在IDE里跑和用java -jar跑行为完全一致。这里我要强调一个很多新手会踩的坑不要在服务器上直接用java -jar跑完后就关掉SSH窗口。你这样一关应用进程会随着终端会话一起被终止。正确做法是写一个启动脚本用nohup方式放到后台运行nohup java -jar demo-0.0.1-SNAPSHOT.jar \ --server.port8080 \ --spring.profiles.activeprod \ app.log 21 nohup的作用是让进程忽略挂断信号即使你退出SSH终端进程也不会被杀掉 app.log 21是把标准输出和错误输出都重定向到app.log文件这样方便排查问题最后的是把进程放到后台。--server.port和--spring.profiles.active是两种典型的运行时参数覆盖方式它们的优先级比application.yml里的配置要高这个机制非常重要——你的配置文件里可以写默认值但实际部署时通过命令行参数覆盖不需要重新打包。启动后怎么看日志我用的是直接tail -f app.log一边观察一边心里默数启动耗时。SpringBoot应用从执行java -jar到端口监听正常在3到10秒之间。如果启动失败重点看日志里有没有APPLICATION FAILED TO START这段标记它会很贴心地列出可能导致失败的原因比你自己瞎猜强太多了。2.3 外部配置文件的加载优先级问题很多项目跑着跑着突然变了个端口或者数据库连接串不对十有八九是配置文件加载顺序搞混了。SpringBoot读取外部配置的优先级顺序是命令行参数 Java系统属性 操作系统环境变量 当前目录下的config子目录中的application.yml 当前目录下的application.yml classpath下的application.yml。这个知识特别实用。你把jar包部署到服务器想要在不重新打包的情况下改数据库密码最省事的办法就是在jar包同目录下放一个config/application-prod.yml或者直接在启动命令里用--spring.config.locationfile:/opt/config/指定外部配置目录。这样做的好处是配置和代码分离安全性也更高——数据库密码这类敏感信息不需要打进jar包里只需要维护服务器上的配置文件即可。我自己的习惯是代码里只放开发环境的默认配置生产环境所有配置全部通过外部方式注入。这样git代码库里永远不会出现生产密码哪怕代码仓库泄露了别人也拿不到线上环境的配置。这个习惯建议你从第一次打包部署就开始养成后面项目复杂了尤其受益。3. war包部署到外部Tomcat老项目的刚需3.1 什么时候必须用war包方案说实话现在新项目很少有必须用war包的了SpringBoot内嵌Tomcat足够应付绝大多数场景。但工作中总有意外比如公司运维统一用Tomcat管理web应用要求所有服务必须部署到同一个Tomcat实例下或者项目里有一些老旧的Servlet过滤器、JSP资源这些在SpringBoot内嵌Tomcat里处理起来并不方便再或者你接手了一个历史遗留项目别人已经把war包的部署方式定死了你只能按规矩来。如果你碰到这些情况就需要把SpringBoot项目改造成war包。这个改造不复杂但细节特别容易出错。我拆成三步按顺序做基本不会翻车。3.2 三步改造从jar包到war包第一步修改pom.xml里的packaging类型告诉Maven你要打war包packagingwar/packaging这是最基础的一步。不改这个Maven死活打出来的都是jar。第二步把内嵌Tomcat的scope改成provided意思是“编译和测试时用打包时不包含”dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId scopeprovided/scope /dependency这一条乍看很容易理解但特别容易漏。很多初学者只改了packaging忘了加provided结果打出来的war包有几十MB里面塞了一个Tomcat部署到外部Tomcat后就会出现ClassCastException: org.springframework.web.SpringServletContainerInitializer cannot be cast这种经典报错。原因就是war包里自带了一套Servlet实现和外部Tomcat的Servlet实现冲突了。所以provided这个scope不是可选项是必选项。第三步修改启动类让它继承SpringBootServletInitializer并重写configure方法SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(DemoApplication.class); } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这一步的原理是外部Tomcat启动时不会执行你的main方法而是通过Servlet标准里的ServletContainerInitializer机制找到你项目里的SpringBootServletInitializer子类并由它来初始化Spring容器。你要是不继承这个类Tomcat加载war包后根本不知道要启动Spring直接给你冒个404。3.3 部署到Tomcat的注意事项改造完成后执行mvn clean package这时target目录下会生成一个demo-0.0.1-SNAPSHOT.war。把这个war包扔到Tomcat的webapps目录下启动Tomcat它就会自动解压war包并部署应用。访问路径要特别注意外部Tomcat部署的web应用访问路径是http://ip:8080/war包名/接口路径。如果你的war包叫demo.war访问/api/hello接口就必须写成http://ip:8080/demo/api/hello。想让应用直接通过根路径访问需要把war包改名为ROOT.war然后访问http://ip:8080/api/hello就行。在服务器上如果只想让Tomcat跑当前这一个应用直接把war包改成ROOT.war是最省心的。改完后删掉webapps里同名目录防止Tomcat不重新解压。为什么非要删因为Tomcat一旦解压过war包webapps下就有了同名的文件夹下次启动时它会优先用这个文件夹如果你改了war包内容但文件夹没变那跑的还是老代码。这个坑我踩过不止一次每次更新版本都以为自己部署了新代码结果跑的还是上一次的旧版本排查了半天才发现是Tomcat缓存的解压目录在作怪。4. Docker镜像打包现代部署的标准姿势4.1 从jar包到Docker镜像的思路转换如果说war包是传统方案的延续那Docker镜像就是目前最主流的部署形态了。核心思路很简单把你打好的jar包放进一个包含JDK运行环境的镜像里作为镜像的启动命令。这样你的应用和环境就一起被封装成了一个不可变的镜像无论在哪个机器上运行行为完全一致。这样做最大的好处是彻底解决“在我机器上是好的”这类问题。同一份镜像开发环境跑是那样测试环境跑还是那样生产环境依然一样。配合容器编排工具滚动升级、弹性伸缩都有成熟的套路。而且镜像一旦构建好部署新版本只是换一个镜像的事不再需要到服务器上手动敲java -jar命令。4.2 Dockerfile实战多阶段构建与镜像瘦身我推荐你用多阶段构建的方式因为这种方式不需要在本地装Maven和JDKDocker会自动拉取带JDK的基础镜像来完成编译打包最终产物里只包含运行环境和你的jar包体积能控制得很小。一个生产可用的Dockerfile长这样# 第一阶段编译 FROM maven:3.8-openjdk-8 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests # 第二阶段打包运行镜像 FROM openjdk:8-jre-alpine WORKDIR /app COPY --frombuilder /app/target/demo-0.0.1-SNAPSHOT.jar ./app.jar ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]第一阶段的RUN mvn dependency:go-offline -B作用很关键先把pom.xml复制进去并下载所有依赖到本地仓库这样后续就算src代码频繁改动依赖层也能复用Docker缓存只有真正代码变了才会重新编译。第二次构建时速度会快非常多。第二阶段的镜像我特意用了openjdk:8-jre-alpine而不是openjdk:8-jdk因为jre镜像只包含Java运行环境不包含编译器体积大概能小两三百MB。设置TZAsia/Shanghai是为了解决容器内默认UTC时间导致日志时间差8小时的问题这个问题在排查线上问题时会非常折磨人所以从一开始就要在镜像里把时区固定住。构建命令和执行命令分别是docker build -t demo:v1.0.0 . docker run -d --name demo-app -p 8080:8080 demo:v1.0.0-p 8080:8080表示把宿主机的8080端口映射到容器的8080端口。如果你宿主机8080已经被占了可以改成-p 18080:8080这样一个应用就能在多个端口上跑了。-d表示后台运行。4.3 日志、时区与容器内外的路径差异容器部署后日志怎么查看不用进容器里直接用docker logs -f demo-app这个命令会输出容器内的标准输出和标准错误。所以你的应用日志框架里千万不要配置SpringBoot内嵌的logback单独打印到固定文件否则容器日志收集工具比如ELK、Loki会抓不到数据。生产实践中应用日志一律打到标准输出采集和落盘交给容器编排层去做这是云原生时代的基本规则。还有路径差异问题jar包里的application.yml写的是相对路径./logs你在本地跑会生成在当前目录下在容器里跑就是/app/logs。这个我没少踩坑——在本地好好的日志路径部署到容器后日志凭空消失其实就是路径写死的问题。解决办法是统一用系统环境变量注入日志路径比如LOG_HOME/data/logs这样本地和容器共享同一套配置逻辑只是变量值不同。如果你有静态文件上传的需求千万别存到容器内。容器一重建里面的数据就全没了。正确做法是用-v /data/upload:/app/upload这种方式挂载一个宿主机目录或者直接上对象存储。这个知识可以等你的项目真正需要时再用但提前在脑子里留个印象是好事。5. 打包过程中的常见问题与排查技巧5.1 高频报错与解决对照打包执行看起来步骤就几个但实际遇到的问题千奇百怪。我把这些年见过的高频报错整理成一个表格方便你直接对照着排查报错现象根本原因解决思路BUILD FAILURE提示Invalid target release: 8Maven编译级别和JDK环境不一致检查pom.xml中java.version、IDEA的Project Structure、Maven的settings.xml是否用了不同JDK启动访问/api/hello返回404应用启动成功但接口路径不对看启动日志确认端口号和context-path检查Controller的RequestMapping路径ClassNotFoundException: org.springframework.web...启动方式不对不是在SpringBoot容器里启动确保用java -jar运行fat jar包而不是用java -cp手动指定classpathPort 8080 was already in use8080端口被占用Linux用netstat -tlnp | grep 8080找到PID并确认是不是你之前启动的应用kill掉即可NoSuchMethodError依赖冲突项目里存在同一个类的不同版本用mvn dependency:tree查看依赖树找到冲突依赖并排除war包部署后ClassCastException内部Tomcat没排除干净检查spring-boot-starter-tomcat的scope是否为provided容器启动后立即退出启动命令错误或内存配置问题docker logs查看容器日志确认ENTRYPOINT命令是否正确、Java进程是否OOM被kill本地能访问服务器上外部访问不通防火墙或云安全组没有放行端口检查服务器防火墙、云控制台安全组入站规则放行8080端口5.2 排查思路和调试技巧排查这类问题我有一套固定的流程按顺序走能省下大量瞎猜的时间。先看启动日志有没有报错信息日志是最有价值的线索然后用jps或者ps -ef | grep java确认进程是不是活着再用curl http://localhost:8080/api/hello看本机访问是否正常最后检查网络层——防火墙、安全组、端口占用。反编译这个话题也顺带提一嘴如果你把jar包发给别人对方用IDEA的反编译插件或者jd-gui这类工具基本能还原出绝大部分源码逻辑。这说明jar包本身并没有你想的那么安全包含商业核心逻辑的产物不应该随意分发。我见过一些公司会把核心业务拆成独立服务只暴露接口给外部系统而不是直接把jar包丢给对方。这个意识从第一天打包开始就要有。5.3 热词场景IDEA打包与Maven打包的选择策略现在很多人的打包操作其实是在IDEA里完成的右侧Maven面板双击package就能构建。这没问题但要注意IDEA里打包使用的Maven版本、JDK版本可能和命令行不一致。建议在IDEA的Settings Build Tools Maven Runner里把JRE设置为和你项目一致的JDK同时在Maven Importing里确认JDK for importer。很多诡异的打包报错最后都查出来是IDEA默认用了内置JDK导致的。另外一个建议是重要项目的打包尽量用命令行mvn脚本而不是IDEA。原因很简单命令行操作是确定的、可复现的写进CI/CD脚本里就能实现自动化构建IDEA点点点每一步都依赖图形界面没法自动化。哪怕你的项目很小养成命令行打包的习惯都值得。以后公司要上一套流水线你只需要把之前手敲的那几条命令整理进Dockerfile或者Jenkinsfile就行平滑得不得了。6. 从打包执行延伸到部署的更多经验6.1 一个jar包的生命周期管理打完包不是终点怎么管理这个产物也是门学问。最基本的几条每个jar包都必须有版本号别用demo-0.0.1-SNAPSHOT.jar这种SNAPSHOT版本直接上生产SNAPSHOT意味着“还会变化”它不该出现在任何正式环境里。发布版本建议用demo-1.0.0.jar加Git标签对应起来这样线上出了问题时你能准确说出“这是哪个commit构建出来的”而不用靠猜。还可以给Maven配置maven-build-info插件或者简单地在application.yml里加一个info.app.version把版本号暴露到/actuator/info接口上需要引入spring-boot-starter-actuator。这样运维和开发在排查问题时一眼就能确认线上跑的是哪个版本避免出现“升级了跟没升级一样”、“回滚了又没完全回滚”的混乱局面。我在实际维护服务时靠这个接口省下了大量沟通成本强烈建议加上。6.2 构建可重复性同一份源码要能产出同一份产物一个容易忽略的问题你的构建过程是不是可重复的如果两个人用不同版本的Maven、不同版本的JDK构建出的jar包内容可能有细微差异导致某些环境能跑、某些环境不能跑。解决方法是锁版本——pom.xml不能用maven-3.9-SNAPSHOT这类浮动版本所有插件和三方依赖都写上完整版本号父工程也锁定具体版本。SpringBoot依赖管理本身已经帮你锁定了一大批三方依赖的版本但插件和个别依赖还是需要自己注意。Spring Initializr生成的工程默认继承spring-boot-starter-parent这个父POM已经把常用的插件版本都固定了所以你不需要人为干预太多只要不去手改版本号就行。很多人遇到奇怪问题都是因为手动改了某个依赖版本结果和SpringBoot内置的版本冲突导致各种ClassNotFoundException。要学会“信任默认值”少改就是少踩坑。6.3 性能够用就行别过度优化最后说点关于性能的题外话。SpringBoot应用启动时JVM默认会分配最大堆内存为物理内存的1/4。如果服务器是2G内存你的jar包启动后最大堆能到500MB左右一般情况下都足够用了。但如果你的服务器内存特别紧张比如只有512MB那启动时就会看到OOM相关的错误解决办法是在启动命令里显式设置内存java -Xms256m -Xmx256m -jar demo-1.0.0.jar-Xms指定JVM初始分配的内存-Xmx指定最大可用内存。把两者设为相同值可以减少运行时动态扩容带来的性能损耗和内存碎片。我见过很多人拿到一个跑批任务的SpringBoot小工具默认堆设成8G把生产服务器撑爆了其实给个512M完全跑得飞起。根据应用实际内存占用情况来设置而不是一味追求很大或默认值这才是部署该有的谨慎。我个人实际操作中的体会是SpringBoot的打包执行流程说到底就是把代码变成产物、把产物变成可用服务的过程。这个过程本身不复杂但每一步都有它自己的隐藏细节。今天这篇写到的所有坑都是我真金白银踩出来的希望你能在面对这个流程时不慌不忙。最后再分享一个小技巧在pom.xml里加上Spring Boot的Maven插件配置后每次打包前多注意看控制台里repackage这一步的日志如果它没出现说明你的fat jar没打对后面java -jar跑不起来就别怪环境了。看清这一步整个流程你已经掌握了大半。