跳到主要内容
Building a secure performant cloud platform

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

我们构建了 Deno Deploy,我们的多租户、全球分布式 V8 isolate 云平台,因为我们希望简化在云端托管 JavaScript(和 TypeScript)的过程。虽然许多用户正在使用 Deno Deploy 运行他们的项目,但也有企业通过 Subhosting 在 Deno Deploy 的基础设施上进行构建,这使得他们的用户可以轻松地在云端编写和运行代码。通过 Subhosting,企业 为他们的用户提供边缘函数将电子商务店面托管在靠近用户的位置,在这些位置,每一毫秒都会影响转化率,并通过 低代码工作流程中的“逃生舱口” 实现代码级别的自定义。我们的基础设施也经过了实战检验 — Netlify 的边缘函数,由 Deno Subhosting 提供支持,每天处理超过 2.55 亿次请求。

虽然许多公司对安全地运行其用户的不受信任的代码感兴趣,但构建一个高性能、安全且经济实惠的多租户 JavaScript 平台可能具有挑战性。在这篇博客文章中,我们将深入探讨 Deno Deploy 的内部原理,分享成本和性能之间的权衡,以及我们做出的决策以及原因。

现代 serverless 平台的设计要求

现代 serverless 平台应该是简单的(需要最少的配置)、高性能的和安全的。这些目标帮助我们确定了以下设计要求:

  • 通过最大程度的租户隔离实现最高级别的安全性
  • 卓越的性能和最短的冷启动时间
  • 自动全球复制(用户无需执行任何操作)
  • 轻松的自动扩容(用户无需执行任何操作)
  • 价格具有竞争力且经济实惠
  • 快速的全球部署,在秒级完成

考虑到这些约束,我们设计了下面的基础设施架构,以在性能和成本之间取得适当的平衡,同时最大化安全性。

A high level overview of the Deno Deploy infrastructure.

Deno Deploy 基础设施内部组件的高级概述。

Deno Deploy 分为两个部分;在典型的网络工程术语中,我们称之为控制层面数据层面

在 Deno Deploy 中,控制层面处理与创建和管理部署、用户和组织帐户、分析、计量和计费以及仪表板相关的所有事务。对于这篇博客文章,控制层面最相关的部分是它参与创建新部署,我们将在部署的生命周期中进行 بررسی。

数据层面是部署代码实际运行的地方,也是处理 направленных на отдельные развертывания HTTP-запросов. Это охватывает все пограничные регионы и все, что они содержат: корзины хранения, пограничные прокси и виртуальные машины, которые запускают изоляты V8 (“runners”). Когда мы углубимся в жизненный цикл запроса, мы более подробно расскажем о том, как все это работает вместе.

部署的生命周期

用户应该能够在全球范围内进行部署,并在几秒钟内看到这些更改。这意味着我们的系统需要优化自动全球复制(无需用户执行任何操作),并创建一种快速路由新部署流量的方法。

用户发起部署

用户可以通过合并到 GitHub 上的 main 分支、通过 deployctl 或通过 GitHub Actions 工作流来触发部署。虽然这些方面对于 Deno Deploy 的易用性很重要,但我们不会详细介绍这些系统如何协同工作。让我们跳到部署启动的时候。

为 V8 预优化部署代码

为了更好地理解旨在最小化将代码加载到 V8 isolate 并执行时的冷启动的预处理步骤,让我们首先回顾一下将代码加载到 V8 isolate 时实际发生的情况。

要执行 JavaScript,首先将其作为入口点文件传递给 V8。V8 查找 import 语句,列出依赖项列表,并请求它们的代码。一旦收到其依赖项的代码,它将扫描 import 语句,并请求这些依赖项的代码。通过广度优先搜索构建整个程序的过程可能非常耗时,尤其是在 V8 无法并行下载所有依赖项的情况下。如果每个依赖项的远程获取需要 100 毫秒,那么根据依赖项的嵌套程度,“网络瀑布”可能会导致在加载所有依赖项之前等待很长时间。

最重要的是,由于 Deno 原生支持 TypeScript,因此在将代码发送到 V8 之前,还有一个额外的转译步骤。

这些步骤增加了冷启动的时间。

幸运的是,我们可以通过预处理部署代码来减少冷启动时间并最大限度地减少延迟。在此步骤中:

  • 使用 swc,一个基于 Rust 的用于处理 TypeScript 和 JavaScript 的平台,代码被转译为 JavaScript
  • 我们遍历 import 语句并从其依赖项下载整个代码。重复此过程,直到下载所有依赖项。
  • 对于 npm 依赖项,我们删除任何不必要的代码(例如测试等),以减小其 footprint 大小。
  • 然后,代码按广度优先搜索依赖项进行排序。顺序很重要,因为这样我们可以将代码流式传输到 V8 以获得额外的性能提升。

Pre-optimizing the code for deployment

预处理步骤对于最大限度地减少稍后在 V8 isolate 上加载和执行代码时的冷启动时间是必要的。

我们通过下载所有依赖项来预处理代码的另一个重要原因是,为了部署的一致性和不变性。每次执行部署时,它的行为都应该完全相同。对于导入未版本化依赖项或 URL 导入的代码,这些依赖项可能会更改或远程地址不再有效。通过预先下载并将依赖项与部署代码一起 vendor,然后将它们全部保存在一起,我们消除了在后续执行中代码行为不同的任何风险。

在 Postgres 中更新数据

在此期间,有关用户项目和部署的重要信息将在 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)构建了 Deno Deploy。这些是 V8 执行环境的实例,类似于 JVM,但用于 JavaScript。由于 V8 isolate 被设计用于 Web 浏览器的上下文中,其中每个打开的选项卡都有自己的 isolate,因此它们被设计为启动非常快且使用相对较少的内存。

Deno Deploy 在虚拟机中创建 V8 isolate 的“池”,我们称之为“runner”。每个 runner 管理大量 isolate 的生命周期,确保每段用户代码在接收到请求时都有可用的 isolate,并在它们空闲时再次关闭它们。它们还强制每个 isolate 行为良好,并收集有关每段用户代码行为方式的大量统计信息,以便边缘代理可以做出良好的扩展决策。

好的,回到我们的边缘代理。当边缘代理收到传入的请求时,它会从内存中的域名映射表中查找对应的 deployment_id,并确定哪个 runner 应该处理该请求。

总的来说,边缘代理的目标是每个部署运行“适当”数量的 isolate,分布在多个 runner 上。“适当”的数量因部署而异,并且随时间变化,具体取决于部署每秒接收多少个请求以及服务一个请求需要多少资源。

在为单个请求做出路由决策时,边缘代理将首先根据它认为部署的适当 isolate 数量来选择一组候选 runner。随后,它将考虑 runner 整体的当前 CPU 负载、该 runner 内特定 isolate 的 CPU 使用率以及 isolate 的内存使用情况,以确定哪个 runner 可以最快地处理请求。

How the edge proxy works

边缘代理使用概率和启发式方法来决定是将传入的请求路由到 warm V8 isolate 还是冷启动新的 isolate。

一旦 runner 收到带有 deployment_id 的传入请求,它会检查是否已经有 V8 isolate 正在运行此部署。如果有,它将直接将请求发送到该 V8 isolate。如果没有,它将从最近的代码存储桶中提取预优化的部署代码,并将其加载到新的 isolate 中。

How the runner works

Runner 检索代码并将其加载到 V8 isolate 中。

代码被安全地执行

由于我们允许任何拥有 GitHub 帐户的人在我们的基础设施上部署和运行代码,因此安全性是首要关注的问题。可以访问另一个部署,甚至底层基础设施的部署都是安全违规行为,可能会导致您的平台失去信心和客户。

这就是构建基础设施以运行第三方不受信任的代码的难点所在。我们在 ValTown 的朋友说得最好 — “运行不受信任的代码的默认方式是危险的。” 为了将其转化为业务,安全性必须是最重要的考虑因素。

那么,当 V8 isolate 中的代码收到请求时,我们如何确保采取所有安全和隔离措施?

我们从头开始设计系统以确保安全。 我们首先提出了一个威胁模型和各种场景,在这些场景中,单个恶意行为者可能会破坏整个系统或破坏其他人的部署。我们制定了 5 项指令,其中每个部署(单个租户)必须:

  • 仅有权访问其自己的运行时(JavaScript 对象、方法)
  • 有权访问其自己的数据(文件、代码、数据库、环境变量)
  • 无权访问 Deno Deploy 内部组件,例如底层操作系统和内部服务
  • 不拒绝其他 isolate 的资源(例如 CPU、内存)
  • 不将 Deno Deploy 用于非预期用例(例如,挖掘比特币)

由此,我们使用了各种技术,例如命名空间、cgroup、seccomp 过滤器等,以确保用户租户进程周围的多层安全性。而且,如果恶意代码以某种方式破解了多层来破坏安全性,我们还构建了其他机制,例如“看门狗”,来监视资源并终止可疑租户。

Diagram of maximum tenant isolation and security

安全性不仅限于我们的技术。为了赢得用户和企业客户的信任,安全性也是一个组织问题,因为我们符合 SOC 2 安全性、可用性和机密性标准

接下来是什么?

构建一个易于使用且具有自动扩展和复制功能、高性能且具有最短延迟和冷启动时间、同时又具有成本竞争力的多租户云平台可能很棘手。但是,我们希望通过这篇博客文章阐明我们构建 Deno DeployDeno Subhosting 的方法。

如果您有兴趣构建一个 serverless 部署平台,以安全地运行用户的不受信任的代码,并具有最短的冷启动时间,但又不想为维护自己的基础设施而烦恼,请查看 Deno Subhosting。它是一个 REST API,允许您以编程方式管理项目、部署等,而无需您或您的用户进行配置或扩展。

🚨️ 想要安全地运行用户的不受信任的代码而无需从头开始构建吗?

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