为什么Cloudflare在后端使用Rust?


几年来我一直在 Cloudflare 使用 Rust 作为高级语言。我所说的“一种高级语言”是指一种性能并不重要的语言。我主要将它用于 API 服务器,其中总体延迟并不重要。我完全可以使用垃圾收集语言或解释语言,因为我不需要为了超快的性能而耗尽每一微秒。我只希望我的服务器保持正常运行,完成它的工作,并让我快速发布功能。

那么为什么要使用 Rust 来完成这样的任务呢?好吧,尽管 Rust 以低级系统语言而著称,但实际上它在表现得像一门高级语言方面做得非常出色。因此,这是我考虑使用 Rust 的原因列表,即使对于性能不重要的项目也是如此。


我的团队为 Cloudflare构建了Data Loss Prevention 。DLP 基本上对通过某些公司网络的流量进行“扫描”,以确保没有人恶意或无意地泄露私人数据。例如,它可以检测并阻止黑客将数百万个信用卡号码从您的数据库上传到 pastebin.org,或者阻止某人将带有特定 Office 标签的 Microsoft Word 文档通过电子邮件发送到 yahoo.com 电子邮件。

实际扫描 HTTP 流量以防止数据丢失的服务被想象地称为dlpscanner。从一开始,我们就知道 dlpscanner 对性能非常敏感,因为它代理了大量的 HTTP 请求,我们不希望用户在打开 DLP 时浏览网页速度变慢。所以,我们用 Rust 编写了它。

对于后端 API,我们有两种选择:Rust 或 Go。使用 Rust 的决定是在考虑哪个更复杂:在后端使用 Rust?还是在我们的代码库中添加第二种语言?

在之前的项目中,我在 JSON 反/序列化中遇到了很多错误,但自从我开始使用 Serde 以来,我从来没有遇到过一个问题。它为我节省了很多时间。如果我不得不编写一个严重依赖反序列化数据的新项目,我会尝试为此使用 Rust(或者 JS,如果我知道数据只会以 JSON 格式传输并且两端都可以使用 JS ).

数据库
Rust 在数据库方面并不出色,但我确实认为它非常擅长数据库。我真的很喜欢使用Diesel ,因为它会根据您的 SQL 迁移生成的类型化 SQL 模式为您生成所有 SQL 查询。这解决了几个问题:

  • 当您删除或重命名 SQL 表中的列时,您如何检查所有现有查询是否已更改以理解新架构?
  • 如果您在代码中对 SQL 表/行进行建模,并且添加/更改/删除列,您如何检查所有代码类型是否准确地对 SQL 类型进行建模?这称为双重模式问题。让你的代码模式(JS、Go、Rust 等等)和你的 SQL 模式保持同步是非常烦人的。

我不喜欢所有语言的对象关系映射器,但 Diesel 非常好,因为现在当我更新我的 SQL 模式时,Diesel 将重新生成适当的 Rust 模型,并且我的 Rust 和 SQL 代码之间的几乎所有不匹配现在都会变成编译器错误我可以修好

在 Rust 类型系统中构建 SQL 类型系统的模型是一项非常了不起的工作。它还会导致非常烦人的问题,因为 Diesel 类型非常复杂。这些包括:

  • 超过 60 行的错误信息
  • 完全没有意义的错误消息(“这个特性没有实现”,好的,当然,但我认为 它是,你能告诉我为什么没有吗?不?好吧,我想我会哭一下)
  • 很难将公共代码提取到共享函数中,因为两个看起来相似的查询具有截然不同的类型

但总的来说,如果您的应用程序的很多功能都非常依赖数据库,我认为确保您的数据库查询得到正确的类型检查是值得的。

在 Diesel 和 Serde 之间,您可以在 API 中生成几乎所有重要的代码(读取请求、执行数据库查询和编写响应),让您有更多时间编写业务逻辑、发布功能并专注于为您的业务领域建模。

更好的业务领域建模
Rust 有两个功能可以真正帮助您准确地建模您的业务领域:枚举和不可克隆类型。

枚举:“这个函数要么返回一个错误,要么返回一个 Person 结构“。
没有枚举时,例如在 Go 中,我需要仔细阅读每个函数并检查返回的函数是否(Person, *err)永远不会返回任何值。

我喜欢用枚举对域进行建模。很高兴地说“这个用户可以使用 TCP 套接字或 Unix 套接字启动我的软件”
“准确地建模业务领域”是我在高级 API 中非常关心的事情。正确性很重要。所以,如果我真的需要确保我的软件模型准确地代表现实世界,Rust 为我提供了比 Go 更好的工具来做到这一点。

不可克隆类型
几年前在 Cloudflare,我需要为一组十个 IP 地址建模。这个想法是 Cloudflare 边缘网络有十个公共 IP,cloudflared在您的服务器上运行并连接到这 10 个 IP 中的 4 个以实现负载平衡。
如果其中一个 IP 是“不健康的”并且断开了 cloudflared,那么 cloudflared 应该避免重复使用它,而是使用一些它以前没有使用过的 IP。对此建模的一种自然方法是每个 IP 具有三种可能的状态:正在使用、未使用和以前使用但现在不健康。这些 IP 中的每一个都可以分配给四个长期存在的 TCP 连接之一。

这听起来像是一个容易解决的问题,但很难对“每个 IP 地址最多可以分配给一个连接”的想法进行建模。我

我不得不编写大量的单元测试来找到两个不同的连接都会尝试获取相同 IP 地址的边缘情况。这很难,因为在 Go 中,每个值都可以被复制。试图确保只有一个特定字符串的副本,如“104.19.237.120”,需要大量的程序员纪律。Go 函数通常会复制值,或复制指向该值的指针,因此很难确保只有一个 goroutine 正在读取您的值。

另一方面,Rust可以很容易地确保特定的值只在一个地方被 "使用"。一个值在任何时候都只能有一个&mut引用,所以只要确保 "使用 "该值的函数对它有一个&mut。或者,确保你的类型没有植入Clone,并确保 "使用 "它的函数对该值有完全的所有权。该值在移动时将被移到函数中,而函数在完成后可以 "返回 "该值。

所以,如果我想在Rust中实现这个系统,我只需保留一个包含10个IP地址的HashSet,并确保每个连接都对它所使用的IP采取&mut。我还会确保这些IP是一个新的UncloneableIp(std::net::IpAddr)类型,并且我没有为这个新类型派生出Clone。

老实说,这种情况在实践中并不经常出现--一般来说,在内存中复制字节是可以的--但是当它出现时,要试图审计每一个函数并确保它们没有复制一个值或共享对它的引用是非常令人沮丧的。你也许可以用RwLock来模拟这一点(就像Rust的借贷检查器一样,它只允许一个线程拥有对一个值的可写引用),但现在如果你弄错了,你的代码就会陷入死锁,哎呀。

可靠性
Rust 不会使用太多内存或泄漏资源(如 TCP 连接或文件描述符),因为当函数终止时,所有内容都会被丢弃和清理。

性能问题最终会变成可靠性问题。如果您的服务泄漏内存的时间足够长,或者摄入的数据足够多,那么性能瓶颈可能会导致您的服务崩溃。


结论
我认为 Rust 作为一种高级语言可以完成令人钦佩的工作。特别是当您处理 Web 服务时,Rust 可以通过serdeDiesel 等库为您节省时间。类型系统使您的业务领域建模变得更加容易。而且您的服务可能不会经常中断。

将 Rust 用于您的 Web 服务可能仍然是一个非常糟糕的主意。
在 Cloudflare,我们的大多数性能敏感服务都使用 Rust,但我们大多数性能宽松的服务(如 API 后端)都使用 Go。