Deno 如何构建安全、高性能的多租户云平台来运行不受信任代码

原作者:Bert Belder;原载 Deno Blog,2024年5月17日。中文翻译与技术审校:未完纪。 原文:How we built a secure, performant, multi-tenant cloud platform to run untrusted code。本文按原文完整翻译,技术架构、请求量和合规状态均描述原文发表时期;文末编辑说明单独列出适用边界。

我们打造 Deno Deploy 这个面向多租户、全球分布式的 V8 isolate 云,是为了让 JavaScript 和 TypeScript 的云端托管更简单。许多用户用 Deno Deploy 运行自己的项目,也有企业通过 Subhosting 使用 Deno Deploy 的基础设施,让自己的用户轻松在云端编写和运行代码。借助 Subhosting,企业可以为用户提供边缘函数,将电商店面部署到靠近用户的位置——在这里,每一毫秒都可能影响转化率——也可以在低代码工作流中提供“逃生舱口”,允许用户在代码层面定制行为。这套基础设施也经过了实际流量的考验:由 Deno Subhosting 支撑的 Netlify 边缘函数,每天处理超过 2.55 亿次请求。

许多公司希望安全地运行用户提供的不受信任代码,但要构建一个兼顾性能、安全与价格的多租户 JavaScript 平台,并不容易。本文将深入 Deno Deploy 内部,介绍成本与性能之间的取舍,以及我们做出了哪些决定、为什么这样决定。我们会依次讨论设计要求、部署的生命周期、请求的生命周期和后续方向。

现代无服务器平台的设计要求

现代无服务器平台应该简单易用,尽量少配置,同时具备良好的性能与安全性。这些目标帮助我们确定了以下设计要求:

  • 尽可能隔离租户,达到尽可能高的安全水平;
  • 尽量缩短冷启动时间,提供出色的性能;
  • 自动在全球复制,用户无须进行任何操作;
  • 自动扩缩容,用户无须进行任何操作;
  • 价格有竞争力,且用户负担得起;
  • 全球部署足够快,达到秒级。

带着这些约束,我们设计了下图所示的基础设施架构,希望在性能与成本之间取得恰当平衡,同时尽可能提高安全性。

Deno Deploy 控制平面与各区域数据平面的整体架构
图1:Deno Deploy 内部架构总览。原图:Deno / Bert Belder,获授权转载。

Deno Deploy 分为两个部分。借用网络工程领域常用的术语,我们将其称为控制平面与数据平面。

控制平面负责创建与管理部署,以及用户和组织账户、分析、用量计量与计费、仪表盘等所有相关事务。与本文最相关的,是它参与创建新部署的过程;我们将在“部署的生命周期”中展开。

数据平面负责实际运行部署代码,并处理发往各个部署的 HTTP 请求。它涵盖全部边缘区域,以及区域中的存储桶、边缘代理和运行 V8 isolate 的虚拟机,也就是 runner。它们如何协同工作,将在“请求的生命周期”中详细介绍。

部署的生命周期

用户应该能够在全球部署应用,并在几秒钟内看到变更。因此,我们既要优化无需用户干预的全球自动复制流程,也要为新部署提供快速路由流量的方法。

用户发起部署

用户可以通过向 GitHub 的 main 分支合并代码、使用 deployctl,或运行 GitHub Actions 工作流来触发部署。这些入口对 Deno Deploy 的易用性很重要,但本文不展开说明它们之间如何协作。让我们直接从部署启动时开始。

为 V8 预优化部署代码

预处理的目的是减少代码加载到 V8 isolate 并开始执行时的冷启动开销。要理解这些步骤,先看看代码加载到 V8 isolate 时究竟发生了什么。

执行 JavaScript 时,首先将一个入口文件交给 V8。V8 查找其中的 import 语句,整理出依赖列表,并请求这些依赖的代码。拿到依赖后,它又扫描其中的 import 语句,继续请求下一层依赖。通过广度优先搜索构建完整程序的过程可能相当耗时,尤其是 V8 无法一次性并行下载所有依赖。假设每次远程获取依赖需要 100 毫秒,那么根据依赖嵌套深度的不同,这种“网络瀑布”可能让完整依赖加载之前的等待时间变得很长。

此外,Deno 原生支持 TypeScript,因此代码交给 V8 之前还要经过一次转译。

这些步骤都会增加冷启动耗时。

好在我们可以通过预处理部署代码来缩短冷启动、降低延迟。这一步包括:

  • 使用基于 Rust、面向 TypeScript 和 JavaScript 的工具平台 swc,把代码转译成 JavaScript;
  • 遍历 import 语句,下载依赖的全部代码,并反复执行这一过程,直到所有依赖都已下载;
  • 对于 npm 依赖,移除测试等不必要的代码,以减小体积;
  • 按照依赖的广度优先搜索顺序排列代码。顺序很重要:这样就能把代码流式传给 V8,进一步提高性能。
部署代码转译、依赖下载与预优化流程
图2:预处理用于减少代码后续加载与执行时的冷启动时间。原图:Deno / Bert Belder,获授权转载。

提前下载全部依赖还有一个重要原因:保证部署的一致性与不可变性。每次运行同一个部署,都应得到相同的代码行为。如果代码引用了没有固定版本的依赖,或直接从 URL 导入,依赖本身可能发生变化,远程地址也可能失效。将依赖预先下载,与部署代码一起固化并保存,就能消除后续执行因这些依赖变化而产生差异的风险。

更新 Postgres 中的数据

与此同时,系统会将用户项目和部署的重要信息更新到 Postgres。它是我们管理用户与项目信息的主数据库。

需要说明的是,Postgres 不在请求处理的关键路径上。也就是说,即使 Postgres 宕机,用户可能无法登录 Deno Deploy 网站,已经部署的应用仍会在线提供服务。我们有意将 Postgres 移出关键路径,以提高系统可靠性。

把部署代码复制到各边缘区域的存储桶

一项核心设计要求是:用户的部署应自动复制到全球,无须额外配置或操作。这样才能让世界各地都拥有快速冷启动,从而尽量降低延迟。这意味着,在邻近区域必须始终有一份源代码副本可用。

预处理后的代码会上传到世界各地的存储桶。稍后有人访问部署时,我们就把这份代码原样加载进 V8 isolate。

更新域名映射表

部署过程的最后一步,是控制平面向所有边缘区域的边缘代理发送信号,通知它们如何更新域名映射表。

这张表对传入 HTTP 请求的路由至关重要。请求中包含域名,代理利用内存中的域名映射表,查找对应的 deployment_id。映射必须准确,才能把每个请求快速送到正确的位置。

边缘代理将新部署及其域名加入内存中的映射表后,部署过程便完成了。

请求的生命周期

Deno Deploy 上的部署必须性能良好、安全,并且能够自动扩缩容。这意味着冷启动时间要尽量短,部署代码必须相互隔离,不能访问其他部署或底层系统,同时还要根据流量自动伸缩,无须用户配置。

接下来看看,当有人访问你在 Deno Deploy 上的部署时,会发生什么。

通过 Anycast 将请求路由到最近的区域

浏览器首次向 Deno Deploy 上的部署发送请求时,会通过 Anycast 被引导到最近的边缘区域。Anycast 是一种由不同地点的服务器共享同一 IP 地址的路由方式,常用于 CDN,让内容更靠近最终用户。

边缘代理将请求路由到 V8 isolate

请求已经抵达边缘区域。在讨论它如何被路由到正确的 V8 isolate 执行之前,我们先简单了解一下虚拟机和 V8 isolate。

许多云托管平台会为每位客户提供一台虚拟机,其中包含运行 Web 服务器所需的二进制文件和其他文件。但为了扩容而创建新虚拟机,速度较慢;若为了降低延迟而让虚拟机始终运行,即使没有流量也不关闭,成本又可能很高。这种模式无法同时满足我们对快速部署、低延迟与成本效率的要求。

因此,我们选择了支撑 Deno 运行时的同一项技术:V8 isolate。它是 V8 执行环境的一个实例,可以类比为面向 JavaScript 的 JVM。V8 isolate 最初面向浏览器场景设计;原文以每个打开的标签页拥有自己的 isolate 来说明这一点。因此,它们启动很快,内存占用也相对较少。

Deno Deploy 在称为 runner 的虚拟机中创建 V8 isolate 池。每个 runner 管理大量 isolate 的生命周期,确保用户代码收到请求时有可用的 isolate,并在它们空闲后关闭它们。runner 还负责约束各个 isolate 的行为,收集每份用户代码的运行统计,供边缘代理做出合理的扩缩容决策。

现在回到边缘代理。它收到请求后,会从内存中的域名映射表查出相应的 deployment_id,再决定由哪个 runner 处理请求。

总体而言,边缘代理的目标是为每个部署运行“适当”数量的 isolate,并把它们分散到多个 runner 上。这个数量会因部署而异,也会随时间变化,取决于每秒请求数和处理一次请求所需的资源。

为单个请求选择路由时,边缘代理会先根据它判断的适当 isolate 数量,选出一组候选 runner;随后综合考虑 runner 整体的 CPU 负载、其中目标 isolate 的 CPU 使用量,以及 isolate 的内存使用量,判断哪个 runner 能最快处理请求。

边缘代理选择已有 isolate 或启动新 isolate 的路由流程
图3:边缘代理使用概率与启发式方法,决定路由到已预热的 V8 isolate,还是冷启动一个新的 isolate。原图:Deno / Bert Belder,获授权转载。

runner 收到携带 deployment_id 的请求后,会检查是否已有 V8 isolate 正在运行该部署。如果有,就将请求直接交给它;如果没有,就从最近的代码存储桶拉取预优化过的部署代码,并加载到一个新的 isolate 中。

runner 从区域代码桶取回部署代码并载入 isolate
图4:runner 取回代码并载入 V8 isolate。原图:Deno / Bert Belder,获授权转载。

安全地执行代码

任何拥有 GitHub 账户的人都能在我们的基础设施上部署、运行代码,因此安全是首要问题。某个部署能够访问另一个部署,乃至访问底层基础设施,都属于安全边界被突破,可能让平台失去信任和客户。

这正是构建第三方不受信任代码运行平台最困难的部分。Val Town 的朋友说得很贴切:“运行不受信任代码,默认就意味着危险。”要把这件事做成一门业务,安全必须是最重要的考量。

那么,当 V8 isolate 中的代码收到请求时,我们如何确保各项安全与隔离措施已经到位?

我们从设计之初就把安全放在首位。首先建立威胁模型,设想单个恶意行为者如何拖垮整个系统,或入侵其他人的部署。由此,我们确立了五项要求,每个部署,也就是单个租户,都必须满足:

  • 只能访问自己的运行时,包括 JavaScript 对象和方法;
  • 只能访问自己的数据,包括文件、代码、数据库和环境变量;
  • 不能访问 Deno Deploy 的内部设施,例如底层操作系统与内部服务;
  • 不能挤占资源,导致其他 isolate 无法使用 CPU、内存等资源;
  • 不能将 Deno Deploy 用于平台预期之外的用途,例如挖比特币。

基于这些要求,我们使用 namespaces、cgroups、seccomp 过滤器等技术,在租户进程周围建立多层安全边界。即使恶意代码设法突破多层防护,我们也部署了其他机制,例如监控资源并终止可疑租户的 watchdog。

Deno Deploy 多层租户隔离与安全机制
图5:围绕租户建立多层安全防护。原图:Deno / Bert Belder,获授权转载。

安全也不只是技术问题。为了赢得用户与企业客户的信任,我们还把它当作组织层面的责任;原文发表时,Deno 已满足 SOC 2 对安全性、可用性与保密性的要求。

接下来

要构建一个易用、自动伸缩与复制、延迟和冷启动时间都很低,同时成本又有竞争力的多租户云平台,并不简单。希望本文能让你更清楚地看到,我们是如何构建 Deno Deploy 与 Deno Subhosting 的。

如果你希望搭建无服务器部署平台,以较短的冷启动时间安全地运行用户的不受信任代码,又不想承担自建基础设施的维护负担,可以了解 Deno Subhosting。它提供 REST API,让你以编程方式管理项目和部署等资源,无须由你或你的用户处理配置和扩缩容。

想安全地运行用户的不受信任代码,又不想从零开始构建?

可以看看 Deno Subhosting:在几分钟内,让来自多个用户的 JavaScript 在托管的安全沙箱中运行。

编辑说明:原文的时间与安全边界

本文是2024年的架构介绍,不构成对当前产品实现或安全性的独立审计。2.55亿次日请求量、秒级部署和SOC 2描述均为作者当时的陈述,没有在本次翻译中重新测量或审核。固化依赖能够避免依赖内容漂移,不能保证网络服务、时间、随机数或外部数据库等输入不变。文中用浏览器标签页类比isolate,是帮助理解的简化,不应据此推断现代浏览器每个标签页与isolate严格一一对应。namespaces、cgroups、seccomp和watchdog是多层防护的组成部分,任何一层都不等于单独提供绝对安全保证。

原文中的参考链接

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容