Modal 发布 serverless Servers,p50 延迟从 39ms 降至 6ms
Modal's serverless Servers
Modal 推出 serverless Servers,为 HTTP、WebSocket 和 gRPC 流量提供区域化、自动扩缩的 HTTP 服务器副本池,面向 LLM 推理等对毫秒级延迟敏感的场景。
Modal 官方披露 Servers 的架构取舍与 p50 延迟从 39ms 降到 6ms 的实现路径,可供做低延迟推理服务的人参考。
Modal 让在云端运行高性能代码变得简单:Python 函数、agent 运行时、notebook、批处理任务等等。现在,你还可以在 Modal 上运行超低延迟的 Servers,用于处理 HTTP、WebSocket 和 gRPC 流量。
Servers 专为每一毫秒都至关重要的应用而设计,例如面向交互式 agent 的 LLM 推理。Servers 在 Modal 的路由层之后为你提供一个区域化、自动扩缩的 HTTP 服务器副本池,并具备我们认为属于基本要求的部署体验、快速反馈循环和自动扩缩能力(对人类和 agent 而言皆是如此)。
这听起来可能很熟悉:借助 Modal Web Functions,你原本就已经可以在 Modal 上暴露 HTTP 端点。但 Web Functions 的架构追求的是开箱即用的稳健性——排队以及平台托管的请求生命周期——而非极致低延迟。随着推理延迟大幅下降,消除正常路径上的延迟、把其他一切都推到应用层,已变得愈发关键。那几十毫秒就是胜负之间的差距。
于是我们在 Modal 上构建了一个开销极低的 HTTP 服务方案,同时不牺牲核心功能。我们把 p50 延迟从 39ms 降到了 6ms:
在这篇博客中,我们将解释其中的原理。难点不在于接收和路由 HTTP 流量;Envoy 就能做到。难点在于在不把控制平面查询或队列放入热路径的前提下,保留 Modal 的语义——认证、动态副本放置、区域路由、自动扩缩、推理功能以及租户隔离。
Modal Servers 是做什么用的?
Modal Servers 通过反向代理路由系统,实现你的客户端与 Modal 上区域化、自动扩缩的 HTTP 服务器副本池之间的通信。
把这个轻量级系统(见下方右图)与 Modal Web Functions 对比一下,后者包含一个支持排队的 I/O 平面(见下方左图)。
要更好地理解这些架构及其取舍,可以想想传输控制协议(TCP)与用户数据报协议(UDP)之间的差异。TCP 为互联网应用提供可靠、有序的字节流。UDP 提供一种延迟更低的原语,但要求应用自行处理乱序/丢包的数据报。抽象地说,两者都谈不上“更好”。它们优化的是不同的目标。
有些应用(例如 GPU 间通信、视频通话)构建在 UDP 之上——通过基于融合以太网的 RDMA(RoCE)v2 或通过 Web 实时通信(WebRTC)。它们实现了比 TCP 更低的抖动/延迟(对比iWARP 这种基于 TCP 的 RDMA 的有限采用度)。代价是要以应用特定的方式重新实现更高层的可靠性/有序性(参见“端到端论证”)。
Modal Web Functions 更接近 TCP:排队是内置的,因此客户端无需操心。Modal Servers 更接近 UDP:请求走的是更轻量、延迟更低的路径,但应用必须处理那些粗糙的边缘情况。如果没有可用副本,客户端会收到 503 Service Unavailable——这与服务根本不存在时的响应相同。排队和负载卸载(通过 503)同样被推给了容器应用(目前如此!)。
但在某些应用中,比如低延迟 LLM 推理,做出这种取舍是合理的。
顺便说一句,这个差异并非字面意义上的——你可以使用任一技术栈在 Modal 容器上提供 UDP 应用,通过 UDP 打洞,例如用于驱动机器人或检测网络摄像头画面中的物体。但工程约束、我们的选择以及你可用的选项是类似的。
设计 Modal Servers
从高层来看,Modal Servers 的路由层包括一个用于 I/O 的流式边缘代理、一个智能无状态代理,以及一个计算负载均衡器。无状态代理由共享的全局状态配置,计算负载均衡器与用户容器以及创建和销毁它们的 Autoscaler 通信。
大致如下所示:
我们为该架构及其内部所做的每一个选择都遵循两条原则:
- 最大化资源共享,同时最小化干扰。我们需要跨租户和请求池化资源(工作窃取、连接池、流多路复用)以获得总体性能,但我们也需要提供专用资源的假象,以保证正确性和每租户/每请求的性能。
- 请求路径上不进行网络调用。不获取元数据,不使用 KV 存储,不回退到 blob 存储。这阻止了我们在此层处理排队和重试(不过我们可以在之后作为可选项重新叠加)。它要求我们在路由层内部物化最小状态,同时保持一致性。
第一条原则最为重要,值得一些评论。高性能代理自然会围绕其管理的资源来组织工作:工作线程、连接、流、缓冲区和流控窗口。但 Modal 的服务路径有一组不同的边界,这些边界对用户更重要,因此对我们也是如此:客户流量到他们的 HTTP Server 副本。这些边界是用户定义正确性和体验延迟的地方。
当这两种视角不一致时,共享代理资源可能变成共享命运。连接池作为优化被添加,但在负载下却成为哪些请求相互干扰的决定因素。队列被添加用于缓冲,却变成了隐式调度器。流控窗口起初是传输细节,但在规模上定义了背压如何在流之间组合。所以并不存在“一个让你的延迟下降的奇怪技巧”。相反,它是一系列微观调整,使资源使用与我们的宏观目标对齐。
Modal Servers 如何工作?
最终的实现如下所示:
路由系统的核心运行在我们的 AWS Elastic Kubernetes Service 上。正如我们喜欢说的,Kubernetes 不是为推理而构建的——但它是为扩展高可用分布式代理而构建的。相比之下,用户容器以及支持它们的自定义容器运行时由我们的调度系统管理,而非 Kubernetes,并且它们运行在遍布全球的数十个云上。
在下文中,我们将逐步介绍该系统中一个请求的生命周期。
客户端请求由 Modal Servers 支持的资源
该资源由类似 https://[domain].[routing_region].modal.direct/[path] 的 URL 标识。
[domain]组件标识一个特定的 Modal Server(即容器的自动扩展池)[routing_region]选择一个代理系统的区域化部署(这些部署共同构成我们的路由平面)modal.direct在公共互联网上标识我们[path]组件标识服务器上的资源——连同请求体和查询参数,这就是用户与你的业务逻辑交互的方式
我们通过 AWS NLB(一种 L4 代理)连接客户端。由于它工作在 L4(此处为 TCP)而非 L7(HTTP),无法实现 L7 的功能,因此仅作为简单的负载均衡器使用。
Envoy 将请求转换为 h2 流
第一批 L7 功能由流式边缘代理实现,我们为此使用了 Envoy。该组件负责终止 TLS,这样我们就能在 VPC 内部无需 TLS 进行通信,并可以操作请求头。它还将所有 HTTP 流量规范化为 HTTP/2。
借助 HTTP/2,我们可以将客户端流多路复用到从连接池中取出的单个 TCP 连接上。此处的连接池化降低了延迟,而多路复用则控制了总资源需求——随总负载扩展,而非直接随客户端数量扩展。但同一连接上不同客户端之间引入的争用需要深入审视。
fprs,我们的自研代理,将客户端流映射到 Server 副本
在架构层面,下一个目标是将 URL 中的 domain 映射到特定的用户部署的 Modal Server 应用(“域名关联”),并在 Modal 计算平面中对该应用的副本进行负载均衡。大多数非路由功能都在这一层实现。
Envoy 在设计上具备出色的性能,但它不允许我们实现这些功能。由于没有这些功能就无法交付产品,我们将 Envoy 保留在边缘,并选择自建系统来实现域名关联和负载均衡,以便在其中注入核心功能。
我们热衷于 深入底层并自研构建,但做出这个决定并非轻率。我们专注于支持 LLM 推理,但并未排除其他工作负载,因此不能使用像 NVIDIA Dynamo 或 llm-d 这样的系统。我们在运维 AWS NLB 和 nginx 方面有大量生产经验,但我们需要一套独特的功能——例如,在用户创建和销毁服务(以及服务创建和销毁副本)时处理上游的快速变动。
fprs 让我们能够轻松交付所需功能
我们使用 CloudFlare 出色的 pingora 库构建了 fprs。我们是相当资深的 Rust 开发者,因此很放心将其引入我们的技术栈并大规模运维。正如其作者在 他们的博客 中指出的,底层的 Tokio 运行时非常适合流式、多路复用的 HTTP 代理。Rust 轻量级线程内协程并发模型中的工作窃取,优雅地将共享资源与共享命运分离开来。
话虽如此,要在我们独特的使用场景中实现 pingora 那样紧凑的尾延迟,我们还需要做一些工作。例如,我们的域名周转速度远超常规。在调查偶发的停顿现象时,我们确定存在一条由这种周转触发的隐藏 DNS 解析路径,需要谨慎地对其进行缓存。
我们将此代理设计为无状态,以便于轻松扩缩容。但配置本身就是状态。而这里的配置中包含 url: host 映射,这些映射变化迅速。配置错误是 导致代理服务宕机的常见原因,因此我们希望确保这一点万无一失。我们从 Spanner(一个全球复制的数据库)读取配置状态。
当然,我们不会对每个请求都从 Spanner 读取数据。遵循避免在热路径上进行网络调用的设计原则,我们维护了一个域关联的内存缓存。该缓存通过 change stream 与全局状态并发协调(Spanner 的外部一致性保证在这里派上了用场)。
性能和路由问题解决后,我们就可以添加关键功能了。
自动扩缩容指标
这一层还会向 Modal Autoscaler 发送指标,由它决定是否创建新容器来处理增加的负载(或在不再需要时将其关闭)。
自动扩缩容器需要信号和算法。路由层发出的信号是基于(逻辑)连接计数的每容器在途请求数。算法是当平均连接数超过/低于用户指定的 target_concurrency 时增加/减少容器数量,并受 scaleup_/scaledown_window 约束。
代理认证
我们还需要路由层认证。用户服务可以自行实现认证,但这只有在至少一个副本启动并能够处理请求之后才会生效。如果你需要启动八块 B200,每块功耗一千瓦,仅仅为了响应几个字节的 HTTP 流量而回复 401 Unauthorized,那你就是在把优势拱手让给拒绝服务攻击者。
因此我们实现了 代理认证,在路由层就拦截这些请求,使其在到达用户容器之前就被阻止。
镜像
推理系统是机器学习系统的最终产物。机器学习系统对其他系统有一些独特的需求。最显著的是,它们非常关心所运行数据的语义,并且它们在生产环境中的行为难以预测。因此,理解生产环境中的用户数据至关重要,所以我们支持将镜像作为一等功能。
一个简单的例子是 A/B 测试。与 UI 变更一样,有时评估 ML 系统变更的唯一方法就是在生产环境中测试,最好是通过随机对照试验,也就是 A/B 测试。对于推理系统,这些流量通常只需通过镜像来影子生产环境就足够了。
我们另一个感到兴奋的镜像用例是持续学习。ML 系统会随着更多数据而变得更好。我们通过为推理加速训练自定义推测模型,直接看到了推理系统性能上的这一效果。这也是模型质量持续改进循环中的关键部分——我们预计,鉴于近期专有模型服务领域的风波,随着行业转向开放权重推理,更多业内人士会意识到这一点。
计算平面中的 worker 将 HTTP 请求中继到容器
由 fprs 转发的请求到达 Modal 计算平面中的容器运行时 worker。这个容器运行时封装用户容器并提供其运行时语义。计算平面涵盖从普通 CPU 服务器到冰箱大小的 NVLink 机架,运营者既有发明云计算的超大规模厂商,也有从加密货币转型而来、刚刚好到足以通过我们的质量评估的新云厂商。
这些 worker 与 fprs 使用 HTTP/2 通信。它们与支持 HTTP/2 的用户服务器也使用 HTTP/2 通信,但必要时可以切换到 HTTP/1——而不会影响我们系统其余部分的 HTTP/2 流。worker 还通过跟踪请求完成情况来处理容器的优雅排空。
该路径反向运行,将响应返回给客户端。
系统中的每个组件(在 AWS NLB 之后)都是一个 HTTP 服务器,因此它们必须按相反顺序向各自的客户端返回响应,直到我们到达最初的客户端。整个过程在 5-7 毫秒内完成。这感觉是一个欣赏计算和网络力量的好时机。上述所有工作都可以在比神经冲动沿你的腿传播更短的时间内完成。
为什么要构建这个?
在 vibe-coding 和短暂软件的时代,Modal 仍然相信基础构建块的力量。这些高质量、可复用、可组合的组件可以用来构建新服务,并为我们和我们的用户解锁新的工作负载。如果说有什么不同的话,那就是随着机器让我们能够更便宜地创建更多软件,以更好地服务最终用户的需求,核心基础设施的强度和效率变得更加重要。
服务器是 Modal 原语的新增内容。用户可以直接通过我们的 SDK使用它们。我们预计某一类用户会接受它并加以发挥——正如一些人已经做的那样!
但它们也被我们的新 Endpoints 产品作为一个组件所使用,该产品让以最先进性能服务 LLM 推理变得轻而易举。
如果你有兴趣解决棘手的基础设施问题并打造强健的骨骼,我们正在招聘。
来源:Modal Blog · modal.com