Obscura 可靠性——超时体系、资源边界与生产调优
Obscura 的可靠性设计介绍:分层超时、V8 终止看门狗、DOM 操作安全、SSRF 防护与生产环境的超时调优经验。
一个坏页面不能拖垮整个服务
把 Obscura 部署到生产环境后,你迟早会喂给它一些"难缠"的页面:含死循环的脚本、永不响应的 API、深度嵌套的 DOM、指向内网的请求。一个可靠的浏览器引擎需要在这些场景下优雅恢复,而不是让单个坏页面把整个 Worker 挂死、拖垮其他正在跑的会话。
本文介绍 Obscura 为此设计的可靠性机制,重点放在你能配置和调优的部分——超时体系和 SSRF 边界。理解这些,是为了在生产环境里把超时旋钮拧到合适的刻度,既不让慢页面拖累吞吐,也不误杀正常的重型页面。
分层超时体系
Obscura 的超时不是单一参数,而是四层独立的边界,从内到外各管一段。这是理解可靠性配置的骨架。
| 层级 | 配置变量 | 默认值 | 作用域 | 说明 |
|---|---|---|---|---|
| 导航超时 | OBSCURA_NAV_TIMEOUT_MS | 30s | Page.navigate + CLI fetch | 单次导航硬限 |
| CDP 命令超时 | OBSCURA_CDP_COMMAND_TIMEOUT_MS | 60s | 所有 CDP 命令 | 防止一个 session 锁住 V8,可关闭(设为 0) |
| Fetch 超时 | OBSCURA_FETCH_TIMEOUT_MS | 30s | JS fetch/XHR/module | 防止无响应服务永久挂起 XHR |
| 进程硬截止(fetch 命令) | 编程设置 | timeout + wait + 10s | fetch 子命令 | 终极兜底 |
配置方式都是环境变量:
OBSCURA_NAV_TIMEOUT_MS=60000 \
OBSCURA_CDP_COMMAND_TIMEOUT_MS=30000 \
OBSCURA_FETCH_TIMEOUT_MS=20000 \
obscura serve导航超时
OBSCURA_NAV_TIMEOUT_MS 是单次导航的硬上限(默认 30 秒),作用于 Page.navigate 和 CLI 的 fetch 命令。目标站慢、或者你抓的是重 SPA,把它调高(60s 或更多);抓静态页为主,30s 通常够用。
CDP 命令超时
OBSCURA_CDP_COMMAND_TIMEOUT_MS 是每个 CDP 命令的 V8 执行预算(默认 60 秒)。它的核心作用是防止一个卡住的页面(比如一个失控的 Runtime.evaluate)长时间占用共享 V8 锁、把其他 session 也堵死。设为 0 可关闭。
这个超时之所以重要,是因为所有 Page 共享一个 V8 Isolate,JS 执行串行化通过一把全局锁。一个不设上限的命令能把整个 Worker 冻结。
Fetch 超时
OBSCURA_FETCH_TIMEOUT_MS 约束页面脚本发起的 fetch()、XMLHttpRequest 和 ES 模块加载(默认 30 秒)。它解决的场景是:服务器接受了连接但永不响应,导致 XHR 永远等不到完成事件。没有这个超时,单个慢接口就能让页面脚本无限挂起。
进程硬截止
fetch 子命令额外有一个进程级硬截止线程,在 timeout + wait + 10 秒后强制退出进程。这是终极兜底——当所有内层超时都失效时(比如同步 Rust op 卡住,V8 看门狗也无法中断),硬截止从操作系统层面保证一次抓取最终会返回。
多 Worker 隔离:让坏页面只影响自己
上面四层超时解决的是"单个页面如何被终止",但还有一个更上层的可靠性手段:多进程隔离。obscura serve --workers N 起多个 Worker 进程,每个进程一个独立的 V8 Isolate。这意味着:
- 一个 Worker 上的坏页面(比如把 V8 锁占满的重型 JS)只会拖慢它自己那个 Worker,其他 Worker 上的会话不受影响。
- 即便某个 Worker 因为极端情况崩溃,主进程的负载均衡器会继续把流量分给其余存活的 Worker。
- Session 对 Worker 是粘性的——同一个浏览器上下文的请求始终落到同一个 Worker,所以隔离不会破坏会话语义。
这层隔离和超时体系是互补关系:超时处理"卡住但能恢复"的情况,多进程隔离处理"彻底坏掉"的情况。生产环境里两者都要开——超时拧到合适刻度,Worker 数等于 CPU 核心数。
V8 终止看门狗
普通的异步超时(如 tokio::time::timeout)只能取消在 await 点的异步任务。但 JavaScript 的死循环是同步的,不会触发任何 await 点,异步超时对它无效。
Obscura 的 V8 终止看门狗(watchdog)专门处理这类同步阻塞:它在一个独立线程里倒计时,超时后直接从该线程终止 V8 的执行。这套机制覆盖 --eval 执行、页面加载后的 JS 微任务、导航事件循环泵取。CDP 层还有一个独立的看门狗,在每个 CDP 命令外层计时(即上面 OBSCURA_CDP_COMMAND_TIMEOUT_MS 控制的那个),保证一个失控的命令不会无限期占住 V8 锁。
对使用者来说,你不需要直接操作看门狗,只需要知道:同步死循环、失控递归、微任务风暴这些"卡住 V8 但又不触发 await"的场景,都有对应的兜底机制,不会让 Worker 永久冻结。
DOM 操作安全
页面里的 DOM 操作是通过 Rust op 桥接执行的。Obscura 对这些 op 做了安全包裹:
- panic 不传播进 V8:DOM op 的 Rust panic 会被捕获并降级为返回
null,而不是穿越 V8 的 FFI 边界导致进程崩溃。为了这个机制生效,release profile 固定使用panic = "unwind"。 - 循环引用守卫:
append_child/insert_before拒绝把祖先节点插入为子节点,DOM 树不会产生环。 - 树遍历长度上限:作为额外兜底,
descendants()等遍历带硬编码长度上限。
这些是引擎内部的安全机制,你不需要配置,但理解它们的存在有助于解释一种现象:偶尔你会看到一个 DOM 操作静默返回 null 而不是抛错——那通常是 panic 被安全层捕获后的降级行为,而不是逻辑错误。
SSRF 防护
Obscura 默认阻止页面访问内网地址,防止一个恶意页面把你的 Worker 变成内网扫描器。被拦截的地址范围包括:
- Loopback:
127.0.0.0/8、::1 - RFC1918:
10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 - Link-local:
169.254.0.0/16(含云元数据端点169.254.169.254) - IPv6 唯一本地地址:
fc00::/7 - 未指定地址:
0.0.0.0、:: - 所有 IPv4-mapped 形式的上述地址
校验执行两次:URL 解析时检查主机名,DNS 解析后再检查一次解析结果(DNS 重新绑定防护)。即使一个公网域名解析到内网 IP,也会在连接时被拒。
本地测试需要访问内网时,用全局参数放开:
obscura fetch http://localhost:8080 --allow-private-network
# 或
OBSCURA_ALLOW_PRIVATE_NETWORK=1 obscura serve生产环境不要随意开
--allow-private-network。除非你明确信任抓取目标的来源,否则放开 SSRF 防护等于把 Worker 的网络可达范围暴露给页面脚本。
常见症状与对策
把可靠性配置和诊断手段结合起来,生产环境里遇到的多数异常都能归到下面几类:
| 症状 | 典型原因 | 对策 |
|---|---|---|
单个会话的 goto 长时间不返回 | 目标站慢,或页面有持续网络请求打破 networkidle | 调高 OBSCURA_NAV_TIMEOUT_MS,或改用 domcontentloaded 等待策略 |
| 整个 Worker 上所有会话变慢 | 一个重 JS 页面占住 V8 锁 | 调低 OBSCURA_CDP_COMMAND_TIMEOUT_MS,或开多 Worker 隔离 |
| 抓取量上升后 RSS 持续增长 | V8 堆过大或 Cookie/localStorage 累积 | 调低 --v8-flags "--max-old-space-size",定期清理 --storage-dir |
| 页面脚本的 fetch 永不回调 | 目标 API 接受连接但不响应 | 确认 OBSCURA_FETCH_TIMEOUT_MS 已设置(默认 30s) |
| 访问内网地址被拒 | SSRF 防护生效 | 仅在可信场景用 --allow-private-network,不要在生产默认开 |
| 偶发 DOM 操作返回 null | panic 被安全层捕获降级 | 检查目标页是否有异常 DOM 结构;这是安全降级行为,非逻辑错误 |
这张表的价值在于:它把"症状"和"该调哪个旋钮"直接对应起来,省去在生产事故中翻文档的时间。
监控与告警
可靠性配置不是一次性的工作,需要持续观测。建议接入的监控指标:
- 会话成功率:成功完成的
Page.navigate占比。低于阈值(比如 95%)就告警,通常指向目标站变化或代理质量问题。 - 平均导航耗时与 P95:反映目标站整体健康度。突然飙升往往意味着目标站加了反爬或代理变慢。
- 超时触发次数:四类超时各计一个计数器。哪类超时频繁触发,就指向哪一层的问题。
- Worker RSS 趋势:每个 Worker 的常驻内存。单调上升不回落是内存泄漏的信号。
- Worker 存活数:多 Worker 模式下,存活 Worker 数小于配置数说明有进程崩溃,需要告警并排查。
这些指标通过 obscura serve --verbose 的日志可以提取,也可以在反向代理层(Nginx/Caddy)统计 WebSocket 连接数和请求耗时。把告警阈值设在"正常波动范围之外"而不是"绝对值",能减少误报——比如目标站周末流量低导致成功率小幅下降,不该触发告警,但工作日突然从 99% 掉到 80% 就该响。
诊断卡住的会话
可靠性配置到位后,下一步是知道"为什么慢"。几个定位手段:
看生命周期事件链。obscura serve --verbose 的日志会打印每个会话的生命周期事件(init → commit → domcontentloaded → load → networkidle2 → networkidle0)。如果一个会话停在 load 迟迟不到 networkidle0,说明有长轮询或持续的网络请求打破空闲判定——这时该调的是 waitUntil 策略或 --wait,而不是超时。
区分导航慢、fetch 慢、JS 慢。三种慢对应不同旋钮:导航慢调 OBSCURA_NAV_TIMEOUT_MS;脚本 fetch 慢调 OBSCURA_FETCH_TIMEOUT_MS;JS 执行慢(典型表现是 CDP 命令超时触发)调 OBSCURA_CDP_COMMAND_TIMEOUT_MS,或者审视页面脚本本身是否过重。
用更细的日志级别定位。RUST_LOG=obscura=debug 看引擎内部,RUST_LOG=obscura_cdp=trace 看 CDP 消息收发,RUST_LOG=obscura_net=debug 看网络层。卡住时,日志里最后一条消息往往就是卡住的位置。
关注 RSS 而非单次延迟。可靠性不只是"不挂死",还包括"不内存泄漏"。用 docker stats 或 systemd 的内存统计观察 Worker 的常驻内存趋势。如果 RSS 随抓取量单调上升不回落,要么是 V8 堆设得太大,要么是 Cookie/localStorage 累积——后者用 --storage-dir 配合定期清理可以缓解。
生产调优经验
把这套机制落到生产环境,有几个实战经验值得记住:
1. 超时调优跟着页面类型走。 抓静态新闻站,导航超时 20s、fetch 超时 15s 就够,能把慢页面的等待成本压到最低;抓重 SPA(电商、社交),导航超时调到 60s、fetch 超时 30s,避免误杀正常加载。一个 Worker 服务多种页面类型时,取最慢那一类的上限。
2. CDP 命令超时不要轻易关闭。 OBSCURA_CDP_COMMAND_TIMEOUT_MS=0 听起来像"永不限制",但代价是一个失控的 Runtime.evaluate 能冻结整个 Worker 上所有 session。除非你完全信任驱动的脚本,否则保留默认 60s 或调到 30s。
3. Worker 数量配合超时一起调。 单 Worker 的并发页面受 V8 锁限制,慢页面会拖累快页面。用 --workers N(N = CPU 核心数)开多进程,让慢页面只阻塞它自己那个 Worker。
4. 用 --verbose 定位卡住的页面。 当吞吐下降但找不到原因,RUST_LOG=obscura=info obscura serve 的日志会显示哪个会话在等待哪个 lifecycleEvent,帮你快速定位是导航慢、fetch 慢还是 JS 执行慢。
5. 进程硬截止只在 fetch 命令上有。 长期运行的 serve 不带这个兜底,因为它不该因为单个抓取而退出。所以 serve 场景下的"挂死"防护完全依赖上面三层超时——务必把它们配好。
总结
Obscura 的可靠性设计可以概括为"每一层都有边界":导航有超时、CDP 命令有超时、脚本 fetch 有超时、同步死循环有 V8 看门狗、DOM 操作有 panic 安全和循环守卫、网络请求有 SSRF 双重校验、fetch 命令有进程硬截止。你不需要直接操作这些机制,但需要根据生产环境的页面特征,把超时旋钮拧到合适的刻度。这套体系确保了在大多数异常情况下,Obscura 不会崩溃或永久挂死,一个坏页面不会拖垮整个服务。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。