Как мы клонируем работающую виртуальную машину за 2 секунды

В CodeSandbox мы запускаем ваш проект разработки и превращаем его в ссылку, которой вы можете поделиться с кем угодно. Люди, переходящие по этой ссылке, могут не только увидеть ваш работающий код, но и нажать кнопку «fork» и получить точную копию этой среды в течение 2 секунд, чтобы они могли легко внести свой вклад. Попробуйте это на этом примере или импортируйте свою копию на GitHub здесь!

Итак, как мы можем создать клонированную среду за 2 секунды? Именно об этом я и расскажу здесь!

Задача: создание среды разработки за две секунды

Мы уже давно используем песочницы, и основная идея всегда была одна и та же: вместо того чтобы показывать статичный код, он должен работать. Мало того, вы должны иметь возможность нажать «вилку» и играть с ним, когда захотите.

В прошлом мы обеспечивали такой опыт, запуская весь ваш код в браузере. Всякий раз, когда вы смотрели на песочницу, вы выполняли код. Это было быстро, потому что мы полностью контролировали, как код был упакован. Форки были быстрыми:

Однако в этом подходе была одна загвоздка: мы были ограничены кодом, который могли запускать в браузере. Если вы хотите запустить большой проект, требующий Docker, это не сработает.

Поэтому последние несколько лет мы задавали себе вопрос: как мы можем обеспечить такой опыт для больших проектов?

Firecracker на помощь

Виртуальные машины часто воспринимаются как медленные, дорогие, раздутые и устаревшие. И я раньше думал так же, но за последние несколько лет многое изменилось. На виртуальных машинах работает большая часть облака (да, даже бессерверные функции!), поэтому многие великие умы работали над тем, чтобы сделать виртуальные машины быстрее и легче. И что ж… они действительно превзошли самих себя.

Firecracker — одна из самых интересных последних разработок в этой области. Amazon создал Firecracker для работы с AWS Lambda и AWS Fargate, а сегодня его используют такие компании, как Fly.io и CodeSandbox. Он написан на языке Rust, и код очень читабелен. Если вам интересно, как это работает, обязательно посмотрите их репозиторий!

Firecracker порождает MicroVM вместо VM. MicroVM более легковесны: вместо того, чтобы ждать 5 секунд загрузки «обычной» VM, вы получите работающую MicroVM в течение 300 миллисекунд, готовую выполнять ваш код.

Это замечательно для нас, но это решает только часть проблемы. Даже если мы можем быстро запустить виртуальную машину, нам все равно нужно клонировать ваш репозиторий, установить зависимости и запустить dev-сервер. Все вместе это может занять более минуты для среднего проекта, что для больших проектов, вероятно, будет означать десятки минут.

Если бы вам приходилось ждать по минуте каждый раз, когда вы нажимаете кнопку «fork» на CodeSandbox, это было бы катастрофой. В идеале вы должны просто продолжать работу с того места, где остановилась старая виртуальная машина. Именно поэтому я начал изучать моментальные снимки памяти.

Темное искусство создания снимков памяти

Firecracker не только порождает виртуальные машины, но и возобновляет их. Итак, что это означает?

Поскольку мы запускаем виртуальную машину, мы контролируем все в этой среде. Мы контролируем, сколько ядер vCPU доступно, сколько памяти доступно, какие устройства подключены. Но самое главное, мы контролируем выполнение кода.

Это означает, что мы можем приостановить работу виртуальной машины в любой момент времени. При этом приостанавливается не только ваш код, но и вся машина, вплоть до ядра.

Пока виртуальная машина приостановлена, мы можем безопасно прочитать полное состояние виртуальной машины и сохранить его на диске. Firecracker предоставляет функцию create_snapshot, которая создает два файла:

Эти два файла, вместе с диском, содержат все необходимое для запуска MicroVM, и она просто продолжит работу с того момента, когда был сделан снимок!

Это невероятно интересно, потому что вариантов использования бесконечное множество! Вот один пример: многие облачные IDE-сервисы «уводят в спячку» вашу ВМ после ~30 минут бездействия. На практике это означает, что они остановят вашу ВМ, чтобы сэкономить расходы на хостинг. Когда вы вернетесь, вам придется ждать, пока ваши серверы разработки снова инициализируются, потому что это полная загрузка ВМ.

Но только не с Firecracker. Когда мы переводим ВМ в спящий режим, мы приостанавливаем ее и сохраняем ее память на диск. Когда вы возвращаетесь, мы возобновляем работу ВМ из снимка памяти, и для вас это будет выглядеть так, как будто ВМ вообще не останавливалась!

Кроме того, возобновление происходит быстро. Firecracker считывает только ту память, которая необходима ВМ для запуска (поскольку память mmaped), что приводит к таймингам возобновления в пределах ~200-300 мс.

Вот сравнение времени запуска нашего собственного редактора (проект Next.js) с различными типами кэширования:

Тип доступного кэша Время до запуска предварительного просмотра
Без кэша (новый запуск) 132.2s
Предустановленные модули node_modules 48.4s
Предустановленный кэш сборки 22.2s
Снимки памяти 0.6s

Здесь тоже есть своя загвоздка. Сохранение снимка памяти на самом деле занимает некоторое время, о чем я расскажу в этой статье.

Я в восторге от этого. Создается ощущение, что виртуальная машина постоянно работает, хотя она и не занимает ресурсы. Мы часто используем это: каждая ветка на CodeSandbox — это новая среда разработки. Вам не нужно помнить об откате миграций или установке зависимостей при смене ветки, потому что для каждой ветки создается новая среда. Мы можем сделать это благодаря моментальному снимку памяти.

Мы также используем это для дешевого размещения некоторых внутренних инструментов. Когда поступает запрос через webhook, мы пробуждаем микросервис, позволяем ему ответить, и через 5 минут он автоматически снова уходит в спячку. Конечно, это не дает «производственного» времени отклика, потому что всегда добавляется 300 мс на пробуждение, но для наших микросервисов бэк-офиса это нормально.

Темное искусство клонирования снимков памяти

Первая важная часть головоломки уже здесь. Мы можем сохранить снимок памяти и возобновить работу виртуальной машины с него в любое время. Это уже делает загрузку существующих проектов более быстрой, но как мы можем на самом деле клонировать их?

Ну, мы уже смогли сериализовать состояние виртуальной машины в файлы… так что же мешает нам скопировать их? Здесь есть некоторые оговорки, но мы к этому придем.

Допустим, мы скопируем существующие файлы состояния и запустим из них пару новых виртуальных машин.

Это действительно работает! Клоны продолжат работу точно с того места, где остановилась последняя ВМ. Вы можете запустить сервер с внутренним счетчиком в памяти, поднять его пару раз, нажать fork, и он продолжит считать с того места, на котором остановился в новой ВМ.

Вы можете поиграть с этим здесь. А это — работающий сервер этой ВМ, что-то вроде счетчика просмотров:

13gise-8080.preview.csb.app

Однако проблема заключается в скорости. Файлы снимков памяти очень большие, занимают несколько гигабайт. Сохранение снимка памяти занимает 1 секунду на гигабайт (поэтому для снимка виртуальной машины объемом 8 ГБ требуется 8 секунд), а копирование снимка памяти занимает столько же времени.

Поэтому, если вы смотрите на песочницу и нажимаете fork, нам придется:

  1. приостановить работу ВМ (~16 мс)
  2. Сохранить снимок (~4 с)
  3. Скопировать файлы памяти + диск (~6 с)
  4. Запустить новую ВМ из этих файлов (~300 мс).

В сумме вам придется ждать ~10 секунд, что быстрее, чем ждать запуска всех dev-серверов, но все равно слишком медленно, если вы хотите быстро протестировать некоторые изменения.

Сам факт того, что это работает, невероятен — клонирование виртуальных машин действительно возможно! Однако нам нужно серьезно сократить время сериализации.

Сохранение моментальных снимков быстрее

Когда мы вызываем create_snapshot на ВМ Firecracker, на запись файла снимков памяти уходит около 1 секунды на гигабайт. Это означает, что если у вас ВМ с 12 ГБ памяти, то на создание моментального снимка уйдет 12 секунд. К сожалению, если вы смотрите на песочницу и нажимаете кнопку fork, вам придется ждать не менее 12 секунд, прежде чем вы сможете открыть новую песочницу.

Нам нужно найти способ сделать создание моментального снимка быстрее, менее чем за секунду, но как?

В данном случае нас ограничивает ввод-вывод. Большая часть времени уходит на запись файла памяти. Даже если мы бросим в эту проблему много дисков NVMe, запись снимка памяти все равно займет более пары секунд. Нам нужно найти способ, при котором нам не придется записывать так много байт на диск.

Мы перепробовали множество подходов. Мы пробовали инкрементные снимки, разреженные снимки, сжатие. В конце концов, мы нашли решение, которое сократило наши тайминги в десять раз, но чтобы объяснить его, нам сначала нужно понять, как Firecracker сохраняет моментальный снимок.

Когда Firecracker загружает снимок памяти для виртуальной машины, он не считывает весь файл в память. Если бы он считывал весь файл, то возобновление работы ВМ из спящего режима заняло бы гораздо больше времени.

Вместо этого Firecracker использует mmap. mmap — это системный вызов Linux, который создает «отображение» данного файла в память. Это означает, что файл не загружается непосредственно в память, но в памяти делается оговорка: «эта часть памяти соответствует этому файлу на диске».

Когда мы пытаемся прочитать из этой области памяти, ядро сначала проверяет, не загружена ли уже эта память. Если это не так, произойдет «ошибка страницы». Во время ошибки страницы ядро считывает соответствующие данные из
резервного файла (нашего снимка памяти), загрузит их в память и вернет обратно.

Самое впечатляющее здесь то, что благодаря использованию mmap мы загружаем в память только те части файла, которые действительно были прочитаны. Это позволяет виртуальным машинам быстро возобновлять работу, поскольку для возобновления требуется всего 300-400 МБ памяти.

Довольно интересно посмотреть, сколько памяти большинство ВМ действительно читают после возобновления. Оказалось, что большинство ВМ загружают в память менее 1 ГБ. Внутри ВМ она фактически говорит, что используется 3-4 ГБ, но большая часть этой памяти все еще хранится на диске, а не в памяти.

Что происходит при записи в память? Синхронизируется ли она обратно в файл памяти? По умолчанию — нет. Обычно изменения хранятся в памяти и не синхронизируются с резервным файлом. Изменения синхронизируются только при вызове create_snapshot, что часто приводит к сохранениям размером 1-2 ГБ. Это занимает слишком много времени для записи.

Однако есть флаг, который мы можем передать. Если мы передадим MAP_SHARED вызову mmap, он действительно синхронизирует изменения с резервным файлом! Ядро делает это лениво: всякий раз, когда у него есть немного свободного времени, оно сбрасывает изменения обратно в файл.

Это идеально подходит для нас, потому что мы можем перенести большую часть работы ввода-вывода по сохранению моментального снимка вперед. Когда мы действительно захотим сохранить моментальный снимок, нам придется синхронизировать лишь небольшое количество изменений!

Это серьезно сократило время сохранения моментальных снимков. Вот график среднего времени, необходимого для сохранения снимка памяти, до и после внедрения этого изменения:

С этим изменением мы перешли от ~8-12 с сохранения моментальных снимков к ~30-100 мс!

Уменьшение времени клонирования до миллисекунд

Теперь мы можем быстро сохранить моментальный снимок, но что насчет клонирования? При клонировании моментального снимка памяти нам все равно нужно скопировать все побайтно в новый файл, что снова занимает ~8-12 с.

Но… действительно ли нам нужно клонировать все байт за байтом? Когда мы клонируем виртуальную машину, >90% данных будет использовано повторно, поскольку она возобновляется с той же точки. Так есть ли способ повторно использовать данные?

Ответ заключается в использовании копирования при записи (CoW). Копирование при записи, как следует из названия, копирует данные только тогда, когда мы начинаем их записывать. Наш предыдущий пример mmap также использует копирование на запись, если не передан параметр MAP_SHARED.

Используя копирование при записи, мы не копируем данные для клона. Вместо этого мы говорим новой ВМ использовать те же данные, что и старая ВМ. Когда новой ВМ потребуется внести изменения в свои данные, она скопирует данные со старой ВМ и применит изменения к этим данным.

Вот пример. Допустим, ВМ B создается из ВМ A. ВМ B будет напрямую использовать все данные ВМ A. Когда ВМ B захочет внести изменения в блок 3, она скопирует блок 3 из ВМ A и только потом применит изменения. Всякий раз, когда она будет читать из блока 3 после этого, она будет читать из своего собственного блока 3.

При копировании при записи копии выполняются лениво. Мы копируем данные только тогда, когда нам нужно их изменить, и это идеально подходит для нашей модели форкинга!

Попутно заметим, что копирование при записи уже давно используется во многих местах. Некоторые известные примеры использования CoW — это Git (каждое изменение — это новый объект), современные файловые системы (btrfs/ zfs) и сам Unix (два примера — fork и mmap).

Эта техника не только делает наши копии мгновенными, но и экономит много дискового пространства. Если кто-то смотрит на песочницу, делает форк и изменяет только один файл, нам придется сохранить только этот измененный файл для всего форка!

Мы используем эту технику как для наших дисков (создавая снимки CoW диска), так и для снимков памяти. Это сократило время копирования с нескольких секунд до ~50 мс.

Но… может ли он клонировать Minecraft?

Применяя копирование на запись и совместное mmapирование файла памяти, мы можем клонировать виртуальную машину очень быстро. Если оглянуться назад на шаги, то новое время будет следующим:

  1. Приостановить ВМ (~16 мс)
  2. Сохранить снимок (~100 мс)
  3. Копирование файлов памяти + диск (~800 мс)
  4. Запуск новой ВМ из этих файлов (~400 мс).

Что дает нам тайминги клонирования, которые значительно ниже двух секунд! Вот форк Vite (вы можете попробовать сами здесь):

Общее время можно увидеть ниже. Обратите внимание, что происходит больше, чем сам клон, но общее время все равно меньше 2 секунд:

И поскольку мы используем копирование по записи, не имеет значения, работаете ли вы с большим сервисом GraphQL с 20 микросервисами или с одноузловым сервером. Мы можем последовательно возобновлять и клонировать виртуальные машины в течение 2 секунд. Нет необходимости ждать, пока
пока загрузится сервер разработки.

Вот пример, где я захожу в наш собственный репозиторий (в котором работает наш редактор, поддерживаемый Next.js), форкаю ветку main (которая копирует виртуальную машину) и вношу изменения:

У нас также есть интеграция Linear, которая интегрируется с этим.

Мы много раз тестировали этот поток с различными средами разработки. Я подумал, что было бы очень интересно попробовать клонировать не только среды разработки.

Итак… Что если мы запустим сервер Minecraft, изменим что-то в мире, а затем клонируем его на новый сервер Minecraft, к которому мы сможем подключиться? Почему бы и нет?

Для этого я создал виртуальную машину, на которой запущены два контейнера Docker:

  1. Сервер Minecraft
  2. Tailscale VPN, который я могу использовать для подключения к серверу Minecraft прямо с моего компьютера.

Давайте посмотрим!

В этом видео я создал структуру на сервере Minecraft. Затем клонировал этот сервер Minecraft, подключился к нему и убедился, что структура существует. Затем я уничтожил структуру, вернулся на старый сервер и убедился, что структура все еще там.

Конечно, никакой реальной пользы от этого нет, но это показывает, что мы можем клонировать виртуальную машину на любой рабочей нагрузке!

Ненаписанные детали

Есть еще детали, о которых я бы с удовольствием написал. Некоторые вещи мы еще не обсуждали:

  • Оверпровизирование памяти с использованием mmap и страничного кэша.
  • Экономика запуска MicroVMs, когда у нас есть спящий режим & overprovisioning
  • Как мы создали оркестратор с учетом моментального снимка/клонирования, и как он работает
  • Как обрабатывать сетевые и IP-дубликаты на клонированных ВМ
  • Превращение Docker-файла в rootfs для MicroVM (быстро)

Есть еще улучшения, которые мы можем сделать для повышения скорости клонирования. Мы по-прежнему выполняем многие вызовы API последовательно, и скорость нашей файловой системы (xfs) может быть улучшена. В настоящее время файлы внутри xfs быстро фрагментируются из-за множества случайных записей.

В ближайшие месяцы мы напишем об этом подробнее. Если у вас есть вопросы или предложения, связанные с этим, не стесняйтесь отправить мне сообщение в Twitter.

Заключение

Теперь, когда мы можем быстро клонировать работающие виртуальные машины, мы можем запустить новые рабочие процессы, в которых вам не придется ждать, пока серверы разработки будут запущены. Вместе с приложением GitHub у вас будет среда разработки для каждого PR, чтобы вы могли быстро просмотреть (или запустить сквозные тесты).

Я хочу выразить огромную благодарность:

  • Команде Firecracker: за поддержку наших запросов и обсуждение с нами возможных решений, когда дело доходит до запуска Firecracker и клонирования виртуальной машины.
  • Команде Fly.io: за то, что поделились с нами своими наработками напрямую и через свой замечательный блог. Также большое спасибо за предоставление исходного кода их init, используемого в виртуальных машинах в качестве ссылки.

Если вы еще не пробовали CodeSandbox и не хотите больше ждать запуска dev-серверов, импортируйте/создайте репо. Это тоже бесплатно (мы работаем над статьей, объясняющей, как это можно сделать).

Если вы хотите узнать больше о CodeSandbox Projects, вы можете посетить projects.codesandbox.io!

Мы будем на @codesandbox в Twitter, когда создадим новый технический пост!

Оцените статью
devanswers.ru
Добавить комментарий