部署这事,看起来简单,实际坑不少。刚学 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] Description=Order Service After=network.target [Service] User=deploy WorkingDirectory=/opt/app/order-service ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/app/order-service/order-service-1.0.0.jar SuccessExitStatus=143 Restart=always RestartSec=10 [Install] WantedBy=multi-user.target这里几个配置要说一下:
User=deploy:不要用 root 跑应用。我见过很多服务器被黑,就是因为应用以 root 权限运行,一旦有漏洞,整个机器都暴露了。WorkingDirectory:指定工作目录,影响应用读取相对路径的配置。Restart=always:进程崩溃或异常退出时自动拉起。RestartSec=10:重启前等 10 秒,避免频繁重启把系统拖垮。SuccessExitStatus=143:Spring 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 包部署到外部 Tomcat
3.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,把打包方式改成 war:
<packaging>war</packaging>第二步,排除内嵌 Tomcat,改为 provided 作用域:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>第三步,修改启动类,继承SpringBootServletInitializer:
@SpringBootApplication 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:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m"多应用共用一个容器的优点和缺点都在这里:内存统一分配、统一管理,但一个应用的内存泄漏可能会拖垮整个容器里的所有应用。
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 --from=builder /build/target/*.jar app.jar ENV TZ=Asia/Shanghai EXPOSE 8080 ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]几个细节说明一下:
RUN mvn dependency:go-offline:把依赖提前拉下来做缓存,后面改代码重新构建时能省很多时间。- 第二阶段用 JRE 而不是 JDK,镜像体积能小一半以上。
ENV TZ=Asia/Shanghai:容器默认是 UTC 时区,不加这个,日志时间和业务时间会差 8 小时,排查问题的时候非常迷惑。-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0:让 JVM 感知容器内存限制,按容器配额比例分配堆内存,避免在 512M 限制的容器里申请 1G 堆导致被系统杀掉。
4.3 构建镜像与运行容器
构建镜像:
docker build -t order-service:1.0.0 .运行容器:
docker run -d \ --name order-service \ -p 8080:8080 \ --restart=always \ -e SPRING_PROFILES_ACTIVE=prod \ order-service:1.0.0这里几个参数要注意:
-d:后台运行。-p 8080:8080:宿主机 8080 映射到容器 8080。--restart=always:容器退出或 Docker 重启时自动拉起。-e SPRING_PROFILES_ACTIVE=prod:指定环境配置。
看容器日志:
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而是用COPY,ADD会自动解压 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/3306 | lsof -i :8080查占用进程,kill 或改端口 |
| 应用启动后马上退出 | 配置文件缺失、数据库连不上 | journalctl -u 应用名或docker logs看日志 |
| MySQL 容器起不来 | 数据目录权限不对 | 挂载卷需要给 mysql 用户写权限,检查 SELinux |
| Docker 时区不对,日志差 8 小时 | 容器默认 UTC | 镜像里ENV TZ=Asia/Shanghai |
| JVM 内存超限被 kill | 堆内存配得比容器限制大 | -XX:MaxRAMPercentage=75按容器配额分配堆空间 |
| systemd 服务起不来 | 配置文件语法错误 | systemctl daemon-reload重新加载,systemctl status看错误 |
| 热部署后应用没更新 | 没重启服务/容器 | 每次更新必须重启对应服务,镜像 tag 必须变 |
6.2 日志排查的独家技巧
部署之后,查日志是日常最频繁的操作。系统里日志位置不统一,是排查效率低下的头号原因。我给自己定了个规矩:不管用哪种部署方式,日志必须输出到统一目录或统一交给日志系统。
- jar + systemd:用
journalctl -u order-service -f,但注意 journald 有存储上限,长期运行要配SystemMaxUse=500M。 - war 外部容器:日志在 Tomcat 的
logs目录,访问日志和业务日志分开。 - Docker:默认用
docker logs,但容器重建后历史日志就没了。如果需要留存日志,加--log-driver json-file --log-opt max-size=20m --log-opt max-file=3,或者收集到 Elasticsearch 之类的外部系统。
日志这个问题我多说一句:能在部署阶段多花 10 分钟把日志配置清楚,后面省下的排查时间绝对远超这 10 分钟。建议从第一天就把日志时区、格式、轮转都处理好,不然等线上出了问题时再补,代价放大十倍不止。
6.3 我踩过的几个典型的坑
第一个坑是启动脚本里的&符号。早年间写启动脚本总是习惯性加nohup java -jar xxx.jar > app.log 2>&1 &,后来发现这个脚本在 CI 里执行时,进程老是被杀掉。原因在于 CI 执行完命令后会把子进程一并清掉。换成 systemd 之后,这个坑彻底消失了。
第二个坑是 Docker 时区问题。有一个项目部署后,业务方反馈“订单创建时间不对”,我排查半天最后发现是容器时区是 UTC,数据库里存的时间比真实时间早了 8 小时。从那以后我写 Dockerfile 必配ENV TZ=Asia/Shanghai,并且docker-compose.yml里也统一加上TZ环境变量。
第三个坑是 MySQL 容器升级时数据丢失。当时我在服务器上直接docker compose up -d,Compose 检测到镜像配置变了,就自动重建容器,而 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,把依赖服务一起编排起来。
这套路径走完,你对“部署”这件事的理解会是完整的——不再是甩几个命令就完事,而是明白每一步在解决什么问题、什么场景下该选哪条路。这也是我这几年帮团队带新手时验证过的最平滑的学习路线。