WebAssembly 进入一个反爬网关,并不是把一段 Rust 编译成 .wasm 就结束了。Anubis 为工作量证明加入 WebAssembly 的过程,经历了几百个 commit、五代 PR、几十个测试,还包括部分 Rust 重写、编译器 bug,以及至少三次把开发机内存跑爆。这个故事的价值,在于它展示了一个看似局部的性能或兼容性改动,怎样变成跨语言、跨运行时、跨平台的系统工程。
为什么 PoW 需要 WebAssembly
Anubis 是部署在网站前面的反爬和反机器人网关。访问者在继续访问站点前,需要完成一个工作量证明挑战。服务端希望挑战足够便宜地发放和验证,却让大规模自动化请求承担真实的计算成本。
纯 JavaScript 可以完成挑战,但它有几个实际边界:不同浏览器的执行效率差异明显,脚本容易受到页面环境影响,复杂计算也会增加主线程压力。WebAssembly 提供了更稳定的低层执行目标,允许把部分计算逻辑用 Rust 等语言实现,再交给浏览器运行。
不过,Wasm 只解决“代码如何在浏览器里执行”的一部分问题。工程上还要处理模块体积、初始化时间、内存增长、错误传播、浏览器兼容性、缓存策略和服务端验证一致性。真正困难的地方,通常就在这些边界上。
一年时间都花在哪里了
1. 语言边界会放大小问题
把部分 Anubis 重写为 Rust,意味着系统需要同时维护原有代码、Rust 代码、Wasm 构建产物和 JavaScript 调用层。数据布局、整数宽度、字节序、字符串编码、异常模型,任何一个约定不一致,都可能让挑战在某些环境下失败。
这类项目的测试不能只验证“正常浏览器能不能算出来”。至少应覆盖:
- 相同输入在服务端和客户端是否得到一致结果;
- 空输入、超长输入和非法字节是否被稳定拒绝;
- Wasm 初始化失败时是否有可诊断的错误;
- 内存消耗是否随请求规模失控;
- 未启用 Wasm 或不支持 Wasm 的环境是否有明确的降级路径。
2. 编译器 bug 和资源问题是真实成本
职业生涯第一次遇到编译器 bug,以及多次把开发机跑爆内存,说明这不是普通的业务逻辑迭代。Wasm 工具链会涉及 Rust 编译、链接、优化、绑定生成和最终打包;当构建目标、优化级别或依赖图发生变化时,资源消耗可能远超本地开发者的直觉。
因此,构建系统本身也需要工程化:固定工具链版本,记录可复现的构建命令,为 CI 设置内存和超时边界,并把产物大小和测试结果作为持续检查项。
一个可改造的 Wasm 构建骨架
下面是一个最小的、概念性的 Rust/Wasm 工作流。它不是 Anubis 的官方配置,而是展示类似项目可以怎样固定工具链、构建产物并检查文件大小。运行前需要安装 Rust、wasm-pack 和 jq;其中 challenge 只是示例函数名,实际项目应替换为自己的 PoW 接口。
# 固定并检查工具链,避免本地与 CI 使用不同编译器
rustup toolchain install stable
rustup default stable
rustc --version
# 构建浏览器可加载的 Wasm 包
wasm-pack build crates/challenge --target web --release --out-dir ../../web/pkg
# 检查产物大小,超过预算时让 CI 失败
WASM=web/pkg/challenge_bg.wasm
MAX_BYTES=262144
SIZE=$(wc -c < "$WASM")
printf 'Wasm size: %s bytes\n' "$SIZE"
test "$SIZE" -le "$MAX_BYTES" || {
printf 'Wasm artifact exceeds the size budget\n' >&2
exit 1
}
# 查看绑定包的元数据,确认构建确实生成了浏览器入口
jq '{name, version, files}' web/pkg/package.json
浏览器侧还需要把初始化失败当作正常的系统状态处理,而不是让页面静默卡住:
import init, { solve_challenge } from "./pkg/challenge.js";
export async function runChallenge(input, difficulty) {
try {
await init();
return solve_challenge(input, difficulty);
} catch (error) {
console.error("Wasm challenge unavailable", error);
throw new Error("challenge_runtime_unavailable");
}
}
在生产实现中,服务端必须独立验证结果,不能信任浏览器返回的“成功”标记。Wasm 是客户端执行载体,不是信任边界。
五代 PR 暴露的协作规律
一个 PR 反复演进,并不一定意味着方向错误。对运行时、编译器和安全边界都敏感的改动,早期实现往往会在测试、性能和可维护性之间来回调整。五代 PR 和几十个测试的意义,是把隐含假设逐步变成可审查的接口和可回归的行为。
更有效的拆分方式通常是分层推进:先确定挑战协议和验证规则,再引入独立的 Rust 核心;随后接入 Wasm 构建和浏览器适配;最后处理缓存、降级、指标和资源预算。每层都能单独测试,问题出现时也更容易定位。
采用 WebAssembly 前的检查清单
- 明确 Wasm 解决的是执行效率、移植性还是代码复用问题;
- 固定 Rust、绑定生成器和构建镜像版本;
- 为
.wasm文件大小、初始化时间和峰值内存设预算; - 用同一组测试向量验证客户端计算与服务端验证;
- 设计 Wasm 加载失败、浏览器不支持和网络传输失败的处理;
- 不把客户端计算结果当作安全事实;
- 把构建资源、产物大小和浏览器矩阵纳入 CI;
- 预留足够时间处理工具链缺陷,而不是只估算业务代码量。
WebAssembly 的难点不在于“能不能编译出来”,而在于它是否能作为一个可观测、可验证、可降级的系统组件长期运行。Anubis 这段漫长的工程过程提醒我们:当代码跨过语言和运行时边界时,真正需要投资的是协议、测试、构建系统和失败路径。