Spring Boot 部署运维
一个 Spring Boot 服务由程序、运行时配置和 Java 进程共同组成。JAR 中保存应用类、依赖与默认配置;启动时指定的配置决定监听地址、数据库连接和其他运行行为。systemd 或容器运行时管理进程,Nginx 等入口决定哪些请求能到达它。
JAR + JDK/JRE + 外部配置
↓ 启动
Java 进程
├── 业务端口 → 接口请求
├── 健康探针 → 是否接收新流量
├── 日志、指标 → 运行观察
└── SIGTERM → 停止接流、等待已有任务、退出程序包相同而配置不同,可能连接完全不同的数据和外部服务。发布时同时保存程序版本、配置版本及入口设置,恢复时才能找到对应组合。
构建、启动与第一条业务请求
完整工程与运行方式
下载部署示例工程,解压进入 deploy-app。工程使用 Java 25、Maven 3.9.12、Spring Boot 4.1.1,包含源码、四个测试、Dockerfile、Compose 和 systemd 单元。升级前按 Spring Boot 系统要求核对 JDK、构建工具和 Servlet 容器兼容性。
应用的启动类和接口位于同一个 Java 文件,业务请求返回两个不同来源的值:
@SpringBootApplication
@RestController
public class DeployApplication {
private final String message;
private final BuildProperties build;
public DeployApplication(@Value("${app.message}") String message,
BuildProperties build) {
this.message = message;
this.build = build;
}
public static void main(String[] args) {
SpringApplication.run(DeployApplication.class, args);
}
@GetMapping("/api/greeting")
public Map<String, String> greeting() {
return Map.of("message", message, "version", build.getVersion());
}
}message 来自运行配置,version 来自 Maven 生成的构建信息。改变环境变量只改变前者,不会把程序版本也变成另一个版本。示例没有数据库,业务项目加入数据库后,需要另外验证账号、迁移脚本和连接池。
Linux 主机已安装 Java 25 与 Maven 3.9.12 时执行:
java -version
mvn -version
mvn -B -ntp clean verify
java -jar target/app.jar应先看到四个测试通过,再启动应用。另开终端请求:
curl -q --noproxy '*' --fail-with-body --silent --show-error --max-time 5 \
http://127.0.0.1:8080/api/greeting返回包含 "message":"hello" 和 "version":"1.0.0" 的 JSON,字段顺序可以不同。完成后在启动终端按 Ctrl+C。没有宿主 JDK 时,可直接使用构建篇的普通用户 Docker 命令,随后执行后面的容器运行流程。
Started ... 表示 Spring 启动到相应阶段;业务请求还会检查实际监听、路由、配置与返回内容。连接拒绝时看启动异常和端口,HTTP 错误时看应用返回及对应日志,不把两类失败混在一起。
JAR 内部怎样启动
Maven 插件把依赖放在 BOOT-INF/lib,应用类和资源放在 BOOT-INF/classes,manifest 指定 Boot loader 与应用 Start-Class。java -jar 先进入 loader,再调用应用 main,创建 Spring 上下文并启动内嵌 Web 服务器。可执行 JAR 结构
java -jar app.jar
→ manifest / Boot loader
→ 应用 main / SpringApplication
→ 配置加载、Bean 创建、Web 服务器启动
→ 启动任务完成、readiness 接受流量依赖类不存在会在类加载或 Bean 创建时失败;配置缺失可能导致绑定失败;端口占用会阻止 Web 服务器监听。首先保留最早的 Caused by 和故障分析,不用增加启动等待时间掩盖这些错误。
配置、健康与退出
配置值从哪里来
示例的 application.properties 包含:
app.message=hello
server.address=127.0.0.1
server.port=8080
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=10s
management.endpoints.web.exposure.include=health
management.endpoint.health.probes.enabled=true
management.endpoint.health.show-details=never对于常见来源,命令行参数通常高于 Java system properties,后者高于 OS 环境变量,再高于配置文件。文件之间还存在打包内外、profile 和位置组的顺序。实际应用也可能添加自己的 PropertySource,排查时应确认最终来源。外部配置规则
前面的进程已停止时,在工程目录创建普通实验配置,再启动:
mkdir -p config
printf 'app.message=from-file\n' > config/application.properties
APP_MESSAGE=from-env java -jar target/app.jar \
--spring.config.additional-location=file:./config/application.properties \
--app.message=from-command同一个 greeting 请求应返回 from-command。去掉最后一个参数并重启,变为 from-env;再去掉该环境变量,变为 from-file。每次先停止旧进程,以免端口冲突干扰比较。
spring.config.additional-location 添加配置来源并保留默认搜索位置,spring.config.location 会替换默认位置。目录位置应以 / 结尾;必需文件不加 optional:,文件不存在时让启动明确失败。用一个确实不存在的路径做负例:
java -jar target/app.jar \
--spring.config.additional-location=file:./missing-lab.properties预期非零退出并报告配置位置不存在。创建正确配置后按原命令重启,不能为消除错误随意添加 optional,导致生产服务带着默认值运行。
profile 会选择条件化配置与 Bean,例如 application-prod.properties 和 @Profile。激活 prod 不会自动启用认证、备份或外部数据隔离;这些需要明确配置。数据库凭据不放命令行、公开日志或 JAR,可由受保护文件、configtree: 挂载目录或密钥服务提供。
健康探针有不同消费者
curl -q --noproxy '*' --fail-with-body --silent --show-error --max-time 5 \
http://127.0.0.1:8080/actuator/health/liveness
curl -q --noproxy '*' --fail-with-body --silent --show-error --max-time 5 \
http://127.0.0.1:8080/actuator/health/readiness应用正常启动后两者预期为 {"status":"UP"}。liveness 用于判断进程自身是否需要重启,readiness 用于判断是否适合接收流量。默认探针组不自动纳入所有外部依赖;是否把数据库等加入 readiness,要看故障时应用还能提供哪些功能,避免某个共享依赖失败让全部实例反复重启。Actuator 与探针
如果用独立管理端口,管理服务器健康也可能掩盖业务端口故障。可以在业务端口增加探针路径,或把真实业务请求加入发布验证。management 只绑定回环或受控网络,公网入口不要转发 env、heapdump、日志配置等管理接口。
等待已有请求退出
示例另提供 /api/slow?seconds=3,等待后返回 {"result":"completed"};参数仅允许 0 到 5 秒。它用于观察退出,不应带入真实业务应用。
Linux 测试终端执行:
java -jar target/app.jar > app-lab.log 2>&1 &
app_pid=$!先请求 readiness 确认启动,再发起慢请求:
curl -q --noproxy '*' --fail-with-body --silent --show-error --max-time 10 \
'http://127.0.0.1:8080/api/slow?seconds=3' > slow-result.json &
request_pid=$!看到本次日志中的 SLOW_REQUEST_STARTED seconds=3 后,在请求完成前执行:
kill -TERM "$app_pid"
wait "$request_pid"
cat slow-result.json
wait "$app_pid"请求应完成,随后进程退出;Java 进程因 TERM 的 shell 等待状态可能为 143,需结合日志和请求结果判断,不能只看这个数字。脚本自动化时应捕获并处理该退出状态。
优雅关闭先停止接收新请求,再等已有请求;不同 Web 服务器拒绝新连接的方式可以不同。timeout-per-shutdown-phase 是每个生命周期阶段的预算,不是所有关闭动作的总时限。容器/systemd 的强制退出期限应覆盖实际阶段、清理和摘流耗时。优雅关闭
入口先摘流、等待连接与探针传播,再终止进程,能减少更新时的新请求失败。正在执行的消息任务、定时任务和数据库事务还需要各自的停止与恢复设计;SIGKILL 不会执行这些清理过程。
在 Linux 托管与更新服务
程序目录与运行账号
在采用 systemd 的专用 Linux 测试服务器部署,管理员具有 sudo 权限。先安装适配架构的 Java 25;可使用发行版维护包或 Eclipse Temurin 安装入口,下载归档时按官方摘要验证,不能把来源不明的 JDK 放进服务路径。
以下假定 java 已指向选定的 Java 25,应用使用固定的 /opt/java/bin/java 路径。创建软链前确认 /opt/java 尚未占用:
java -version
java_bin=$(readlink -f "$(command -v java)")
java_home=$(dirname "$(dirname "$java_bin")")
test ! -e /opt/java && test ! -L /opt/java && sudo ln -s "$java_home" /opt/java
/opt/java/bin/java -version若目标已存在,先检查它服务于哪些应用,不替换共享 JDK。应用升级与 JDK 升级分开安排,便于识别失败来源。
在 deploy-app 工程目录,先确认测试通过、target/app.jar 是本次产物,再准备首次部署。下列名称应在专用测试主机上尚未使用:
sudo useradd --system --user-group --no-create-home \
--home-dir /var/lib/deploy-app --shell /usr/sbin/nologin deployapp
sudo install -d -o root -g root -m 0755 /opt/deploy-app/releases/r1
sudo install -o root -g root -m 0644 target/app.jar /opt/deploy-app/releases/r1/app.jar
sudo ln -s releases/r1 /opt/deploy-app/current
sudo install -d -o root -g deployapp -m 0750 /etc/deploy-app
sudo install -o root -g deployapp -m 0640 /dev/null /etc/deploy-app/runtime.env
sudo install -o root -g deployapp -m 0640 /dev/null /etc/deploy-app/application.properties已存在同名账号或部署目录时停下来核对,后两条创建空文件的命令只用于首次安装,不能在更新时执行。程序由管理员持有,运行账号只读;确需写入的数据放 /var/lib/deploy-app,避免应用被利用后改写自己的下次启动程序。
用 sudoedit 写入 /etc/deploy-app/application.properties:
app.message=systemd-one
server.address=127.0.0.1
server.port=8080/etc/deploy-app/runtime.env 写入:
SPRING_PROFILES_ACTIVE=prodEnvironmentFile 使用 systemd 的赋值格式,不是任意 shell 脚本;不要写命令替换或依赖 export。配置中如有凭据,保留读权限限制,日志和诊断输出不得打印文件内容。
systemd 单元与启动判断
工程附带的 deploy-app.service 使用前台 Java 进程:
[Unit]
Description=Deployment teaching application
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=3
[Service]
Type=exec
User=deployapp
Group=deployapp
WorkingDirectory=/opt/deploy-app/current
EnvironmentFile=/etc/deploy-app/runtime.env
ExecStart=/opt/java/bin/java -Xms128m -Xmx256m -jar /opt/deploy-app/current/app.jar --spring.config.additional-location=file:/etc/deploy-app/application.properties
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
KillSignal=SIGTERM
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
StateDirectory=deploy-app
UMask=0027
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetType=exec 能识别用户或可执行文件等启动错误,但不会等待 HTTP 就绪。After=network.target 是单元顺序,不保证远程数据库已可连接。应用应给外部依赖设置有限超时和适当重试,避免永久卡在启动中。systemd.service
StateDirectory 由 systemd 创建应用状态目录并安排权限;ProtectSystem=strict 限制其他路径写入,PrivateTmp 提供服务私有临时空间。需要额外上传、日志或诊断目录时逐项授权,不为一个写入错误取消所有保护。systemd.exec
sudo install -o root -g root -m 0644 deploy-app.service /etc/systemd/system/deploy-app.service
sudo systemd-analyze verify /etc/systemd/system/deploy-app.service
sudo systemctl daemon-reload
sudo systemctl enable --now deploy-app
sudo systemctl status deploy-app --no-pager
sudo journalctl -u deploy-app -n 80 --no-pager验证命令没有该单元相关错误后再启动。用回环 curl 分别检查 readiness 和 greeting,预期业务 message 为 systemd-one。若权限错误,执行 sudo -u deployapp test -r /opt/deploy-app/current/app.jar、namei -l /etc/deploy-app/application.properties 检查账号实际经过的路径;不要改成 root 运行。
修改单元后需要 daemon-reload;只修改应用配置后需要重启应用,不必重载 systemd 单元定义。连续失败达到启动限制时,先修日志里的根因,再 sudo systemctl reset-failed deploy-app 和重新启动。
新版本与恢复
单实例 restart 存在中断窗口。需要连续服务时,先在另一实例启动并验证,通过入口切换流量后再停止旧实例,见灰度发布。数据库结构、消息格式和外部副作用必须兼容恢复版本,否则仅切换 JAR 无法恢复业务。
新 JAR 已在受控交付目录校验后,安装到新的 releases/r2,不要覆盖 r1。下面用示例已有的 JAR 演示目录切换,它只验证切换机制,不代表完成了真实代码升级:
sudo install -d -o root -g root -m 0755 /opt/deploy-app/releases/r2
sudo install -o root -g root -m 0644 target/app.jar /opt/deploy-app/releases/r2/app.jar
sudo cp -a /etc/deploy-app/application.properties /etc/deploy-app/application.properties.r1
sudo cp -a /etc/deploy-app/runtime.env /etc/deploy-app/runtime.env.r1
sudo ln -s releases/r2 /opt/deploy-app/current.next
sudo mv -Tf /opt/deploy-app/current.next /opt/deploy-app/current
sudo systemctl restart deploy-app这些备份名和临时链接应尚未存在;实际发布使用唯一发布标识并保留权限。软链替换在同一目录中完成,正在运行的旧进程不会因链接变化自动换代码,因此仍需重启和重新验证。
失败时先保留新进程日志,确认允许恢复旧程序与配置,再执行:
sudo cp -a /etc/deploy-app/application.properties.r1 /etc/deploy-app/application.properties
sudo cp -a /etc/deploy-app/runtime.env.r1 /etc/deploy-app/runtime.env
sudo ln -s releases/r1 /opt/deploy-app/current.restore
sudo mv -Tf /opt/deploy-app/current.restore /opt/deploy-app/current
sudo systemctl restart deploy-app复查 readiness、业务内容和程序版本,之后再恢复入口流量。已完成的数据迁移、已发送消息和外部支付不会随着文件恢复而撤销,需要项目定义补偿或向前修复方案。
用 Docker Compose 运行同一个程序
镜像构建与运行身份
构建 JAR 后,在工程目录使用附带 Dockerfile:
FROM eclipse-temurin:25.0.4_7-jre
USER root
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY target/app.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]镜像安装 curl 时使用构建层 root,应用运行时使用 UID/GID 10001。curl 用于容器健康检查,不能在不含它的镜像里照抄探针。JAR 由 root 持有且普通用户可读,应用不能修改它。Dockerfile 参考
.dockerignore 只允许 target/app.jar 进入上下文,防止源码缓存、凭据和测试输出被带入镜像。它不能把整个 target 排除后又期待 COPY 成功,实际文件与规则需一起核对。
Compose 配置怎样生效
附带的 Compose 配置把宿主入口限制在回环地址,核心部分如下:
name: deploy-lab
services:
app:
image: local/deploy-app:1.0.0
build: .
environment:
SERVER_ADDRESS: 0.0.0.0
APP_MESSAGE: ${APP_MESSAGE:-compose-one}
JAVA_TOOL_OPTIONS: -Xms128m -Xmx256m
ports:
- "127.0.0.1:18090:8080"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,mode=1777
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 512m
cpus: 1.0
pids_limit: 128
stop_grace_period: 30s
restart: unless-stopped
healthcheck:
test: [CMD, curl, -q, --noproxy, '*', --fail-with-body, --silent, --show-error, --max-time, '2', 'http://127.0.0.1:8080/actuator/health/readiness']
interval: 5s
timeout: 3s
start_period: 30s
retries: 5
logging:
driver: local
options:
max-size: 10m
max-file: '3'容器中的应用必须监听容器网卡,因此设置 SERVER_ADDRESS=0.0.0.0;宿主端口仍只绑定 127.0.0.1。EXPOSE 只是镜像元数据,真正的宿主发布由 ports 决定。内存、CPU 和 PID 数是实验配额,生产按实测负载调整。
docker compose config --quiet
docker compose up -d --build --wait --wait-timeout 120
docker compose ps
docker compose logs --tail 60 app
curl -q --noproxy '*' --fail-with-body --silent --show-error --max-time 5 \
http://127.0.0.1:18090/api/greeting
docker compose exec app id -u
docker compose exec app id -g预期容器 healthy,message 为 compose-one,UID 为 10001。up --wait 在容器存在 healthcheck 时等待其健康状态;超时后查看应用日志及 docker inspect 的 State.Health,不直接延长等待掩盖配置错误。Compose 启动顺序
restart 重启现有容器,不更新创建时环境。对照实验:
APP_MESSAGE=compose-two docker compose restart app服务恢复后仍返回 compose-one。然后执行:
APP_MESSAGE=compose-two docker compose up -d --wait --wait-timeout 120 appCompose 根据变化重建容器,恢复后返回 compose-two。测试结束用 APP_MESSAGE=compose-one docker compose up -d --wait --wait-timeout 120 app 恢复原值。Compose restart
healthcheck 变为 unhealthy 通常不会让普通 Compose 的 restart policy 自动重启容器;后者主要响应进程退出。需要持续摘流与自愈时,要有相应监控或编排机制,不能把一次启动依赖排序当作长期调度。
Nginx 入口与容器网络
宿主 Nginx 访问本机发布端口,可以在目标 server 加入:
location ^~ /api/ {
proxy_pass http://127.0.0.1:18090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Forwarded "";
}
location ^~ /actuator/ { return 404; }这里保留 /api/ URI,并把 Nginx 作为单一可信入口覆盖转发字段。加载前 nginx -t,reload 后分别请求 greeting 与 /actuator/health:业务路径应正常,管理路径应 404。证书、默认站点和入口日志按 Nginx配置,不能把这段 location 当作完整站点。
若 Nginx 也在容器中,127.0.0.1 指向 Nginx 容器自身。两个服务应处于同一用户网络,通过 app:8080 访问;此时 app 可以不发布宿主端口。服务重建会改变容器 IP,Nginx 静态解析场景需要重载或适配版本的动态解析,参见 Compose 网络。
更新镜像时为新产物使用独立版本或受控 digest,保留旧镜像与配置。compose up 可以更新服务;普通单机 Compose 不提供自动滚动发布和失败回滚。恢复时明确选择旧镜像与旧配置,再验证,不通过 down 停掉整个依赖栈来代替应用更新。
结束本次无状态实验:
docker compose down只移除 deploy-lab 的容器和默认网络,镜像保留。业务应用的数据卷需按数据恢复方案处理,不在普通清理中追加 --volumes。
从进程与请求定位运行故障
先区分启动失败、请求失败和资源耗尽
| 现象 | 首次检查 | 修复后验证 |
|---|---|---|
| systemd 的 203/EXEC、217/USER | Java 绝对路径、执行权限、User/Group 是否存在 | 按服务账号检查可读/可执行,再启动 |
| 找不到配置、Bean 创建失败 | 最早异常、实际配置位置和激活 profile | 恢复必需文件或依赖,重启后查业务值 |
| Address already in use | sudo ss -lntp 的 PID、监听地址和端口 | 调整本服务端口或有依据地停止占用者 |
| 本机可用,入口 502 | Nginx 的网络空间、上游地址、容器发布与日志 | 从入口位置请求上游,再走外部路径 |
| readiness 失败 | health 响应与启动任务、所纳入的依赖 | 修具体组件,确认同样探针恢复 |
| HTTP 5xx,但进程存活 | 同一请求的日志、依赖超时和业务异常 | 复现并修请求路径,不能只重启进程 |
| 延迟上升、CPU 高 | 请求量、线程、GC、CPU 限额与热点代码 | 相同负载对照吞吐和尾延迟 |
| 容器突然退出 | ExitCode、OOMKilled、日志和宿主事件 | 确认内存来源后再调整配额或修泄漏 |
readiness 为 UP 后仍要验证应用业务契约。示例的无状态 greeting 不会访问数据库,所以它无法验证真实项目的数据连接、写入权限或事务行为;业务发布验证应选择安全的真实操作,使用专用测试数据并定义清理方式。
JVM 堆与进程内存
-Xmx256m 只约束 Java 堆。进程还使用 metaspace、线程栈、直接内存、JIT code cache、GC 与本地库内存;容器还可能计入文件缓存等。把容器限制也设成 256 MiB,会挤掉这些空间。
示例使用 512 MiB 容器限制与 256 MiB 堆,只是便于实验的配置。观察真实的峰值、线程数和直接缓冲需求后再决定比例。容器 OOMKilled 是内核层面的杀进程,可能来不及生成 Java OOME 或 heap dump。
在运行本实验的主机检查指定容器:
app_container=$(docker compose ps -q app)
test -n "$app_container"
docker inspect "$app_container" \
--format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} memory={{.HostConfig.Memory}}'
docker stats --no-stream "$app_container"137 常与 SIGKILL 有关,还可能来自人工 kill 或超时强制停止,须结合 OOMKilled 与宿主记录。CPU 限流也可能延长 GC 和请求处理,单看进程 CPU 百分比容易漏掉容器配额。
systemd 部署可以用其确定的 MainPID 定位,不用模糊 pgrep java 选进程。在同主机具有匹配 JDK 诊断工具且允许 attach 时:
app_pid=$(systemctl show deploy-app -p MainPID --value)
test "$app_pid" -gt 0
sudo -u deployapp /opt/java/bin/jcmd "$app_pid" VM.version
sudo -u deployapp /opt/java/bin/jcmd "$app_pid" Thread.print -lJRE 镜像未必有 jcmd,容器 PID/用户空间也需要匹配。线程处于 WAITING 只是当前状态,要结合调用栈、等待对象和时间变化判断;不能据此直接认定数据库连接池耗尽。更深入的 JFR、GC 和内存分析参见JVM 诊断。
heap dump 可能包含用户数据和凭据,且消耗磁盘与暂停时间。先选择应用可写的受控诊断目录、确认剩余容量和收集权限,再按问题需要采集,不把 dump 放公开目录或随意上传。
日志、指标与长期运行
systemd 路径默认输出到 journal,容器路径输出 stdout/stderr,分别由 journal 或 Docker 日志驱动限制容量。应用再写一套无限增长的文件会形成第二份保留责任。设置保留容量时同时考虑故障增长速度和调查所需时间。
sudo journalctl -u deploy-app -p warning -n 80 --no-pager
docker compose logs --tail 100 appjournal 的优先级取决于应用输出方式和识别规则;普通文本日志中的 ERROR 未必被映射成 journal error,查不到时回看未加优先级筛选的日志。筛选用于缩小范围,不能替代原始上下文。
HTTP 延迟和错误率、JVM 内存/GC、线程与连接池、外部依赖超时、进程重启次数需要连续观察。Actuator 的 Prometheus 端点还需要相应 registry 依赖与显式暴露,当前工程仅暴露 health,不能直接套用一个不存在的 /actuator/prometheus 地址。指标接入
依赖和 JDK 的安全更新进入正常发布流程:重新构建、运行测试、验证配置兼容、保留旧版本并演练恢复。内网运行可以使用已验证的 JAR 或镜像;拉取镜像的 Engine、镜像构建中的包管理器、Java 访问外部服务使用各自网络与信任配置,参见离线部署与构建制品。
