我们如何构建一个安全、高性能、多租户的云平台来运行不可信代码
我们构建了 Deno Deploy,这是一个多租户、全球分布式的 V8 隔离云平台,旨在简化云端 JavaScript (和 TypeScript) 的托管。尽管许多用户正在使用 Deno Deploy 运行他们的项目,但也有企业通过 Subhosting 在 Deno Deploy 的基础设施上进行构建,这使得他们的用户能够轻松地在云端编写和运行代码。借助 Subhosting,企业可以为用户提供边缘函数,在靠近用户的地方托管电子商务店面(每一毫秒都可能影响转化率),并通过低代码工作流中的“逃生舱口”实现代码层面的定制。我们的基础设施也经过了实战检验——由 Deno Subhosting 提供支持的 Netlify 边缘函数,每天处理超过 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 之前,还会进行一个额外的转译步骤。
这些步骤增加了冷启动时间。
幸运的是,我们可以通过预处理部署代码来减少冷启动时间并最小化延迟。在此步骤中:
- 使用基于 Rust 的 TypeScript 和 JavaScript 处理平台
swc
,代码被转译为 JavaScript - 我们遍历导入语句并从其依赖项中下载整个代码。重复此过程直到所有依赖项都被下载
- 对于 npm 依赖项,我们删除任何不必要的代码(例如测试等),以减小其占用空间
- 然后,代码按广度优先搜索依赖项的顺序排列。这个顺序很重要,因为这样我们可以将代码流式传输到 V8 以获得额外的性能提升
我们预处理代码(通过下载所有依赖项)的另一个重要原因是确保部署的一致性和不变性。每次执行部署时,其行为都应完全相同。对于导入未版本化依赖项或 URL 导入的代码,这些依赖项可能会发生变化或远程地址不再可用。通过预下载并将依赖项与部署代码一起打包,然后一并保存,我们消除了代码在后续执行中行为不同的任何风险。
更新 Postgres 中的数据
在此期间,有关用户项目和部署的重要信息会在 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 运行时提供动力的相同技术——V8 隔离环境——构建了 Deno Deploy。它们是 V8 执行环境的实例,类似于 JVM,但用于 JavaScript。由于 V8 隔离环境被设计用于 Web 浏览器环境(每个打开的标签页都有自己的隔离环境),因此它们启动速度非常快,并且内存占用相对较少。
Deno Deploy 在虚拟机中创建 V8 隔离环境的“池”,我们称之为“运行器”(runner)。每个运行器管理大量隔离环境的生命周期,确保每段用户代码在接收请求时都有一个可用的隔离环境,并在空闲时将其关闭。它们还强制每个隔离环境行为良好,并收集大量关于每段用户代码行为的统计数据,以便边缘代理可以做出良好的扩缩决策。
好了,回到我们的边缘代理。当边缘代理接收到传入请求时,它会从内存中的域名映射表中查找对应的 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 代码。