c26d6b9a73
Reviewer findings on p5-prep batch:
1. BLOCKING: prod-smoke.sh visibility grep used "id" but API items
serialize photoId (cn public.service / ov public-ov.service) — every
healthy deployment would false-FAIL release gate ④. 4 sites fixed.
2. web/web-ov healthchecks used curl on nginx:alpine (absent) → perpetual
false-unhealthy; switched to busybox wget.
3. RUNBOOK rollback rule cited nonexistent sync_outbox → outbox_events.
4. cdn-checklist invalidation sample key shape aligned to actual
variantsObjectKey layout (.../albums/{albumId}/variants/{photoId}/{version}/{variant}.jpg).
Co-Authored-By: Claude Code <noreply@anthropic.com>
187 lines
11 KiB
Markdown
187 lines
11 KiB
Markdown
# RUNBOOK —— 发布 / 迁移 / 回滚 / 紧急通道运行手册(P5-prep Task 2)
|
||
|
||
> 适用对象:photo-live 双地域生产栈(编排见 `docker-compose.cn.yml` /
|
||
> `docker-compose-ov.yml`,环境说明见 `README-1Panel.md`)。
|
||
> 原则:**一切发布向前兼容**(迁移只前滚不回滚;回滚=旧镜像跑新 schema);
|
||
> **同步账本(outbox / sync_inbox / sync_state 水位 / outbox_stat_events)任何
|
||
> 情况下不清理不重置**——幂等与补漏对账都建立在水位连续之上。
|
||
|
||
## 0. 角色与容器速查
|
||
|
||
| 地域 | 容器 | 角色 | 端口(容器内) |
|
||
| --- | --- | --- | --- |
|
||
| cn | api-cn | 写路径 API + cn 公开读 + user-sync | 8080(/v1/health) |
|
||
| cn | worker | sharp 变体流水线 + 发布事务 + outbox | 无 HTTP |
|
||
| cn | sync | outbox→ov 推送 + 对账 + 统计回传(双库直连) | 无 HTTP |
|
||
| cn | web | nginx:viewer + admin + /v1 反代 | 宿主机 127.0.0.1:8088 |
|
||
| ov | api-ov | ov 公开读 + 互动 + WS + inbox + emergency | 8081(/v1/health) |
|
||
| ov | web-ov | nginx 网关 | 宿主机 127.0.0.1:8089 |
|
||
|
||
## 1. 发布前检查单(每次发布,逐项打勾)
|
||
|
||
- [ ] **变更范围确认**:本次代码是否含 Prisma 迁移(`packages/db-*/prisma/migrations`
|
||
新增目录)?含则必须先走 §2 迁移步骤,且确认迁移向前兼容(新增列可空/带默认、
|
||
不删旧列不收窄类型——旧镜像需能跑在新 schema 上)。
|
||
- [ ] **数据库备份**(两地域各自):
|
||
`docker exec <mysql容器> mysqldump -uroot -p<密码> --single-transaction photo_cn | gzip > photo_cn_$(date +%Y%m%d%H%M).sql.gz`
|
||
(ov 用 photo_ov;备份文件留存 ≥ 2 份历史)。
|
||
- [ ] **水位与账本快照登记**(回滚后比对用,只记不删):
|
||
cn:`SELECT count(*) FROM outbox_events WHERE deliveredAt IS NULL;`(未投递账本)
|
||
ov:`SELECT entityType, entityId, appliedVersion FROM sync_state;`(行内水位)
|
||
+ sync 容器最近 20 行日志截图/粘贴。
|
||
- [ ] **镜像 tag 登记**:新 tag = `git短sha-日期`(server 与 web 同 tag 配套),登记到
|
||
发布记录;两台服务器均已导入该 tag(`docker images | grep photo-live`)。
|
||
- [ ] **env 变更 diff**:`.env.production-*` 与仓库 example 对比,确认无缺键、
|
||
`SYNC_INTERNAL_KEY` 两端一致、无真实密钥入 git。
|
||
- [ ] **磁盘与负载水位**:1Panel 概览确认磁盘 < 80%、MySQL/Redis 内存正常;
|
||
worker 队列无积压(api-cn 日志无 process job 超时)。
|
||
- [ ] **冷备窗口**:选择业务低峰;ov 端确认无正在进行的展示位活动(嵌入轮播依赖
|
||
ov 公开读可用性)。
|
||
|
||
## 2. 发布步骤(cn 与 ov 各自执行;顺序:迁移 → 服务 → 冒烟)
|
||
|
||
1. **迁移先行**(若本次含迁移;无迁移跳过):
|
||
|
||
```bash
|
||
# cn 服务器
|
||
docker run --rm --env-file /opt/photo-live/.env.production-cn --network 1panel-network \
|
||
photo-live-server:<新tag> migrate-deploy cn
|
||
# ov 服务器
|
||
docker run --rm --env-file /opt/photo-live/.env.production-ov --network 1panel-network \
|
||
photo-live-server:<新tag> migrate-deploy ov
|
||
```
|
||
|
||
Prisma `migrate deploy` 只应用未执行过的迁移,可安全重跑。
|
||
2. **切镜像起服务**(编排目录内;`SERVER_IMAGE`/`WEB_IMAGE` 指新 tag):
|
||
|
||
```bash
|
||
# ov 先(cn sync 依赖 ov 可达)
|
||
SERVER_IMAGE=photo-live-server:<新tag> WEB_IMAGE=photo-live-web:<新tag> \
|
||
docker compose -f docker-compose-ov.yml up -d
|
||
docker compose -f docker-compose-ov.yml ps # 等 api-ov healthy
|
||
# cn 后,逐容器替换(单实例"滚动":起稳一个再下一个)
|
||
SERVER_IMAGE=photo-live-server:<新tag> WEB_IMAGE=photo-live-web:<新tag> \
|
||
docker compose -f docker-compose.cn.yml up -d api-cn
|
||
docker compose -f docker-compose.cn.yml ps # api-cn healthy 后
|
||
SERVER_IMAGE=photo-live-server:<新tag> docker compose -f docker-compose.cn.yml up -d worker sync web
|
||
```
|
||
|
||
3. **四点冒烟**(发布完成的判定口径):
|
||
|
||
```bash
|
||
scripts/prod-smoke.sh https://photo.example.cn https://photo.example.com <账号> <密码>
|
||
```
|
||
|
||
覆盖:①两地域 /v1/health ②登录 ③建册→presign 上传→complete→公开可见
|
||
(worker 处理 + cn 公开读,轮询 ≤ 60s)④ov 公开读确认。脚本输出 PASS/FAIL
|
||
摘要并软删清理测试照片。任一 FAIL → 停在原地排查,必要时按 §3 回滚。
|
||
4. **观察 10 分钟**:sync 日志有 `outbox batch pushed`、无连续 `push failed`;
|
||
api 无 5xx 洪峰;1Panel 容器监控正常。之后发布记录归档(tag、时间、检查单)。
|
||
|
||
## 3. 回滚步骤(镜像回旧 tag;数据与同步状态保留)
|
||
|
||
1. **决策口径**:新版本故障且冒烟/观察发现 → 回滚镜像。**迁移不回滚**(发布前
|
||
检查单已保证旧代码兼容新 schema;反向迁移会破坏账本与水位)。
|
||
2. 执行(与发布 §2.2 相同的命令,tag 换回上一个登记过的稳定 tag,顺序 ov→cn、
|
||
api→worker/sync→web):
|
||
|
||
```bash
|
||
SERVER_IMAGE=photo-live-server:<旧tag> WEB_IMAGE=photo-live-web:<旧tag> \
|
||
docker compose -f docker-compose-ov.yml up -d
|
||
SERVER_IMAGE=photo-live-server:<旧tag> WEB_IMAGE=photo-live-web:<旧tag> \
|
||
docker compose -f docker-compose.cn.yml up -d
|
||
```
|
||
|
||
3. **数据与同步状态保留原则**(硬性):
|
||
- 不清 `outbox_events` / `sync_inbox` / `sync_state` / `outbox_stat_events`;
|
||
未确认事件在新一轮推送中按 eventId 幂等重放,水位断层才需要人工补漏
|
||
(对账循环 5min 自动按水位拉 reconcile,无需干预)。
|
||
- 不 `migrate resolve`/手工改 `_prisma_migrations`;异常迁移记录单独记录并
|
||
上报,由开发者出修正迁移。
|
||
- 回滚后必须重跑 §2.3 冒烟 + 比对 §1 水位快照(outbox 计数只允许持平或下降
|
||
到「已确认」值,不允许突降为 0)。
|
||
4. 若故障来自**数据本身**(坏行导致 sync/apI 循环报错):先 `docker compose stop
|
||
sync`(冻结推送,cn 本地功能不受影响),保留现场日志与 outbox 行,联系开发
|
||
处理;恢复后 `up -d sync` 从冻结水位继续。
|
||
|
||
## 4. 紧急停用通道(ov 侧 emergency,独立于同步链路)
|
||
|
||
用途:跨境同步中断/内容事故时,**直接调用 ov 的 emergency 端点**下架单册内容;
|
||
语义:仅允许更严屏蔽(disable),不修改 cn 权威数据,不因普通同步事件自动解除,
|
||
恢复需显式 enable(技术方案 §2A;鉴权=与 sync 同一把 `SYNC_INTERNAL_KEY` 的
|
||
HMAC-SHA256 原始请求体签名 + ±300s 时间戳)。
|
||
|
||
**可复制样例**(bash + openssl + curl;OV_BASE=ov 域名,KEY=SYNC_INTERNAL_KEY):
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
OV_BASE="https://photo.example.com"
|
||
KEY="你的SYNC_INTERNAL_KEY"
|
||
ALBUM_TOKEN="目标相册publicToken" # 与 albumId 恰好提供其一
|
||
REASON="2026-09-21 事故下架:演示"
|
||
|
||
BODY=$(printf '{"albumToken":"%s","reason":"%s"}' "$ALBUM_TOKEN" "$REASON")
|
||
TS=$(date +%s)
|
||
# 签名 = hex(HMAC-SHA256(原始请求体字节, KEY));curl -d 发送的必须是同一字符串
|
||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$KEY" | awk '{print $NF}')
|
||
|
||
curl -sS -X POST "$OV_BASE/v1/internal/emergency/disable" \
|
||
-H "Content-Type: application/json" \
|
||
-H "x-sync-timestamp: $TS" \
|
||
-H "x-sync-signature: $SIG" \
|
||
-d "$BODY"
|
||
# 恢复:body 换 {"albumToken":"..."}(无 reason),端点换 /enable,签名同样重算
|
||
```
|
||
|
||
注意:
|
||
- 401 = 签名/时间戳问题(两端正则:`X-Sync-Signature = hex(HMAC-SHA256(rawBody, KEY))`,
|
||
`X-Sync-Timestamp` Unix 秒且与 ov 服务器时差 < 300s——先 `date +%s` 对时)。
|
||
- 若 ov 整域不可达(DNS/断网),本通道同样不可达——这是它设计上依赖 ov API 存活
|
||
的边界;整域故障直接在 1Panel 停 web-ov/api-ov 容器(服务面下线,数据面无恙)。
|
||
- 调用成功后在 cn 管理端补记事件(audit 无自动跨域记录,凭 reason 字符串回溯)。
|
||
|
||
## 5. 故障速查(三类典型)
|
||
|
||
### 5.1 同步积压(cn 相册更新后 ov 长时间不出现)
|
||
|
||
1. `docker logs --tail 100 photo-live-cn-sync-1`:连续 `outbox push failed` →
|
||
看 err:`401/403` = SYNC_INTERNAL_KEY 两端不一致或时钟漂移;`timeout/ECONNREFUSED`
|
||
= cn 服务器到 OV_API_URL(公网域名)不通;`P1001` = 到 ov MySQL 3306 不通
|
||
(防火墙/白名单/SSL)。
|
||
2. 修复连通后无需人工干预:失败退避封顶 60s 自动重推;对账循环每 5min 按水位
|
||
补漏;满批(100/批)连续追推排空积压。
|
||
3. 单事件卡死(如 ov 库坏行):`docker compose stop sync` 冻结 → 定位
|
||
`sync_inbox` 对应 eventId 行与错误日志 → 上报开发 → 恢复 `up -d sync`。
|
||
|
||
### 5.2 计数差异(ov 显示的点赞/浏览与 cn 统计对不上)
|
||
|
||
1. 链路:ov `like_batches`/`visit_sessions` 聚合 → `outbox_stat_events` →
|
||
sync 统计回传循环(60s)→ cn `stat-snapshots` 版本替换。差异先看回传:
|
||
sync 日志 `stat snapshots returned` 是否持续推进。
|
||
2. `docker logs --tail 50 photo-live-cn-sync-1` 有 `stat return cycle failed` →
|
||
多为 cn 库连接闪断,幂等重试自愈;持续失败查 `outbox_stat_events` 未确认行
|
||
(ov 库)与 cn 库可达性。
|
||
3. 版本不前进:cn `stat-snapshots` 的 statVersion 单调,若 ov 侧聚合停滞
|
||
(互动未入账)→ 查 api-ov 日志 likes/visits 429 与 Redis ov 连接。
|
||
4. 窗口语义提醒:互动流水超窗口清理(like_batches 24h/visit 30min)只清流水,
|
||
累计总数永不清——对账以 `stat-snapshots` 权威,勿手工 UPDATE 副本计数。
|
||
|
||
### 5.3 CDN 缓存(隐藏照片仍可见 / 旧图不刷新)
|
||
|
||
1. 图片 URL 两类缓存语义:**variants 内容寻址键**(/variants/…按 photoId+规格,
|
||
长缓存 immutable)与**签名 URL 短缓存**(OSS 现签 GET,TTL=SIGNED_URL_TTL_
|
||
SECONDS=900s)。CDN 接入清单(P5-prep Task 3 `cdn-checklist.md`)落地前,
|
||
本节按「无 CDN」基线排查。
|
||
2. 隐藏/删除照片后旧链接仍出图:先确认语义——公开 API 立即 404(PHOTO_NOT_
|
||
PUBLIC),但**已签发的旧 URL 在 TTL 内仍可取到对象**(设计允许的失效窗口,
|
||
上限 900s;P4a 契约决策)。超过 900s 仍可取 → 查 OSS 桶是否误配公共读。
|
||
3. 缩略图/浏览图更新不刷新:variants 键含内容版本——同一 photoId 重新处理
|
||
会生成新键;旧键对象留存(禁止覆盖语义),前端拿到新签名 URL 即新键。若
|
||
页面仍显示旧图 → 浏览器本地缓存,强刷;CDN 接入后另见 cdn-checklist 的
|
||
失效 API 样例与隐藏照片失效窗口验证步骤(待凭证执行)。
|
||
|
||
---
|
||
|
||
*本手册全部命令以 1Panel 目录约定 `/opt/photo-live/` 与网络 `1panel-network` 为例;
|
||
实际路径/网络名不同处按 README-1Panel.md §4 的说明替换。*
|