
为什么防火墙引擎的性能变得紧迫Cloudflare 的防火墙规则产品Firewall Rules背后是一个叫做 Wirefilter 的表达式匹配引擎。用户写下类似这样的规则ip.geoip.country eq CN and http.request.uri.path contains /wp-admin/每一条进入 Cloudflare 网络的 HTTP 请求都要被拿来和这些规则做匹配判断是否需要拦截、放行或者做其他处理。Wirefilter 最初只为防火墙规则服务。但随着 WAFWeb 应用防火墙等更多产品计划接入同一套引擎它的 CPU 使用量将在不久后占到整个边缘节点相当可观的份额。在安全产品里做性能优化是一件需要格外谨慎的事。本文记录的正是 Cloudflare 的工程师们在这个约束下所做的一系列工作。原文地址https://blog.cloudflare.com/building-even-faster-interpreters-in-rust/先解决怎么量的问题在动手优化之前需要一套可靠的测量方法。最直觉的指标是时间但时间在分布式环境下太不稳定——同样的代码跑两次可能差出 20%。Cloudflare 的工程师转向了硬件计数器其中指令数instruction count被证明是最稳定的一个指标而 CPU 周期数则波动太大不适合做对比。测量工具选用了 Linux 内核提供的perf_event_open系统调用直接在 Wirefilter 的二进制内部插桩分阶段采集数据解析parsing、编译compilation、分析analysis、执行execution各自单独记录互不干扰。这个思路来源于 Rust 编译器的自剖析机制。还有一个容易被忽视的工程细节Cloudflare 常规的 CI 节点使用了虚拟化和沙箱技术这让访问硬件计数器几乎不可能。他们最终在专用的裸机节点上运行基准测试才拿到了可复现的结果。这套框架还被嵌入到发布流程里每次发新版本之前都会跑一次完整的基准测试用于检测性能回退。让基准测试本身跑得起来有了测量框架之后第一个实际问题出现了基准测试太慢了。Cloudflare 存储了大量真实客户的防火墙规则如果把所有规则都跑一遍需要好几个小时这完全不现实。工程师们用了三个办法把这个时间压到 20 分钟以内去重检查后发现大约三分之二的规则在结构上和其他规则是重复的。Wirefilter 可以把规则序列化为 JSON结构相同的规则序列化结果也相同去掉这些重复的规则之后测试集直接缩减到三分之一。采样去重之后规则量仍然很大随机采样是简单有效的解法。关键是要保证每次采样的结果相同否则不同时间的跑分结果就没有可比性。分区直接采样有一个风险可能某些重要的语言特性恰好没被采到。解决方案是先按 Wirefilter 的语言特性对规则分区再在每个分区内采样。这样不仅保证了覆盖面还让测试结果能按特性拆开来看更容易定位优化效果来自哪里。优化从哪里入手先看数据有了基准测试框架接下来是找优化点。最直觉的猜测是Wirefilter 是一个解释器动态分派dynamic dispatch应该是性能瓶颈——每次执行操作都要通过虚函数表查找有开销。JIT 编译、选择性内联、复制等解释器优化技术都在考虑范围内。但 profiler 的数据打破了这个预期操作CPU 时间占比matches操作符0.6%in操作符1.1%eq操作符11.8%contains操作符51.5%其他35.0%动态分派的开销不是主要矛盾。真正的瓶颈是contains这一个操作符独占了超过一半的 CPU 时间。优化方向从此清晰了先搞定contains。一次意外发现引出的优化路径contains是字符串子串搜索给定一段文本haystack判断其中是否包含某个子串needle。Wirefilter 最初用的是memmemcrate它实现了经典的两路two-way子串搜索算法在理论上是高效的。但有工程师注意到一个奇怪的现象把contains改写成等价的正则表达式速度反而更快。# 原写法 http.host contains example # 改成正则表达式后莫名其妙更快 http.host matches example正则表达式比contains功能强大得多在简单场景里不应该更快。顺着这个线索查下去发现了原因Rust 的 regex crate 内部对它认为简单的模式做了特殊处理会自动分派到专门的 SIMD 加速路径而不是走通用的正则匹配逻辑。既然 regex crate 能做到为什么不直接用呢问题在于 Wirefilter 是一个解释器使用了动态分派机制。如果把contains的匹配逻辑塞进 regex crate 深处的某个 enum 分支里额外的调度开销会抵消 SIMD 带来的收益。更理想的方案是在contains表达式被调用时直接分派到一个专用的 SIMD 实现不走任何中间层。工程师们找到了 Wojciech Muła 此前开源的 sse4-strstr 库把它接入了 Wirefilter。结果令人满意基准指令数改善使用contains的表达式72.3%简单表达式无contains0.0%所有表达式综合31.6%SIMD 子串搜索算法是怎么工作的这个算法的核心思想是用 SIMD 指令做批量过滤把大量不可能匹配的位置快速排除掉只对少数候选位置做精确比较。以在一段文本里搜索example为例步骤如下第一步把 needle 的第一个字节e填满一个 SIMD 寄存器比如 AVX2 的 32 字节寄存器里放 32 个e。第二步把 haystack 的前 32 个字节加载进另一个寄存器与第一个寄存器做按位相等比较。结果中不等于e的位置全部置 0——这些位置不可能是匹配的起点直接排除。第三步对 needle 的最后一个字节e重复同样的操作但把 haystack 偏移 needle 长度减一的距离。第四步把两次比较的结果按位 AND进一步缩小候选集合。第五步对剩余的候选位置用memcmp做完整比较。第六步如果没找到移动到 haystack 的下一段重复直到搜索完毕。这个算法快的本质是SIMD 寄存器一次比较 32 个字节而且绝大多数位置在前两步就被过滤掉了真正需要memcmp的候选极少。一个必须解决的安全问题原始的 sse4-strstr 有一个问题当 haystack 的长度不是 32 字节的整数倍时最后一批数据装不满一个寄存器算法会继续读取 haystack 末尾之后的内存直到填满寄存器为止然后用位掩码忽略越界部分。这种行为在 Cloudflare 的环境里是不可接受的。读取越界内存属于未定义行为在安全产品里有不可预测的风险。Cloudflare 的工程师把 sse4-strstr 移植到了 Rust并引入了重叠寄存器技巧当最后一批数据装不满时把寄存器往前移让它和前一个寄存器有所重叠用已经读过的合法内存来填满寄存器而不是越界读取。重复的字节不会影响最终的匹配结果修改是安全的。当 haystack 本身就小于一个寄存器宽度时重叠技巧无法使用这时会降级到更小的指令集AVX2 → SSE2 → SWAR直到能容纳 haystack 为止。再小的情况则使用 Rabin-Karp 算法。这个移植版本以sliceslice为名开源在了 crates.io 上。还有一个最坏情况问题选用 needle 的首字节和末字节作为过滤条件是个聪明的设计但也引入了一个可被利用的弱点。考虑这条规则http.request.uri.path contains /wp-admin/needle 是/wp-admin/首字节和末字节都是/。如果攻击者构造一个全是/的超长请求路径每个位置都会通过前两步的过滤导致每个位置都要做memcmp整个算法退化为暴力搜索。对于首末字节的选择无论选哪两个字节攻击者都可以针对性地构造最坏情况。解决方案是引入随机性第一个比较字节仍然固定选 needle 的首字节但第二个比较字节每次随机选取 needle 中的某个位置。攻击者无法预知这个随机选择因此无法稳定地构造最坏情况。代价是性能有所损失——指令数提升从 72.3% 回落到 49.1%但这仍然是对原方案的巨大改进而且不以牺牲安全性为代价。基准sse4-strstr原版slicesliceCloudflare 版使用contains的表达式72.3%49.1%简单表达式0.0%0.1%所有表达式综合31.6%24.0%几点值得记住的东西性能测量先于性能优化。这篇文章花了相当篇幅在讲测量框架的建设不是凑字数而是因为没有可靠的测量所有优化都是在猜。用时间作指标、在虚拟化环境里跑基准——这两个常见做法在 Cloudflare 的实践里都被证明是不够的。Profiler 的结果经常打破直觉。动态分派被认为是解释器的主要开销但实测下来只占一小部分真正的瓶颈在一个具体的字符串操作上。在数据面前预设的性能模型需要修正。安全边界不能因为性能压力而放松。原始的 sse4-strstr 在边界处理上走了捷径这在通用工具里或许可以接受但在防火墙引擎里不行。额外的移植工作是必要成本。随机化是对抗最坏情况攻击的有效手段。当算法的确定性行为可以被预测和利用时引入随机性能以较低的性能代价换来对攻击者的不可预测性。