403 lines
11 KiB
Markdown
403 lines
11 KiB
Markdown
# Graceful Shutdown Development Guide
|
||
|
||
本文档定义服务下线、重启和重新部署时必须遵守的进程生命周期规则。目标是让实例退出时不再接新流量,不截断关键请求,不留下错误 lease,不制造重复消费。
|
||
|
||
## Scope
|
||
|
||
适用范围:
|
||
|
||
- `gateway-service`
|
||
- `room-service`
|
||
- `wallet-service`
|
||
- `user-service`
|
||
- `activity-service`
|
||
- `room-service` outbox worker
|
||
- 后续所有 consumer worker
|
||
|
||
不在本文档内解决:
|
||
|
||
- 不设计业务补偿逻辑。
|
||
- 不定义房间命令语义。
|
||
- 不替代健康检查文档。
|
||
- 不依赖人工在发布时手动清理状态。
|
||
|
||
健康检查规则见:
|
||
|
||
```text
|
||
docs/健康检查.md
|
||
```
|
||
|
||
## Shutdown State Machine
|
||
|
||
所有服务必须实现相同状态机:
|
||
|
||
```text
|
||
starting -> running -> draining -> stopped
|
||
```
|
||
|
||
状态含义:
|
||
|
||
| State | Meaning |
|
||
| --- | --- |
|
||
| `starting` | 进程启动中,不应该接正式流量 |
|
||
| `running` | 进程 ready,可以接新请求或新连接 |
|
||
| `draining` | 进程正在下线,必须拒绝新流量并处理已有任务 |
|
||
| `stopped` | listener、worker、连接池已经关闭 |
|
||
|
||
进入 `draining` 后必须立即让 `ready=false`。`live` 是否继续成功取决于进程是否仍健康;正常下线期间 `live` 可以继续成功。
|
||
|
||
## Signal Handling
|
||
|
||
所有服务必须监听:
|
||
|
||
```text
|
||
SIGTERM
|
||
SIGINT
|
||
```
|
||
|
||
处理要求:
|
||
|
||
1. 第一次收到信号,进入 `draining`。
|
||
2. 第二次收到信号,可以加速退出,但必须打印未完成任务数量。
|
||
3. 超过 `shutdown_timeout` 后允许强制关闭,但必须记录被截断的资源类型。
|
||
|
||
推荐默认值:
|
||
|
||
| Config | Default |
|
||
| --- | ---: |
|
||
| `shutdown_timeout` | `30s` |
|
||
| `http_drain_timeout` | `20s` |
|
||
| `grpc_drain_timeout` | `20s` |
|
||
| `worker_drain_timeout` | `30s` |
|
||
| `room_snapshot_flush_timeout` | `10s` |
|
||
| `outbox_flush_timeout` | `10s` |
|
||
|
||
这些配置必须允许线上按服务覆盖。`room-service` 不建议使用过短 timeout,因为它要完成 Room Cell snapshot、command log 和 outbox 安全收尾。
|
||
|
||
## Required Order
|
||
|
||
标准下线顺序:
|
||
|
||
```text
|
||
1. mark draining
|
||
2. readiness returns false
|
||
3. stop accepting new requests/connections/work
|
||
4. wait for in-flight work
|
||
5. flush or release service-owned state
|
||
6. close listeners and clients
|
||
7. exit
|
||
```
|
||
|
||
不能先关闭 MySQL、Redis、gRPC client,再等待业务 drain。依赖提前关闭会导致正在处理的请求无法提交或无法释放状态。
|
||
|
||
## gateway-service
|
||
|
||
`gateway-service` 是 HTTP JSON 入口,优雅下线重点是停止接新请求并等待正在处理的 HTTP 请求结束。
|
||
|
||
进入 `draining` 后必须:
|
||
|
||
- `GET /healthz/ready` 返回 `503`。
|
||
- 停止从负载均衡接新流量。
|
||
- HTTP server 调用 graceful shutdown,不再 accept 新连接。
|
||
- 已经进入 handler 的请求继续执行,直到完成或 deadline 到期。
|
||
- 给下游 gRPC 调用保留原有请求 timeout,不因为进程下线取消所有已开始请求。
|
||
|
||
允许行为:
|
||
|
||
- 对新连接返回连接关闭或 `503`。
|
||
- 对超过 drain deadline 的请求返回 `503` 或上下文取消错误。
|
||
|
||
禁止行为:
|
||
|
||
- 收到 `SIGTERM` 后直接 `os.Exit`。
|
||
- 在 handler 未完成时关闭 room-service gRPC client。
|
||
- 继续通过 readiness 接收新请求。
|
||
|
||
## room-service
|
||
|
||
`room-service` 是房间状态 owner,下线时必须保护 Room Cell、Redis lease、snapshot、command log 和 outbox。
|
||
|
||
进入 `draining` 后必须:
|
||
|
||
- `ready=false`,让负载均衡摘流。
|
||
- 停止接管新房间。
|
||
- 停止创建新的 Room Cell。
|
||
- 停止获取新的 `room:route:{room_id}` lease。
|
||
- 对已经进入处理中的命令,允许在 deadline 内完成提交。
|
||
- 命令提交后必须完成必要 command log、snapshot dirty 标记和 outbox 写入。
|
||
- 对本节点持有的 Room Cell 执行 snapshot flush。
|
||
- 停止 node registry 心跳;gRPC drain 完成后删除 `room:node:{node_id}`。
|
||
- gRPC drain 完成后释放本节点仍持有的已加载房间 lease,让其他节点无需等待 `lease_ttl` 才能接管。
|
||
- outbox worker 处理完当前批次,未处理事件保持在 MySQL 中等待其他 worker 或下次启动补投。
|
||
|
||
Room Cell 下线顺序:
|
||
|
||
```text
|
||
1. cell stops accepting new commands
|
||
2. mailbox drains current command
|
||
3. command log/outbox transaction completes
|
||
4. snapshot flush runs with timeout
|
||
5. lease renew stops
|
||
6. owner route is released after in-flight commands finish
|
||
7. node registry is removed
|
||
8. cell is removed from local manager
|
||
```
|
||
|
||
如果 shutdown timeout 到期:
|
||
|
||
- 不能删除未安全释放的持久状态。
|
||
- 不能清空 outbox。
|
||
- 可以让 Redis lease 自然过期,由其他节点通过 snapshot + command log 恢复。
|
||
- 如果主动释放 lease 未完成,不能强删 MySQL 状态;让 Redis TTL 自然过期是安全兜底。
|
||
|
||
禁止行为:
|
||
|
||
- `ready=false` 后继续接管新 room lease。
|
||
- 还没刷 snapshot 就强制清空本地 Room Cell。
|
||
- outbox 投递失败后删除 outbox 记录。
|
||
- shutdown 时把 Redis route 直接改到另一个节点,除非有明确的 lease token 校验和接管协议。
|
||
|
||
## Tencent Cloud IM
|
||
|
||
客户端长连接、房间群消息、公屏、单聊、离线和漫游由腾讯云 IM 承载,本仓库不再部署自研 `im-service`,因此没有本地 WebSocket drain。
|
||
|
||
本仓库下线时只处理两个边界:
|
||
|
||
- `gateway-service` 不再接收 `/api/v1/im/usersig` 新请求,但已经进入 handler 的 UserSig 签发可以在 HTTP drain deadline 内完成。
|
||
- `room-service` 已提交的房间系统事件如果同步推腾讯云 IM 失败,必须保留在 `room_outbox`,由后续 worker 补投。
|
||
|
||
客户端重连策略由腾讯云 IM SDK 驱动。服务端不能假装能控制腾讯云连接关闭码,只能通过业务快照、房间系统事件和客户端重进群保证恢复。
|
||
|
||
## wallet-service
|
||
|
||
`wallet-service` 下线重点是不能截断扣费 RPC。
|
||
|
||
进入 `draining` 后必须:
|
||
|
||
- `ready=false`。
|
||
- gRPC server 停止接新 RPC。
|
||
- 已开始的扣费 RPC 在 deadline 内完成。
|
||
- 如果未来接入账务数据库,必须先完成事务提交或回滚,再关闭 DB 连接。
|
||
|
||
禁止行为:
|
||
|
||
- 关闭数据库连接后再等待扣费 RPC。
|
||
- 对未知提交状态的扣费请求返回成功。
|
||
|
||
## user-service
|
||
|
||
`user-service` 当前是基础服务骨架,下线重点是保持 gRPC 生命周期一致。
|
||
|
||
进入 `draining` 后必须:
|
||
|
||
- `ready=false`。
|
||
- 停止接新 gRPC 请求。
|
||
- 等待 in-flight 查询结束。
|
||
- 最后关闭外部依赖连接。
|
||
|
||
## activity-service
|
||
|
||
`activity-service` 未来会消费 room outbox 或 MQ 事件。下线重点是消费位点安全。
|
||
|
||
进入 `draining` 后必须:
|
||
|
||
- `ready=false`。
|
||
- 停止拉取新消息。
|
||
- 当前正在处理的消息必须完成以下任一结果:
|
||
- 成功处理并提交消费位点。
|
||
- 失败并不提交消费位点,等待后续重试。
|
||
- 明确写入失败状态,交给补偿逻辑处理。
|
||
|
||
禁止行为:
|
||
|
||
- 消息还没处理完就提交消费位点。
|
||
- 处理成功后因进程退出丢失位点提交。
|
||
- 同一消息在本地并发处理多次。
|
||
|
||
## Outbox Worker
|
||
|
||
Outbox worker 的下线必须保证“不会丢事件,可以接受重复投递”。
|
||
|
||
进入 `draining` 后必须:
|
||
|
||
- 停止扫描新的 outbox 批次。
|
||
- 当前批次逐条完成投递或释放。
|
||
- 投递成功且确认后,才能更新 outbox 状态。
|
||
- 投递失败或状态未知时,保留 outbox 记录为可重试状态。
|
||
- 退出前记录当前 batch size、成功数、失败数、未知数。
|
||
|
||
推荐事件状态:
|
||
|
||
```text
|
||
pending -> delivering -> delivered
|
||
pending -> delivering -> retryable
|
||
pending -> delivering -> failed
|
||
```
|
||
|
||
状态更新要求:
|
||
|
||
- `delivered` 只能在下游确认成功后写入。
|
||
- `retryable` 必须保留下一次投递所需 payload。
|
||
- `failed` 只用于不可重试错误,并且要有人工或补偿路径。
|
||
|
||
禁止行为:
|
||
|
||
- 进程退出时把 `delivering` 全部改成 `delivered`。
|
||
- 未确认成功就删除 outbox 记录。
|
||
- 同一 worker 关闭时继续抢新批次。
|
||
|
||
## Database And Redis Closing
|
||
|
||
依赖关闭顺序必须靠后。
|
||
|
||
推荐顺序:
|
||
|
||
```text
|
||
1. stop listeners
|
||
2. stop accepting new work
|
||
3. drain in-flight requests
|
||
4. flush snapshots/outbox/consumer offsets
|
||
5. close Redis clients
|
||
6. close MySQL pools
|
||
7. exit process
|
||
```
|
||
|
||
Redis 注意:
|
||
|
||
- lease 释放必须校验 `lease_token`。
|
||
- 不能删除不属于自己的 `room:route:{room_id}`。
|
||
- 无法确认 owner 时,让 lease 自然过期比错误删除更安全。
|
||
|
||
MySQL 注意:
|
||
|
||
- 不要在事务未提交时关闭连接池。
|
||
- shutdown 时不做 schema migration。
|
||
- shutdown 时不跑大范围清理 SQL。
|
||
|
||
## Docker And Deployment
|
||
|
||
Dockerfile 或部署系统必须给进程发送 `SIGTERM`,不能默认直接杀死。
|
||
|
||
Docker Compose 本地建议:
|
||
|
||
```yaml
|
||
stop_signal: SIGTERM
|
||
stop_grace_period: 45s
|
||
```
|
||
|
||
线上建议:
|
||
|
||
```text
|
||
terminationGracePeriodSeconds >= shutdown_timeout + 10s
|
||
preStop: mark instance not ready, wait load balancer drain window
|
||
```
|
||
|
||
发布系统顺序:
|
||
|
||
```text
|
||
1. start new instance
|
||
2. wait new instance ready=true
|
||
3. mark old instance draining
|
||
4. remove old instance from load balancer
|
||
5. wait drain timeout
|
||
6. stop old process
|
||
```
|
||
|
||
## Logging
|
||
|
||
下线日志只记录状态变化和关键资源结果,不要刷每秒心跳。
|
||
|
||
必须记录:
|
||
|
||
```text
|
||
service
|
||
node_id
|
||
signal
|
||
state
|
||
ready
|
||
inflight_requests
|
||
active_room_cells
|
||
outbox_batch_size
|
||
shutdown_deadline_ms
|
||
duration_ms
|
||
result
|
||
```
|
||
|
||
`room-service` 额外记录:
|
||
|
||
```text
|
||
room_id
|
||
lease_token
|
||
snapshot_flush_result
|
||
outbox_flush_result
|
||
command_inflight
|
||
```
|
||
|
||
不再记录本地 `conn_id`、`close_code`、`close_reason`。腾讯云 IM 连接诊断应通过腾讯云侧日志、客户端 SDK 回调和服务端 REST API 错误日志关联。
|
||
|
||
## Metrics
|
||
|
||
建议指标:
|
||
|
||
```text
|
||
service_draining
|
||
service_shutdown_duration_ms
|
||
service_shutdown_forced_total
|
||
inflight_requests
|
||
grpc_inflight_requests
|
||
http_inflight_requests
|
||
```
|
||
|
||
`room-service`:
|
||
|
||
```text
|
||
room_cells_draining
|
||
room_snapshot_flush_total
|
||
room_snapshot_flush_failed_total
|
||
room_lease_release_total
|
||
room_lease_release_failed_total
|
||
outbox_shutdown_pending
|
||
```
|
||
|
||
Worker:
|
||
|
||
```text
|
||
worker_inflight_messages
|
||
worker_shutdown_released_messages
|
||
worker_shutdown_committed_messages
|
||
```
|
||
|
||
## Test Requirements
|
||
|
||
每个服务至少覆盖:
|
||
|
||
- 收到 `SIGTERM` 后 `ready=false`。
|
||
- `draining` 后不再接受新请求或新连接。
|
||
- in-flight 请求能在 deadline 内完成。
|
||
- 超过 deadline 后会强制退出并记录日志。
|
||
- 依赖关闭顺序正确。
|
||
|
||
`gateway-service`:
|
||
|
||
- 构造一个慢 HTTP handler,确认 shutdown 等待已有请求。
|
||
|
||
`room-service`:
|
||
|
||
- 构造持有 Room Cell 的进程,发送 shutdown,确认停止获取新 lease。
|
||
- 确认 snapshot flush 被调用。
|
||
- 确认 outbox 当前批次不会被错误删除。
|
||
|
||
Worker:
|
||
|
||
- 构造投递中消息,shutdown 后状态必须是 `delivered`、`retryable` 或保持可重试,不能丢失。
|
||
|
||
## Do Not
|
||
|
||
- 不要用 `kill -9` 做正常发布。
|
||
- 不要在 `SIGTERM` 后继续接新请求。
|
||
- 不要在 `ready=false` 后继续接管新 room lease。
|
||
- 不要先关闭依赖再 drain 请求。
|
||
- 不要在 shutdown 里做大范围数据修复。
|
||
- 不要删除状态未知的 outbox 事件。
|
||
- 不要把消费者位点提交放在业务处理之前。
|