P5-prep Task 2(docs/plans/2026-09-21-P5-准备件.md): - infra/deploy/RUNBOOK.md:发布前检查单(备份/水位快照(outbox_events deliveredAt、sync_state appliedVersion)/镜像 tag/env diff/磁盘);发布步骤 (迁移先行 → ov 后 cn 逐容器替换 → 四点冒烟 → 观察);回滚步骤(镜像回旧 tag、迁移不回滚的向前兼容原则、outbox/inbox/水位账本硬性保留、冻结 sync 的故障处置);紧急停用通道(ov emergency HMAC 调用样例,openssl dgst 可 复制片段,签名公式与 internal-sync.ts 实现交叉验证一致);故障速查三类 (同步积压/计数差异/CDN 缓存与隐藏照片失效窗口)。 - scripts/prod-smoke.sh:四点冒烟(两地域 health → 登录 → 时间戳相册 + 内嵌 800x600 JPEG 真实 presign PUT → complete → cn/ov 公开读轮询 ≤60s), PASS/FAIL 摘要 + 软删照片清理;--dry-run 打印调用序列、--skip-ov/--ov 跳过 ov 段、--wait-seconds 可调;仅依赖 bash+curl+openssl+base64。 验证:bash -n 通过;--dry-run / --skip-ov / 用法错误三路径本地实测输出正确 (含密码 JSON 转义);真实栈未起(无凭证,符合准备件边界),首次真实验证 点在 README-1Panel.md §7 演练步骤。 Co-Authored-By: Claude Code <noreply@anthropic.com>
11 KiB
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 各自执行;顺序:迁移 → 服务 → 冒烟)
-
迁移先行(若本次含迁移;无迁移跳过):
# 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 ovPrisma
migrate deploy只应用未执行过的迁移,可安全重跑。 -
切镜像起服务(编排目录内;
SERVER_IMAGE/WEB_IMAGE指新 tag):# 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 -
四点冒烟(发布完成的判定口径):
scripts/prod-smoke.sh https://photo.example.cn https://photo.example.com <账号> <密码>覆盖:①两地域 /v1/health ②登录 ③建册→presign 上传→complete→公开可见 (worker 处理 + cn 公开读,轮询 ≤ 60s)④ov 公开读确认。脚本输出 PASS/FAIL 摘要并软删清理测试照片。任一 FAIL → 停在原地排查,必要时按 §3 回滚。
-
观察 10 分钟:sync 日志有
outbox batch pushed、无连续push failed; api 无 5xx 洪峰;1Panel 容器监控正常。之后发布记录归档(tag、时间、检查单)。
3. 回滚步骤(镜像回旧 tag;数据与同步状态保留)
-
决策口径:新版本故障且冒烟/观察发现 → 回滚镜像。迁移不回滚(发布前 检查单已保证旧代码兼容新 schema;反向迁移会破坏账本与水位)。
-
执行(与发布 §2.2 相同的命令,tag 换回上一个登记过的稳定 tag,顺序 ov→cn、 api→worker/sync→web):
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 -
数据与同步状态保留原则(硬性):
- 不清
sync_outbox/sync_inbox/sync_state/outbox_stat_events; 未确认事件在新一轮推送中按 eventId 幂等重放,水位断层才需要人工补漏 (对账循环 5min 自动按水位拉 reconcile,无需干预)。 - 不
migrate resolve/手工改_prisma_migrations;异常迁移记录单独记录并 上报,由开发者出修正迁移。 - 回滚后必须重跑 §2.3 冒烟 + 比对 §1 水位快照(outbox 计数只允许持平或下降 到「已确认」值,不允许突降为 0)。
- 不清
-
若故障来自数据本身(坏行导致 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):
#!/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-TimestampUnix 秒且与 ov 服务器时差 < 300s——先date +%s对时)。 - 若 ov 整域不可达(DNS/断网),本通道同样不可达——这是它设计上依赖 ov API 存活 的边界;整域故障直接在 1Panel 停 web-ov/api-ov 容器(服务面下线,数据面无恙)。
- 调用成功后在 cn 管理端补记事件(audit 无自动跨域记录,凭 reason 字符串回溯)。
5. 故障速查(三类典型)
5.1 同步积压(cn 相册更新后 ov 长时间不出现)
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)。- 修复连通后无需人工干预:失败退避封顶 60s 自动重推;对账循环每 5min 按水位 补漏;满批(100/批)连续追推排空积压。
- 单事件卡死(如 ov 库坏行):
docker compose stop sync冻结 → 定位sync_inbox对应 eventId 行与错误日志 → 上报开发 → 恢复up -d sync。
5.2 计数差异(ov 显示的点赞/浏览与 cn 统计对不上)
- 链路:ov
like_batches/visit_sessions聚合 →outbox_stat_events→ sync 统计回传循环(60s)→ cnstat-snapshots版本替换。差异先看回传: sync 日志stat snapshots returned是否持续推进。 docker logs --tail 50 photo-live-cn-sync-1有stat return cycle failed→ 多为 cn 库连接闪断,幂等重试自愈;持续失败查outbox_stat_events未确认行 (ov 库)与 cn 库可达性。- 版本不前进:cn
stat-snapshots的 statVersion 单调,若 ov 侧聚合停滞 (互动未入账)→ 查 api-ov 日志 likes/visits 429 与 Redis ov 连接。 - 窗口语义提醒:互动流水超窗口清理(like_batches 24h/visit 30min)只清流水,
累计总数永不清——对账以
stat-snapshots权威,勿手工 UPDATE 副本计数。
5.3 CDN 缓存(隐藏照片仍可见 / 旧图不刷新)
- 图片 URL 两类缓存语义:variants 内容寻址键(/variants/…按 photoId+规格,
长缓存 immutable)与签名 URL 短缓存(OSS 现签 GET,TTL=SIGNED_URL_TTL_
SECONDS=900s)。CDN 接入清单(P5-prep Task 3
cdn-checklist.md)落地前, 本节按「无 CDN」基线排查。 - 隐藏/删除照片后旧链接仍出图:先确认语义——公开 API 立即 404(PHOTO_NOT_ PUBLIC),但已签发的旧 URL 在 TTL 内仍可取到对象(设计允许的失效窗口, 上限 900s;P4a 契约决策)。超过 900s 仍可取 → 查 OSS 桶是否误配公共读。
- 缩略图/浏览图更新不刷新:variants 键含内容版本——同一 photoId 重新处理 会生成新键;旧键对象留存(禁止覆盖语义),前端拿到新签名 URL 即新键。若 页面仍显示旧图 → 浏览器本地缓存,强刷;CDN 接入后另见 cdn-checklist 的 失效 API 样例与隐藏照片失效窗口验证步骤(待凭证执行)。
本手册全部命令以 1Panel 目录约定 /opt/photo-live/ 与网络 1panel-network 为例;
实际路径/网络名不同处按 README-1Panel.md §4 的说明替换。