Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
7.7 KiB
7.7 KiB
生产图片/视频处理内存事故与处理记录
日期:2026-07-31
结论
这次“处理图片后整个服务崩掉”不是单一 Node.js 请求超出 6GB 容器限制,而是生产宿主机发生了全局内存耗尽(global_oom)。
真正的内存来源分两层:
- 宿主机的 TrueNAS
middlewared长时间占用异常高的内存,历史峰值约 9.54GiB,并设置了oom_score_adj=-1000,内核不会优先杀它。 - 应用容器内的
libvips(Sharp 的底层图片库)和ffmpeg在处理大图/视频时产生原生内存尖峰。它们是把整机推过 OOM 阈值的触发器,内核随后杀掉了next-server或 MinIO。
因此,单纯把 Node.js 堆内存或应用容器上限调大不能解决问题,反而可能更快吃光宿主机余量。
已核实的生产时间线
| 时间(北京时间) | 生产证据 | 业务对应 |
|---|---|---|
| 7 月 30 日 15:18 | libvips worker 触发 global_oom;内核杀掉 next-server,其匿名 RSS 约 1.405GiB |
同时段任务详情完成接口进入 sharp.metadata()、sliceImage/compressImage,Nginx 随后出现 502 |
| 7 月 30 日 16:18 | 同一应用容器同时有两个 ffmpeg,RSS 约 642MiB 和 372–408MiB;之后 MinIO 也被杀 |
事故时旧实现未配置转码并发,默认并发为 2;后续已固定为同一容器 1 条任务、FFmpeg 1 线程(见下文) |
| 7 月 30 日 17:39 | libvips worker 再次触发 global_oom;同时还有一个约 325MiB 的 ffmpeg,最终 next-server 被杀 |
图片任务详情和抖音视频任务叠加,随后大量 502/504 |
三次事件都显示:宿主机没有 Swap,且是 global_oom,不是应用容器自己的 cgroup OOM。
本次代码处理
- Sharp 默认并发降为 1,限制 libvips 缓存和输入像素,并增加进程级图片处理门闩。
- 白底图内部和外部接口统一增加请求大小、单图大小、图片数量、格式、像素和动图门禁;服务层再次校验,防止新调用方绕过路由。
- 主上传的多张预览图改为串行处理,不再通过
Promise.all同时解码多张图片。 - 任务详情预览在第一次
sharp.metadata()前增加像素上限;长图限制最大尺寸和切片数。 - 视频转码队列固定为同一容器最多 1 个任务,FFmpeg 默认 1 线程,并限制进度日志缓存。
- 同批次关闭上传路径越界、任意文件删除、跨用户白底图替换等高危入口,并移除头像菜单中的网络诊断/缓存刷新入口。
仍需运维处理的风险
代码修复不能替代宿主机治理。生产运维需要单独完成:
- 查明
middlewared从正常内存增长到数 GiB 的原因,并设置可用内存告警。 - 评估在维护窗口增加 Swap 或等效内存缓冲;这不是永久修复,只是降低瞬时 OOM 概率。
- 评估把 FFmpeg/Sharp 拆到独立 worker,避免媒体任务与 Next.js、MinIO 共用进程/容器。
- 持续采集容器 RSS、cgroup
memory.events、宿主机可用内存和 OOM 日志。
本次排查未重启、杀进程、清理生产数据或修改生产部署脚本。
2026-08-01 第二阶段处理记录
新确认的根因
- 用户在 Edge 中看到“浏览器预览处理失败”并不等于 Edge 不受支持。原链路把“浏览器能力不足、图片损坏、图片超限”等不同原因混成了同一个提示,导致用户更换浏览器后仍然失败。
- 任务详情上传在请求内直接完成图片预览、切图、压缩和打包,浏览器断开、进程重启或多用户同时上传时,容易出现长时间等待、任务丢失和服务器内存尖峰。
- 生产日志中的大量
P2024主要来自素材筛选与部门权限查询放大,并不是本轮图片处理再次触发 OOM。素材筛选一次请求曾重复建立多套查询上下文,部门权限也存在逐部门查询。 - 同一任务号并发完成时缺少数据库级互斥,存在两个素材同时占用同一任务号的竞争窗口。
- Edge 服务器兜底链路曾存在客户端与服务器限制不一致:超宽长图、动画图、预览总量和源文件总量可能在文件全部上传后才失败。
- 同一批同名 PSD 的永久文件名只依赖毫秒时间,极端情况下可能覆盖;PSD 上传会话也可能在完成或失败后残留。
已完成的代码处理
- 浏览器一次只处理一张图片;网络层允许 2 个文件并行、每个文件最多 2 个分片请求,在上传速度和内存峰值之间取平衡。
- 浏览器能力不足时上传原图,由服务器受限兜底;损坏图片、格式不支持、宽度/像素/切片数量超限会在客户端直接拦截,不再伪装成浏览器兼容问题。
- 上传完成改为持久化后台任务:接口快速返回任务编号,页面轮询状态;进程重启后可恢复,卡死任务有心跳与超时恢复。
- 全站服务器图片 worker 同时只处理 1 个任务;单用户最多排队 3 个、全站最多排队 20 个,避免多容器并发重新制造内存尖峰。
- 任务详情服务器兜底统一为:源图宽度不超过 12,000px、高度不超过 90,000px、总像素不超过 6,000 万;输出宽度不超过 1,600px、单片高度不超过 1,500px、单图最多 60 片。
- 任务详情动画 GIF/WebP 在服务器兜底时只处理首帧;其它图片处理链路仍默认拒绝动画,避免扩大影响范围。
- 服务端重新校验真实文件格式、尺寸、动画帧、文件数量和大小;源文件总量 1GB 与客户端预览总量 512MB 分开计算。
- 上传取消和失败会等待并发请求全部收口后执行一次完整回滚;大量会话清理采用最多 12 路受控并发。
- 同名 PSD 使用带序号和随机值的唯一永久文件名;PSD 上传会话纳入成功与失败清理。
- 素材筛选复用同一个查询上下文,并把数据库查询数从 13 次降到 7 次;部门权限改为一次批量加载并在内存中解析,减少数据库连接占用。
- 任务号完成写入增加 PostgreSQL 事务级互斥;直接主管匹配同时配置工号和钉钉 ID 时改为两者都必须匹配。
本轮验证证据
- 全量 Jest:135 个测试套件、873 项测试全部通过。
- TypeScript 类型检查通过。
- Prisma 数据模型校验通过。
- 全项目 ESLint:0 个错误;剩余提示均为项目原有提示,本轮修改未新增阻断项。
$impeccable前端反模式扫描:0 项命中。- Next.js 正式生产构建编译成功并生成
BUILD_ID与 standalone 产物。本地隔离工作树使用外部node_modules联接,因此日志中出现一次 traced files 联接权限提示;生产 Docker 构建不使用该本地联接。
尚未关闭但不阻断本轮发布的事项
- 极端上限任务可能包含 1,140 个上传会话,后台 worker 当前逐个核验 Redis/元数据。它不占用 PostgreSQL 连接,也不阻塞 HTTP 返回,但极端任务的“校验中”阶段会较长;后续应压测后再决定使用 8~12 路有限并发或 Redis 批量读取。
- 素材月份筛选仍会读取符合条件的
createdAt后在应用内汇总。当前已经显著减少查询次数,但数据量继续增长后应改为数据库按月聚合并增加短时缓存。 - 上线后必须观察
P2024数量、数据库连接数、素材筛选耗时、后台任务队列长度、容器 RSS 和宿主机可用内存;若P2024仍持续出现,需要继续定位剩余长事务或连接池等待。 - 宿主机
middlewared异常内存、无 Swap 等运维风险仍未由代码解决,继续按本文“仍需运维处理的风险”执行。
截至本记录更新时,本轮代码已完成本地验证,尚未合并到 main,也尚未部署生产。