Files
live-hub-py/docs/上线清单-S1-S7部署.md
yml2213 26066858b6 docs: 记一个部署坑 —— 重建镜像后 up -d 不一定换容器
2026-10-18 部署礼包修复时实际踩到:`docker compose build` 出了新镜像
(ID 变了),`docker compose up -d douyu-login` 只打了句 Started,
容器仍是上一版(ps 显示 Up 11 hours,容器里搜不到新函数),差点把
「没生效」报成「部署成功」。

- AGENTS 坑 #10:重建过镜像就 `up -d --force-recreate <服务名>`
  (必须带服务名,不带会把 mysql 一起重建),部署后核对镜像 ID 或 grep 容器内新代码。
- 部署清单同步改命令(顺带给 `docker compose run` 补上 -T 与 </dev/null,
  同 AGENTS 坑 #5)。
2026-10-18 11:02:09 +08:00

255 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 上线清单:S1–S7 账号全生命周期(斗鱼)
> 已在本地的**生产克隆库**上完整演练(2026-10-04)。演练证据见文末。
> 演练环境:生产库 15,182 账号 / 14,324 持有 CK / 当前版本 `20261003_0040`。
---
## 0. 先明确现状(部署前必读)
| | |
|---|---|
| 生产迁移版本 | **`20261003_0040`** —— S1–S7(含 6 个迁移)**一个都没上** |
| `account_events` 表 | **不存在** |
| CK 在哪 | **`login_tasks.cookie`**(旧代码在跑,这是当前的唯一数据源) |
| 账号数 / 持有 CK | 15,182 / 14,324 |
| 整库 | 972 MB(其中 `douyu_tasks` 837 MB) |
本次上线内容:迁移 `0041 → 0046` + 对应代码。
**不含 S8(删旧列)** —— 那要等本次稳定运行之后再说。
---
## 1. 部署前检查(缺一不可)
```bash
# 1) 确认生产当前版本(应输出 20261003_0040)
docker exec live-hub-py-mysql-1 sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B douyu_login -e "SELECT version_num FROM alembic_version;"'
# 2) 备份(务必确认文件非空、且能列出来)
docker exec live-hub-py-mysql-1 sh -c \
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick \
--default-character-set=utf8mb4 --routines --triggers douyu_login' \
| gzip > /root/backup_$(date +%Y%m%d_%H%M).sql.gz
ls -lh /root/backup_*.sql.gz # 体积应接近 100MB 量级
gzip -t /root/backup_*.sql.gz && echo "备份完整性 OK"
# 3) 磁盘(迁移会给 accounts 加列、重建索引)
df -h /var/lib/docker # 至少留 5GB
# 4) CK 可解密(部署前基线,部署后要对比)
docker exec -i live-hub-py-douyu-login-1 python - <<'PY'
from web.backend.database import SessionLocal
from web.backend.models import LoginTask
db = SessionLocal()
rows = (db.query(LoginTask).filter(LoginTask.cookie.isnot(None), LoginTask.cookie != "")
.order_by(LoginTask.id.desc()).limit(5).all())
print([len(t.cookie or "") for t in rows]) # 应都是 ~1600,不是 0
db.close()
PY
```
> ⚠️ **第 4 步是本次演练唯一无法在本地做的检查**(本地没有生产的加密密钥)。
> 已在生产上实测:抽样 5 条 **5/5 解密正常**,长度 1613–1660。
---
## 2. 部署执行
**顺序:先停应用 → 再跑迁移 → 再起新代码。** 不要反过来。
```bash
# 1) 停应用(只停 app,数据库别停)
docker compose stop douyu-login
# 2) 跑迁移(实测 9 秒)
docker compose run --rm -T douyu-login alembic upgrade head </dev/null
# 3) 起新代码
# ⚠️ **镜像重建过就必须 --force-recreate + 服务名**:2026-10-18 踩到
# 「build 出了新镜像,up -d 只打了句 Started,容器还在跑旧镜像」。
# 带服务名只会重建 app,不会连 mysql 一起重建(见 AGENTS 坑 #10)。
docker compose up -d --force-recreate douyu-login
```
**为什么先停应用**:迁移期间 `accounts.cookie` 还没回填完,此时旧代码在跑会
继续往 `login_tasks` 写而新表接到不完整的状态。停机窗口实测 <1 分钟。
### 迁移做了什么(`0041 → 0046`)
| 迁移 | 内容 | 破坏性 |
|---|---|---|
| `0041` | 建 `account_events`、`audit_logs.account_id` | 无(纯新增) |
| `0042` ★ | `accounts` 加 15 列 + 3 索引;**CK/健康/重登进度从 `login_tasks` 回填**;**失败即中止**的自校验 | 无(只读旧表) |
| `0043` | 删掉废弃的收集器作业行 | 无 |
| `0044` ★ | `bind_status` 三列拆解(新增 `role_bind_status`/`esports_opened`,**归一化** 电竞/小店两列并收窄成 `varchar(16)`) | ⚠️ **唯一破坏性的一个** |
| `0045` | 放宽 `account_events.source_ref` 为可空 | 无(放宽) |
| `0046` | `accounts.points_expire_at`(积分到期用) | 无(纯新增) |
> **`0044` 是唯一需要留意的**:它会覆盖 `esports_bind_status` / `xpd_bind_status`
> 的旧自由文本取值。`accounts.bind_status` **原值未动**(保留到 S8)。
> 回滚要回到旧代码,必须先跑第 3 节的反向回填。
---
## 3. 部署后校验(逐条核对,任一条不过就按第 4 节回滚)
```bash
docker exec live-hub-py-mysql-1 sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -t douyu_login' <<'SQL'
-- ① 账号数不变(应为 15182)
SELECT COUNT(*) AS accounts FROM accounts;
-- ② CK 已搬到账号侧(应为 14323,等于旧侧去重账号数)
SELECT COUNT(*) AS account_side FROM accounts WHERE cookie IS NOT NULL AND cookie <> '';
SELECT COUNT(DISTINCT account_id) AS login_task_side FROM login_tasks
WHERE cookie IS NOT NULL AND cookie <> '';
-- ③ 精英绑定(预期 bound≈410 / pending≈75 / unbound 14 / 其余 unknown)
SELECT role_bind_status, COUNT(*) FROM accounts GROUP BY 1 ORDER BY 2 DESC;
-- ④ 电竞(预期 bound 360 / pending 66 / unbound 26;unbound 就是本次修的映射)
SELECT esports_bind_status, COUNT(*) FROM accounts GROUP BY 1 ORDER BY 2 DESC;
-- ⑤ esports_opened 救回(应为 4)
SELECT COUNT(*) FROM accounts WHERE esports_opened = 1;
-- ⑥ source_ref 可空(应为 YES)
SELECT is_nullable FROM information_schema.columns
WHERE table_schema='douyu_login' AND table_name='account_events' AND column_name='source_ref';
-- ⑦ 版本(应为 20261004_0046)
SELECT version_num FROM alembic_version;
SQL
```
**功能性验证(新代码起来后)**:
1. 打开账号列表 —— 状态卡有分布数字,账号行有 CK / 绑定 / 积分鱼翅 / 标签列
2. 打开任一账号详情 —— 生命周期状态卡正常、时间线能加载
3. **CK 仍可解密**(与部署前基线对比,长度量级一致)
4. 手动跑一次「账号检测」(选一个账号)—— 应能完成,且该账号详情页时间线出现一条检测事件
---
## 4. 回滚预案(**顺序不能错**)
> 🔴 **关键:`accounts.cookie` 在降级时会被 drop。S2 之后 CK 只写账号、不双写
> `login_tasks`。所以「部署期间新拿到的 CK」如果不先搬回去,会永久丢失。**
>
> CK 丢失是本项目唯一不能承受的损失(重登成功率接近 0、全量重登 ≈ 2.7 天)。
> **演练验证过:按下面的顺序,部署期间新拿到的 CK 能完整幸存。**
```bash
# 1) 先反向回填(把账号侧 CK/健康/重登进度搬回 login_tasks)
docker compose run --rm douyu-login \
python scripts/backfill_login_tasks_from_accounts.py --dry-run # 先看会改什么
docker compose run --rm douyu-login \
python scripts/backfill_login_tasks_from_accounts.py --apply
# 脚本自带自检:「账号侧 CK 数」必须 ≤「登录记录侧去重账号数」。
# 输出 «不通过 ❌» 就**不要**继续降级。
# 2) 再降级迁移
docker compose run --rm douyu-login alembic downgrade 20261003_0040
# 3) 最后切回旧代码镜像
docker compose up -d douyu-login # 用旧 tag
```
**回滚后应确认**:
| 检查 | 期望 |
|---|---|
| `alembic_version` | `20261003_0040` |
| `accounts` 行数 | 不变(15182) |
| `login_tasks` 里持有 CK 的去重账号数 | ≥ 14323,且**包含部署期间新增的** |
| `accounts.esports_bind_status` 类型 | 回到 `varchar(32)`(旧代码要写自由文本) |
| `accounts.bind_status` | 原值完好(S1–S7 从未改它) |
| `role_bind_status` / `esports_opened` / `points_expire_at` / `account_events` | 已 drop(预期) |
> 降级实测 **2 秒**。
---
## 5. 已知限制与后续
| 项 | 说明 |
|---|---|
| **`0044` 的旧值不可恢复** | 归一化覆盖了 `esports_bind_status`/`xpd_bind_status` 的旧自由文本取值。`bind_status` 原值保留、可人工比对。回退旧代码不影响(旧代码读 `bind_status`)。 |
| **积分到期金额是「预计」** | 到期时刻由作业补记,但**实际扣了多少没亲眼看到**,事件标 `confirmed=false`。 |
| **任务级失败不进时间线** | 设计上只记成功的事实;失败只在 `douyu_tasks.message`(保留 90 天)。 |
| **应用宝充值未并入** | 它按游戏角色记账、当前未启用(按决定先放着)。 |
| **S8(删旧列)** | 本次**不做**。要等稳定运行、且做了逻辑备份 + 反向回填演练之后。 |
| **`douyu_tasks` 837MB** | 保留策略作业(`douyu_tasks_retention`)随本次一起上线,但**默认关闭**,需要时手动打开。首次开启预计清掉 82,185 行(噪声档超 7 天的)。 |
---
## 6. 演练证据(本地生产克隆库)
| 步骤 | 结果 |
|---|---|
| 克隆生产库 | 15,182 账号 / 14,324 CK / 版本 `0040` / 27 张表 —— 与生产逐项一致 |
| **迁移全链** | 6 个迁移,**9 秒**,无错误(含 S1 的 fail-closed 自校验) |
| ① 账号数 | 15,182 ✓ 不变 |
| ② CK 搬运 | 账号侧 14,323 = 登录记录侧**去重账号数 14,323**(精确相等)✓ |
| ③ 精英映射 | bound 410 / pending 75 / unbound 14 ✓(与迁移前基线一致) |
| ④ 电竞映射 | bound 360 / pending 66 / **unbound 26** ✓ ← 修复生效 |
| ⑤ esports_opened | **4** ✓ ← 抢救生效 |
| ⑥ source_ref | `YES` ✓ |
| **回滚** | 6 个迁移降级,**2 秒**,`bind_status` 原值完好、两列回到 `varchar(32)` |
| **回滚前置脚本** | 模拟「部署期间新拿到的 CK」→ 回填 → 降级 → **该 CK 完整幸存** ✓ |
| **生产 CK 解密** | 抽样 5 条,**5/5 正常**(长度 1613–1660)✓ |
---
## 附:把生产库同步到本地(测试用)
生产已于 2026-10-04 部署完成(`main@2f610a5`,迁移 `20261004_0046`)。
本地已建立一份**与生产逐项一致**的镜像库,方便后续测试在本地跑。
```bash
# 重新同步(默认全量,含 douyu_tasks 的 837MB)
scripts/refresh_local_prod_mirror.sh
# 精简版(跳过 douyu_tasks 行,43MB,够绝大多数测试)
scripts/refresh_local_prod_mirror.sh --no-tasks
```
然后用镜像库跑应用 / 脚本:
```bash
DB_NAME=douyu_login_prod ./dev.sh
# 或
DATABASE_URL="mysql+pymysql://douyu_login:<pw>@127.0.0.1:3307/douyu_login_prod?charset=utf8mb4" ...
```
### 当前本地镜像的内容(2026-10-04 建立)
| | 本地 `douyu_login_prod` |
|---|---|
| 迁移版本 | `20261004_0046`(与生产一致) |
| 账号 / 持有 CK | 15,182 / 14,323 |
| 登录记录 | 15,169 |
| `douyu_tasks` | **253,424**(含 837MB JSON —— 可用来实测保留策略) |
| `account_events` | 34(部署后产生的真实事件) |
| 库大小 | 907 MB |
### 两个关键前提
1. **`APP_ENCRYPTION_KEY` 必须与生产一致**,否则镜像里的 CK 解不开、CK 相关测试全废。
已验证:当前本地 `.env` 的密钥**与生产一致**(抽样解密正常,CK 前缀 `dy_accounts_main`,长度 1659–1702)。
2. **不要在镜像库上跑测试套件**。测试用的是独立库 `douyu_login_test`
(由 `TEST_MYSQL_DATABASE` 指定,`init_test_schema` 会 `create_all`)。
镜像库只用于手工/联调。
### 踩过的坑(脚本里已规避)
- **不要把 `mysqldump` 串进 SSH 管道**:实测只有 ~49KB/s(MySQL 读 + gzip + 网络互相拖);
改成"服务器本地 dump 成文件 + rsync"后 ~8MB/s,快两个数量级。
- 用 **rsync 而非 scp**:可断点续传。
- **`docker compose run` 默认读 stdin** —— 脚本如果通过 `ssh 'bash -s' < 脚本` 喂进去,
它会把**脚本剩下的行**当 stdin 吃掉(这就是本次部署 90 秒停机的根因)。
正确写法:`docker compose run -T --rm ... </dev/null`。