Modal 重建沙箱平台,可在一分钟内创建 100 万个并发沙箱
Scaling to 1 million concurrent sandboxes in seconds
Modal 重建了核心沙箱平台,演示中并发运行 100 万个沙箱并在不到一分钟内全部创建完成,用户可并发运行数百万沙箱、每秒创建数万个。新架构取消了中心协调和关键路径上的数据存储,调度服务器基于内存缓存数据直接向 worker 发起 RPC,沙箱创建只需两次网络跳转和一次 CPU 操作。该平台已开放 Beta,用户可通过一处代码改动选择启用。
Modal 公开了沙箱平台从强一致架构转向无中心协调的取舍,读者可据此理解大规模 Agent 基础设施的扩容思路。
在 Modal,我们构建沙箱等产品。Agent 在沙箱中运行,而 Agent 正在吞噬软件。如今,Modal 每天运行数百万个沙箱,支持每位客户最多五万个并发沙箱,并支持从强化学习到后台 Agent的各种大规模用例。
我们的用户越来越需要越来越多的沙箱,且创建速率越来越高。强化学习可能需要同时运行数百万个沙箱,并在 rollout 开始时突发创建数十万个沙箱。同样,Agent 越来越需要大规模和高并发创建速率来应对流量突发。
我们现有的沙箱平台非常出色,但它并非为这些规模而设计;其他现有解决方案也是如此。我们痴迷于规模和性能,我们希望我们的基础设施能够加速 Agent 的增长,而不是增加阻力。因此我们重新从头开始设计。
在过去几个月里,我们从头重建了核心沙箱平台,兼顾规模和可靠性。在我们的新系统上,用户可以同时运行数百万个沙箱,并每秒创建数万个沙箱。我们移除了控制平面中的所有中心瓶颈,因此没有实际的扩展限制,并且我们优化了容器调度和启动的每一个环节,将调度路径简化为直接在我们的工作节点集群上创建容器的负载均衡器层。
作为我们平台能力的展示,我们同时运行了一百万个沙箱,并在不到一分钟内创建了全部 100 万个。
为什么大多数解决方案无法扩展
运行 100 万个沙箱会挑战任何容器平台的极限,这既是因为容器数量庞大,也是因为运行这么多沙箱需要数万个计算节点。会有许多操作是 O(容器)、O(节点) 或两者兼有,这会导致传统容器平台触及扩展极限。
以 Kubernetes 为例:
- 在最坏情况下,对于
n个节点和p个 Pod,调度算法为O(n x p),并且调度默认是串行的。 - 每个 Pod 在其生命周期内会对 etcd(Kubernetes 的中心持久化存储)产生多次写入,这在高 Pod 创建速率或高 Pod 变动率下可能造成严重问题,而 etcd 在键空间内原生不支持分片
- 每个节点必须至少每个心跳间隔向 etcd 写入一次以表明存活,因此基线 etcd 写入负载是 O(节点),完全独立于 Pod 创建
Kubernetes 可以扩展,但需要大量工作。要运行大量节点,etcd 通常必须被重写或替换。支持高调度吞吐量需要构建一个复杂的 scatter-gather 系统来并行化调度算法,同时仍保持 Pod 状态的单一事实来源。默认情况下分片和并行化并不容易,因为 Kubernetes 依赖强一致性作为其设计的支柱。
Modal 最初的沙箱架构也存在类似问题。与 Kubernetes 一样,我们在整个后端依赖强一致性,因此创建和调度沙箱需要全局协调,并且需要对 Postgres 进行 O(sandboxes) 次写入,而我们无法轻易对其进行分片。
由于我们并非构建在 Kubernetes 之上,我们得以将这一系统的许多部分横向扩展。例如,调度默认是并行化的,这使我们能够实现极高的突发沙箱创建速率。但随着我们扩展到越来越多的节点和沙箱,我们不断遇到新的瓶颈,这些瓶颈源于那些要么是 O(sandboxes) 要么是 O(nodes) 但不易横向扩展的操作。
例如,我们为每个完成的沙箱运行一个持久化工作流,因此高沙箱流失率会造成大量事件积压。我们反复遇到以 O(sandboxes) 速率调用的 RPC,这在整个系统中引发了意料之外的负载问题。而运行大量沙箱所需的节点数量本身,也在节点管理和自动扩缩容方面引发了多个下游问题。最后,尽管我们可以绕过它,但让一个未分片的 Postgres 实例处于所有沙箱创建和调度的关键路径上,已被证明是一个糟糕的主意。
解锁无限扩展
我们很快意识到,实现我们想要的规模需要从根本上重新思考我们的架构。我们希望运行数百万个沙箱,并每秒创建数万个沙箱,这需要比现有任何方案都更好的扩展特性。与其试图演进我们已有的东西,我们认为最快、最干净的路径是重新开始。
为了针对规模进行优化,我们决定,任何承担 O(sandboxes) 或 O(nodes) 负载的部分都必须默认具备横向扩展能力,沙箱创建路径应尽可能简单,其他一切都应居于次要地位。我们得出的解决方案与现有系统显著不同。我们完全摒弃了任何形式的中央协调,并在运行和创建沙箱的关键路径上处处以全局一致性换取可扩展性和性能。以下是其工作原理:
- 我们不再使用单一的、串行化的调度器,而是运行一组调度服务器,并发处理沙箱创建请求。为了处理创建请求,调度服务器会针对内存中缓存的数据运行快速调度算法。结果是调度可以横向扩展,看起来更像是负载均衡,而非传统的容器调度。
- 我们不再使用一个中央的、持久化的数据存储作为沙箱和工作节点状态的唯一事实来源(这是大多数容器平台的工作方式),而是让新系统中的每个工作节点都成为其自身的事实来源。工作节点会定期将其状态发布到 Redis 流中。调度服务器异步消费这些状态,并用其做出调度决策。一旦调度服务器决定在哪个工作节点上创建沙箱,它就会通过 RPC 直接联系该工作节点,请求创建沙箱。工作节点如果有空闲资源,就接受该调度请求,否则拒绝。
- 我们在沙箱创建的关键路径上完全没有数据存储,这提升了可扩展性和可靠性。虽然我们确实需要将沙箱元数据和结果写入持久存储,但我们主要是异步进行的。
- 除了沙箱创建之外,我们没有任何复杂度为 O(沙箱数量) 的 RPC。Worker 会将多个沙箱的控制消息批量合并到单个 RPC 中,这遵循了面向数据设计的理念。
结果是,沙箱创建路径只需要两次网络跳转和一次廉价的 CPU 操作。没有中心瓶颈或协调成本,没有单点故障,因此聚合沙箱规模或沙箱创建吞吐量实际上没有上限。我们可以根据需要添加更多调度器或 worker。最紧迫的瓶颈是所有 worker 都将状态发布到单个 Redis 流,但负载测试表明,这在远超 100,000 个 worker 之前仍然可行;而且我们本来也不依赖流上的顺序,所以很容易就可以添加更多流。通过设计,我们避免了那些阻碍现有解决方案扩展的问题。
构建这个解决方案并不容易!整个开发过程花费了数月的工作,涉及我们后端的大多数主要系统。我们在白板上花了数小时。我们中的四个人驻扎在迈阿密海滩的一栋出租屋里,不受干扰地构建我们想要的新系统原型。我们花了八天时间写代码,直到身体实在撑不住,下快棋来恢复精力,跳进海里,然后又立刻回到代码中,努力让我们的新系统干净且可用。
当我们让核心部分运转起来(并回到纽约)后,我们还需要在新系统之上重新实现每一个 Sandbox 功能以及所有 Sandbox 可观测性。这个项目还需要对我们核心的 worker 管理栈以及容器运行时进行更改。例如,我们遇到的一个有趣问题是,我们新的沙箱调度器可以将容器如此之快地推送到 worker,以至于许多容器同时启动时,在设置容器网络规则时会争抢 Linux 内核中的 rtnl 锁,并需要数十秒才能启动,因此我们不得不为沙箱更改容器网络设置,这样我们的 worker 在被大量沙箱创建请求淹没时才不会崩溃。
我们的性能表现如何
我们通过尽可能快地启动 100 万个沙箱来对系统进行基准测试。从高层来看,我们可以在不到一分钟内创建 100 万个沙箱,而主要瓶颈是基准测试本身。单个沙箱的可交互时间始终保持在较低水平,而且我们没有看到随规模增长而出现的实际性能下降。
我们认为这符合预期,因为我们的设计如此。调度路径中没有任何协调,因此调度应当保持非常快,与并发量和规模无关。就我们而言,除了可用容量之外,并发沙箱调度或规模没有严重限制,而容量管理已经是我们擅长的事情。
在我们新系统上的沙箱启动时间(从客户端首次尝试创建沙箱,到沙箱可以运行用户代码的延迟)中位数不到半秒,并且在大规模下依然稳定。它们也比我们的旧系统快得多,主要是因为调度快得多——现在只需要几十毫秒。延迟的长尾比我们希望的要长一些。我们将这一长尾的很大一部分归因于当许多沙箱在同一 worker 上同时启动时的内核和网络争用(包括前面提到的 rtnl 锁争用),我们正在努力减少它。此外,大规模下的长尾是真实存在的。我们预计随着我们优化容器启动路径,这一点会有所改善。
总体而言,我们对这些性能数字非常满意。随着 agent 接管世界,显然我们可以与它们一起扩展。
亲自试试
很快这个新系统将支撑 Modal 的所有沙箱调度,但它已经在 Beta 中可用。你只需对代码做一处简单修改即可选择启用。如果你需要运行大量沙箱,试试看,并和我们聊聊!
致谢
很多人为这个项目付出了血汗和泪水。我们的 Miami POC 由 Colin Weld(我)、Daniel Shaar、Walter Tang 和 Gleb Posobin 构建,随后由 Walter、Colin、Connor Adams、Akshay Balwally、Tom Wildenhain、Scott Hao 和 Taylor Baldwin 投入生产。
来源:Modal Blog · modal.com