057-Spring Boot项目如何部署到云服务器?后台运行与日志管理教程

Spring Boot 的部署体验在 Java 生态里算是相当友好的:默认打成 fat jar,一个 java -jar 就能跑,不依赖外部 Servlet 容器。但"能跑"和"跑得规范"之间还有不小的距离。用过 nohup java -jar app.jar & 的人都知道,这种方式有几个立刻能感受到的毛病:关机不自启、日志文件无限增长、重启要手动 kill 找进程号、崩溃了没人管。这些问题在测试机上无伤大雅,上了生产就是隐患。

这篇文章把 Spring Boot 上线到云服务器的完整流程讲清楚:怎么打包(jar 和 war 各自适用什么场景)、后台运行的两套方式(nohup 与 systemd)以及为什么强烈推荐 systemd、配置怎么外部化才能做到"一次构建、多环境部署"、日志怎么写和怎么切割、Nginx 怎么配反向代理、开机自启怎么设置,最后整理一批常见启动报错和对应的排查方向。

命令部分给 RHEL/CentOS/Rocky 与 Debian/Ubuntu 两套。涉及 kill、覆盖配置文件的操作用风险提示标出。

一、打包:先用对方式

Spring Boot 项目有两种打包形态,选错了会在部署阶段付出额外成本。

打成可执行 jar 是默认也是最推荐的方式。mvn clean package 之后在 target 目录下会得到一个几十兆的 jar,里面包含了应用代码、依赖和三方库,还内嵌了 Tomcat(或 Jetty、Undertow)。部署时只要有 JDK,java -jar 就能起,端口、线程数全由配置文件控制。

打成 war 的传统场景是:公司已有统一的 Tomcat 集群,运维要求所有应用必须以 war 形式丢进 webapps,用同一个容器跑多个应用。这种方式的好处是容器可以统一管理连接池、统一调优;坏处是多个应用共享一个 JVM,一个应用出问题(比如内存泄漏)会拖垮其他应用,而且不能单独调整某个应用的 JVM 参数。

给个判断标准:新项目一律用 jar;只有明确要求"统一 Tomcat 容器、多应用共用一个实例"时才用 war。这篇文章后面的内容都基于 jar 方式。

# Maven 打包,跳过测试加快速度(正式发布建议保留测试)
mvn clean package -DskipTests

# Gradle
./gradlew clean bootJar

# 确认产物,正常是一个几十 MB 的 fat jar
ls -lh target/*.jar

有一个细节值得注意:Spring Boot 打的 fat jar 不能当普通 jar 用 java -cp 的方式加载,它是特殊的可执行格式,必须用 java -jar 运行。如果你需要在脚本里指定主类,用 -Dloader.main 之类的属性,不要试图拆开。

二、后台运行:nohup 与 systemd

SSH 会话一断开,前台启动的进程就会被挂起或终止,所以必须让它在后台跑。两种做法。

nohup 是最原始的方案:

# 最简单的写法
nohup java -jar app.jar > app.log 2>&1 &

# 重定向分开写更清楚
nohup java -jar app.jar > /var/log/app/stdout.log 2>&1 &

# 记下进程号便于后续管理
echo $! > /var/run/app.pid

nohup 能让进程忽略挂断信号,配合 & 放到后台。它的问题在于:没有自动重启,进程崩了就没了;没有开机自启,服务器重启后需要人工登录启动;停止服务要靠 kill 加进程号,容易杀错进程;日志切割要另外配;启动顺序依赖也管不了。适合临时验证,不适合长期运行。

systemd 是现代 Linux 发行版标准的服务管理方式,把上面的问题全部解决:自动重启、开机自启、标准化的启停命令、集中收集日志、可以声明依赖关系和资源限制。生产环境没有理由不用它。

维度 nohup systemd
崩溃自动重启 不支持 支持(Restart=always)
开机自启 需手动做 一条命令 enable
启停方式 kill + 进程号 systemctl start/stop/restart
日志管理 自己重定向和切割 journald 自动收集
资源限制 需 ulimit 等手工配置 服务文件里声明
适合 临时验证 生产环境

三、用 systemd 托管应用(推荐)

先规划目录结构。一个清晰的结构能让后面的备份、发布、回滚都变简单:

# 约定一套目录
sudo mkdir -p /opt/app/current          # 当前运行版本
sudo mkdir -p /opt/app/releases         # 历史版本,便于回滚
sudo mkdir -p /etc/app                  # 外部配置
sudo mkdir -p /var/log/app              # 日志

# 建一个专用运行用户,不要用 root
sudo useradd -r -s /sbin/nologin appuser
sudo chown -R appuser:appuser /opt/app /var/log/app
sudo chown root:appuser /etc/app
sudo chmod 750 /etc/app

把 jar 放进去:

# 传到目标位置,带上版本号便于识别
scp target/app-1.0.0.jar deploy@server:/tmp/
sudo mv /tmp/app-1.0.0.jar /opt/app/releases/
sudo ln -sfn /opt/app/releases/app-1.0.0.jar /opt/app/current/app.jar
sudo chown -R appuser:appuser /opt/app

用符号链接指向当前版本,好处是发布时只改链接、不动服务文件,回滚就是把链接指回上一个 jar。

接下来写服务文件:

# /etc/systemd/system/myapp.service
[Unit]
Description=My Spring Boot Application
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/app/current

# Java 环境,必须显式声明,systemd 不读 /etc/profile
Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk"
Environment="PATH=/usr/lib/jvm/java-17-openjdk/bin:/usr/local/bin:/usr/bin:/bin"

# 外部配置目录和配置文件
Environment="SPRING_CONFIG_ADDITIONAL_LOCATION=/etc/app/"

# 用 ExecStart 前置减号可以让 systemd 忽略该行的失败(此处不用,保持严格)
ExecStart=/usr/lib/jvm/java-17-openjdk/bin/java \
  -Xms1g -Xmx1g -Xss512k \
  -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai \
  -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/ \
  -Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20M \
  -jar /opt/app/current/app.jar \
  --spring.profiles.active=prod

SuccessExitStatus=143
Restart=always
RestartSec=5
# 限制重启风暴:5 分钟内超过 5 次就放弃重启
StartLimitIntervalSec=300
StartLimitBurst=5

# 优雅停止:先发 SIGTERM,等 30 秒再强杀
KillSignal=SIGTERM
TimeoutStopSec=30

# 限制单个服务的资源,避免拖垮整机
LimitNOFILE=65535

StandardOutput=append:/var/log/app/stdout.log
StandardError=append:/var/log/app/stderr.log

[Install]
WantedBy=multi-user.target

几个要点解释一下。SuccessExitStatus=143 是因为 JVM 收到 SIGTERM 后会以 143(128+15)退出,不加这一行 systemd 会把它判为异常退出,日志里全是失败记录。Restart=always 配合 RestartSec 和启动频率限制,既保证崩溃能自动恢复,又避免进程一直起不来时疯狂重启把 CPU 吃满。KillSignal 和 TimeoutStopSec 决定优雅停止的行为,Spring Boot 默认支持收到 SIGTERM 后执行优雅停机(需要配置 server.shutdown=graceful),给 30 秒足够处理完存量请求。

日志用 append: 写到文件,同时也会进 journald。这是有意为之:文件方便自己切割和归档,journald 方便用 journalctl 按时间查。

启用服务:

sudo systemctl daemon-reload        # 每次改服务文件后都要执行
sudo systemctl enable myapp         # 设置开机自启
sudo systemctl start myapp
sudo systemctl status myapp

日常操作记熟这几条:

sudo systemctl restart myapp        # 重启
sudo systemctl stop myapp           # 停止
sudo systemctl reload-or-restart myapp
journalctl -u myapp -n 100 --no-pager     # 看最近 100 行日志
journalctl -u myapp -f                    # 实时跟随
journalctl -u myapp --since "10 min ago"  # 按时间过滤

四、外部化配置:一次构建,多环境

配置外部化是部署规范的核心。目标很明确:同一个 jar 在 dev、test、prod 上都要能跑,区别只在外部提供的配置。

Spring Boot 加载配置的优先级从高到低大致是:命令行参数 → 环境变量 → application-{profile}.yml → application.yml。外部文件(如 --spring.config.additional-location 指定的目录)会覆盖 jar 内部打包的配置。这个优先级设计的目的就是让"外部覆盖内部"成为常态。

# 方式一:命令行参数(优先级最高,适合临时调试)
java -jar app.jar --server.port=8081 --spring.datasource.url=jdbc:mysql://...

# 方式二:外部配置文件目录(生产推荐)
java -jar app.jar --spring.config.additional-location=/etc/app/

# 方式三:环境变量(容器和 CI 环境常用,注意下划线和大小写转换规则)
export SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/appdb
export SPRING_PROFILES_ACTIVE=prod
java -jar app.jar

关于环境变量有个容易踩的坑:Spring Boot 的宽松绑定会把 SPRING_DATASOURCE_URL 映射成 spring.datasource.url,但如果你同时在 yml 里也配了同名项,环境变量的优先级是高于 yml 文件的,所以改 yml 可能"不生效"。排查配置问题时,第一件事就是看有没有被环境变量覆盖。

敏感信息(数据库密码、密钥)绝对不要提交到仓库,也不要写进 jar 里。生产环境的做法是放在 /etc/app/application-prod.yml,文件权限设为 600、属主是运行用户:

sudo chown appuser:appuser /etc/app/application-prod.yml
sudo chmod 600 /etc/app/application-prod.yml

风险提示:配置文件的权限如果过于开放(比如 644),同机器上的其他用户都能读到数据库密码。多租户或共用服务器时尤其要注意。另外,任何修改配置的操作都要先在测试环境验证,配置错误是导致启动失败最常见的原因之一。

五、日志管理与切割

Spring Boot 用 Logback(默认)或 Log4j2 输出日志。默认配置下日志量会很大,而且不切割,几天就能把一个 20G 的盘写满。日志爆盘是所有运维事故里最没技术含量、也最常见的一种。

基础配置放在外部 yml 里:

# /etc/app/application-prod.yml
logging:
  file:
    name: /var/log/app/application.log
  level:
    root: INFO
    com.example: DEBUG        # 只对自己包的代码开 DEBUG
  logback:
    rollingpolicy:
      max-file-size: 100MB     # 单文件上限
      max-history: 30          # 保留 30 天
      total-size-cap: 5GB      # 总量上限

如果内置的切割策略不够细(比如想按天加按大小双重切割),放一个 logback-spring.xml 到外部配置目录:



    
    
        ${LOG_PATH}/application.log
        
            ${LOG_PATH}/application.%d{yyyy-MM-dd}.%i.log.gz
            100MB
            30
            5GB
        
        
            %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
            UTF-8
        
    
    
        
    

里的 UTF-8 不能省,这是中文日志乱码的常见原因。

除了应用自己的日志,还有 systemd 收集的 stdout/stderr 和 journald 的日志。journald 的日志默认存在 /var/log/journal 或 /run/log/journal,受 SystemMaxUse 限制。检查一下当前占用:

journalctl --disk-usage
# 限制 journald 总大小,编辑 /etc/systemd/journald.conf 加 SystemMaxUse=500M
sudo systemctl restart systemd-journald

清理旧日志是个危险操作,务必谨慎:

# 安全做法:按时间清理,保留最近 7 天
sudo journalctl --vacuum-time=7d

# 限制总大小
sudo journalctl --vacuum-size=500M

# 危险操作:直接删 /var/log 下的日志文件
# 不要这么做,会破坏日志系统的文件句柄,导致服务和日志工具行为异常

风险提示:直接 rm 正在被进程写入的日志文件,看起来像释放了空间,但在进程重启前磁盘空间不会真正回收(文件句柄还开着),而且日志工具可能报错。正确做法是用应用自带的滚动策略和 logrotate,或者用 truncate -s 0 file 清空而不是删除。

六、Nginx 反向代理与对外暴露

Spring Boot 直接监听 8080 对外提供服务不是好做法,原因有三:Java 进程处理静态文件和 TLS 的效率不如 Nginx;80/443 端口需要 root 权限,让 Java 进程用 root 跑是安全风险;没有统一的入口做限流、日志、灰度。所以标准架构是 Nginx 监听 443,把请求转发给本地的 8080。

upstream app_backend {
    server 127.0.0.1:8080;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate     /etc/nginx/ssl/app.crt;
    ssl_certificate_key /etc/nginx/ssl/app.key;

    # 上传大小限制,按业务调整
    client_max_body_size 20m;

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        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;
        proxy_set_header Connection        "";

        # 超时设置,别用默认的 60s 一刀切
        proxy_connect_timeout 5s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;
    }

    # 静态资源交给 Nginx,不经过 Java
    location /static/ {
        alias /opt/app/static/;
        expires 30d;
        access_log off;
    }
}

两个必须注意的点。一是 proxy_set_header X-Forwarded-For,不加的话 Java 侧拿到的客户端 IP 全是 127.0.0.1,日志和限流都会失效;Spring Boot 侧要配 server.forward-headers-strategy=framework 才能正确解析。二是 proxy_read_timeout,默认 60 秒,如果业务有耗时超过一分钟的接口,必须调大,否则会出现"服务端还在处理,Nginx 已经返回 504"。

同时也要把 Java 进程监听的地址限制在本地:在配置文件里设 server.address=127.0.0.1,这样即使防火墙配错了,8080 也不会直接暴露在公网。防火墙层面再用 firewall-cmd(RHEL 系)或 ufw(Debian 系)只放行 80、443 和 SSH 端口。

七、常见启动报错与排查

Port 8080 was already in use 表示端口被占。查是谁占的:sudo ss -lntp | grep 8080,然后决定是停掉冲突进程还是给应用换端口。

Unable to start web server 或 Failed to bind 通常是权限或地址问题,比如想监听 80 但进程不是 root,或者 server.address 配成了不可用的地址。

Error creating bean / Failed to configure a DataSource 基本是配置问题:数据源地址、用户名密码、驱动版本。检查外部配置文件是否被正确加载,可以用 --debug 参数启动 Spring Boot,它会打印一份自动配置报告,明确告诉你哪些配置生效了、哪些没有。

Communications link failure 是连不上数据库,按网络顺序查:目标 IP 和端口是否可达(nc -vz db 3306)、账号是否允许从这台机器连接、密码是否正确、数据库是否正常运行。

OutOfMemoryError: Java heap space 是堆不够,需要调整 -Xmx 或者排查内存泄漏(用 jmap -histo 看什么对象最多、导出 heap dump 分析)。注意 -XX:+HeapDumpOnOutOfMemoryError 导出的堆文件可能有几个 G,要确保 /var/log/app 所在分区有空间。

java.lang.UnsupportedClassVersionError 是 JDK 版本不匹配,见上一节 Java 环境那篇。

服务起不来但日志里没有明显错误时,先看 journalctl:

# 服务状态和最近日志
sudo systemctl status myapp
sudo journalctl -u myapp -n 200 --no-pager

# 手工用同样的命令跑一次,往往能直接看到报错
sudo -u appuser /usr/lib/jvm/java-17-openjdk/bin/java -jar /opt/app/current/app.jar --spring.profiles.active=prod

手工执行是排障的利器:它绕过了 systemd 包装,报错会直接打在终端上。注意用 sudo -u appuser 以运行身份执行,这样才能复现出权限和环境变量相关的问题。

常见问题

Q:发布新版本时怎么做到不影响用户?

基础做法是滚动替换:把新 jar 放到 releases 目录,改软链接指向新版本,systemctl restart。这中间会有几秒中断。要做到零中断,需要多实例加负载均衡,逐个摘流量、更新、探活、再挂回去。单机单实例的场景下,可以配优雅停机(server.shutdown=graceful)把中断压缩到一两秒。

Q:应用启动很慢,能优化吗?

看是哪一段慢。如果是 Spring 扫描和初始化慢,可以精简组件扫描范围、按需引入 starter。如果是数据库连接池初始化慢,可以配懒加载。JDK 17 以上还可以用 AppCDS 或把启动优化成 AOT(Spring Boot 3 支持)。但反过来想,启动慢只影响发布速度,除非要做到秒级扩缩容,否则优先优化的是运行期间的表现。

Q:多个 Spring Boot 应用部署在同一台机器上,怎么避免互相影响?

给每个应用分配明确的 JVM 堆上限,总和不超过物理内存的 80%;用 systemd 的 LimitNOFILE、MemoryMax、CPUQuota 做资源限制;日志写各自的目录避免混在一起;端口和 Nginx 的 upstream 分开配置。任何一个应用都不应该能拖垮整机。

Q:怎么查看应用实际使用的启动参数?

ps -ef | grep app.jar 能看到完整的命令行,或者用 cat /proc/$(pgrep -f app.jar)/cmdline | tr '\0' ' ' 更准确。另外 jinfo -flags 能看到 JVM 最终生效的参数值,包括那些没写出来用的默认值。

Q:服务器重启后应用没起来?

检查是否执行过 systemctl enable myapp(注意 enable 和 start 是两件事,很多人只 start 了)。再检查服务文件里有没有 After=network-online.target,如果应用启动时依赖网络,而起得太早会连接失败。

总结

Spring Boot 部署的规范做法可以概括成五步:打成 fat jar、用 systemd 托管、配置外部化、日志切割、Nginx 反向代理。

systemd 是这套方案的地基。服务文件里把 JAVA_HOME 和 PATH 显式声明、配好 Restart=always 与重启频率上限、写对 SuccessExitStatus=143、设置优雅停机的超时,这几点做到,进程的稳定性问题基本就解决了。

配置外部化的目标是"一次构建多环境运行",同时把密码这类敏感项从仓库里挪到权限受控的外部文件。日志必须配切割和总量上限,这是防爆盘最有效的手段。

最后,出问题时不要慌,journalctl -u 加手工执行一次启动命令,这两步能解决八成以上的启动故障。