[中文正文内容]

New

CodeSandbox 现在是 Together AI! 的一部分了!我们已强强联合,推出 CodeSandbox SDK,并将代码解释引入生成式 AI。

了解更多

arrow_back 博客 chevron_right Insights

2022 年 9 月 1 日

我们如何在 2 秒内克隆一个运行中的 VM

或者...如何克隆一个正在运行的 Minecraft 服务器

Ives van Hoorne Ives van Hoorne

How we clone a running VM in 2 seconds

在 CodeSandbox,我们会运行你的开发项目,并将其转化为一个链接,你可以与任何人分享。访问此链接的人不仅可以查看你正在运行的代码,还可以点击 "fork",在 2 秒内获得该环境的精确副本,从而轻松地做出贡献。用这个例子 试试看,或者在这里 导入你的 GitHub 仓库!

那么,我们是如何在 2 秒内启动一个克隆环境的呢?这正是我要在这里讨论的内容!

挑战:在两秒内启动一个开发环境

我们运行沙箱已经很长时间了,核心前提始终如一:不应该只展示静态代码,而是应该让代码运行起来。不仅如此,你应该能够随时按下 fork 键并进行实验。

过去,我们通过在你的浏览器中运行所有代码来实现这种体验。每当你查看一个沙箱时,你都会执行代码。这非常快,因为我们可以完全控制代码的打包方式。Fork 也很快:

但是,这种方法有一个缺陷:我们只能运行可以在浏览器中运行的代码。如果你想运行一个需要 Docker 的大型项目,那就行不通了。

因此,在过去的几年里,我们一直在问自己:如何为更大的项目启用这种体验?

Firecracker 来救援

虚拟机通常被认为是缓慢、昂贵、臃肿和过时的。我也曾经这么认为,但过去几年发生了很大的变化。虚拟机为大多数云提供动力(是的,甚至包括 serverless 函数!),所以许多杰出的人才一直在努力让虚拟机更快、更轻量级。而且...他们真的做得非常出色。

Firecracker 是该领域最近最令人兴奋的进展之一。Amazon 创建了 Firecracker,为 AWS Lambda 和 AWS Fargate 提供支持,如今它被 Fly.io 和 CodeSandbox 等公司使用。它用 Rust 编写,代码非常易读。如果你对它的工作原理感兴趣,绝对应该查看 他们的 repo

Firecracker 启动的是 MicroVM 而不是 VM。MicroVM 更轻量级:你不需要等待 5 秒钟才能启动一个 "正常" 的 VM,而是在 300 毫秒内获得一个正在运行的 MicroVM,随时可以运行你的代码。

这对我们来说非常棒,但它只解决了部分问题。即使我们可以快速启动虚拟机,我们仍然需要克隆你的仓库,安装依赖项并运行开发服务器。对于一个普通项目来说,这可能需要一分钟的时间,而对于更大的项目来说,可能意味着几十分钟。

如果你每次点击 CodeSandbox 上的 "fork" 都必须等待一分钟,那将是一场灾难。理想情况下,你应该继续之前虚拟机的工作。这就是我开始研究内存快照的原因。

内存快照的黑暗艺术

Firecracker 不仅可以启动 VM,还可以恢复 VM。那么,这实际上意味着什么?

因为我们运行的是虚拟机,所以我们控制着环境中的一切。我们控制着有多少 vCPU 核心可用,有多少内存可用,以及附加了哪些设备。但最重要的是,我们控制着代码的执行。

这意味着我们可以随时暂停 VM。这不仅会暂停你的代码,还会完全停止整个机器,一直到内核。

当虚拟机暂停时,我们可以安全地读取虚拟机的完整状态,并将其保存到磁盘。Firecracker 公开了一个 create_snapshot 函数,该函数会生成两个文件:

这两个文件,连同磁盘一起,包含了我们启动 MicroVM 所需的一切,它将从拍摄快照时继续运行!

这令人难以置信的兴奋,因为用例是无穷无尽的!这里有一个例子:许多云 IDE 服务会在大约 30 分钟不活动后 "休眠" 你的 VM。实际上,这意味着他们将停止你的 VM 以节省托管成本。当你回来时,你将不得不等待你的开发服务器再次初始化,因为它是一个完整的 VM 启动。

有了 Firecracker 就不用了。当我们休眠一个 VM 时,我们会暂停它并将它的内存保存到磁盘。当你回来时,我们会从该内存快照恢复 VM,对你来说,它看起来就像 VM 从未停止过一样!

而且,恢复速度很快。Firecracker 将只读取 VM 启动所需的内存(因为内存是 mmaped),这导致恢复时间在 ~200-300ms 之间。

以下是使用不同类型的缓存启动我们自己的编辑器(一个 Next.js 项目)的时间比较:

| 可用的缓存类型 | 运行预览的时间 | | :--------------------- | :-------- | | 没有缓存(全新启动) | 132.2 秒 | | 预装的 node_modules | 48.4 秒 | | 预装的构建缓存 | 22.2 秒 | | 内存快照 | 0.6 秒 |

这也有一个缺点。保存内存快照实际上需要一段时间,我将在本文中介绍这一点。

我对这一点感到非常兴奋。它给人一种 VM 始终在运行的感觉,即使它没有占用资源。我们经常使用它:CodeSandbox 上的每个分支都是一个新的开发环境。切换分支时,你无需记住回滚迁移或安装依赖项,因为每个分支都是一个全新的环境。我们可以通过内存快照来实现这一点。

我们还使用它来廉价地托管一些内部工具。当收到一个 webhook 请求时,我们会唤醒微服务,让它响应,并在 5 分钟后自动休眠。诚然,它没有给出 "生产" 响应时间,因为唤醒总是需要在顶部添加 300 毫秒,但对于我们的后台微服务来说,这很好。

克隆内存快照的更黑暗的艺术

拼图的第一块重要的部分已经存在。我们可以保存内存快照,并随时从中恢复虚拟机。这已经使加载现有项目更快,但是我们如何实际克隆它们呢?

嗯,我们已经能够将虚拟机状态序列化为文件......那么,是什么阻止了我们复制它们呢?这里有一些注意事项,但我们会讲到。

假设我们复制现有的状态文件,并从这些文件启动几个新的 VM。

A diagram showing VM files cloning between different versions

这实际上有效!克隆将完全从最后一个 VM 离开的地方继续。你可以启动一个带有内部内存计数器的服务器,将其增加几次,按下 fork,它将在新的 VM 中继续计数。

你可以在 这里 试玩。它在休眠之间保留状态,有点像运行视图计数。你可以在这里看到预览:

但是,挑战在于速度。内存快照文件很大,跨越多个 GB。保存内存快照每 GB 需要 1 秒(因此 8GB VM 需要 8 秒进行快照),复制内存快照也需要相同的时间。

因此,如果你正在查看一个沙箱并按下 fork,我们将不得不:

  1. 暂停 VM (~16ms)
  2. 保存快照 (~4s)
  3. 复制内存文件 + 磁盘 (~6s)
  4. 从这些文件启动一个新的 VM (~300ms)

加起来,你将不得不等待约 10 秒,这比等待所有开发服务器启动要快,但如果你想快速测试一些更改,它仍然太慢。

仅仅是这个有效的事实令人难以置信 - 克隆 VM 实际上是可能的!但是,我们需要认真减少序列化时间。

更快地保存快照

当我们在 Firecracker VM 上调用 create_snapshot 时,它大约需要每 GB 1 秒的时间来写入内存快照文件。这意味着如果你有一个具有 12GB 内存的 VM,则创建快照将花费 12 秒。可悲的是,如果你正在查看一个沙箱,并且你按下 fork,你将必须至少等待 12 秒才能打开新的沙箱。

我们需要找到一种方法来使快照的创建速度更快,降至不到一秒,但是如何呢?

在这种情况下,我们受到 I/O 的限制。大部分时间都花在写入内存文件上。即使我们向问题中投入许多 NVMe 驱动器,仍然需要几秒钟以上的时间才能写入内存快照。我们需要找到一种不必将这么多字节写入磁盘的方法。

我们尝试了很多方法。我们尝试了增量快照、稀疏快照、压缩。最后,我们找到了一个将我们的时间减少十倍的解决方案 - 但是要解释它,我们首先需要了解 Firecracker 如何保存快照。

当 Firecracker 加载 VM 的内存快照时,它不会将整个文件读入内存。如果它读取整个文件,则从休眠状态恢复 VM 将花费更长的时间。

相反,Firecracker 使用 mmapmmap 是一个 Linux syscall,它创建一个给定文件到内存的 "映射"。这意味着该文件不会直接加载到内存中,而是在内存中有一个预留,说明 "内存的这一部分对应于磁盘上的此文件"。

每当我们尝试从此内存区域读取时,内核将首先检查内存是否已加载。如果不是这种情况,它将 "页面错误"。在页面错误期间,内核将从后备文件(我们的内存快照)读取相应的数据,将其加载到内存中并返回。

Example of mmap reading from a file

关于这一点最令人印象深刻的是,通过使用 mmap,我们将仅加载实际读取的文件部分到内存中。这使 VM 能够快速恢复,因为恢复仅需要 300-400MB 的内存。

看到大多数 VM 在恢复后实际读取多少内存非常有趣。事实证明,大多数 VM 加载的内存少于 1GB。在 VM 内部,它实际上会说使用了 3-4GB,但大多数内存仍存储在磁盘上,实际上并未存储在内存中。

那么,如果你写入内存会发生什么?它是否会同步回内存文件?默认情况下,不会。通常,更改保留在内存中,并且不同步到后备文件。只有当我们调用 create_snapshot 时,更改才会同步回来,这通常会导致保存大小为 1-2GB。这需要太长时间才能写入。

但是,我们可以传递一个标志。如果我们将 MAP_SHARED 传递给 mmap 调用,它实际上会将更改同步回后备文件!内核会懒惰地执行此操作:每当它有空闲时间时,它会将更改刷新回文件。

这对我们来说是完美的,因为我们可以提前移动大部分保存快照的 I/O 工作。当我们实际上想要保存快照时,我们只需要同步回少量!

这严重减少了我们的快照时间。这是在部署此更改之前和之后,保存内存快照的平均时间图:

Timings of saving snapshot, showing that after Aug 1 it reduces from 4s to 50ms

通过此更改,我们从 ~8-12s 的保存快照到 ~30-100ms

将克隆时间降至毫秒

我们现在可以快速保存快照,但是克隆呢?当克隆内存快照时,我们仍然需要将所有内容逐字节复制到新文件,这又需要 ~8-12s。

但是......我们真的必须逐字节复制所有内容吗?当我们克隆 VM 时,>90% 的数据将被重用,因为它从同一点恢复。那么,有没有一种方法可以重用数据?

答案是使用 copy-on-write (CoW)。Copy-on-write,顾名思义,仅在我们开始写入数据时才复制数据。我们之前的 mmap 示例在未传递 MAP_SHARED 时也使用 copy-on-write。

通过使用 copy-on-write,我们不复制克隆的数据。相反,我们告诉新的 VM 使用与旧 VM 相同的数据。每当新的 VM 需要对其数据进行更改时,它将从旧 VM 复制数据,并将更改应用于该数据。

Showing a diagram with VM B reading from VM B

这是一个例子。假设从 VM A 创建了 VM B。VM B 将直接使用来自 VM A 的所有数据。当 VM B 想要更改块 3 时,它将从 VM A 复制块 3,然后才应用更改。在此之后,每当它从块 3 读取时,它将从它自己的块 3 读取。

通过 copy-on-write,副本是懒惰的。我们仅在需要修改数据时才复制数据,这非常适合我们的 fork 模型!

顺便说一句,copy-on-write 已经在许多地方使用了很长时间。CoW 的一些众所周知的例子是 Git(每个更改都是一个新对象)、现代文件系统 (btrfs/ zfs) 和 Unix 本身(两个例子是 forkmmap)。

这项技术不仅使我们的副本是即时的,而且还节省了大量的磁盘空间。如果有人正在查看沙箱,创建了一个 fork,并且仅更改了单个文件,我们将只需要保存该已更改的文件用于整个 fork!

我们对磁盘(通过创建磁盘 CoW 快照)和内存快照都使用此技术。它将我们的复制时间从几秒钟减少到 ~50 毫秒。

但是......它可以克隆 Minecraft 吗?

通过应用 copy-on-write 和共享的内存文件 mmaping,我们可以非常快速地克隆 VM。回顾这些步骤,新的时间是:

  1. 暂停 VM (~16ms)
  2. 保存快照 (~100ms)
  3. 复制内存文件 + 磁盘 (~800ms)
  4. 从这些文件启动新的 VM (~400ms)

这使我们的克隆时间远低于 2 秒!这是 Vite 的一个 fork(你可以自己尝试 这里):

可以在下面看到总时间。请注意,除了克隆本身之外,还有更多事情发生,但总时间仍然低于 2 秒:

Trace timings from Honeycomb

并且因为我们使用 copy-on-write,所以无论你运行的是具有 20 个微型服务的大型 GraphQL 服务,还是单个节点服务器,都无关紧要。我们可以在 2 秒内持续恢复和克隆 VM。无需等待开发服务器启动。

这是一个例子,我转到我们自己的 repo(运行由 Next.js 支持的编辑器),fork main 分支(它复制 VM)并进行更改:

我们还有一个 Linear 集成,它与此集成。

我们已经使用不同的开发环境对此流程进行了大量测试。我认为如果我们不仅可以尝试克隆开发环境,还可以尝试克隆更多内容,那将非常有趣。

所以......如果我们运行一个 Minecraft 服务器,更改世界中的某些内容,然后将其克隆到一个我们可以连接到的新的 Minecraft 服务器呢?为什么不呢?

为此,我创建了一个运行两个 Docker 容器的 VM:

  1. 一个 Minecraft 服务器
  2. 一个 Tailscale VPN,我可以使用它直接从我的 PC 连接到 Minecraft 服务器

让我们看看!

在这个视频中,我在一个 Minecraft 服务器中创建了一个结构。然后克隆了该 Minecraft 服务器,连接到它,并验证了该结构是否存在。然后我摧毁了该结构,回到旧服务器,并验证了该结构仍然存在。

当然,这样做没有任何实际好处,但它表明我们可以克隆任何类型工作负载上的 VM!

未写下的细节

还有一些细节我很想写下来。我们尚未讨论的一些事情:

我们仍然可以做一些改进来提高克隆的速度。我们仍然按顺序进行许多 API 调用,并且可以提高文件系统 (xfs) 的速度。目前,由于许多随机写入,xfs 内部的文件会快速碎片化。

在接下来的几个月中,我们将对此进行更多介绍。如果你对此有任何问题或建议,请随时 在 Twitter 上给我发送消息

结论

现在我们可以快速克隆正在运行的 VM,我们可以启用新的工作流程,你无需等待开发服务器启动。与 GitHub 应用程序一起,你将为每个 PR 都有一个开发环境,以便你可以快速查看(或运行端到端测试)。

我要非常感谢:

如果你还没有尝试过 CodeSandbox 并且不想再等待开发服务器启动,请 导入/创建一个 repo。它也是免费的(我们正在撰写一篇帖子来解释我们如何实现这一点)。

如果你想了解有关 CodeSandbox 项目的更多信息,可以访问 projects.codesandbox.io

当我们创建新的技术帖子时,我们将会在 Twitter 上的 @codesandbox 上发布!

arrow_back 返回所有文章

继续阅读关于 Insights

Insights2024 年 5 月 29 日

我们如何使用低延迟内存解压缩来扩展我们的 microVM 基础设施

从原始内存快照到压缩内存快照的转变,以及对后续挑战的优化

Insights2024 年 4 月 11 日

如何将 CodeSandbox 与你的设计系统一起使用

以下是将设计与代码结合使用时使用 CodeSandbox 的一些最流行的方法。

Insights2024 年 4 月 9 日

CodeSandbox CDE 独到之处

我经常被问到:“CodeSandbox 与其他 CDE 有什么区别?”。以下是我们独特功能的列表。

Insights2024 年 1 月 23 日

为什么我在云中编码

开发正在转移到云端。以下是这对团队来说是改变游戏规则的原因。