我们如何构建一个安全、高效、多租户的云平台来运行不受信任的代码
我们构建了 Deno Deploy,我们的多租户、全球分布式 V8 独立云,因为我们希望简化 JavaScript(和 TypeScript)在云中的托管。虽然许多用户正在使用 Deno Deploy 来运行他们的项目,但也有企业通过 子托管 在 Deno Deploy 的基础设施上构建,这使得他们的用户能够轻松地在云中编写和运行代码。通过子托管,企业可以 为用户提供边缘函数,在靠近用户的边缘托管电子商务店面,其中每毫秒都会影响转化率,并通过 低代码工作流程中的“逃生舱” 启用代码级别的自定义。我们的基础设施也经过了实战检验——Netlify 的边缘函数由 Deno Subhosting 提供支持,每天处理超过 2.55 亿次请求。
虽然许多公司都对安全地运行用户的不信任代码感兴趣,但构建一个安全、高效且经济实惠的多租户 JavaScript 平台可能会很具有挑战性。在这篇博文中,我们将深入研究 Deno Deploy 的内部结构,分享成本和性能之间的权衡,以及我们做出的决定以及原因。
现代无服务器平台的设计要求
现代无服务器平台应该简单(需要最少的配置)、高效且安全。这些目标帮助我们确定了以下设计要求
- 通过最大租户隔离实现最高级别的安全性
- 冷启动时间最短的出色性能
- 自动全局复制(用户无需执行任何操作)
- 轻松的自动缩放(用户无需执行任何操作)
- 价格具有竞争力且经济实惠
- 快速全球部署,以秒为单位
考虑到这些限制,我们设计了以下基础设施架构,以在性能和成本之间取得平衡,同时最大限度地提高安全性。
Deno Deploy 分为两个部分;用典型的网络工程术语来说,我们称它们为控制平面和数据平面。
在 Deno Deploy 中,控制平面处理与创建和管理部署、用户和组织帐户、分析、计量和计费以及仪表板相关的所有内容。对于这篇博文来说,控制平面最重要的部分是它参与创建新部署,我们将在部署生命周期中进行研究。
数据平面是部署代码实际运行的地方,也是处理发往各个部署的 HTTP 请求的地方。这涵盖了所有边缘区域以及它们包含的所有内容:存储桶、边缘代理以及运行 V8 独立进程的虚拟机(“运行程序”)。我们将详细了解它们是如何协同工作的,当我们深入研究请求生命周期时。
部署的生命周期
用户应该能够全局部署并在几秒钟内看到这些更改。这意味着我们的系统需要针对自动全局复制(无需用户执行任何操作)进行优化,以及创建一个快速路由新部署流量的方式。
用户启动部署
用户可以通过将代码合并到 GitHub 上的 main
分支、通过 deployctl
或通过 GitHub Actions 工作流来触发部署。虽然这些方面对于 Deno Deploy 的易用性很重要,但我们不会详细介绍这些系统如何协同工作。让我们直接跳到部署启动时。
预优化部署代码以用于 V8
为了更好地理解旨在将代码加载到 V8 独立进程中并执行它时最大限度地减少冷启动时间的预处理步骤,让我们先了解将代码加载到 V8 独立进程中时实际发生了什么。
为了执行 JavaScript,它首先作为入口点文件传递给 V8。V8 查找导入语句,生成依赖项列表,并请求它们的代码。一旦它收到其依赖项的代码,它将扫描导入语句,并请求这些依赖项的代码。这个通过广度优先搜索构建整个程序的过程可能很耗时,尤其是因为 V8 不能并行下载所有依赖项。如果每个远程依赖项获取需要 100 毫秒,那么根据依赖项的嵌套程度,这个“网络瀑布”可能会导致在所有依赖项加载完成之前出现很长的等待时间。
最重要的是,由于 Deno 本机支持 TypeScript,因此在将代码发送到 V8 之前,还有一个额外的转译步骤。
这些步骤会增加冷启动时间。
幸运的是,我们可以通过预处理部署代码来减少冷启动时间,并将延迟降到最低。在此步骤中
- 使用
swc
,这是一个基于 Rust 的用于处理 TypeScript 和 JavaScript 的平台,代码被转译成 JavaScript - 我们遍历导入语句并下载其依赖项的全部代码。反复进行,直到所有依赖项都被下载
- 对于 npm 依赖项,我们删除任何不必要的代码(例如测试等),以减少其占用空间大小
- 然后,代码按广度优先搜索依赖项的顺序排列。顺序很重要,因为这样我们就可以将代码流式传输到 V8 中,以获得更高的性能
我们通过下载所有依赖项来预处理代码的另一个重要原因是确保部署的一致性和不变性。每次执行部署时,它都应该以完全相同的方式运行。对于导入非版本化依赖项或 URL 导入的代码,这些依赖项可能会发生更改,或者远程地址可能不再有效。通过预先下载并将依赖项与部署代码一起进行版本化,然后将它们全部保存在一起,我们消除了代码在后续执行中运行结果不同的风险。
更新 Postgres 中的数据
在此期间,有关用户项目和部署的重要信息将在 Postgres 中更新,它是我们用于管理用户和项目信息的主要数据库。
需要注意的是,Postgres 不属于关键路径。也就是说,如果 Postgres 出现故障,您可能无法登录 Deno Deploy 网站,但现有的部署仍然可以正常使用。将 Postgres 排除在关键路径之外是我们的一个有意选择,目的是提高系统可靠性。
跨边缘区域将部署代码复制到存储桶中
我们的核心设计要求之一是,用户部署会自动在全球范围内复制,无需任何额外的配置或操作。这种复制对于通过在全球范围内实现快速冷启动来最大限度地减少延迟是必要的。这意味着您的源代码的副本必须始终在附近的区域可用。
预处理后的代码将上传到世界各地的存储桶中。稍后,当访问部署时,我们将原封不动地将此代码加载到 V8 独立进程中。
更新域名映射表
作为部署过程的最后一步,控制平面向所有边缘区域的边缘代理发送信号,通知它们如何更新其域名映射表。
此表对于路由传入的 HTTP 请求至关重要。传入的请求包含一个域名,该域名用于在存储在内存中的域名映射表上查找其对应的 deployment_id
。此域名映射表必须准确,以便每个传入请求都能快速路由到正确的位置。
一旦边缘代理更新内存中的域名映射表以包含新部署及其域名,部署过程就完成了。
请求的生命周期
Deno Deploy 上的部署必须高效且安全,并且可以自动扩展。这意味着最小的冷启动,部署代码是隔离的,不能访问其他部署或底层系统,并且部署根据流量自动扩展,无需用户配置。
让我们看看当有人访问您在 Deno Deploy 上的部署时会发生什么。
使用 Anycast 将传入请求路由到最近的区域
当浏览器首次向 Deno Deploy 上的部署发送请求时,它会通过 Anycast 被定向到最近的边缘区域。Anycast 是一种路由方法,其中单个 IP 地址由多个位置的服务器共享。它通常用于 CDN,以将内容靠近最终用户。
边缘代理路由到 V8 隔离区
传入请求已到达边缘区域。但在深入探讨如何将其路由到正确的 V8 隔离区以执行之前,让我们快速了解一下虚拟机和 V8 隔离区。
对于许多云托管平台,每个客户都会拥有自己的虚拟机,其中包含运行 Web 服务器所需的二进制文件和文件。但是,为扩展分配新虚拟机很慢。或者,始终让它们运行以获得更快的延迟,即使在没有处理流量时,也会很昂贵。这种模式不支持我们关于快速部署、最小延迟和成本效益的设计要求。
相反,我们使用与支持 Deno 运行时的技术相同的技术构建了 Deno Deploy - V8 隔离区。它们是 V8 执行环境的实例,类似于 JVM,但用于 JavaScript。由于 V8 隔离区旨在用于 Web 浏览器的上下文中,其中每个打开的选项卡都会获得自己的隔离区,因此它们的设计目标是快速启动并使用相对较少的内存。
Deno Deploy 在虚拟机中创建 V8 隔离区的“池”,我们称之为“运行器”。每个运行器管理大量隔离区的生命周期,确保每段用户代码在收到请求时都有一个隔离区可用,并在它们变为空闲时将其关闭。它们还强制执行每个隔离区都行为良好,并收集有关每段用户代码行为的一系列统计信息,以便边缘代理可以做出良好的扩展决策。
好了,回到我们的边缘代理。当边缘代理收到传入请求时,它会从内存中的 域名映射表 中查找相应的 deployment_id
,并确定哪个运行器应该处理该请求。
总的来说,边缘代理会努力在每个部署中运行“适当”数量的隔离区,分散在多个运行器中。根据部署每秒接收的请求数量以及为服务一个请求所花费的资源数量,"适当"的数量会因部署而异,并且会随着时间的推移而变化。
在为单个请求做出路由决策时,边缘代理将首先根据其认为对部署适当的隔离区数量选择一组候选运行器。随后,它将考虑运行器整体的当前 CPU 负载、该运行器内特定隔离区的 CPU 使用率以及隔离区的内存使用率,以确定哪个运行器可以最快速地处理请求。
一旦运行器收到带有 deployment_id
的传入请求,它会检查是否已经存在一个运行此部署的 V8 隔离区。如果有,它会将请求直接发送到该 V8 隔离区。如果没有,它会从最近的代码存储桶中拉取 预优化的部署代码,并将其加载到一个新的隔离区中。
代码安全执行
由于我们允许任何拥有 GitHub 帐户的人在我们的基础设施上部署和运行代码,因此安全性至关重要。可以访问其他部署甚至底层基础设施的部署是安全违规行为,可能导致用户对您的平台失去信心和客户。
这就是构建基础设施以运行第三方不可信代码的难点。我们 ValTown 的朋友说得最好——“运行不可信代码的默认方式非常危险。” 为了将其转变为一项业务,安全性必须是最重要的考虑因素。
因此,当请求被 V8 隔离区中的代码接收时,我们如何确保采取所有安全和隔离措施?
我们从头开始设计了该系统,使其安全。 我们首先提出了一个威胁模型和各种场景,其中单个攻击者可以使整个系统崩溃或破坏他人的部署。我们制定了 5 项指令,其中每个部署(单个租户)必须
- 只能访问其自己的运行时(JavaScript 对象、方法)
- 可以访问其自己的数据(文件、代码、数据库、环境变量)
- 无法访问 Deno Deploy 内部,例如底层操作系统和内部服务
- 不拒绝其他隔离区的资源(例如 CPU、内存)
- 不将 Deno Deploy 用于意外用例(例如开采比特币)
在此基础上,我们使用了命名空间、cgroups、seccomp 过滤器等各种技术,以确保用户租户进程周围的多层安全。而且,如果恶意代码以某种方式入侵多层以破坏安全性,我们已经构建了其他机制,例如“看门狗”,以监控资源并终止可疑租户。
安全性超出了我们的技术。为了赢得用户和企业客户的信任,安全性也是一项组织性问题,因为 我们符合 SOC 2 标准的安全、可用性和机密性要求.
下一步
构建一个易于使用、自动扩展和复制、性能出色(最小延迟和冷启动时间)、同时具有成本竞争力的多租户云平台可能很棘手。但是,我们希望通过这篇博文为您阐明我们构建 Deno Deploy 和 Deno Subhosting 的方法。
如果您有兴趣构建一个无服务器部署平台,以安全地运行用户不可信的代码,并最大程度地减少冷启动时间,但又不想维护自己的基础设施,请查看 Deno Subhosting。它是一个 REST API,它允许您以编程方式管理项目、部署等,而无需您或您的用户进行配置或扩展。
🚨️ 想在不从头开始构建的情况下安全地运行用户的不可信代码吗?
查看 Deno Subhosting,您可以在几分钟内在一个安全的托管沙箱中运行来自多个用户的 JavaScript。