Skip to content

Linux 服务部署与运维 ​

S3-034必学进阶约 90 分钟JDK 21企业开发基础 · Linux 与 Shell

1. 本节定位 ​

让代码"能跑"的是 《Maven 基础》(构建)与 《Maven 进阶:依赖管理与多模块》(依赖)。本节让它**"稳定地跑在服务器上"**:

text
本地:     java -jar app.jar        (Ctrl+C 就结束了,可接受)
服务器:   服务要 7×24 运行、开机自启、挂了能自动拉起、能优雅停机 ★

本节最重要的一个概念是"优雅停机" ✓ ——它直接决定你的应用"重启时会不会丢数据/报错"。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出部署一个 Java 应用需要做的六件事;
  • 会用 nohup + & + PID 文件让进程在后台常驻,并知道这"手写方案"缺了什么 ★;
  • 会写一个 systemd unit 文件(ExecStart / User / Restart / WantedBy)★★;
  • 用 systemctl 管理服务(start / stop / restart / status / enable);
  • 用 journalctl 看服务日志;
  • 说出优雅停机的完整流程(SIGTERM → 等待 → SIGKILL)★★★;
  • 说出"应用该自己处理 SIGTERM 吗"(以及 Spring Boot 怎么配合);
  • 说出部署的基本流程与回滚方式。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看"只在本地跑过"的人第一次部署会踩什么:

问题原因
SSH 一断开,服务就停了 ✗进程是终端的子进程,终端关闭就被杀 ✗
服务器重启后要手动启动 ✗没有"开机自启"
进程挂了没人知道 ✗✗没有守护/自动重启
重启时用户看到一堆 500 ✗没有优雅停机(请求被直接掐断)✗✗

前三个是"运维不方便",第四个是"影响用户" ✓ ——本节的重点是第四个 ✓

5. 核心概念 ​

5.1 部署一个 Java 应用要做什么 ​

六件事(记住这个清单,部署时不会漏):

#事项说明
①装 JDK版本与本地一致(《JDK 与 Java 版本》)✓
②建专用用户不要用 root(《Linux 文件、用户与权限管理》)★
③放好 jar 与配置如 /opt/myapp/;配置外置(《Spring Boot 配置管理与多环境》)✓
④配置服务(systemd)开机自启、自动重启、日志 ✓
⑤开放端口防火墙 ✓
⑥验证健康检查、看日志 ✓

5.2 手工方案:nohup + & + PID ​

这是理解"服务管理到底在管什么"最好的方式 ✓ —— 本节的演示脚本就是这么做的。

bash
nohup java -jar app.jar > app.log 2>&1 &
echo $! > app.pid

四个要素:

要素作用
nohup忽略 SIGHUP(终端断开时进程不被杀)★
> + 2>&1日志重定向(《Linux 常用命令与部署》)✓
&放到后台 ✓
$! ★刚启动的后台进程的 PID ——记下来才能管理它 ✓

实测(本项目的 deploy-demo.sh):

text
启动 demo-service ...
启动成功,PID=1835
日志文件:/tmp/demo-service/app.log

demo-service 运行中(PID=1835)
运行中 status 退出码=0
--- 最后 10 行 ---
[2026-09-21 16:44:16] heartbeat
[2026-09-21 16:44:19] heartbeat
[2026-09-21 16:44:21] heartbeat
[2026-09-21 16:44:24] heartbeat

停止 demo-service(PID=1835)...
已停止 ✓
demo-service 未运行
停止后 status 退出码=1

★ 注意两处退出码:运行中 = 0,未运行 = 1 ✓ ——这就是监控与 CI 能判断服务状态的依据(《代码规范与自动校验》 的"退出码是关键")✓

检查进程存活的标准技巧:

bash
kill -0 "$pid"    # ★ 不发任何信号,只检查"进程是否存在"

这个手写方案缺了什么(这正是 systemd 存在的理由):

缺的systemd 怎么补
开机不自启 ✗systemctl enable ✓
挂了不会自动重启 ✗✗Restart=always ★
没有统一的启停命令systemctl start/stop/restart ✓
日志要自己管文件(轮转、清理)✗journalctl + 日志轮转 ✓
没有资源限制MemoryMax= / CPUQuota= ✓
依赖顺序(如先启数据库)After= / Requires= ✓

5.3 systemd:标准做法 ​

⚠️ 环境说明:systemd 只存在于 Linux(且需要是 systemd 发行版)。本机是 Windows,无法演示 systemctl ——本节这部分为概念与配置讲解,并给出可直接使用的配置模板 ✓

一个 unit 文件(/etc/systemd/system/myapp.service)★:

ini
[Unit]
Description=My Java Application
After=network.target            # 网络就绪后再启动 ✓
# After=postgresql.service      # 如果依赖数据库,写这里 ★

[Service]
Type=simple
User=deploy                     # ★ 专用用户,不用 root(S3-033)
Group=deploy
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar --spring.profiles.active=prod
# ★ 不要用 shell 包装(如 ExecStart=/bin/sh -c "...")—— 会让信号传递出问题,见 5.6

Restart=always                  # ★ 挂了自动重启
RestartSec=5                    # 重启前等 5 秒(避免"疯狂重启")✓
StandardOutput=append:/var/log/myapp/app.log
StandardError=append:/var/log/myapp/app.log

[Install]
WantedBy=multi-user.target      # enable 后开机自启 ✓

关键配置项:

配置含义建议
User= ★以哪个用户运行专用用户 ✓
ExecStart=启动命令绝对路径 ✓
Restart= ★什么时候重启always / on-failure ✓
RestartSec=重启间隔3~10 秒(避免瞬间重启打爆日志)✓
After=启动顺序依赖的服务
WorkingDirectory=工作目录相对路径的基准 ✓
Environment=环境变量或 EnvironmentFile=(推荐,便于管理)★

常用命令:

bash
systemctl start myapp              # 启动
systemctl stop myapp               # ★ 停止(会发 SIGTERM,见 5.6)
systemctl restart myapp            # 重启
systemctl status myapp             # ★ 看状态(还会显示最近几行日志)
systemctl enable myapp             # ★ 开机自启
systemctl disable myapp            # 取消开机自启
systemctl daemon-reload            # ★ 改了 unit 文件后必须执行 ✓
journalctl -u myapp -f             # ★ 实时看日志(-f = follow)
journalctl -u myapp --since "10 min ago"     # 看最近 10 分钟 ✓

⚠️ 一个新手必踩的坑:改了 .service 文件后不执行 daemon-reload → 改动不生效 ✗

5.4 优雅停机 ★★★ ​

这是本节最重要的内容 ✓

信号是什么 ​

Linux 用"信号"通知进程发生了什么 ✓

信号编号含义进程能"拒绝"吗
SIGTERM15请求退出(礼貌)★能(可以处理、可以清理)✓
SIGKILL9强制杀死 ✗不能(内核直接干掉)✗
SIGHUP1终端挂断(常用来"重新加载配置")✓能 ✓
SIGINT2Ctrl+C能 ✓

优雅停机的流程 ​

text
① 发 SIGTERM(15)                ← systemctl stop / kill <pid> 的默认行为 ★
② 应用收到后:
     停止接收新请求
     等待正在处理的请求完成(给一个超时,如 30 秒)
     关闭连接池、刷写缓存、释放资源
     自己退出
③ 如果超时还没退出 → 发 SIGKILL(9)   ← 强制(可能有请求被掐断)✗

本项目的脚本就是这么实现的(5.2 的实测)✓:

bash
kill "$pid"                       # ① 先礼貌请求(SIGTERM)
for _ in 1 2 3 4 5; do            # ② 最多等 5 秒
    sleep 1
    kill -0 "$pid" || break       #    还在吗?不在就结束
done
if kill -0 "$pid"; then
    kill -9 "$pid"                # ③ 超时才强制 ✗
fi

Spring Boot 怎么配合 ★★ ​

Spring Boot 默认已经处理了 SIGTERM ✓ —— 它会:

  1. 关闭 Web 容器(停止接收新请求)
  2. 等待正在处理的请求完成(有一个宽限期)
  3. 调用 @PreDestroy / DisposableBean(你可以在这里关资源)
  4. 关闭 ApplicationContext

关键的配合点(这是后端必须知道的):

要点说明
宽限期server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase=30s ★
K8s / systemd 的超时要大于应用的宽限期 ★★否则外部先发 SIGKILL,优雅停机就白做了 ✗
别用 ExecStart=/bin/sh -c "java ..." ⚠️sh 会"吃掉"信号(不转发给 java)→ 优雅停机失效 ✗✗

★ 第三条最容易踩:

ini
# ✗ 错误:sh 收到 SIGTERM,但它不会转发给 java
ExecStart=/bin/sh -c 'java -jar /opt/myapp/app.jar'

# ✓ 正确:直接启动 java
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar

后果:每次重启都有请求被掐断 ——用户看到 500,而你以为"我配了优雅停机" ✗

优雅停机为什么重要 ​

不做优雅停机的后果说明
用户看到 500 ✗正在处理的请求被中断
数据不一致 ⚠️事务未提交、消息未确认
监控误报重启期间的错误率尖峰
发版不敢在白天做 ✗只能半夜停机发布(这是"技术债"影响业务的典型例子)✓

5.5 日志:文件 vs journald ​

方式优点缺点
应用写文件(如 /var/log/myapp/app.log)★简单、可用 tail -f、便于采集要自己管轮转(否则撑爆磁盘)✗
journald(systemd 的日志)自动管理、可按服务查与"应用自己打文件"容易分裂 ⚠️
两者结合 ★应用写文件(给采集用)+ journalctl 看启动/崩溃—

日志轮转(必须配,否则磁盘会被撑爆):

text
# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
    daily                 # 每天轮转
    rotate 14             # 保留 14 份 ✓
    compress              # 压缩旧日志
    missingok             # 文件不存在也不报错
    copytruncate          # ★ Java 进程持有文件句柄时用这个(或让应用支持重开)
    # 或者:postrotate 里 systemctl reload / 发信号让应用重开日志
}

⚠️ copytruncate 的取舍:

copytruncate通知应用重开
做法复制一份再清空原文件发信号让应用重新打开日志
优点不需要应用配合 ✓不丢日志(几乎无窗口)✓
缺点复制与清空之间有一小段窗口,可能丢几行 ⚠️需要应用支持

现代做法:用 Logback 的 RollingFileAppender(《日志框架与日志规范》 讲过)——应用自己按大小/日期滚动,logrotate 只做兜底 ✓

5.6 部署流程与回滚 ​

一次发布的标准步骤:

text
① 备份当前版本(jar 与配置)★
② 停服务(优雅停机)
③ 放新 jar
④ 启动
⑤ 健康检查(curl 一个 /health 接口)★
⑥ 观察日志与指标(错误率、响应时间)
⑦ 有问题 → 回滚(把备份放回去,重启)✓

回滚为什么必须提前设计 ★:

"发布时才发现没有回滚方案"是最糟的时刻 ——那时你既慌又没时间 ✗

所以"备份"与"回滚命令"要在发布前就准备好 ✓

两种减小风险的做法:

做法说明
蓝绿部署两套环境,切换流量(回滚 = 切回去,最快)✓
滚动发布逐个实例更新(K8s 默认)✓
金丝雀先放 1% 流量,观察后再全量 ★

健康检查接口(Spring Boot Actuator)★:

bash
curl -f http://localhost:8080/actuator/health || echo "健康检查失败 ✗"

-f 很重要:HTTP 非 2xx 时 curl 返回非 0 退出码 → 脚本能判断成败 ✓

⚠️ 注意:/actuator/health 默认只显示"UP/DOWN",不暴露细节 ✓ ——不要为了"看得更清楚"把它暴露到公网(会泄漏数据库、磁盘等信息)✗

6. 递进示例 ​

6.1 最小示例:后台启动 ​

bash
nohup java -jar app.jar > app.log 2>&1 &
echo $! > app.pid

6.2 业务示例:systemd 托管 ​

见 5.3 的 unit 文件 + 5.4 的优雅停机配置 ✓

6.3 反例:用 kill -9 停服务 ​

bash
kill -9 $(cat app.pid)        # ✗

后果:

后果说明
正在处理的请求被掐断 ✗用户看到 500
资源没释放连接池、临时文件、锁
数据可能不一致 ⚠️事务未提交

正确做法:先 kill(SIGTERM),等一会儿,实在不行才 kill -9 ✓(5.2 的脚本就是这么做的)

7. 完整可运行演示 ​

⚠️ 环境说明:systemd 无法在 Windows 上演示。 本节提供了一个"手写版服务管理脚本" labs/engineering/deploy-demo.sh,它演示的是服务管理的核心机制(PID、后台、日志、优雅停机的信号流程)——这些在 Git Bash 上可以跑通;systemd 部分见 5.3 的配置模板 ✓

运行(Windows 用 Git Bash):

powershell
& "C:\Program Files\Git\bin\bash.exe" -c 'cd <仓库根目录>; bash labs/engineering/deploy-demo.sh start; bash labs/engineering/deploy-demo.sh status; sleep 4; bash labs/engineering/deploy-demo.sh log; bash labs/engineering/deploy-demo.sh stop; bash labs/engineering/deploy-demo.sh status'

预期输出:

text
启动 demo-service ...
启动成功,PID=1835
日志文件:/tmp/demo-service/app.log

demo-service 运行中(PID=1835)
运行中 status 退出码=0
--- 最后 10 行(用 tail -f 可以实时跟踪)---
[2026-09-21 16:44:16] heartbeat
[2026-09-21 16:44:19] heartbeat
[2026-09-21 16:44:21] heartbeat
[2026-09-21 16:44:24] heartbeat

停止 demo-service(PID=1835)...
已停止 ✓
demo-service 未运行
停止后 status 退出码=1

四处值得注意:

观察说明
PID 被写进文件 ★之后才能 stop / status($! 的价值)✓
日志在持续写入nohup ... >> log 2>&1 & 的效果 ✓
status 返回 0 / 1 ★运行中 = 0、未运行 = 1 ——可被监控/CI 判断 ✓
stop 先 TERM 再 KILL ★优雅停机的信号流程(5.4)✓

8. Debug 观察点 ​

  1. 启动后 ps -ef | grep java:确认进程真的在 ✓;
  2. kill -0 $(cat app.pid):练习"检查存活"的标准方式 ✓;
  3. 删掉 PID 文件再 status:观察它报"未运行"(PID 文件与实际进程可能不一致 ⚠️)★;
  4. 改 RestartSec:观察"崩溃后多久才重启" ✓;
  5. 在 Linux 上:systemctl stop 后立刻 ps ——观察进程不是立刻消失(它在优雅退出)✓✓ ——这个过程就是 5.4 的意义 ✓。

9. 常见错误 ​

错误 1:SSH 断开后服务停了 ​

原因:没用 nohup / 没托管给 systemd ✗

修:用 systemd(或至少 nohup ... &)✓

错误 2:ExecStart 用 sh -c 包一层 ​

见 5.4。信号被 sh 吃掉,优雅停机失效 ✗✗

错误 3:改了 unit 文件不 daemon-reload ​

见 5.3。改动不生效 ✗

错误 4:外部超时短于应用宽限期 ​

ini
# systemd 默认 TimeoutStopSec=90s(但有些环境更短)
# K8s 的 terminationGracePeriodSeconds 默认 30s

如果应用的宽限期是 60s 而外部只给 30s → 30 秒后被 SIGKILL → 优雅停机白做 ✗

修:让两边对齐(且外部 > 应用)✓

错误 5:日志没有轮转 ​

见 5.5。磁盘会被撑爆 ✗✗ ——而磁盘满会导致数据库等一切服务异常 ✓

错误 6:用 root 跑应用 ​

见 《Linux 文件、用户与权限管理》 的 5.8。

错误 7:发布没有回滚方案 ​

见 5.6。出事时才发现没退路 ✗✗

10. 动手实验 ​

目标:把"后台运行、PID、日志、优雅停机"亲手做一遍。

步骤:

  1. 按第 7 节跑一遍 deploy-demo.sh 的完整生命周期 ✓;
  2. 观察 PID 文件:cat /tmp/demo-service/app.pid —— 理解"为什么要记 PID" ✓;
  3. 模拟"进程没了但 PID 文件还在":kill -9 <pid> 后不删 PID 文件 → 跑 status —— 观察它怎么判断(kill -0 的作用)★;
  4. 用 tail -f 跟日志:启动时另开一个终端 tail -f /tmp/demo-service/app.log ✓;
  5. 在 Linux / WSL 上:写一个真正的 .service 文件并 systemctl 管理一遍 ✓;
  6. 验证优雅停机:在 Linux 上 systemctl stop 后立刻 ps —— 观察进程不是瞬间消失 ✓✓。

验收方式:能说出第 3 步"kill -0 为什么比"看文件在不在"可靠"。

扩展任务:给 deploy-demo.sh 加两个功能:

  • restart(stop + start)✓
  • "启动失败要报警":启动后等 3 秒,如果进程不在就输出错误并返回非 0 ✓;
  • 说明:为什么"启动脚本要检查'启动是否真的成功'"(而不是"命令执行了就算成功")?

11. 练习题 ​

基础题

  1. 判断:kill -9 是让进程优雅退出的正确方式。(对 / 错)
  2. 填空:让进程在 SSH 断开后继续运行用 ______;记录后台进程的 PID 用变量 ______;检查进程是否存在(不发信号)用 ______。
  3. 简答:为什么 ExecStart=/bin/sh -c 'java -jar app.jar' 会导致优雅停机失效?

代码阅读题

ini
[Unit]
Description=Order Service
After=network.target

[Service]
Type=simple
User=root
ExecStart=/bin/sh -c 'java -jar /opt/order/app.jar --spring.profiles.active=prod'
Restart=on-failure
RestartSec=1

[Install]
WantedBy=multi-user.target
  1. 这个 unit 文件有几处问题?分别指出并说明后果。
  2. 改成一个"合格"的版本。

编程题

  1. 写一个"发布脚本":
    • 需求:把新版本 jar 发布到服务器并验证;
    • 要求:
      • ① 备份当前 jar(带时间戳)★;
      • ② 优雅停止(SIGTERM + 等待 + 超时才 KILL)✓;
      • ③ 替换 jar;
      • ④ 启动;
      • ⑤ 健康检查(curl 一个接口,最多重试 10 次,每次间隔 3 秒) ★;
      • ⑥ 失败自动回滚(把备份放回去并重启)★★;
      • ⑦ 全程输出日志(便于事后复盘)✓;
    • 回答:为什么"健康检查要重试"而不是"启动后就检查一次"?
    • 回答:为什么"备份"必须在"停止服务之前"?

综合任务

  1. 设计一套"单机到多机"的部署演进方案:
    • 现状:一台服务器,手动 java -jar 启动,没有监控,发版要半夜停机;
    • 目标:白天也能发版,且单机故障不影响服务;
    • 要求:
      • 分阶段给出演进路线(每个阶段解决什么问题、引入什么);
      • 说明每个阶段的"停机时间"是多少;
      • 说明什么时候该引入负载均衡、容器化、编排(K8s);
      • 说明"应用要做什么改造才能支持多实例"(提示:无状态、会话、定时任务、文件存储)★;
    • 额外要求:说明"如果预算只允许做一件事,先做哪件",并说明理由。
分级提示

提示 1(方向):第 1 题的方向是"9 是强制,15 才是请求"。

提示 2(关键点):第 4 题至少有三处问题,注意 User 和 ExecStart。

提示 3(接近答案):第 7 题的"应用改造"——多实例最大的障碍是"本地状态"(session、本地文件、本地缓存、定时任务跑多次)。

12. 参考答案 ​

基础题
  1. 错。kill -9(SIGKILL)是强制杀死,进程无法做任何清理 ✗

正确的流程(5.4):

text
kill <pid>        # SIGTERM(15):请求退出,应用可以清理 ★
    ↓ 等待(给一个宽限期)
如果还在 → kill -9 <pid>     # 最后手段 ✗

为什么 -9 不能做"正常手段":

后果说明
正在处理的请求被掐断用户看到 500 ✗
事务未提交数据不一致 ⚠️
连接池/文件句柄没释放资源泄漏(如果是分布式锁,可能长期占着)✗

一个真实的场景:定时任务执行到一半被 KILL → 数据处于"改了一半"的状态 ✗✗ ——这就是为什么"优雅停机 + 幂等"都很重要 ✓

  1. nohup(配合 &);$!;kill -0 <pid> ✓

三个要点展开:

命令说明
nohup cmd &忽略 SIGHUP + 后台运行 ✓
echo $! > app.pid$! = 最近一个后台进程的 PID ★ ——记下来才能管理
kill -0 <pid>不发信号,只检查存在性 ✓ ——脚本里判断"是否还活着"的标准写法 ✓

⚠️ kill -0 的一个注意点:PID 会被复用 ——如果进程早就死了、PID 被分配给别的进程,kill -0 可能"误报存活" ⚠️

更稳的做法:结合"启动时间"或"进程名"再确认一次(生产环境的服务管理工具/systemd 已经处理了这些细节 —— 这就是用 systemd 而不是手写脚本的理由之一 ✓)

  1. 因为 sh 收到 SIGTERM 后,默认不会把它转发给子进程(java) ✗

流程对比:

text
正确(直接启动 java):
  systemd --SIGTERM--> java    → java 收到 → 优雅停机 ✓

错误(sh 包一层):
  systemd --SIGTERM--> sh      → sh 收到,但**不转发给 java**
                               → java 收不到 → 一直跑
                               → 超时后 systemd 发 SIGKILL → java 被强杀 ✗✗

所以:"优雅停机"需要"信号能一路传到应用进程" ✓ ——中间任何一层"吃掉"信号都会让它失效 ✓

这个坑很常见,因为:

为什么大家会这么写问题
想要"环境变量 + 管道 + 重定向"那些可以用 systemd 的 Environment= / StandardOutput= 代替 ✓
想要"java -jar $(ls *.jar)" 这种动态不如在 unit 里写死版本,发布时改 unit 或用软链接(如 app.jar -> app-1.2.0.jar) ★

★ 推荐做法:用软链接 ——/opt/myapp/app.jar 指向实际版本,发布时只改软链接的指向,unit 文件不用动 ✓

bash
ln -sfn /opt/myapp/app-1.2.0.jar /opt/myapp/app.jar
systemctl restart myapp

这也是"回滚"最方便的方式:把软链接指回上一个版本 ✓✓

代码阅读题(第 4、5 题)

第 4 题:三处问题

#问题后果
1User=root ✗应用有漏洞时整台机器沦陷(《Linux 文件、用户与权限管理》 5.8)✗✗
2ExecStart=/bin/sh -c '...' ✗✗信号被 sh 吃掉 → 优雅停机失效 ✗(见基础题 3)
3RestartSec=1 ⚠️重启间隔太短 —— 如果应用启动就失败(如配置错),会每秒重启一次,日志被刷爆 ✗

还有两处可改进:

#改进理由
4没写 WorkingDirectory相对路径(如 ./logs)的基准不明确 ⚠️
5没有 StandardOutput日志只会进 journald,应用自己的日志文件不会生成 ⚠️(取决于你想要哪种)

第 5 题:合格版本

ini
[Unit]
Description=Order Service
After=network.target
# 如果依赖数据库:
# After=postgresql.service
# Requires=postgresql.service

[Service]
Type=simple
User=deploy                    # ★ ① 专用用户
Group=deploy
WorkingDirectory=/opt/order    # ★ ② 明确工作目录
# ★ ③ 直接启动 java,不用 sh 包装
ExecStart=/usr/bin/java -jar /opt/order/app.jar --spring.profiles.active=prod
# 或者用软链接:ExecStart=/usr/bin/java -jar /opt/order/app.jar

Restart=on-failure             # 只在失败时重启(always 会让"手动 stop"也被重启 ⚠️)
RestartSec=5                   # ★ ④ 合理的间隔
TimeoutStopSec=60s             # ★ ⑤ 给优雅停机留出时间(要 ≥ 应用宽限期)

StandardOutput=append:/var/log/order/app.log
StandardError=append:/var/log/order/app.log

[Install]
WantedBy=multi-user.target

几个改动点的说明:

改动说明
User=deploy最小权限 ✓(但要确保 /opt/order 与 /var/log/order 属主是 deploy)✓
Restart=on-failure ⚠️always 会导致你 systemctl stop 后它又起来 ✗ ——排障时会很困扰;on-failure 更符合直觉 ✓
TimeoutStopSec=60s ★★必须 ≥ 应用内部的宽限期(5.4 的错误 4)——否则外部先 KILL ✗
StandardOutput=append:append 表示追加(不覆盖已有日志)✓

对应的 Spring Boot 配置(让两边对齐):

yaml
server:
  shutdown: graceful              # ★ 优雅停机
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s     # ★ 宽限期 30s < TimeoutStopSec=60s ✓

★ 这个"30 < 60"的不等式就是优雅停机的全部要点 ✓

编程题(第 6 题)
bash
#!/usr/bin/env bash
# deploy.sh —— 发布并自动回滚
set -uo pipefail

APP_DIR=/opt/order
JAR="$APP_DIR/app.jar"
BACKUP_DIR="$APP_DIR/backup"
PID_FILE="$APP_DIR/app.pid"
LOG_DIR=/var/log/order
HEALTH_URL="http://127.0.0.1:8080/actuator/health"
NEW_JAR="${1:?用法:deploy.sh <新 jar 路径>}"

mkdir -p "$BACKUP_DIR" "$LOG_DIR"

log() { echo "[$(date '+%F %T')] $*"; }

# ① 备份(★ 带时间戳,且在停止服务之前)★
if [ -f "$JAR" ]; then
    backup="$BACKUP_DIR/app-$(date '+%Y%m%d-%H%M%S').jar"
    cp "$JAR" "$backup" || { log "✗ 备份失败,中止"; exit 1; }
    log "已备份到 $backup"
else
    log "当前没有已部署的 jar(首次发布)"
    backup=""
fi

# ② 优雅停止
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
    pid=$(cat "$PID_FILE")
    log "停止服务(PID=$pid,先 SIGTERM)"
    kill "$pid"
    for _ in $(seq 1 30); do                    # ★ 最多等 30 秒
        sleep 1
        kill -0 "$pid" 2>/dev/null || break
    done
    if kill -0 "$pid" 2>/dev/null; then
        log "  超时,强制终止 ✗"
        kill -9 "$pid" 2>/dev/null
    fi
fi

# ③ 替换 jar
cp "$NEW_JAR" "$JAR" || { log "✗ 替换失败"; exit 1; }
log "已替换 jar"

# ④ 启动
nohup java -jar "$JAR" --spring.profiles.active=prod >> "$LOG_DIR/app.log" 2>&1 &
echo $! > "$PID_FILE"
log "已启动,PID=$(cat "$PID_FILE")"

# ⑤ 健康检查(★ 重试,因为应用启动需要时间)
healthy=0
for attempt in $(seq 1 10); do
    sleep 3
    if curl -fsS --max-time 3 "$HEALTH_URL" > /dev/null 2>&1; then
        log "健康检查通过(第 $attempt 次)✓"
        healthy=1
        break
    fi
    log "  健康检查未通过,重试 $attempt/10 ..."
done

# ⑥ 失败则回滚
if [ "$healthy" -ne 1 ]; then
    log "✗ 健康检查失败,开始回滚"
    pid=$(cat "$PID_FILE" 2>/dev/null)
    [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null

    if [ -n "$backup" ]; then
        cp "$backup" "$JAR"
        nohup java -jar "$JAR" --spring.profiles.active=prod >> "$LOG_DIR/app.log" 2>&1 &
        echo $! > "$PID_FILE"
        log "已回滚到 $backup,PID=$(cat "$PID_FILE")"
    else
        log "首次发布失败,没有可回滚的版本 ✗"
    fi
    exit 1
fi

log "发布完成 ✓"
exit 0

两个关键问题的回答:

为什么"健康检查要重试" ​

因为应用启动不是瞬间的 ✓

阶段耗时
JVM 启动1~2 秒
Spring 容器初始化几秒到几十秒(取决于 Bean 数量、是否初始化连接池)
首次请求(懒加载、JIT 预热)可能更慢

如果"启动后立刻检查一次":几乎必然失败(那时应用还没初始化完)→ 明明发布成功了,脚本却判定失败并回滚 ✗✗

所以要有"重试 + 超时":

text
最多 10 次 × 间隔 3 秒 = 最多等 30 秒

这个"总超时"要与应用的启动时间匹配(应用启动要 60 秒,你只等 30 秒 → 误判)⚠️

还有一个细节:每次 curl 也要 --max-time(否则一个卡住的请求会拖垮整个流程)✓

为什么"备份"必须在"停止服务之前" ​

两个理由:

理由说明
① 备份的是"正在运行的版本" ★停止后 jar 不会被修改,但在停止前备份才能保证"备份的就是刚才在跑的那个" ✓
② 万一停止/替换步骤出问题备份已经安全了,随时能恢复 ✗

更根本的一点:

"备份"是"回滚能力"的前提 ——没有备份就没有回滚,发布就是在赌 ✗

所以备份要放在"任何破坏性操作之前" ✓

这与 《Git 分支、合并与回退》 的"reset --hard 前先记下 HEAD"、《Linux 文件、用户与权限管理》 的"打包成功再删原文件"是同一种谨慎 ✓

补充:生产环境的备份还应该:

  • 保留多个版本(不只最新一个)✓
  • 放在别的机器/对象存储(本机磁盘坏了就没了)✗
  • 记录"哪个备份对应哪个版本"(否则回滚时不知道该选哪个)✓
综合任务(第 7 题)

演进路线(五个阶段)

阶段做什么停机时间解决了什么
① 服务化systemd 托管 + 优雅停机 + 日志轮转几秒(一次重启)SSH 断开不停、崩溃自启、优雅停机 ✓
② 脚本化发布发布脚本(备份 + 健康检查 + 自动回滚)几秒,且失败自动回滚 ✓发版不再靠人记得每一步 ✓
③ 多实例 + 负载均衡 ★两台服务器 + Nginx0(滚动重启)✓✓单机故障不影响服务 ✓
④ 容器化Docker 镜像0环境一致、部署单元标准化 ✓
⑤ 编排K8s0自动扩缩、自愈、滚动发布 ★

★ 阶段 ③ 是"能白天发版"的分水岭 ✓ ——因为它是第一个能做到"零停机"的 ✓

阶段的判断标准:不要跳步 ——如果阶段 ① 没做(没有优雅停机),即使上了 K8s,重启时还是会掐断请求 ✗

工具不能替代应用自身的准备 ✓

这与 《MySQL 性能优化基础》 的"先归档再考虑分库分表"同理:先做成本低的基础项 ✓

应用要做什么改造才能支持多实例 ★

核心:消除"本地状态" ✓

本地状态问题改造
Session ★用户 A 的请求打到实例 1,下次打到实例 2 → 登录状态没了 ✗① Token(JWT)(无状态)★ ② Session 存 Redis(Spring Session)✓
本地缓存(如 HashMap 缓存配置)★实例间不一致 ⚠️用 Redis / 配置中心 ✓
本地文件(上传的文件)★实例 1 存的,实例 2 读不到 ✗对象存储(S3/OSS)✓
定时任务 ★★每个实例都跑一次 → 重复执行 ✗✗① 分布式锁 ② 独立的调度服务 ③ 只让一个实例跑(如 K8s 的 CronJob)✓
本地锁(synchronized)只能锁本实例 ✗分布式锁(Redis / DB)⚠️
内存计数器各实例各算各的 ✗Redis 计数 ✓

★ 定时任务这一条最容易被忘,后果也最严重:

  • 重复的对账任务 → 重复打款 ✗✗
  • 重复的清理任务 → 通常还好,但浪费资源

判断"你的应用能否多实例"的最快方法:

问自己:如果同一个用户的两次连续请求打到不同实例,会有问题吗?再问:如果这个应用同时在两台机器上跑,定时任务会怎样? ✓

什么时候引入什么

引入什么时候
负载均衡 ★只要需要"高可用"或"零停机发布" ——这是最优先的 ✓
容器化① 环境不一致的痛很明显 ② 要标准化部署 ③ 有 CD 需求 ⚠️(不是必需)
K8s ⚠️① 实例多(几十个以上)② 需要自动扩缩 ③ 有专门的运维能力 ——小团队不要为了"先进"上 K8s ✗✗

⚠️ 关于 K8s 的提醒:

K8s 的运维复杂度很高 ——8 人团队维护 5 个服务,"两台机器 + Nginx + 脚本"往往比 K8s 更合适 ✓

这与 《Git Flow 与团队协作》 的"别为了规范选重分支模型"、《MySQL 性能优化基础》 的"归档优于分库"是同一类判断 ✓

技术选型要匹配"团队规模与运维能力",而不是"听起来先进" ✓

预算只做一件事,先做哪件

答:阶段 ③ —— 加一台服务器 + 负载均衡(并配合优雅停机与滚动发布) ✓

理由:

理由说明
直接达成目标 ★★"白天能发版"(零停机)+ "单机故障不影响服务"(高可用)——这两个目标它一步到位 ✓✓
成本可控一台机器 + 一个 Nginx(比引入 K8s 便宜太多)✓
不依赖应用大改造 ⚠️至少读多写少的服务可以先把"无状态的部分"摘出来 ✓
为后续铺路 ★有了 LB 之后,容器化、K8s 都是"在上面的演进",不会推翻重来 ✓

⚠️ 但有一个前提:必须先做阶段 ①(优雅停机) ✓

text
有 LB + 没有优雅停机:
  滚动重启时,被摘掉的实例上"正在处理的请求"还是会被掐断 ✗
  → 用户仍然会看到 500

所以完整答案是:

"先做优雅停机(阶段 ①,成本最低),再加负载均衡(阶段 ③,达成目标)" ✓

如果只能选一件"花钱的",选负载均衡;但优雅停机必须先补上 ✓

这也体现了本知识库反复出现的思路:

先做成本低、收益明确的基础项(优雅停机、脚本化), 再上"看起来高级"的东西(容器、编排)✓

因为前者往往是后者能否发挥作用的前提 ✓

13. 掌握标准 ​

  • [ ] 能说出部署 Java 应用的六件事;
  • [ ] 会用 nohup + & + $! 让进程后台常驻并记录 PID ✓;
  • [ ] 会用 kill -0 检查进程存活 ✓;
  • [ ] 能写一个 systemd unit 文件(User / ExecStart / Restart / RestartSec / WantedBy)★;
  • [ ] 知道改了 unit 文件要 daemon-reload;
  • [ ] 能说出优雅停机的完整流程(SIGTERM → 等待 → SIGKILL)★★★;
  • [ ] 知道"不要用 sh -c 包 java",以及它为什么会让优雅停机失效 ★★;
  • [ ] 知道外部超时(TimeoutStopSec / K8s grace period)要大于应用宽限期 ★;
  • [ ] 会配日志轮转;
  • [ ] 能说出发布流程与回滚方案。

14. 扩展资源 ​

15. 下一步学习 ​

《Shell 脚本基础》:把本节的手工步骤变成可重复执行的脚本。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引