本文由 Colin Weld 和 Connor Adams 撰写。在此阅读或 移步我们的博客。
在 Modal,我们构建沙箱,这只是众多工作之一。Agent 在沙箱中运行,而 Agent 正在吞噬软件。如今,Modal 每天运行数百万个沙箱,每个客户支持多达五万个并发沙箱,并大规模支持多种用例,从强化学习到后台 Agent。
越来越多的用户需求更多沙箱,且创建速率越来越高。强化学习可能需要同时运行数百万个沙箱,并在推出初期创建数十万个沙箱的爆发。同样,Agent 越来越需要大规模和高并发创建速率来应对流量突发。
我们现有的沙箱平台非常出色,但它并非为这种规模而设计;现有其他解决方案也做不到。我们痴迷于规模和性能,我们希望基础设施能加速 Agent 的发展,而不是增加阻力。因此,我们重新开始设计。
过去几个月,我们从头重建了核心沙箱平台,兼顾了规模和可靠性。在新系统上,用户可以同时运行数百万个沙箱,每秒创建数万个沙箱。我们移除了控制平面中的所有中心瓶颈,因此没有实际扩展限制,并优化了容器调度和启动的每个环节,将调度路径简化为一个负载均衡器层,直接在我们的工作节点集群上创建容器。
为了展示平台能力,我们同时运行了一百万个沙箱,并在不到一分钟内创建了全部一百万个沙箱。

证据表明我们可以运行大量沙箱。
为什么大多数解决方案无法扩展
运行一百万个沙箱挑战了任何容器平台的极限,不仅是因为容器数量庞大,还因为运行这么多沙箱需要数万个计算节点。许多操作要么是 O(容器数),要么是 O(节点数),或者两者兼有,这会导致传统容器平台遇到扩展瓶颈。
以 Kubernetes 为例:
- 调度算法在最坏情况下是 O(n x p),其中 n 是节点数,p 是 Pod 数,且默认情况下调度是串行的。
- 每个 Pod 在其生命周期内会对 etcd(Kubernetes 的中心持久化存储)进行多次写入,在高 Pod 创建速率或高 Pod 变更率下会引发严重问题,而 etcd 在键空间内无法原生分片。
- 每个节点必须至少在每个心跳间隔向 etcd 写入一次以表明存活,因此 etcd 的基准写入负载是 O(节点数),完全独立于 Pod 创建。

Kubernetes 调度流程的近似图。新 Pod 由 API 服务器写入 etcd(强一致持久化存储)。Kubernetes 调度器监视新未分配的 Pod,并通过 API 服务器调用将其分配给节点,这再次写入 etcd;该写入完成后,节点才能启动 Pod。
Kubernetes 可以扩展,但需要大量工作。要运行大量节点,etcd 通常必须被重写或替换。支持高调度吞吐量需要构建一个复杂的散列-收集系统来并行化调度算法,同时保持 Pod 状态的单一真实来源。分片和并行化并非易事,因为 Kubernetes 将强一致性作为其设计的核心。
Modal 最初的沙箱架构也存在类似问题。与 Kubernetes 一样,我们整个后端依赖强一致性,因此创建和调度沙箱需要全局协调,并且需要 O(沙箱数) 次对 Postgres 的写入,而 Postgres 无法轻易分片。

Modal 最初的沙箱控制平面架构。创建沙箱时,它们被放入队列并写入 Postgres。调度是乐观的且并行运行,但需要中心协调以避免冲突。将沙箱分配给工作节点(计算节点)需要额外写入 Postgres。
由于我们不基于 Kubernetes 构建,我们已经能够扩展该系统的许多部分。例如,调度默认是并行的,这使我们能够实现非常高的突发沙箱创建速率。但是,随着我们扩展到越来越多的节点和沙箱,我们不断遇到新的瓶颈,这些瓶颈源于 O(沙箱数) 或 O(节点数) 的操作,且不易扩展。
例如,我们为每个完成的沙箱运行一个持久化工作流,因此高沙箱变更率会导致大量事件积压。我们反复遇到以 O(沙箱数) 速率调用的 RPC,这在整个系统中引发了意外的负载问题。运行大量沙箱所需的节点数量也在节点管理和自动扩缩容中引发多个下游问题。最后,尽管我们可以绕开,但将未分片的 Postgres 实例留在所有沙箱创建和调度的关键路径上已被证明是一个坏主意。
解锁无限扩展
我们很快意识到,要达到所需的规模,必须从头重新思考架构。我们希望运行数百万个沙箱,每秒创建数万个沙箱,这需要比现有任何方案更好的扩展特性。我们认为,最简单、最清晰的路径不是逐步改进现有系统,而是重新开始。
为了优化扩展性,我们决定:所有带来 O(沙箱数) 或 O(节点数) 负载的操作必须默认可水平扩展,沙箱创建路径应尽可能简单,其他一切皆为次要。我们得出的解决方案与现有系统显著不同。我们完全摒弃了任何形式的中心协调,并在运行和创建沙箱的关键路径上,用全局一致性换取了可扩展性和性能。具体原理如下:
- 我们不再使用单一的串行调度器,而是运行一个调度服务器集群,并发处理沙箱创建请求。调度服务器处理创建请求时,会针对内存中的缓存数据运行快速调度算法。结果是调度水平扩展,看起来更像负载均衡,而非传统容器调度。
- 大多数容器平台使用中心持久化数据存储作为沙箱和工作节点状态的单一真实来源,而我们的新系统中,每个工作节点本身就是自己的真实来源。工作节点定期将其状态发布到 Redis 流中。调度服务器异步消费这些状态,并据此做出调度决策。调度服务器决定将沙箱创建到哪个工作节点后,直接通过 RPC 联系该工作节点,请求创建沙箱。工作节点在有可用资源时接受调度请求,否则拒绝。
- 沙箱创建的关键路径上完全没有数据存储,这提高了可扩展性和可靠性。虽然我们确实需要将沙箱元数据和结果写入持久化存储,但大部分是异步完成的。
- 除了沙箱创建,我们没有 O(沙箱数) 的 RPC。工作节点将多个沙箱的控制消息批量合并到单个 RPC 中,遵循数据导向设计的理念。

我们最终的设计,首次在白板上绘制。

Modal v2 沙箱架构中的沙箱创建路径。沙箱创建请求由水平扩展的调度服务器处理,然后通过快速的内存负载均衡算法选择工作节点,并直接联系工作节点(计算节点)创建沙箱。沙箱对象存储在 Redis 中,但不在关键路径上。
结果是,沙箱创建路径只需要两次网络跳转和一次轻量级 CPU 操作。没有中心瓶颈或协调成本,没有单点故障,因此沙箱总规模或创建吞吐量没有实际上限。我们可以根据需要添加更多调度器或工作节点。最接近的瓶颈是所有工作节点向单个 Redis 流发布状态,但负载测试表明,即使在超过 100,000 个工作节点时,这仍然可行;而且我们并不依赖流的顺序,因此很容易增加更多流。通过设计,我们避免了现有解决方案在扩展时遇到的问题。
构建这个解决方案并不容易!整个开发过程耗时数月,涉及我们后端的大部分主要系统。我们花了数小时在白板上讨论。我们四人搬到了迈阿密海滩的一间出租屋,在没有干扰的情况下构建我们想要的新系统原型。我们花了八天时间写代码,直到身体撑不住,下下快棋恢复精力,跳进海里,然后立刻回到代码上,努力让新系统变得清晰、功能完善。

我们最优秀的工程师在迈阿密海滩放松。
一旦核心部分工作正常(并回到纽约),我们还需要在全新系统的基础上重新实现每一个沙箱功能以及所有沙箱可观测性。这个项目还要求我们修改核心工作节点管理栈以及容器运行时。例如,我们遇到的一个有趣问题是,新的沙箱调度器向工作节点推送容器的速度非常快,以至于大量容器同时启动时会争夺 Linux 内核中的 rtnl 锁(设置容器网络规则时),导致启动时间长达数十秒。因此,我们不得不修改沙箱的容器网络设置,以防工作节点在被沙箱创建请求淹没时崩溃。
性能表现如何
我们通过尽可能快地启动一百万个沙箱来测试系统。总体来看,我们可以在不到一分钟内创建一百万个沙箱,主要瓶颈在于基准测试本身。单个沙箱的交互时间保持稳定,且未观察到随规模增加而明显退化。

沙箱创建请求的分布和 eCDF。沙箱创建请求返回时,我们的调度服务器已成功将沙箱分配给工作节点,并开始启动。
我们认为这符合设计预期。调度路径中无需协调,因此无论并发度和规模如何,调度都应保持非常快速。就我们而言,除了可用容量之外,并发沙箱调度或规模没有严重限制,而管理容量我们已经做得很好了。

从 100 万沙箱测试中随机抽取的 10,000 个沙箱启动时间散点图。
新系统上沙箱的启动时间(从客户端首次尝试创建沙箱到沙箱可运行用户代码的延迟)中位数不到半秒,并且在规模下表现稳定。它们也比旧系统快得多,主要原因是调度更快——现在只需几十毫秒。延迟的长尾部分比我们希望的要长一些。我们将大部分尾部延迟归因于内核和网络争用(包括之前提到的 rtnl 锁争用),当大量沙箱在同一工作节点上同时启动时尤为明显,我们正在努力减少它。此外,规模下的尾部延迟是真实存在的。我们期望随着优化容器启动路径,这部分会有所改善。
总体而言,我们对这些性能数据非常满意。随着 Agent 接管世界,我们显然能够与之一起扩展。
亲自体验
很快,这个新系统将支持 Modal 的所有沙箱调度,但目前已作为 Beta 版提供。您只需对代码做一处简单修改即可选择加入。如果您需要运行大量沙箱,请尝试一下,并与我们联系!
致谢
许多人为此项目付出了心血和汗水。我们的迈阿密概念验证由 Colin Weld(我)、Daniel Shaar、Walter Tang 和 Gleb Posobin 构建,随后由 Walter、Colin、Connor Adams、Akshay Balwally、Tom Wildenhain、Scott Hao 和 Taylor Baldwin 将其投入生产。





