gongxiangp2/docs/视频预览任务中断事故复盘-2026-08-11.md
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

184 lines
17 KiB
Markdown
Raw 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.

# 视频预览任务中断事故复盘(2026-08-11)
## 结论
本次“新视频一直没有封面”不是浏览器、源视频格式、对象存储或内存爆掉造成的。直接原因是:发布切换只等待网页连接结束,却没有等待后台视频队列结束;旧容器在 FFmpeg 转码过程中被正常停止,新容器又没有在启动时自动接回数据库里的未完成任务。
页面还有第二层问题:15 天保护规则会有意隐藏 `videoSourceUrl`,首页却把“是否有源地址”当成启动状态轮询的条件。因此后台即使稍后生成了封面,已经打开的页面也可能一直显示旧占位,直到用户手动刷新。
排查还发现同一稳定性链路的第三层风险:旧上传入口会在没有总量门禁时直接解析 multipart 并整体复制源文件;Nginx 又允许约 1GB 请求并先缓冲到临时盘。大请求可能同时推高 Node.js 内存和系统盘占用,而且缓冲期间没有旧容器 upstream 连接,发布脚本会误以为上传已经结束。
## 生产证据
- 截图中的 11 条素材在 10:21–10:24 已成功建档并进入队列,不是漏上传。
- 旧容器 10:58 开始处理第一条,720P 已上传、10 秒片段处理到约 72% 时,容器在发布切换中被正常停止;不是 OOM,也不是进程崩溃。
- 新容器启动后没有执行视频自动恢复。数据库留下 1 条 `processing` 和 10 条 `pending`,心跳停在旧容器停止时刻。
- 11/11 正式源视频仍在,字节数与数据库一致,FFprobe 均可正常解析。
- 本轮按精确名单安全恢复了 16 条可恢复素材的封面、720P 预览和短片,并逐条核对数据库、对象存储与源文件;已知缺失源文件的模型 290 未被盲目重试或伪造结果。
## 误导链路与复发入口
| 文件 | 隐患 | 用户可见结果 |
| --- | --- | --- |
| `scripts/git-incremental-deploy.ps1` | 旧版 drain 只看 HTTP/TCP 连接,不知道后台 FFmpeg 和等待队列 | 网页请求结束后就停止旧容器,后台任务被截断 |
| `app/api/materials/upload/route.ts` | 上传后异步入队;入队失败时旧逻辑还会删除正式源视频 | 数据库有素材,但源文件可能被删,无法安全重做 |
| `lib/video-transcode-queue.ts` | 主要状态在单个 Node 进程内;数据库记录不是跨容器唯一执行租约 | 重启后丢队列,蓝绿重叠时还可能重复执行 |
| `app/api/materials/transcode/route.ts` | 恢复只在系统管理员访问接口时懒触发 | 普通上传者无法让新容器接回中断任务 |
| `instrumentation.ts` | 过去只恢复“任务-详情”后台任务,没有恢复视频 | 服务健康但视频队列静默停住 |
| `lib/video-processor.ts` | 旧逻辑在 S3 预览上传后、数据库完成回写前删除正式源视频 | 此窗口崩溃会留下“预览未入库、源文件也没了” |
| `lib/video-download-policy.ts` | 15 天保护期隐藏源视频和预览视频地址,这是正确的访问控制 | 页面不能依赖这些字段判断后台任务状态 |
| `app/page.tsx` | 首页过去只有 `videoSourceUrl` 存在才轮询;上传后只刷新一次 | 保护期内的卡片不会自动拿到新封面 |
| `components/home/MaterialCard.tsx` | 没源地址就隐藏进度,15 天遮罩还盖住失败/重试 | 用户只看到“15天后可查看详情”,看不到真正失败原因 |
| `app/api/materials/upload/route.ts` 与 `/chunk` | 旧版缺少请求总量、分片、逻辑源和写盘硬上限 | 大请求可能拖高内存或写满上传盘,半文件残留 |
| 本机 Nginx 上传规则 | 旧版允许约 1GB 且默认先缓冲请求 | 系统临时盘承压;发布 drain 看不到尚未转发的上传 |
| 买家秀/小红书批量上传 | 多批次曾并发发送;小红书附加图片虽发送却未被后端处理 | 多个大 multipart 同时占内存,附加图片静默丢失 |
## 永久修复
### 后台任务
- 用 PostgreSQL advisory transaction lock 加素材级 token/过期时间,保证蓝绿容器之间同一素材只有一个有效执行者。
- 成功、失败、续租、进度和释放都必须匹配当前租约;旧容器租约失效后会中止 FFmpeg,不能回写覆盖新容器结果。
- 应用启动立即恢复,随后每 60 秒重扫;数据库暂时不可用时下一轮继续,不再依赖管理员打开页面。
- 正常完成和入队失败都不再删除 canonical 源视频;只清理本次任务创建的临时副本。
- FFmpeg 不可用或三类输出不完整时明确失败,禁止把空预览误标为 `done`。
- 本地源视频直接只读使用,远端兼容输入和中间产物落在 uploads/NAS 的任务目录;磁盘状态未知或空间不足时不启动 FFmpeg,避免把大视频复制到系统盘。
- 预览视频、片段和封面从文件流式上传,不再整体读入 Node.js 内存;FFmpeg 任意非零退出、超时或被取消都明确失败。
- 转码完成时重新锁定并读取数据库最新值,只填仍为空的封面/首页预览字段,不覆盖用户在排队或转码期间后补的手动结果。
### 发布切换
- 新 drain 协议由当前容器实例写入 capability;共享盘残留的旧文件不能冒充当前容器。
- 旧容器有活动任务、状态未知或协议不匹配时一律拒绝停止;失败路径会恢复旧队列。
- 候选容器带 drain 启动,所有切流门禁通过后才允许恢复视频任务;切流提交后旧槽保持 drain,避免回滚清理逻辑把旧 worker 再次唤醒。
- 意外残留的 direct 容器也必须走同一套 capability、drain、active 验证;禁止直接 `rm -f`。未完成独立隔离前,影子部署入口 fail-closed。
- 第一次从旧镜像升级时旧容器不支持新协议,自动部署会按设计 fail-closed,必须按本文“首次过渡”单独执行,不能删门禁强推。
### 页面
- 首页根据 `pending/processing` 状态轮询,不再依赖被 15 天规则隐藏的源视频地址。
- 多卡片请求错峰,常规间隔改为 15 秒,429 按 `Retry-After` 退避。
- 正常排队可能超过 30 分钟,页面不再用固定时长停止轮询;只在完成、失败、素材离开当前列表或页面关闭时清理。
- 处理中、失败和重试按钮位于 15 天遮罩之上,并增加实时状态和进度语义。
- 完成后重新获取素材列表,从标准列表接口取得经过权限策略和 URL 重写的封面地址。
### 上传与内存保护
- 普通 multipart 在解析前强制验证长度,最大 128MiB;源文件达到 5MiB 必须走分片。
- 分片单块最大 20MiB,初始化时固定分片布局,每个位置的声明与实际字节数都必须精确匹配;超限或不一致立即删除半文件,不能用很多合法小请求突破会话总大小。断点续传始终沿用原会话布局,不会因公网/内网切换改用另一种切片方式。
- 服务端在解析 multipart 分片前先进入最多 8 路的处理槽,每次重试写入唯一临时文件,完整校验后原子替换 `chunk-N`;既保留上传吞吐,也避免并发解析占满内存或同一分片交错写坏。
- 逻辑源最大 10GiB;初始化前确认 uploads/NAS 可同时容纳分片与合并文件并保留安全余量,状态未知时拒绝开始。合并阶段全局单槽并再次复查“完整文件大小 + 2GiB”,写入唯一 partial、同步落盘后才原子改名,任何失败都会清理本次半成品。
- `complete` 是幂等操作:首次合并成功但公网响应丢失时,可依据当前用户的 `meta.json` 和已校验 merged 文件返回同一结果,不要求重新上传整个源文件。
- 普通图片/建档处理限制同时活跃数并设置有界等待队列;大文件分片网络上传保持原有并发能力。
- 买家秀和小红书改为逐批提交;小红书附加图片进入标准展示图字段,不再被静默忽略。
- 本机代理按主上传 `129m`、分片 `21m`、其余上传 JSON 子路由 `2m` 精确分层,关闭上传请求缓冲,使上传期间 upstream 连接始终可被蓝绿 drain 观察。
- 通用部署脚本检测到历史 direct 拓扑时在任何变更前拒绝继续;一次性 direct→proxy 迁移必须另走停新上传、确认连接和后台任务均为空的受控流程。
## 数据安全边界
- 任何重做前先确认正式源文件存在、大小一致且可解析;缺源素材不得盲排。
- 修复预览时先做不可变保全副本并核对 SHA256;验收通过前不清理保全文件。
- 不把 S3 HEAD 成功、健康接口 200 或数据库状态 `done` 单独当作成功;必须同时核对数据库字段、三类对象 HEAD、实际解码和 canonical 源文件。
- 新增 migration 只增加可空租约列和索引,不删除或改写现有素材数据;生产执行前仍需数据库备份和迁移记录核对。
## 首次生产过渡门禁
第一次从不支持 drain capability 的旧镜像升级时,不能删除普通发布脚本的门禁,也不能在共享盘伪造 capability 文件。唯一允许的入口是 `scripts/legacy-worker-bootstrap-deploy.ps1`;它默认只做审计,写操作必须同时显式提供 `-Apply` 和 `-AllowLegacyDrainBootstrap`。
### 过渡前准备
1. 明确获得生产迁移和部署授权,确定短时 503 维护窗口,并冻结唯一的 40 位目标 Git SHA。
2. 在触碰流量和数据库前,先用普通发布脚本的 `-SkipDeploy` 路径构建并推送同一 SHA 的 runner;migrator 必须按《生产数据库安全增量迁移 SOP(2026-08-12)》独立构建,并通过已固定主机指纹的 SSH 传到生产机。runner 与 migrator 分别记录自己的 image ID/digest、OCI revision 和 baked commit,二者必须来自同一 SHA,但不能误写成同一个 digest。在生产机只预拉取/导入镜像,不启动应用。维护窗口内不得再临时找镜像或改目标提交。
3. 在生产机只读取得下面全部现场值;任何一项为空、存在多值歧义或随后发生变化,都停止过渡:
```bash
legacy=youyidian-3d-material
docker inspect -f '{{.Id}}' "$legacy"
docker inspect -f '{{.Image}}' "$legacy"
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$(docker inspect -f '{{.Image}}' "$legacy")"
docker inspect -f '{{with index .NetworkSettings.Networks "youyidian-3d_app-network"}}{{.IPAddress}}{{end}}:3000' "$legacy"
docker port "$legacy" 3000/tcp
findmnt -rn -o TARGET,SOURCE,FSTYPE -T /mnt/PoolOne/youyidian-uploads
stat -f -c '%i:%t' -- /mnt/PoolOne/youyidian-uploads
```
4. 把上述值和目标镜像值逐项填入一个 PowerShell splat;不要把密码、Token 或 `.env` 内容写入命令、文档或 Git:
```powershell
$bootstrap = @{
TargetCommit = '<40位小写Git SHA>'
LegacyContainerId = '<64位旧容器ID,不是短ID>'
LegacyImageId = 'sha256:<64位旧镜像ID>'
LegacyImageRepoDigest = '<旧镜像仓库>@sha256:<64位digest>'
LegacyUpstream = '<旧容器IPv4>:3000'
UploadsStatfsSignature = '<stat -f输出>'
PublishedEndpoints = '0.0.0.0:4320,[::]:4320' # 必须按 docker port 实际输出填写
TargetImageReference = '<目标镜像仓库>@sha256:<64位digest>'
TargetImageId = 'sha256:<生产机上的64位目标镜像ID>'
TargetImageRepoDigest = '<目标镜像仓库>@sha256:<64位digest>'
}
```
如果线上仍使用兼容挂载 `/mnt/PoolOne/apps/youyidian-3d/uploads`,还必须显式传入 `UploadsMount`、`UploadsMountTarget=/mnt/PoolOne` 和 `UploadsMountSource=PoolOne`;不得让脚本从旧容器自动推断挂载路径。
### 只读审计与维护交接
先执行只读审计。它会核对完整容器 ID、image ID/RepoDigest、当前 upstream、全部发布端点、ZFS source/target/statfs、目标镜像 digest/提交号,并列出数据库中所有非终态视频、任务详情 job 和 `taskStatus=uploading` 素材;任一计数非零即失败。
```powershell
.\scripts\legacy-worker-bootstrap-deploy.ps1 @bootstrap -Mode Audit
```
审计通过并再次确认维护窗口后,才允许进入一次性维护交接:
```powershell
.\scripts\legacy-worker-bootstrap-deploy.ps1 @bootstrap `
-Mode EnterMaintenance `
-Apply `
-AllowLegacyDrainBootstrap
```
该步骤在生产机同时持有 bootstrap deployment flock 和代理 reconciliation flock;用带唯一注释的 IPv4/IPv6 防火墙规则只阻断新连接,并同时取得两套连接证据:宿主侧 `ss`,以及宿主 `nsenter` 进入固定旧容器 network namespace 后对服务端口 3000 的 `ss`。两侧都必须连续两次 `ESTABLISHED=0`;只看宿主到容器 IP 的连接在 Docker DNAT 且未启用 userland-proxy 时可能漏掉仍在上传的公网连接。随后再次检查数据库三类非终态计数和宿主机/旧容器 FFmpeg,再 `docker pause` 旧容器并用固定目标镜像的非应用 Node 入口只读查询数据库,以关闭“检查完又启动任务”的竞态。全部通过后使用 `docker stop --signal SIGKILL --time 0`:普通 stop 会在发送优雅退出信号后恢复 paused 容器,存在旧用户代码短暂重跑的窗口,因此这里不能使用普通 stop。脚本必须确认旧容器精确进入 `exited`,再用固定目标镜像复查数据库三类计数并确认宿主机无 FFmpeg,才允许改名保存旧容器;旧镜像不删除。蓝槽的视频和任务详情 drain marker 会预先创建,但 capability 必须由新容器启动后自行发布。
成功后的入口会明确返回 HTTP 503、`Retry-After` 和 `X-Material-Proxy-Target: legacy-bootstrap-maintenance`。这是短维护交接点,不是部署成功。只有到此时,写请求和旧后台 worker 才被技术手段停止;不能以“当前没人使用”代替这个门禁,也不能在 bootstrap 前重建生产索引。
保持 503 维护状态,按《生产数据库安全增量迁移 SOP(2026-08-12)》依次执行:机器生成的生产备份、隔离库恢复、源库与恢复库有界摘要一致性验证、四份 additive migration,以及 migration checksum/列/索引/default 的只读后验审计。任何一步失败都停止,保留旧容器、旧镜像、备份和维护入口;不得继续切流。
上述数据库证据全部通过后,才用普通脚本发布同一个精确 SHA,并保留旧镜像:
```powershell
.\scripts\git-incremental-deploy.ps1 `
-Target production `
-Commit $bootstrap.TargetCommit `
-MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-<UTC时间>.json' `
-MigrationAuditSha256 '<审计文件的64位小写SHA-256>' `
-KeepOldImages
```
普通脚本仍必须独立验证候选镜像内置提交号、数据库 schema/migration、真实视频与任务详情 capability、预置 drain、健康接口和切流目标。bootstrap 不会创建候选容器,也不会绕过普通门禁。
### 失败恢复边界
- bootstrap 在维护交接提交前的可确认安全状态失败,会在同一进程内恢复精确旧容器 ID 和原直连入口;旧容器或旧镜像绝不 `rm`。一旦已发出 SIGKILL,但停止状态、停止后的数据库 idle 或宿主机无 FFmpeg 不能被证明,脚本会保持入口阻断、保留旧容器并要求人工恢复,不会在证据未知时自动重启旧 worker。
- 普通部署失败且入口仍明确是 `legacy-bootstrap-maintenance`、蓝绿容器都未运行时,可用相同 pins 执行下面的恢复命令。恢复脚本会再次核对保存容器的完整 ID/image ID/RepoDigest;只要候选可能已经接收流量,就拒绝自动恢复,避免两个应用世界同时写生产数据。
```powershell
.\scripts\legacy-worker-bootstrap-deploy.ps1 @bootstrap `
-Mode RestoreLegacy `
-Apply `
-AllowLegacyDrainBootstrap
```
- 如果恢复门禁拒绝执行,保留维护入口、保存的旧容器和候选现场,先人工判定真实代理目标及是否已有写请求;不能强制启动旧容器。
- 首个新容器上线后验证自动恢复、60 秒重扫、租约唯一性、源文件保留、健康接口和公网真实页面。再用一条小视频做 canary:上传、排队、生成三类预览、卡片自动出现封面、源文件仍在;任一步失败立即停止继续放量,不回滚 additive 数据列。
## 验收清单
- 服务重启前上传的未完成视频能被新容器自动接回。
- 同一素材不存在两个有效租约或两个 FFmpeg。
- 部署期间有后台任务时旧容器不会被停止。
- 部署期间有活动上传或分片写入时,旧容器不会被误判为空闲。
- 转码成功、失败、重试和部署中断后 canonical 源视频都仍存在。
- 15 天保护期不暴露源视频,但卡片仍显示处理/失败状态并在完成后自动出现封面。
- 已知缺源素材保持明确失败/待人工恢复,不进入批量自动重试。