Files
photo-live/infra/deploy/RUNBOOK.md
T
浅念 a79fff5b9c docs(deploy): runbook with smoke script and emergency channel
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>
2026-09-21 01:40:04 +08:00

11 KiB
Raw Blame History

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. 迁移先行(若本次含迁移;无迁移跳过):

    # 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):

    # 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. 四点冒烟(发布完成的判定口径):

    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):

    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. 数据与同步状态保留原则(硬性):

    • 不清 sync_outbox / 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):

#!/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 的说明替换。