PostHog Replay Vision 图像清洗 Sidecar 深度解析:原生 ML 管线、Kafka 背压与去标识化工程设计
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
导读
本文以 PostHog 仓库中 Replay Vision ML Mirror Image Scrub Sidecar 说明文档 为主体,结合其源码、消费者实现与构建文件,完整拆解这套「会话回放(Session Replay)图像清洗」系统的设计:它如何用一套原生 ML 管线(ONNX Runtime + zxing-wasm + sharp/libvips)把每帧回放截图中的人脸、文本、二维码/条形码做不可逆的实心填充,如何以「等待即背压」的消费模型对抗 Kafka 分区级 head-of-line 阻塞,以及如何用「自验证测试」保证清洗质量可度量。读完本文,你将掌握该 sidecar 的 HTTP 契约、死信判定规则、分辨率规划算法、环境变量矩阵与可观测性信号,可直接对照仓库源码部署或二次开发。
一、系统定位:为什么需要一个独立的 Sidecar
该组件全称ML Mirror Image Scrub Sidecar,是「会话回放 ML 镜像」链路(replay_vision的 image-scrub 阶段)的一部分。它被设计为与 Kafka 图像清洗消费者同 Pod 部署的边车容器,消费者位于 ingestion-session-replay-ml-image-scrub-server.ts,sidecar 自身则是一个独立 npm 包@posthog/ml-mirror-image-scrub-sidecar(见 package.json)。
1.1 与根工作区刻意隔离的原因
包说明文档明确写出:该包有意不纳入根 pnpm workspace(pnpm-workspace.yaml之外),因为 ML 依赖体积达数百 MB:
- 不会拖慢每次 CI 构建;
- 不会污染每个开发者的工作树;
- 不会把
onnxruntime-node、sharp等重型原生二进制带进主 plugin-server 镜像。
从 Dockerfile.ml-mirror-image-scrub 可以看到其独立构建路径:只 COPY 该包自身的package.json与pnpm-lock.yaml,以pnpm install --prod --frozen-lockfile独立安装,使用node:24.13.0-bookworm-slim基础镜像并精确锁定 Node 版本。
1.2 双监听器架构:信任边界从网络开始
sidecar 同时起两个 HTTP 服务(见 server.ts):
| 监听器 | 绑定地址 | 端口(默认) | 职责 |
|---|---|---|---|
/scrub | 127.0.0.1(仅回环) | 9010(IMAGE_SCRUB_PORT) | 接收原始图像字节,返回清洗后字节 |
/metrics+/_health+/_ready | 0.0.0.0(全部接口) | 9011(IMAGE_SCRUB_METRICS_PORT) | Prometheus 抓取与 kubelet 探针,不暴露任何图像字节 |
/scrub是完全受信任的接口——它只与同 Pod 内的 Kafka 消费者通信,因此必须作为共享网络命名空间的 sidecar 运行,绝不能单独部署为独立 Service。代码中注释明确:loopback 监听器绝不能暴露到 Pod IP 之外(Loopback only: the consumer shares the pod netns; the pod IP must not expose /scrub)。
二、HTTP 契约与状态码语义
2.1 核心接口
POST /scrub,请求体为原始图像字节(content-type: application/octet-stream),成功时返回 200 与清洗后字节。状态码的拆分是承重的(load-bearing),消费者与 sidecar 两侧必须同步修改,见消费者半边的 scrub-client.ts:
| 状态码 | 含义 | 消费者行为 |
|---|---|---|
200(带字节) | 清洗成功 | 返回字节,写入 S3 |
200(空 body) | 极端情况:清洗产物为空 | 非契约错误,按可重试处理(避免一张图导致全 Pod crash-loop) |
413 | 请求体过大(默认上限20 MiB) | 永久跳过(不重试) |
422 | 无法解码 / 元数据禁止 AI 训练(XMP opt-out) | 永久跳过 |
500 | 任务超时后 worker 被替换("considered answer") | 等待并重试 |
503 | 繁忙(并发上限被触发,提前拒绝) | 等待并重试 |
408/429/其他 5xx | 暂时不可服务 | 等待并重试 |
| 其余状态码 | 未预期契约(如错误SIDECAR_URL导致的 404) | 立即抛错失败 batch,绝不静默等待 |
2.2 繁忙时的 503 是如何提前发送的
server.ts 中的shedIfBusy中间件在 body 解析之前就检查inFlight >= maxConcurrency:
- 先
req.resume()排空请求体再回 503——否则 Node 会在响应完成时销毁未读请求体的 socket,消费者看到的是 reset 而非 503,导致"过载"与"sidecar 崩溃"无法区分; maxConcurrency = ADMITTED_PER_WORKER(2) × SCRUB_WORKERS,每 worker 预留 1 个额外槽位喂饱请求体读取间隙,绝不让请求进入无界 accept 队列。
2.3 为什么排队对消费者是"失联"而非"繁忙"
消费者的每次请求超时是非活跃超时(inactivity timeout):排队的请求不产生任何字节,会让消费者以为 sidecar 无响应。因此 sidecar 选择「提前拒绝、快速 503」,这是消费者唯一能据以行动的繁忙信号。
三、等待即背压:消费侧的可靠性模型
这是整个系统最核心的设计思想,文档用一句话概括:
一个繁忙的 sidecar 会被一直等待,绝不放弃;没有任何图像会因容量不足而被丢弃。
3.1 为什么永不限时重试
Kafka 已经持久保存了图像,有界重试唯一买到的是「把日志里安全的数据丢掉的机会」;而消费得慢一点只付出 lag 的代价——那正是 topic 存在的意义。因此:
- 没有 batch 时间上限,没有丢弃路径:poll 到的一批消息必须在任何 offset 前进之前全部完成;
- 等待本身就是背压:batch 花在卡死 sidecar 上的时间越长,下一次
consume()就越晚,消费者自动按 sidecar 实际吞吐自我限速,无需显式 pause 分区; - 一个 wedged(卡死)的 sidecar 依然会阻塞其分区而不是排空它,任何 batch size 都无法改变这一点。
3.2 小 batch 的由来
由于 batch 无时间上限,其时长由包含的图像数量决定,所以本 lane 用很小的CONSUMER_BATCH_SIZE(默认50,主默认是 500)。原因:batch 存活超过max.poll.interval.ms(300s)会让 Pod 在 batch 中途被逐出(eviction),而这不是干净的失败重试——被逐出的 Pod 丢失已处理工作的 offset,分区落到的下一个 Pod 其 sidecar 同样繁忙,会重做同样的图像,导致已提供的负载上升而吞吐下降。见 ingestion-session-replay-ml-image-scrub-server.ts 中buildImageScrubConsumerConfig的注释——batch size 被写死在配置里(fetchBatchSize: config.SESSION_RECORDING_ML_IMAGE_SCRUB_BATCH_SIZE),而不是作为 deployment 值,防止与设计脱节。
3.3 中途 revoke 的处理
如果 batch 中途发生分区 revoke,batch 会在 flush 发现不再拥有该分区时立即停止,而不是继续清洗并为一个新 owner 已在写入的 span 再写一个 shard。
3.4 心跳策略
本 lane 的 batch 会阻塞 poll 循环长达数分钟(scrub + S3 写可能远超常规心跳间隔),因此每10 秒手动刷新一次心跳(BATCH_HEARTBEAT_INTERVAL_MS = 10_000,必须低于CONSUMER_MAX_HEARTBEAT_INTERVAL_MS30s)。
四、死信队列:什么才配叫"毒图像"
4.1 分区级阻塞问题
一张永远以同样方式失败的图像会卡住其分区头部(head of line),而分区内混着所有团队的记录,且图像字节是用户可控的——也就是说,任何人(一张恶意图片)都能制造全局停滞。因此这样的图像被停放到session_replay_image_scrub_dlq,分区继续前进。
4.2 DLQ 内容的三个铁律
- 只放原始字节,绝不放已清洗字节:该 topic 持有未脱敏内容,任何下游都不得把它当已清洗数据处理;
- 保留它就是目的:丢弃等于销毁 sidecar bug 唯一的复现样本。30 天保留期就是修复 sidecar 并重放的窗口(源 topic 7 天);
- 同样的集群、同样的清理策略:两个 topic 都不设置
cleanup.policy,走 Kafkadelete默认值,段文件自然老化。被停放的图像之所以比源副本多活 23 天,正是这个窗口的宽度。
停放原因通过headers传递,因此无需读取图像内容即可分诊整个 topic。
4.3 判定逻辑:别的图成功,你才被怪罪
什么让一张图是"毒"而不是"倒霉",是这里唯一重要的问题。
饱和时每张图都等很久,所以任何以等待时长或失败次数为唯一依据的判定都会在积压期把整条流停掉——这正是「等待」机制要阻止的大规模丢失,只不过换了一扇门进来。判定条件(见 scrub-client.ts):
- 只有「经过考虑的答案」(considered answer)才计入怪罪,即
rejected原因:sidecar 收下了图、看了它、却产不出字节(500); - 503 是 sidecar 拒绝看(declining to look at all);refused/reset socket 对每张图都成立——这两类都不怪罪;
- 必须同时满足:
- 可怪罪失败数 ≥
POISON_MIN_FAILURES(12); - 期间其他图成功数 ≥
POISON_MIN_OTHER_SUCCESSES(3),证明 sidecar 是健康的; - 或者:
rejected累计时间 ≥POISON_MAX_REJECTED_MS(120s,含退避),作为"无 peer 可作证"时的兜底出口。
- 可怪罪失败数 ≥
POISON_MIN_OTHER_SUCCESSES = 3是刻意的小数,且必须低于 Pod 的 scrub 并发:batch 中任何一张图在途时 batch 无法结束,Pod 在 batch 结束前无法 poll 新工作,所以能到达的成功只可能来自与这张图并行的少数几个槽位。要求更多会使 batch 后段的图像永远无法达到门槛——batch 永不返回,Pod 完全停止消费却仍报 Ready。ImageBatcher在构造时会校验该关系,未来并发数改动会在启动时失败而非在流量中失败。
4.4 两个超时的顺序是承重的
- sidecar 先放弃它无法完成的任务(
IMAGE_SCRUB_JOB_TIMEOUT_MS,默认 15s),退休该 worker,回答 500——这是关于这张图的考虑性答案,使它可以被怪罪和停放; - 消费者等待更久(
SESSION_RECORDING_ML_IMAGE_SCRUB_SCRUB_TIMEOUT_MS,默认 45s,覆盖 job 期限 + admission 队列等待),确保答案真的到达; - 颠倒顺序会重新打开漏洞:消费者先放弃 → 无法处理的图像每次尝试都只是"看起来慢",永远不获怪罪,永远卡住共享分区头部。此时超时的含义才是它应有的:sidecar 什么都没说,对每张图都成立,因此不可怪罪。
4.5 各原因退避上限与指标
scrub-client.ts 中按原因区分退避上限(指数退避 + 全抖动,避免同 Pod 多张在途图锁步重发):
| 原因 | 退避上限 |
|---|---|
busy(503) | 30s |
timeout | 5s |
refused | 5s |
reset | 5s |
transport | 5s |
rejected | 30s |
refused= 端口上无人监听,boot 后即意味着 image-scrub 容器已退出;因其常见原因是同 Pod 内 sidecar 重启加载模型,退避短(5s)以免白等半分钟;reset= 连接被接受后又被丢弃——sidecar 关闭时对空闲 keep-alive socket 就是这么做的,所以每次 rollout/缩容都会出现 resets,不可达告警只选择refused和transport,绝不选reset;transport= 其他任何 socket 失败,在 rollout 之外持续出现就是需要调查的故障。
4.6 发布失败的处理
停放发生在 ref 标记之前、slot 退休之前,因此发布失败时图像原地不动(仍未清洗、仍未提交)。发布持续失败会被重试而非抛出——因为 Kafka 循环在 batch 任何错误时都会退出进程,一个缺失/错集群/过小的 DLQ topic 会让整个 lane 的每个 Pod 在同一张图上 crash-loop。ml_mirror_image_scrub_consumer_dead_letter_failed_total指标即此状态,表示「该看 topic 了,而不是该看图像」。未配置 DLQ 目的地时客户端继续等待——因为唯一替代方案是丢弃。
4.7 专属 producer slot
DLQ 通过本 lane 自己的 producer slot 发布到 replay 集群(源 topic 所在处),其message.max.bytes是按这些负载定制的;通用 slot 指向别处并带 librdkafka 的 1MB 默认值,停放一张普通图像会在每次尝试都失败。topic 必须预先存在,且max.message.bytes至少等于源 topic 的。回滚方式 = 清空SESSION_RECORDING_ML_IMAGE_SCRUB_DLQ_TOPIC,客户端即恢复为等待行为。
4.8 等待的边界:哪些状态码值得等
只有「重试可能改变答案」的状态才等待:5xx、408、429、socket 失败。任何其他状态码、以及不带字节的 200,都意味着 sidecar 在回答一个「我们没以为会问的问题」,因此要大声地让 batch 失败。等待一个错误SIDECAR_URL的 404,会把部署失误变成「不消费任何东西、通过所有探针、只以 lag 显现」的幽灵 Pod。
五、清洗管线:NSFW 门 + 人脸/文本/码实心填充
advancedScrub(scrub.ts)按以下顺序处理一张图:
- 图像策略(opt-out):XMP
plus:DataMining声明禁止 AI 训练时拒绝处理(422); - 规划尺寸:
planScales(scale-plan.ts)在读任何像素之前,仅凭源尺寸决定所有缩放——解码帧、每个检测器看到什么、存什么; - NSFW/gore 门:
nsfl + nsfw概率 ≥NSFW_THRESHOLD(默认 0.6,刻意宽松,这是安全网)时整帧塌缩为 1×1 空白; - 人脸填充:每个 YuNet 检出的人脸用其平均色填充;
- 文本填充:每个 DBNet 检出的文本区域同样填充,边距按框高(= 字号代理)缩放——只检测文本在哪,从不读取文本;
- 码填充:每个可解码的 QR/条形码(zxing)同样填充——TOTP 配置 QR、票券条码是面部/文本检测器看不见的机器可读 PII。
目标:保护数据标注人员、降低 PII 暴露面。它不需要完美——下文的"自验证测试"负责监督它。
5.1 为什么是实心填充而不是模糊/马赛克
模糊和马赛克是低通滤波器:移除细节但保留粗结构。大字号文本(标题、大标题)对有能力的人依然可读——文档记录了一个实证:一个 LLM 仍能读出测试页上模糊的标题和开头句子;被马赛克的人脸也能被重新平滑回检测器可再次发现的样子。而实心、量化的平均色填充彻底移除信息,人脸/文本/码统一处理。
填充实现细节(compose与encodeStored,scrub.ts):
- 填充色量化为每通道高 4 位(24bit → 12bit),携带更少的颜色信号;
- 边缘羽化只模糊填充的颜色层,alpha 掩码保持硬边——框内原始像素永远不被泄露;
- 先填后缩:
encodeStored在两次独立 pass 中先完成所有填充再降采样存储尺寸。若先缩放后填充,resize 核会越过框边缘把框内内容加权平均到框外 1px 处——实测在温和降采样下残渣可达到满强度; - 纯色帧(
isUniform)直接走快速路径跳过全部检测——回放里大量空白页加载/过渡/清空视图都是这种帧。
5.2 分辨率规划:一条规则决定所有尺寸
核心规则:每个检测器看到的主题必须比存储图保留的至少大ratio倍(每轴)。于是任何在产物中仍可读的内容,当初必然大到足以被检出并填充。
ratio不是拍脑袋选的,而是从 floors.ts 实测的"地板"推导:
| 主题 | 可检出尺寸(检测器输入处) | 可从存储图读出的尺寸 | 绑定 ratio |
|---|---|---|---|
| 人脸 | 64px(YuNet 输入) | 21px(存储图) | 64/21 ≈3.05 |
| 文本 | 7px(DBNet 输入) | 3px(存储图) | 7/3 ≈2.33 |
| 码 | 96px | 280px 才能解码 | 无约束(自限制) |
SCRUB_SAFETY_FACTOR(默认1.3)是额外余量——两个地板都来自近黑底白字的一种字体,低对比度文本会让检测地板向错误方向移动。
SCRUB_OUT_MAX_PIXELS(默认 50,000)是大多数人唯一需要动的旋钮:它就是存储尺寸,其余一切随之推导——帧预算 =stored × ratio²(检测器必须看得足够大才能守住规则)。过去曾把帧预算设成独立参数,结果两个各自合理的设置组合成了一条欠脱敏的管线,所以SCRUB_MAX_PIXELS仍保留为 override 但默认由推导得出。默认值下,一张 1080p 截图存成约161×90。
存储小是刻意的、且是大部分保证的来源:下游消费者只需要判断会话发生在什么类型的站点,需要场景结构而非可读性——产物中文本不可读正是目的而非代价。
补充:面积的预算而非长边上限,使高页面保持可读的原生分辨率而不被压扁。人脸在 letterboxed(绝不 squash)的 640×640 输入上检测;超过 3:1 宽高比的帧沿长轴分块平铺(重叠窗口,
FACE_TILE_ABOVE = 3、FACE_TILE_ASPECT = 6),使高页面上的脸不至于缩小到低于检测器最小尺寸。分块次数有上限(scale-plan.ts 中FACE_MAX_TILES,由推断次数上限约束)。
六、这是原生代码,不是 JS 里的 ML
所有模型推理与图像处理都运行在优化的原生库中,TypeScript 只是编排 + 轻量输出解码(在小的降采样检测图上,而非整图):
| 阶段 | 库 | 原生引擎 |
|---|---|---|
| NSFW/gore 分类(SwiftFormer) | onnxruntime-node | ONNX Runtime (C++) |
| 人脸检测(YuNet) | onnxruntime-node | ONNX Runtime (C++) |
| 文本检测(DBNet / PP-OCRv3) | onnxruntime-node | ONNX Runtime (C++) |
| QR/条码检测 | zxing-wasm | zxing-cpp (C++/wasm) |
| resize / blur / composite / encode | sharp | libvips (C++) |
- 不训练任何东西,JS 里不跑任何神经网络;
- 唯一手写 JS 是模型输出解码(DBNet 阈值 + 膨胀 + 连通域、YuNet anchor 解码 + NMS、tensor 打包、掩码填充),跑在小的检测图上,不是瓶颈;
- 所有模型形态统一跑在一个ML runtime(onnxruntime-node)上是有意的:第二个 ML runtime 意味着第二个原生二进制兼容面、第二套失败模式(Node 版本耦合、慢速 fallback 后端)。
6.1 并发与线程模型(cores.ts)
onnxruntime-node的run会阻塞调用线程,所以单线程进程无论多少请求在途都只能有一份推理在执行。设计如下:
SCRUB_WORKERS:每个核心一个 worker 线程(默认),上限 32,并受内存上限约束——onnxruntime的会话、zxing wasm、V8 isolate 都无法跨 isolate 共享,内存随 worker 数线性增长;- 线程数从cgroup CPU 配额推导(读
/sys/fs/cgroup/cpu.maxv2 /cpu.cfs_quota_usv1),而非availableParallelism()(后者报告主机核数,对限额容器过度订阅一个数量级); ORT_THREADS默认 =cores / SCRUB_WORKERS(每 worker 一个会话,intra-op 线程数相乘必须适配配额,否则以 CFS 节流偿还);WORKER_HEAP_MB按每 worker 的帧预算推导(实测约 240MB 固定 + 每百万像素约 240MB),使 V8 isolate 按份额而非整容器限制成长;- Dockerfile 设
OMP_NUM_THREADS=1、UV_THREADPOOL_SIZE=8(libuv 在加载器代码之前创建线程池且永不扩容,须与 worker 数匹配,否则 sharp 阶段会在 worker 数之上重新串行化)。
七、目录布局与运行方式
7.1 目录结构
src/是生产代码(进入 sidecar 镜像;测试以*.test.ts同居并被rm -f src/*.test.ts从镜像中剥离);dev/全部是非生产代码(基准、评测框架、数据准备)。生产代码绝不 importdev/。
生产侧关键文件(README 原文 + 代码确认):
src/main.ts:入口——先起 worker 池再起监听器;src/pool.ts:推理 worker——派发、每 job 期限、替换死 worker;src/scrub-worker.ts:一个 worker 线程——持有自己的 ONNX 会话,一次清洗一张图;src/worker-protocol.ts:跨线程 job/reply 消息;src/cores.ts:从 cgroup CPU 配额与内存上限推导 worker 与 ORT 线程数;src/server.ts:/scrub+/metrics监听器,清洗实现注入;src/config.ts/src/env.ts:环境变量运行时配置与校验——无效数值拒绝启动(never fail open);src/blur.ts:基线模糊(与 blur.rs 保持同步,供对照);src/scrub.ts:ML 清洗管线;src/yunet.ts/src/dbnet.ts/src/qr.ts:三个检测器封装;src/scale-plan.ts/src/floors.ts:分辨率规划与实测地板;src/safety.ts:NSFW/gore 门;src/smoke.ts:镜像构建期冒烟测试;src/metrics.ts:Prometheus 注册表;src/image-input.ts/src/xmp.ts:接受的解码器、像素上限、内嵌元数据策略与 PLUS Data Mining 解析。
7.2 环境变量矩阵(sidecar 侧)
| 变量 | 默认值 | 范围 | 含义 |
|---|---|---|---|
IMAGE_SCRUB_PORT | 9010 | 1–65535 | /scrub回环监听端口 |
IMAGE_SCRUB_METRICS_PORT | 9011 | 1–65535 | 指标/探针监听端口 |
IMAGE_SCRUB_MAX_BODY_BYTES | 20 MiB | 1024–512 MiB | 请求体上限(413 之上即异常,服务自持内存边界) |
IMAGE_SCRUB_JOB_TIMEOUT_MS | 15_000 | 1000–600_000 | worker 持有单图最长时间,超时退休并回 500 |
SCRUB_WORKERS | 派生(min(核心数, 32, 内存上限)) | 1–32 | 推理 worker 线程数 |
ORT_THREADS | 派生(cores/workers) | 1–32 | 每个 ONNX 会话的 intra-op 线程 |
NSFW_THRESHOLD | 0.6 | 0.05–0.95 | NSFL+NSFW 组合门限 |
PNG_LEVEL | 3 | 0–9 | sharp PNG 压缩级别(低=快、大) |
TEXT_MARGIN_FRAC | 0.25 | 0–2 | 顶部/侧边文本边距(框高分数) |
TEXT_MARGIN_BOTTOM_FRAC | 0.45 | 0–2 | 底部额外边距(覆盖 descender: g/y/p/q/j) |
TEXT_MARGIN_MIN | 4 | 0–64 | 小文本最小边距(px) |
EDGE_BLUR | 4 | 0–32 | 填充边缘羽化 sigma(0=硬边,永不泄露) |
SCRUB_OUT_MAX_PIXELS | 50_000 | — | 存储尺寸预算(唯一大多数人该动的旋钮) |
SCRUB_MAX_PIXELS | 派生 | — | 解码帧面积上限(override) |
SCRUB_SAFETY_FACTOR | 1.3 | — | 地板之上余量,调大=花更多 CPU 换召回 |
PARALLEL_DETECT | 关 | 1开启 | 并行运行三个检测器(低延迟,但每 worker 占多核) |
所有数值旋钮经numFromEnv校验(env.ts):解析为 NaN 会失败打开(例如prob >= NaN恒为 false → 零文本框 → 未脱敏输出被静默计为已清洗),因此任何数值在模块加载时必须是有限且范围内的值,否则进程拒绝启动。
7.3 消费者侧关键环境变量
| 变量 | 默认值 | 含义 |
|---|---|---|
SESSION_RECORDING_ML_IMAGE_SCRUB_SIDECAR_URL | http://127.0.0.1:9010 | 注意用 127.0.0.1 而非 localhost(sidecar 绑定 IPv4 回环,localhost 可能先解析到 ::1) |
SESSION_RECORDING_ML_IMAGE_SCRUB_SCRUB_TIMEOUT_MS | 45_000 | 单次请求超时,必须 > job 超时 + 队列等待 |
SESSION_RECORDING_ML_IMAGE_SCRUB_BATCH_SIZE | 50 | 每 poll 消息数,界住 batch 墙钟时间(默认主消费为 500) |
SESSION_RECORDING_ML_IMAGE_SCRUB_DLQ_TOPIC | KAFKA_SESSION_REPLAY_IMAGE_SCRUB_DLQ | 停放 topic,清空即回滚为等待行为 |
SESSION_RECORDING_ML_IMAGE_SCRUB_GROUP_ID | session-replay-ml-image-scrub | 消费组 |
SESSION_RECORDING_ML_IMAGE_SCRUB_SCRUB_CONCURRENCY | 8 | 每 Pod 并发清洗数 |
SESSION_RECORDING_ML_IMAGE_SCRUB_MAX_IMAGES/MAX_BYTES | 1000 / 128 MiB | batch flush 阈值 |
SESSION_RECORDING_ML_IMAGE_SCRUB_DEDUP_MAX_REFS | 250_000 | 每 Pod seen-ref LRU(topic 按 ref 键控,重复分区亲和) |
SESSION_RECORDING_ML_IMAGE_SCRUB_S3_WRITE_TIMEOUT_MS | 30_000 | 每次 S3 写超时(一次 flush 两次写,总界 2×) |
以上默认值来自 config.ts 的getDefaultMlMirrorConfig()。
7.4 本地运行
pnpm install --ignore-workspace # 独立包:自己的 lockfile,位于根 workspace 之外 npm run setup # 下载 ONNX 模型 + 示例测试图,生成语料 npm run test:unit # 快速单元测试(不依赖模型/网络) npm run eval # 清洗质量评测套件(文本 + 人脸)跑真实图像 npm run bench # 延迟 + 分阶段明细 npm run smoke # 模型加载 + 一次端到端清洗(镜像构建时运行) npm run start # 启动 sidecar 服务(需先 npm run setup 获取模型)TLS 坑:如果模型/数据下载报 TLS 链错误,说明机器缺少中间 CA。将
NODE_EXTRA_CA_CERTS指向完整 bundle(如 certifi 的cacert.pem),而不是禁用证书校验。
7.5 镜像构建期冒烟测试
Dockerfile.ml-mirror-image-scrub 用ADD --checksum=sha256:...从 commit 固定的上游 URL 拉取三个 ONNX 模型(与dev/setup.ts的 pin + sha256 保持一致),随后在--network=none下执行tsx src/smoke.ts(smoke.ts):
- 走完整 worker 池而非直接调
advancedScrub,顺带证明 tsx 加载器能到达 worker 线程(否则 Pod 永不 Ready); - 输入必须有内容且结果被断言而非只查字节——纯色帧走 uniform 快速路径会跳过全部推理,一个能加载但不能
run的 ONNX 二进制就漏进生产了;文本断言走的是最长路径(DBNet → composite); - 因此模型损坏、原生二进制不匹配、意外的运行时网络依赖都会让镜像构建失败,而不是部署后 crash-loop;sidecar 启动时不做任何网络抓取。
八、自验证测试:用 OCR 和重新检测监督清洗质量
生产路径用 DBNet检测文本(快);测试用 tesseract OCR读取清洗输出(一个做识别而非检测的不同模型)并统计置信的多字符单词数。OCR 通常比人更能读退化文本,所以「OCR 读不出」是「标注员读不出」的保守代理。人脸检查则在清洗输出上以高灵敏度重跑 YuNet,断言原人脸位置(按 IoU)不再有任何脸——成功实心填充的脸已不可检测。
套件对会话回放的典型域(清晰渲染 UI 文本 + 人脸)门禁(gated),对更难的扫描文档集报告(report):
UI TEXT (gated): 31/31 clean, 0.0% leak [PASS] # rendered screenshots DOCUMENT TEXT (report): 19/20 clean, 2.7% worst [report] # faint fax/scan print, out of domain FACE: 89/89 faces redacted (100%)8.1 语料为什么必须包含大图
语料覆盖0.3 到 8.3 兆像素、两种 device pixel ratio。过去语料最高到 1440×900,完全在帧预算之下,没有任何东西在检测前被降采样,套件因此看不见该上限的代价。加入显示器尺寸帧后立即以14.3% 泄漏打脸门禁:4K 在 DPR=1 时 14px 文本经两次降采样到 DBNet 处只剩约 6px。DET_SHARPEN关闭了这个漏洞,上面的数字是在其开启状态下测得的。
浅色、低对比度的传真扫描线偶尔会幸存——那是对比度限制而非尺寸限制,分辨率提高救不了每条褪色线;它属于域外(rendered-UI 之外),符合「尽力而为、漏一点不是灾难」的标准。调大SCRUB_SAFETY_FACTOR花更多 CPU 换召回:它会放大每个检测器看到的帧(帧预算由它派生)。
8.2 重推导地板
用tsx dev/glyph-floor.ts(文本)与tsx dev/floors.ts(人脸与码)重新推导地板;两者都从limitsFromEnv()读取几何,因此不可能与随镜像发布的配置漂移。
九、可观测性:隐私控制的信号
/metrics在 HTTP 结果计数器(scrubbed/failed/undecodable/rejected/too-large/aborted、时长、输出字节)之外,携带隐私控制所需的结果信号:
..._blanked_total— NSFW 门空白是破坏性且不可逆的,对速率尖峰告警;..._faces_redacted_total、..._text_boxes_redacted_total、..._codes_redacted_total— 流量下持续为零意味着检测器故障(未脱敏输出),而不是干净的流。
消费者侧关键指标:
ml_mirror_image_scrub_consumer_scrub_waits_total(按reason:busy/timeout/refused/reset/transport/rejected)— 无字节返回且将被重试的尝试数,是饱和信号,绝不是丢失信号;ml_mirror_image_scrub_consumer_stuck_images_total— 任何一张图被重试超过健康 sidecar 应完成的时间后反复自增,读作水位而非一次性边缘事件;ml_mirror_image_scrub_consumer_dead_lettered_total— 应保持零;任何超过涓流的值都是 sidecar bug 在多张图上复现,修复应落在 sidecar 里。
9.1 探针设计:stalled 的 Pod 看起来是健康的
本 lane 运行遗留心跳健康检查(CONSUMER_LOOP_BASED_HEALTH_CHECK未设置),消费者每 10s 为整个 batch 刷新心跳,因此一个被单图卡住的 Pod 会永远保持 Ready/Live。这是刻意的——重启它只会重放同一张图——但也意味着lag 和上面两个计数器是唯一证据,因此该 lane 的告警都是 lag 形态的。
Sidecar 侧探针的取舍(server.ts):
/_health在无可用 worker(canScrub() === false)时回 503——这是存活探针而非就绪探针:推理在 worker 线程上,丢了全部 worker 的 Pod 对这个监听器的响应速度与健康 Pod 无异,没有它 kubelet 永远不会重启该 Pod;/_ready恒回 200——失败就绪探针会把 Pod 从 Prometheus 抓取的 Service 中摘掉,在指标最能解释问题的那一刻失去指标。
9.2 什么都不能把消费者打挂
消费者 Kafka 循环在 batch 任何错误时退出进程,因此到达它的失败会赔上整个 Pod,并把分区交给同样繁忙的 Pod,扩散饱和。况且消费者重启本来就修不好 sidecar——那是同 Pod 内的独立容器,会继续运行。
十、对仓库的延伸阅读
- 消费者侧完整实现:scrub-client.ts、ingestion-session-replay-ml-image-scrub-server.ts
- 配置与默认值:config.ts
- sidecar 生产源码:src/(含
scrub.ts、scale-plan.ts、cores.ts、server.ts、smoke.ts等) - 构建镜像:Dockerfile.ml-mirror-image-scrub
- 评测与基准:dev/(
scrub-eval.ts、bench.ts、floors.ts、glyph-floor.ts、make-corpus.ts) - 单元测试:src/*.test.ts(
scale-plan.characterisation.test.ts、server.test.ts、pool.test.ts、cores.test.ts等)
该组件与 Rust 侧的回放匿名化器共享设计意图:blur.ts明确注明与 blur.rs 保持同步,作为对照基线。若要继续深入回放数据脱敏体系,可从这两个文件对照阅读开始。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考