Rust 如何让我们监控每分钟 3 万次 API 调用
在 Bearer,我们是一个多语言工程团队,既精通口语,也精通编程语言。我们的技术栈由 Node.js、Ruby、Elixir 以及其他一些语言编写的服务组成,此外还有我们代理库支持的所有语言。与大多数团队一样,我们会在“使用合适的工具完成工作”和“使用合适的工具适应当前情况”之间找到平衡。最近,我们的一项服务遇到了限制,导致我们不得不将其从 Node.js 迁移到 Rust。这篇文章将探讨导致我们不得不迁移语言的一些细节,以及我们在此过程中做出的一些决策。
一些背景信息
我们正在构建一个解决方案,帮助开发者监控他们的 API。每当客户的应用程序调用 API 时,都会有日志发送给我们,我们会对其进行监控和分析。
问题发生时,我们平均每分钟处理 3 万次 API 调用。这意味着我们所有客户发出的 API 调用数量非常庞大。我们将整个流程分为两个关键部分:日志提取和日志处理。
我们最初用 Node.js 构建了采集服务。它会接收日志,与 elixir 服务通信以检查客户访问权限,使用 Redis 检查速率限制,然后将日志发送到 CloudWatch。在那里,它会触发一个事件,通知我们的处理工作器接管。
我们会捕获有关 API 调用的信息,包括用户应用程序发送的每次调用的有效负载(请求和响应)。目前,有效负载的最大大小限制为 1MB,但这仍然是一个需要处理的海量数据。我们异步发送和处理所有内容,目标是尽快将信息提供给最终用户。
我们将所有内容都托管在 AWS Fargate(一个用于弹性容器服务 (ECS) 的无服务器管理解决方案)上,并将其设置为在每分钟 4000 个请求后自动扩展。一切都很顺利!然后,发票来了😱。
AWS 发票基于 CloudWatch 存储。存储越多,付费越多。
幸运的是,我们有一个备用计划。
Kinesis 来救援?
我们不再将日志发送到 CloudWatch,而是使用Kinesis Firehose。Kinesis Firehose 本质上是 AWS 提供的 Kafka 版本。它使我们能够以可靠的方式将数据流传输到多个目的地。只需对日志处理工作器进行少量更新,我们就能同时从 CloudWatch 和 Kinesis Firehose 提取日志。这一改变将每日成本降至之前的 0.6% 左右。
更新后的服务现在将日志数据通过 Kinesis 传递到 S3,从而触发工作线程接管处理任务。我们实施了更改,一切恢复正常……或者说我们以为如此。不久之后,我们开始在监控仪表板上注意到一些异常。
我们进行了很多垃圾收集。垃圾收集 (GC) 是某些语言自动释放不再使用的内存的一种方式。发生这种情况时,程序会暂停。这称为GC 暂停。对内存的写入越多,需要进行的垃圾收集就越多,因此暂停时间就会增加。对于我们的服务,这些暂停次数增长到足以导致服务器重启并给 CPU 带来压力。当这种情况发生时,看起来就像服务器已关闭 - 因为它是暂时关闭的 - 并且我们的客户开始看到我们的代理尝试提取的大约 6% 的日志出现 5xx 错误。
下面我们可以看到垃圾收集的暂停时间和暂停频率:
在某些情况下,暂停时间超过4 秒(如左图所示),在我们的实例中每分钟最多暂停 400 次(如右图所示)。
经过进一步研究,我们似乎又一次遭遇了AWS Javascript SDK 内存泄漏的困扰。我们尝试将资源分配增加到极限,例如在每分钟 1000 个请求后自动扩展,但没有任何效果。
可能的解决方案
由于备用方案不再可行,我们开始寻找新的解决方案。首先,我们研究了那些过渡路径最便捷的解决方案。
Elixir
如前所述,我们正在使用 Elixir 服务检查客户访问权限。该服务是私有的,只能在我们的虚拟私有云 (VPC) 内访问。我们从未遇到过该服务的可扩展性问题,而且大部分逻辑都已实现。我们可以直接从该服务内部将日志发送到 Kinesis,跳过 Node.js 服务层。我们觉得值得一试。
我们开发了缺失的部分并进行了测试。结果有所改善,但仍然不够完美。基准测试表明,垃圾收集仍然很严重,并且在使用日志时仍然会向用户返回 5xx 错误。此时,高负载触发了我们的一个 elixir 依赖项的问题(现已解决) 。
去
我们也考虑过 Golang。它本来是个不错的选择,但最终它只是另一种垃圾收集语言。虽然它可能比我们之前的实现更高效,但随着规模的扩大,我们很可能会遇到类似的问题。考虑到这些局限性,我们需要一个更好的选择。
以 Rust 为核心进行重构
无论是我们最初的实现还是备份,核心问题始终如一:垃圾回收。解决方案是迁移到一种内存管理更完善、没有垃圾回收机制的语言。于是 Rust 应运而生。
Rust 不是一种支持垃圾收集的语言。相反,它依赖于一种称为所有权的概念。
所有权是 Rust 最独特的特性,它使 Rust 无需垃圾收集器就能保证内存安全。—— 《Rust 之书
》
所有权这个概念常常使 Rust 学习和编写起来困难,但同时也使它非常适合我们这样的情况。Rust 中的每个值都有一个唯一的所有者变量,因此在内存中只有一个分配点。一旦该变量超出作用域,内存就会立即被归还。
由于采集日志所需的代码非常少,我们决定尝试一下。为了测试这一点,我们解决了我们遇到的问题——向 Kinesis 发送大量数据。
我们的第一次基准测试证明非常成功。
从那时起,我们非常有信心 Rust 可以解决问题,并且我们决定将原型充实为可用于生产的应用程序。
在这些实验过程中,我们没有直接用 Rust 替换原有的 Node.js 服务,而是重构了日志提取的大部分架构。新服务的核心是一个Envoy代理,Rust 应用程序作为 Sidecar。
现在,当用户应用程序中的 Bearer Agent 向 Bearer 发送日志数据时,它会进入 Envoy 代理。Envoy 会查看请求并与 Redis 通信,以检查速率限制、授权详细信息和使用配额等信息。接下来,与 Envoy 一起运行的 Rust 应用程序会准备日志数据,并通过 Kinesis 将其传递到 S3 存储桶中进行存储。然后,S3 会触发我们的工作程序来获取和处理数据,以便 Elastic Search 对其进行索引。此时,我们的用户可以在我们的仪表板中访问数据。
我们发现,使用更少、更小的服务器,我们能够处理更多的数据,而不会出现任何先前的问题。
如果我们查看 Node.js 服务的延迟数字,我们可以看到峰值的平均响应时间接近 1700 毫秒。
通过 Rust 服务实现,即使在最高峰时,延迟也降至 90 毫秒以下,平均响应时间保持在 40 毫秒以下。
原始的 Node.js 应用程序在任何给定时间都使用大约 1.5GB 内存,而 CPU 负载约为 150%。新的 Rust 服务使用大约 100MB 内存,CPU 负载仅为 2.5%。
结论
与大多数初创公司一样,我们发展速度很快。有时,当时最好的解决方案并非永远都是最好的。Node.js 就是这种情况。它让我们得以前进,但随着业务的增长,我们也逐渐超越了它。随着我们开始处理越来越多的请求,我们需要改进基础设施以满足新的需求。虽然这个过程最初只是用 Rust 替换了 Node.js,但它促使我们重新思考整个日志采集服务。
我们仍然在整个堆栈中使用多种语言,包括 Node.js,但现在会考虑在有意义的新服务中使用 Rust。
文章来源:https://dev.to/bearer/how-rust-lets-us-monitor-30k-api-calls-min-2n30