部署这事看起来简单实际坑不少。刚学 Spring Boot 的时候我都是本地mvn spring-boot:run跑起来就完事直到有一次要把一个“某订单服务”项目丢到测试服务器上给同事联调才真正体会到“跑起来”和“部署好”是两码事——进程一关就没了、端口被占、日志不知道在哪、版本更新要重新打包上传……折腾一轮之后我把常见的部署方式整理成了四种可执行 jar 直接部署、war 包丢外部容器、Docker 单容器运行、Docker Compose 编排运行。这篇文章就把这四种方式的选型思路、具体操作、以及我踩过的坑一次讲清楚适合刚接触部署的开发者也适合正在为项目选部署方案的同学参考。1. 内容整体设计与思路拆解1.1 为什么“能跑”不等于“能部署”很多人第一次部署 Spring Boot 项目就是本地java -jar一下看到 Tomcat 启动日志就以为成功了。实际上部署要考虑的东西更多进程能不能在 SSH 断开后继续存活日志写到哪、怎么轮转依赖的数据库、Redis 怎么一起起来新版本上线怎么切换出了问题怎么快速回滚我见过不少同事把java -jar直接扔在终端里窗口一关服务就没了也有人把 jar 包放在/tmp下服务器重启后连文件都没了。这些都不是“部署”只是“临时跑了一下”。真正的部署需要把应用变成服务器上一个可管理、可监控、可更新的“服务”。正是因为这些需求才会衍生出不同的部署方式。这四种方式不是互相替代的关系而是对应不同阶段、不同规模、不同运维能力的场景。我一开始也纠结“到底用哪种最好”后来发现根本没有最好的只有最合适的。1.2 四种方式的核心选型逻辑先给一张总览表方便你对号入座部署方式核心思路适用场景学习成本运维成本jar 包 systemd应用打包成可执行 jar用 systemd 托管进程单机部署、中小项目、刚接触 Linux 运维低中war 包 外部 Tomcat打成 war 包交给外部 Servlet 容器已有 Tomcat 运维体系、多应用共用容器中中高Docker 单容器镜像构建 容器运行环境固化环境一致性要求高、要快速迁移中中Docker Compose 编排多容器统一编排一条命令拉起整套环境微服务、依赖数据库/中间件、团队协作中高低从左到右其实是一个“环境管理能力”的升级过程jar 方式是你自己管环境war 方式交给容器管Docker 方式把环境打成镜像Compose 方式把多个镜像作为一个整体来管。选择的时候主要看三个问题服务器上已经有什么团队谁会运维服务依赖多少个组件如果服务器上已经跑着 Tomcat那 war 方式最省事如果要从零搭建Docker 几乎是无脑首选如果只有一个应用、一台机器、不需要频繁变动systemd 管 jar 反而最直接。2. 方式一可执行 jar 包 systemd 进程托管2.1 为什么推荐配 systemd 而不是 nohup先说一个最常见的错误nohup java -jar app.jar 。这个命令确实能让进程在后台跑但问题是进程的管理很原始你要自己记 PID、写脚本判断存活、开机不会自动启动、崩溃了也没人管。我早年间就这么干有一次服务器凌晨重启服务没起来第二天早上业务那边炸了我才知道什么叫“睡梦中被电话吵醒”。systemd 是 Linux 上的标准进程管理工具它可以帮你解决开机自启、崩溃自动重启、日志统一收集、进程状态查看。把 jar 包交给 systemd本质上就是让操作系统帮你“看着”这个进程比 nohup 靠谱得多。2.2 具体操作步骤第一步打包。在项目根目录执行mvn clean package -DskipTests打包完成后jar 文件生成在target/目录下。Spring Boot 默认打的是可执行 jar里面有内嵌的 Tomcat直接java -jar就能跑。第二步把 jar 放到固定目录。我习惯放在/opt/app/下每个应用一个子目录比如/opt/app/order-service/jar 命名带上版本号方便后面回滚sudo mkdir -p /opt/app/order-service sudo cp target/order-service-1.0.0.jar /opt/app/order-service/第三步创建 systemd 服务文件。新建/etc/systemd/system/order-service.service内容如下[Unit] DescriptionOrder Service Afternetwork.target [Service] Userdeploy WorkingDirectory/opt/app/order-service ExecStart/usr/bin/java -Xms512m -Xmx1024m -jar /opt/app/order-service/order-service-1.0.0.jar SuccessExitStatus143 Restartalways RestartSec10 [Install] WantedBymulti-user.target这里几个配置要说一下Userdeploy不要用 root 跑应用。我见过很多服务器被黑就是因为应用以 root 权限运行一旦有漏洞整个机器都暴露了。WorkingDirectory指定工作目录影响应用读取相对路径的配置。Restartalways进程崩溃或异常退出时自动拉起。RestartSec10重启前等 10 秒避免频繁重启把系统拖垮。SuccessExitStatus143Spring Boot 正常停机时会发送 SIGTERM退出码是 143标记为“正常退出”避免被误判为崩溃而重启。第四步加载并启动服务sudo systemctl daemon-reload sudo systemctl enable order-service # 开机自启 sudo systemctl start order-service查看状态和日志sudo systemctl status order-service sudo journalctl -u order-service -f # 实时看日志2.3 升级与回滚操作更新版本时替换 jar 文件后重启即可sudo cp target/order-service-1.1.0.jar /opt/app/order-service/ sudo systemctl restart order-service要回滚到旧版本只要把旧 jar 复制回去再重启。因为每个版本都保留了 jar 文件所以回滚成本极低不需要重新打包。这是我喜欢这种方式的一个重要原因——简单到不需要任何自动化工具。注意journalctl默认只存一定量的日志长期使用建议配日志轮转或者把日志输出到文件交给logrotate处理。不然后期查问题翻日志会很难受。3. 方式二war 包部署到外部 Tomcat3.1 什么时候选外部容器团队里如果已经有成熟的 Tomcat 管理经验或者一台服务器上要跑多个应用、想共用一个容器资源war 包方式反而比“一个应用一个 Docker 容器”更省内存。我的一个旧项目中一台 4G 内存的服务器跑了 6 个 Spring Boot 应用如果每个都带一个内嵌 Tomcat 独立 JVM内存根本不够分。那时用外部 Tomcat多个 war 共用一个 JVM 的线程池资源利用率高不少。这个方案同样适合那种“运维只会用 Tomcat”的团队。不是所有的运维都熟悉 Docker与其逼着人家学新工具不如尊重现有体系用 war 方式把应用塞进去完事。3.2 从 jar 到 war 要改什么Spring Boot 默认是 jar 打包改成 war 需要三步。第一步改pom.xml把打包方式改成 warpackagingwar/packaging第二步排除内嵌 Tomcat改为 provided 作用域dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId scopeprovided/scope /dependency第三步修改启动类继承SpringBootServletInitializerSpringBootApplication public class OrderServiceApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(OrderServiceApplication.class); } }这三步是必须的。我见过有人只改了 packaging没改启动类结果 war 包丢进 Tomcat 后一直加载失败报错找不到 DispatcherServlet。原因就是外部容器启动时用的是configure方法而不是原来的main方法。3.3 部署到 Tomcat 的细节把打出来的order-service.war复制到 Tomcat 的webapps目录下Tomcat 会自动解压并部署sudo cp target/order-service.war /opt/tomcat/webapps/如果不指定路径应用访问地址就是http://服务器IP:8080/order-service/如果想用根路径访问把 war 包改名为ROOT.war再放进去sudo cp target/order-service.war /opt/tomcat/webapps/ROOT.war这里有两个坑要说清楚一是版本问题。Tomcat 10 与 Tomcat 9 的 Servlet API 命名空间不同Spring Boot 2.x 项目打出来的 war 丢到 Tomcat 10 会直接报错。我建议统一用 Tomcat 9和 Spring Boot 2.x 兼容得最好。二是 JVM 内存。外部容器方式下应用不自己设置 JVM 参数而在TOMCAT_HOME/bin/catalina.sh里配置JAVA_OPTS-Xms512m -Xmx1024m -XX:MetaspaceSize128m -XX:MaxMetaspaceSize256m多应用共用一个容器的优点和缺点都在这里内存统一分配、统一管理但一个应用的内存泄漏可能会拖垮整个容器里的所有应用。3.4 多应用管理与隔离问题既然选择了外部 Tomcat就要接受一个事实应用之间的隔离是靠 ClassLoader 实现的。Tomcat 每个 webapp 都有自己的类加载器所以 jar 包冲突一般不会出现。但问题出在更底层——如果有应用用了javaagent或者在$CATALINA_BASE/lib下放了公共 jar就可能互相影响。我的经验是外部 Tomcat 方案适合“共用基础设施、应用相对稳定”的场景不适合频繁发布、快速迭代的团队。每次发布都要把 war 拷到服务器、等 Tomcat 重新解压、再重启容器这个过程在频繁发布时非常痛苦。如果你受够了这种节奏就该考虑 Docker 了。4. 方式三Docker 单容器部署4.1 Docker 解决了什么问题传统的 jar 包部署有一个隐性成本环境差异。开发机上是 JDK 8测试机上是 JDK 11生产机上又是另一个版本再遇到系统库缺失、时区不一致、字符集问题往往是“我本地跑得好好的服务器上就是不行”。Docker 的思想是把应用连同运行环境一起打包成镜像——JDK 版本、系统依赖、时区、环境变量全部固化在镜像里。镜像到哪运行行为就完全一致。我第一次用 Docker 部署时最大的感受是终于不用再为“环境不一致”背锅了。4.2 写一个规范的 Dockerfile项目根目录新建Dockerfile我常用的是多阶段构建# 第一阶段使用 Maven 镜像构建 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段运行镜像只留 JRE FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --frombuilder /build/target/*.jar app.jar ENV TZAsia/Shanghai EXPOSE 8080 ENTRYPOINT [java, -XX:UseContainerSupport, -XX:MaxRAMPercentage75.0, -jar, app.jar]几个细节说明一下RUN mvn dependency:go-offline把依赖提前拉下来做缓存后面改代码重新构建时能省很多时间。第二阶段用 JRE 而不是 JDK镜像体积能小一半以上。ENV TZAsia/Shanghai容器默认是 UTC 时区不加这个日志时间和业务时间会差 8 小时排查问题的时候非常迷惑。-XX:UseContainerSupport -XX:MaxRAMPercentage75.0让 JVM 感知容器内存限制按容器配额比例分配堆内存避免在 512M 限制的容器里申请 1G 堆导致被系统杀掉。4.3 构建镜像与运行容器构建镜像docker build -t order-service:1.0.0 .运行容器docker run -d \ --name order-service \ -p 8080:8080 \ --restartalways \ -e SPRING_PROFILES_ACTIVEprod \ order-service:1.0.0这里几个参数要注意-d后台运行。-p 8080:8080宿主机 8080 映射到容器 8080。--restartalways容器退出或 Docker 重启时自动拉起。-e SPRING_PROFILES_ACTIVEprod指定环境配置。看容器日志docker logs -f order-service进入容器排查docker exec -it order-service sh4.4 镜像体积优化实践我见过有人把镜像干到 1GB 以上原因无外乎用 JDK 不用 JRE、不在构建阶段清理中间产物、把源码也 COPY 进去了。优化镜像体积这件事对部署效率的影响很大尤其是频繁发布的时候——几百 MB 的镜像推到仓库、再拉下来时间成本肉眼可见。我的做法是基础镜像用 alpine 版 JRE比如eclipse-temurin:17-jre-alpine只有几十 MB。多阶段构建最终运行镜像里只有 jar 包和 JRE。不用ADD而是用COPYADD会自动解压 tar 包行为不可控。如果 jar 包体积本身很大还可以用docker buildx配合--squash参数压掉中间层。不过说实话在多阶段构建模式下这些优化空间已经比较小了最重要的就是别把构建工具链塞进运行镜像。4.5 单容器部署的局限单容器部署最大的问题是只解决了“应用本身”的部署没有解决“应用依赖”的部署。实际项目里几乎没有哪个 Spring Boot 应用是纯单体的——至少得有个 MySQL可能还有 Redis、RabbitMQ。你不可能在生产机器上手动把这些依赖一个个装好然后祈祷版本和环境和应用期望的一致。这就要靠 Compose 或者更上层的编排工具了。如果你只有一个应用、依赖都是外部已有的服务单容器就够用如果你的应用自带数据库、缓存等中间件建议直接把 Compose 学起来。5. 方式四Docker Compose 编排部署5.1 一条命令拉起整套环境Docker Compose 的本质是“容器编排”的入门工具用一份docker-compose.yml文件把应用、数据库、缓存等服务定义在一起docker compose up -d一条命令全部拉起。我在一个模拟项目里搭过一套订单服务环境应用 MySQL Redis。用 Compose 之前要分别启动三个容器、配置网络、处理依赖关系用 Compose 之后一条命令搞定而且整套环境的定义都写在文件里新同事克隆代码后执行一条命令就能把依赖环境跑起来。这个体验上的提升是非常明显的。5.2 docker-compose.yml 实践示例以“某订单服务”为例完整文件如下version: 3.8 services: mysql: image: mysql:8.0 container_name: order-mysql restart: always environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: order_db TZ: Asia/Shanghai ports: - 3306:3306 volumes: - mysql_data:/var/lib/mysql - ./init:/docker-entrypoint-initdb.d healthcheck: test: [CMD, mysqladmin, ping, -h, localhost] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine container_name: order-redis restart: always command: redis-server --requirepass redis123 ports: - 6379:6379 volumes: - redis_data:/data app: build: . container_name: order-app restart: always ports: - 8080:8080 environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql REDIS_HOST: redis depends_on: mysql: condition: service_healthy redis: condition: service_started volumes: mysql_data: redis_data:几个关键点depends_on配合healthcheck确保 MySQL 真正就绪后才启动应用。如果不做健康检查应用启动时数据库还没就绪就会出现连接失败。volumes数据持久化。mysql_data是命名卷容器删了数据还在。environment里用服务名mysql、redis作为主机名因为 Compose 创建的默认网络中服务名就是 DNS 名称应用配置里直接写jdbc:mysql://mysql:3306/order_db即可。启动整套环境docker compose up -d查看所有服务状态docker compose ps查看应用日志Compose 会带服务名前缀多个服务混着看不会乱docker compose logs -f app停止并删除所有资源数据卷不受影响docker compose down5.3 从“部署”升级到“发布流程”Compose 把环境定义做成了“代码”这已经是自动化发布的基础。我在实际项目里做了一套固定的发布流程docker compose build构建镜像docker compose push推到镜像仓库如果配了 registry在服务器上docker compose pull docker compose up -d新版本自动替换。配合 Git 分支或 Tag 管理版本整个过程完全可以脚本化。比如用以下脚本一键发布#!/bin/bash # deploy.sh set -e export IMAGE_TAG$1 docker build -t order-service:${IMAGE_TAG} . docker compose up -d --no-deps app这里的--no-deps很关键只更新应用服务不重启 MySQL 和 Redis避免不必要的停机。我在这个坑上吃过亏——有一次图省事直接docker compose up -d结果 MySQL 容器被重建了数据差点出问题。后来所有更新操作都加上--no-deps只在真正需要时才重启依赖服务。5.4 单机 Compose 的边界在哪里Compose 的便利建立在“单机”之上。当你的应用需要横向扩容或者多台机器上要协同部署Compose 就力不从心了。这时候要么上完整的 Kubernetes要么用轻量的 Docker Swarm 过渡。我的建议是不需要为了“以后可能用得上 K8s”而一开始就上重型方案。先掌握 Compose把应用容器化和依赖编排做好以后再迁移到 K8s 反而轻松。6. 常见问题与排查技巧实录6.1 部署问题速查表现象可能原因排查与解决端口被占用其他进程占用了 8080/3306lsof -i :8080查占用进程kill 或改端口应用启动后马上退出配置文件缺失、数据库连不上journalctl -u 应用名或docker logs看日志MySQL 容器起不来数据目录权限不对挂载卷需要给 mysql 用户写权限检查 SELinuxDocker 时区不对日志差 8 小时容器默认 UTC镜像里ENV TZAsia/ShanghaiJVM 内存超限被 kill堆内存配得比容器限制大-XX:MaxRAMPercentage75按容器配额分配堆空间systemd 服务起不来配置文件语法错误systemctl daemon-reload重新加载systemctl status看错误热部署后应用没更新没重启服务/容器每次更新必须重启对应服务镜像 tag 必须变6.2 日志排查的独家技巧部署之后查日志是日常最频繁的操作。系统里日志位置不统一是排查效率低下的头号原因。我给自己定了个规矩不管用哪种部署方式日志必须输出到统一目录或统一交给日志系统。jar systemd用journalctl -u order-service -f但注意 journald 有存储上限长期运行要配SystemMaxUse500M。war 外部容器日志在 Tomcat 的logs目录访问日志和业务日志分开。Docker默认用docker logs但容器重建后历史日志就没了。如果需要留存日志加--log-driver json-file --log-opt max-size20m --log-opt max-file3或者收集到 Elasticsearch 之类的外部系统。日志这个问题我多说一句能在部署阶段多花 10 分钟把日志配置清楚后面省下的排查时间绝对远超这 10 分钟。建议从第一天就把日志时区、格式、轮转都处理好不然等线上出了问题时再补代价放大十倍不止。6.3 我踩过的几个典型的坑第一个坑是启动脚本里的符号。早年间写启动脚本总是习惯性加nohup java -jar xxx.jar app.log 21 后来发现这个脚本在 CI 里执行时进程老是被杀掉。原因在于 CI 执行完命令后会把子进程一并清掉。换成 systemd 之后这个坑彻底消失了。第二个坑是 Docker 时区问题。有一个项目部署后业务方反馈“订单创建时间不对”我排查半天最后发现是容器时区是 UTC数据库里存的时间比真实时间早了 8 小时。从那以后我写 Dockerfile 必配ENV TZAsia/Shanghai并且docker-compose.yml里也统一加上TZ环境变量。第三个坑是 MySQL 容器升级时数据丢失。当时我在服务器上直接docker compose up -dCompose 检测到镜像配置变了就自动重建容器而 mysql 的数据目录没有挂载到宿主机重建后数据全没了。这个教训让我养成了两个习惯写docker-compose.yml必加volumes数据卷更新容器尽量用--no-deps只更新业务服务。7. 个人经验总结与推荐组合写了这么多最后聊聊我自己现在实际用的是哪种组合。单机项目我几乎不用纯java -jar裸跑了“jar systemd”组合目前还在维护一个老项目其余新项目一律 Docker Compose。原因很简单部署本质上是在管理“环境 应用 依赖”这三样东西。Compose 能把它们一起定义、一起启动、一起销毁这是裸 jar 和外部 Tomcat 都给不了的好处。我的推荐路径是第一次接触部署先从java -jar跑通流程理解 Spring Boot 的内嵌容器和可执行 jar 结构然后学 systemd把进程托管交给系统体会“服务化”和裸跑的区别等手动流程跑熟再切 Docker把环境做成镜像最后上 Compose把依赖服务一起编排起来。这套路径走完你对“部署”这件事的理解会是完整的——不再是甩几个命令就完事而是明白每一步在解决什么问题、什么场景下该选哪条路。这也是我这几年帮团队带新手时验证过的最平滑的学习路线。