消除冷启动2:分片与征服

本文深入探讨Cloudflare Workers如何通过一致性哈希环和分片技术优化冷启动问题,详细解析了技术架构演进、负载均衡策略以及性能提升效果,将热请求率从99.9%提升至99.99%。

消除冷启动2:分片与征服

五年前,我们宣布通过Cloudflare Workers消除冷启动。当时我们介绍了一种在TLS握手期间预热Worker的技术。该技术利用了TLS握手第一条消息中的服务器名称指示(SNI)信息,使我们能够预先预热目标Worker。

虽然通过TLS握手预热Worker在消除冷启动方面迈出了一大步,但"消除"这个词可能过于绝对。当时Workers规模相对较小,冷启动受到本文后续说明的限制约束。随着我们放宽这些限制,用户现在经常在Workers上部署复杂应用,甚至替代源服务器。

本月早些时候,我们完成部署了一项旨在进一步降低冷启动的新技术。这项新技术(或者说旧技术,取决于你的视角)使用一致性哈希环来利用我们的全球网络。我们称这种机制为"Worker分片"。

冷启动的构成

Worker是我们无服务器计算平台的基本计算单元。它有一个简单的生命周期:我们从源代码(通常是JavaScript)实例化它,让它处理一批请求(通常是HTTP,但不限于此),最后在停止接收流量一段时间后关闭它以回收资源。我们称这个关闭过程为"驱逐"。

Worker生命周期中最昂贵的部分是初始实例化和首次请求调用。我们称这部分为"冷启动"。冷启动包含几个阶段:获取脚本源代码、编译源代码、执行结果JavaScript模块的顶层执行,最后执行初始调用来服务触发整个事件序列的传入HTTP请求。

冷启动时间已超过TLS握手时间

从根本上说,我们的TLS握手技术依赖于握手持续时间长于冷启动时间。这是因为无论怎样,TLS握手持续时间都是访问者必须等待的时间,所以如果我们在那段时间尽可能多地工作,对所有人都有益。如果我们在握手仍在进行时在后台运行Worker的冷启动,并且冷启动在握手完成前结束,那么请求最终将看到零冷启动延迟。

早期,TLS握手持续时间长于Worker冷启动是一个安全的赌注,冷启动通常会赢得比赛。我们早期的一篇博客文章提到5毫秒的冷启动时间——这在当时是正确的!

我们放宽了两个影响冷启动时间的关键限制:Worker脚本大小和启动CPU时间限制。自上次"消除冷启动"博客文章以来,我们已悄悄提高了这两个限制:

  • 付费用户的Worker脚本大小(压缩后)从1 MB增加到5 MB,然后再次从5 MB增加到10 MB
  • 免费用户的Worker脚本大小(压缩后)从1 MB增加到3 MB
  • 启动CPU时间从200ms增加到400ms

我们放宽这些限制是因为用户希望在我们的平台上部署日益复杂的应用程序。但增加这些限制是有代价的:增加脚本大小增加了我们必须从脚本存储传输到Workers运行时的数据量,也增加了脚本编译阶段的时间复杂度;增加启动CPU时间限制增加了最大顶层执行时间。综合起来,复杂应用的冷启动开始输掉与TLS握手的比赛。

将请求路由到现有Worker

随着脚本大小和启动时间限制的放宽,直接优化冷启动时间变成了一场必输的战斗。相反,我们需要弄清楚如何减少冷启动的绝对数量,从而降低请求遇到冷启动的可能性。

一个选择是将请求路由到现有的Worker实例,而之前我们可能选择启动新实例。

以前,我们并不特别擅长将请求路由到现有的Worker实例。如果请求恰好落在已托管Worker的机器上,我们可以轻松将请求合并到单个Worker实例,因为在这种情况下这不是分布式系统问题。但如果Worker已经存在于我们数据中心的不同服务器上,而其他服务器收到了该Worker的请求呢?我们总是选择在接收请求的机器上冷启动新Worker,而不是将请求转发到已有Worker的机器,即使转发请求可以避免冷启动。

假设访问者每分钟向一个有300台服务器的数据中心发送一个请求,并且流量在所有服务器之间均匀负载平衡。平均每台服务器每五小时接收一个请求。在特别繁忙的数据中心,这个时间跨度可能足够长,我们需要驱逐Worker以重新利用其资源,导致100%的冷启动率。这对访问者来说是糟糕的体验。

如果这些请求全部合并到一台服务器上,我们会注意到多个好处。Worker将每分钟接收一个请求,这个时间间隔短到几乎可以保证它不会被驱逐。这意味着访问者可能经历一次冷启动,然后有100%的"热请求率"。我们还将使用减少99.7%(299/300)的内存来服务此流量。这为其他Worker腾出了空间,降低了它们的驱逐率,并提高了它们的热请求率——一个良性循环!

将请求合并到单个实例是有代价的,对吧?毕竟,如果我们必须将请求代理到数据中心的不同服务器,就会增加请求的延迟。

实际上,增加的首字节时间不到一毫秒,并且这是我们IPC和性能团队持续优化的主题。一毫秒远少于典型的冷启动,这意味着在每种可衡量的方式上,将请求代理到热Worker总是比冷启动新Worker更好。

一致性哈希环

这个问题的解决方案是我们许多产品的核心,包括我们最古老的产品之一:内容分发网络中的HTTP缓存。

当访问者通过Cloudflare请求可缓存的Web资源时,请求会通过一系列代理管道路由。其中一个代理是缓存代理,它存储资源以供以后使用,这样我们就可以为未来的请求提供服务,而无需再次从源请求。

Worker冷启动类似于HTTP缓存未命中,而对热Worker的请求类似于HTTP缓存命中。

当我们的标准HTTP代理管道将请求路由到缓存层时,它会根据请求的缓存键选择缓存服务器以优化HTTP缓存命中率。缓存键是请求的URL,加上一些其他细节。这种技术通常称为"分片"。服务器被认为是一个更大逻辑系统(在这种情况下是数据中心的HTTP缓存)的独立分片。

直到最近,我们还不能对数据中心中的Workers集合做出同样的声明。相反,每个服务器包含自己独立的Workers集合,它们很容易重复工作。

我们借用缓存的技巧来解决这个问题。事实上,我们甚至使用与HTTP缓存选择服务器相同类型的数据结构:一致性哈希环。一个简单的分片实现可能使用经典的哈希表将Worker脚本ID映射到服务器地址。这对于一组永不变化的服务器可以正常工作。但服务器实际上是短暂的,有自己的生命周期。它们可能崩溃、重启、进行维护或停用。新的服务器可能上线。当这些事件发生时,哈希表的大小会改变,需要重新哈希整个表。每个Worker的主服务器都会改变,所有分片的Worker都会再次冷启动!

一致性哈希环显著改善了这种情况。我们不是建立脚本ID和服务器地址之间的直接对应关系,而是将两者映射到一个首尾相连的数字线,也称为环。要查找Worker的主服务器,首先我们哈希其脚本,然后找到它在环上的位置。接下来,我们取环上该位置直接或之后出现的服务器地址,并将其视为Worker的主服务器。

如果由于某种原因出现新服务器,环上位于它之前的所有Worker都会重新定位,但其他Worker不会受到干扰。类似地,如果服务器消失,环上位于它之前的所有Worker都会重新定位。

我们将Worker的主服务器称为"分片服务器"。在涉及分片的请求流中,还有一个"分片客户端"。它也是一台服务器!分片客户端最初接收请求,并使用其一致性哈希环查找应将请求发送到哪个分片服务器。我将在本文的其余部分使用这两个术语——分片客户端和分片服务器。

处理过载

HTTP资源的性质非常适合分片。如果它们可缓存,则它们是静态的,至少在缓存生存时间(TTL)期间是这样。因此,服务它们需要的时间和空间复杂度随其大小线性扩展。

但Worker不是JPEG。它们是实时的计算单元,每个请求最多可以使用五分钟的CPU时间。它们的时间和空间复杂度不一定随输入大小扩展,并且可能远远超过我们为从缓存服务甚至大文件而必须投入的计算能力。

这意味着单个Worker在获得足够流量时很容易过载。所以,无论我们做什么,都需要记住我们必须能够无限扩展。我们永远无法保证数据中心只有一个Worker实例,并且我们必须始终能够随时水平扩展以支持突发流量。理想情况下,所有这些都在不产生任何错误的情况下完成。

这意味着分片服务器必须能够拒绝在其上调用Worker的请求,并且分片客户端必须始终优雅地处理这种情况。

两种负载卸载选项

我知道两种优雅卸载负载而不服务错误的通用解决方案。

在第一种解决方案中,客户端礼貌地询问是否可以发出请求。如果收到肯定响应,则发送请求。如果收到"请离开"响应,则以不同方式处理请求,例如在本地服务。在HTTP中,这种模式可以在Expect: 100-continue语义中找到。主要缺点是在发送请求之前引入了一次往返延迟来设置成功期望。

第二种通用解决方案是在不确认服务器能否处理的情况下发送请求,然后依靠服务器在需要时将请求转发到其他地方。这甚至可以回到客户端。这避免了第一种解决方案产生的往返延迟,但有一个权衡:它将分片服务器置于请求路径中,将字节泵送回客户端。幸运的是,我们有一个技巧来最小化实际上必须以这种方式发送回的字节数量,我将在下一节中描述。

乐观发送分片请求

我们选择不等待许可就乐观发送分片请求有几个原因。

第一个值得注意的原因是我们预计在实践中会看到很少的拒绝请求。原因很简单:如果分片客户端收到Worker的拒绝,那么它必须在本地冷启动Worker。因此,它可以在本地服务所有未来请求而不会产生另一个冷启动。所以,在单次拒绝后,分片客户端将不再分片该Worker(至少在Worker的流量减少到足以驱逐之前)。

一般来说,这意味着我们预计如果请求被分片到不同的服务器,分片服务器很可能会接受请求进行调用。由于我们预计会成功,乐观地将整个请求发送到分片服务器比首先产生往返延迟来建立许可更有意义。

第二个原因是我们有一个技巧来避免为将请求代理回客户端付出太高代价,正如我上面提到的。

我们使用Cap’n Proto RPC在Workers运行时中实现跨实例通信,其分布式对象模型支持一些令人难以置信的功能,如JavaScript原生RPC。它也是刚刚发布的Cap’n Web的精神兄长。

在分片的情况下,Cap’n Proto使得实现最优请求拒绝机制非常容易。当分片客户端组装分片请求时,它包含一个指向Worker延迟加载本地实例的句柄(在Cap’n Proto中称为能力)。这个延迟加载的实例具有与通过RPC公开的任何其他Worker完全相同的接口。区别只是它是延迟的——直到被调用才会冷启动。如果分片服务器决定必须拒绝请求,它不会返回"请离开"响应,而是返回分片客户端自己的延迟能力!

分片客户端的应用程序代码只看到它从分片服务器接收了一个能力。它不知道该能力实际在哪里实现。但分片客户端的RPC系统确实知道能力的位置!具体来说,它识别出返回的能力实际上是本地能力——与它传递给分片服务器的相同。一旦意识到这一点,它也就意识到继续发送到分片服务器的任何请求字节只会循环回来。所以,它停止发送更多请求字节,等待从分片服务器接收回所有已发送的字节,并尽快缩短请求路径。这完全将分片服务器从循环中移除,防止了"长号效应"。

Worker调用Worker

解决了负载卸载行为后,我们认为困难的部分已经结束。

但是,当然,Worker可能会调用其他Worker。这可以通过多种方式发生,最明显的是通过服务绑定。不太明显的是,我们许多最喜欢的功能,如Workers KV,实际上是跨Worker调用。但有一个产品特别突出,因为它调用其他Worker的强大能力:Workers for Platforms。

Workers for Platforms允许你在Cloudflare基础设施上运行自己的函数即服务。要使用该产品,你需要部署三种特殊类型的Worker:

  • 动态调度Worker
  • 任意数量的用户Worker
  • 可选的参数化出站Worker

Workers for Platforms的典型请求流如下:首先,我们调用动态调度Worker。动态调度Worker选择并调用用户Worker。然后,用户Worker调用出站Worker来拦截其子请求。动态调度Worker在调用用户Worker之前选择了出站Worker的参数。

为了真正增加乐趣,动态调度Worker可以附加一个尾部Worker。这个尾部Worker需要使用与所有先前调用相关的跟踪信息进行调用。重要的是,它应该被调用一次,包含与请求流相关的所有事件,而不是为请求流的不同片段多次调用。

你可能会进一步问,可以嵌套Workers for Platforms吗?我不知道官方答案,但我可以告诉你代码路径确实存在,并且确实被执行。

为了支持这个嵌套的Worker,我们在调用期间维护一个上下文堆栈。这个上下文包括诸如所有权覆盖、资源限制覆盖、信任级别、尾部Worker配置、出站Worker配置、功能标志等等。当所有东西都在单线程上执行时,这个上下文堆栈还算可管理。但为了让分片真正有用,我们需要能够将这个上下文堆栈移动到其他机器上。

我们选择Cap’n Proto RPC作为主要通信媒介帮助我们理解这一切。为了分片调用堆栈深处的Worker,我们将上下文堆栈序列化为Cap’n Proto数据结构并将其发送到分片服务器。分片服务器将其反序列化为本机对象,并在中断处继续执行。

与负载卸载一样,Cap’n Proto的分布式对象模型为我们提供了对原本困难问题的简单答案。以尾部Worker问题为例——我们如何将可能分散在任何数量其他服务器上的调用跟踪数据合并回一个地方?简单:在动态调度Worker的主服务器上为reportTraces()回调创建一个能力(一个活的Cap’n Proto对象),并将其放入序列化的上下文堆栈中。现在,该上下文堆栈可以随意传递。该上下文堆栈将最终出现在多个地方:至少,它将最终出现在用户Worker的分片服务器和出站Worker的分片服务器上。如果这些Worker中的任何一个调用了服务绑定,它也可能找到其他分片服务器的路径!这些分片服务器中的每一个都可以调用reportTraces()回调,并确信数据将返回到正确的位置:动态调度Worker的主服务器。这些分片服务器都不需要实际知道主服务器在哪里。呼!

驱逐率下降,热请求率上升

像这样的功能推出总是令人满意,因为它们产生的图表显示了巨大的效率提升。

完全推出后,只有约4%的企业流量总请求最终被分片。换句话说,96%的所有企业请求都是针对负载足够高的Worker,我们必须在数据中心运行它们的多个实例。

尽管总的分片率很低,但我们还是将全局Worker驱逐率降低了10倍。

我们的驱逐率是衡量系统内内存压力的指标。你可以将其视为宏观层面的垃圾收集,它具有相同的含义。更少的驱逐意味着我们的系统更有效地使用内存。这带来了使用更少CPU来清理内存的愉快结果。与Workers用户更相关的是,效率的提高意味着我们可以将Worker在内存中保持一个数量级更长的时间,提高它们的热请求率并降低延迟。

显示的高杠杆作用——仅分片4%的流量就将内存效率提高了10倍——是互联网流量幂律分布的结果。

幂律分布是发生在许多科学领域的现象,包括语言学、社会学、物理学,当然还有计算机科学。遵循幂律分布的事件通常看到大量集中在少量"桶"中,其余部分分散在大量"桶"中。词频是一个经典例子:像"the"、“and"和"it"这样的少数词在文本中出现频率极高,而像"eviction"或"trombone"这样的其他词可能在文本中只出现一两次。

在我们的案例中,大多数Worker请求流向少数高流量Worker,而非常长的尾部流向大量低流量Worker。被分片的4%请求都是针对低流量Worker,这些Worker从分片中受益最多。

那么我们消除了冷启动吗?或者未来会有"消除冷启动3”?

对于企业流量,我们的热请求率从99.9%增加到99.99%——那是从三个9到四个9。相反,这意味着冷启动率从请求的0.1%下降到0.01%,减少了10倍。稍加思考,你就会意识到这与我上面分享的驱逐率图表一致:我们随时间销毁的Worker数量减少10倍必须意味着我们一开始创建的Worker就减少了10倍。

同时,我们的热请求率在一天中的波动变小了。

嗯。

我讨厌向你承认这一点,但我仍然注意到图表顶部有一点空间。😟

你能帮助我们达到五个9吗?

Cloudflare的连接云保护整个企业网络,帮助客户高效构建互联网规模应用,加速任何网站或互联网应用,抵御DDoS攻击,阻止黑客,并可以帮助你完成零信任之旅。

从任何设备访问1.1.1.1开始使用我们的免费应用,使你的互联网更快更安全。

要了解我们帮助构建更好互联网的使命,请从这里开始。如果你正在寻找新的职业方向,请查看我们的空缺职位。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计