gongxiangp2/docs/视频预览任务中断事故复盘-2026-08-11.md

17 KiB
Raw Blame History

视频预览任务中断事故复盘(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. 在生产机只读取得下面全部现场值;任何一项为空、存在多值歧义或随后发生变化,都停止过渡:
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
  1. 把上述值和目标镜像值逐项填入一个 PowerShell splat;不要把密码、Token 或 .env 内容写入命令、文档或 Git:
$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 素材;任一计数非零即失败。

.\scripts\legacy-worker-bootstrap-deploy.ps1 @bootstrap -Mode Audit

审计通过并再次确认维护窗口后,才允许进入一次性维护交接:

.\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,并保留旧镜像:

.\scripts\git-incremental-deploy.ps1 `
  -Target production `
  -Commit $bootstrap.TargetCommit `
  -MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-<UTC时间>.json' `
  -MigrationAuditSha256 '<审计文件的64位小写SHA-256>' `
  -RequireMigrationAudit `
  -KeepOldImages

普通脚本仍必须独立验证候选镜像内置提交号、数据库 schema/migration、真实视频与任务详情 capability、预置 drain、健康接口和切流目标。bootstrap 不会创建候选容器,也不会绕过普通门禁。

失败恢复边界

  • bootstrap 在维护交接提交前的可确认安全状态失败,会在同一进程内恢复精确旧容器 ID 和原直连入口;旧容器或旧镜像绝不 rm。一旦已发出 SIGKILL,但停止状态、停止后的数据库 idle 或宿主机无 FFmpeg 不能被证明,脚本会保持入口阻断、保留旧容器并要求人工恢复,不会在证据未知时自动重启旧 worker。
  • 普通部署失败且入口仍明确是 legacy-bootstrap-maintenance、蓝绿容器都未运行时,可用相同 pins 执行下面的恢复命令。恢复脚本会再次核对保存容器的完整 ID/image ID/RepoDigest;只要候选可能已经接收流量,就拒绝自动恢复,避免两个应用世界同时写生产数据。
.\scripts\legacy-worker-bootstrap-deploy.ps1 @bootstrap `
  -Mode RestoreLegacy `
  -Apply `
  -AllowLegacyDrainBootstrap
  • 如果恢复门禁拒绝执行,保留维护入口、保存的旧容器和候选现场,先人工判定真实代理目标及是否已有写请求;不能强制启动旧容器。
  • 首个新容器上线后验证自动恢复、60 秒重扫、租约唯一性、源文件保留、健康接口和公网真实页面。再用一条小视频做 canary:上传、排队、生成三类预览、卡片自动出现封面、源文件仍在;任一步失败立即停止继续放量,不回滚 additive 数据列。

验收清单

  • 服务重启前上传的未完成视频能被新容器自动接回。
  • 同一素材不存在两个有效租约或两个 FFmpeg。
  • 部署期间有后台任务时旧容器不会被停止。
  • 部署期间有活动上传或分片写入时,旧容器不会被误判为空闲。
  • 转码成功、失败、重试和部署中断后 canonical 源视频都仍存在。
  • 15 天保护期不暴露源视频,但卡片仍显示处理/失败状态并在完成后自动出现封面。
  • 已知缺源素材保持明确失败/待人工恢复,不进入批量自动重试。