Представьте типичную ситуацию. У вас есть сервер на каком-то языке (сейчас какой именно для нас не важно, но допустим, это Rust). И в нём по каким-то причинам нужно исполнить JavaScript-код. Например, фронтенд написан на React, и вы хотите рендерить HTML на сервере (SSR). Или есть бизнес-логика, которая проще пишется на JS. Или библиотека, которая существует только в npm, и переписывать её на Rust нет ни сил, ни желания. Таких примеров можно привести множество: markdown, dompurify, highlight.js и т.д.
Давайте рассмотрим варианты, как это можно сделать:
Вариант 1 — переписать на Rust. На первый взгляд логично: зачем держать несколько runtime-ов? Проблема в том, что появляется вторая реализация той же логики. Если исходная библиотека продолжает развиваться, её поведение приходится воспроизводить и поддерживать отдельно. Кроме того, Rust и JavaScript сами по себе разные. Например, Promise в JavaScript и Future в Rust решают похожие задачи, но являются разными абстракциями с разной семантикой и моделью исполнения. Rust также не предоставляет встроенный runtime для асинхронного выполнения, аналогичный окружению JavaScript. То же касается генераторов: в стабильном Rust нет полноценного аналога JavaScript generators с той же семантикой. Поэтому попытка механически перенести сложную JS-логику в Rust довольно быстро превращается в отдельный проект. Нейросеть здесь помогает лишь частично: сгенерировать первоначальный код она может, но добиться полного совпадения поведения двух runtime-моделей навряд ли.
Вариант 2 — поднять отдельный микросервис на Node.js. То, что приходит в голову первым. Пишете API на Rust, рядом ставите Node, он отдаёт HTML / RSC / что вам нужно, и они общаются по HTTP или gRPC. Минусы очевидные: у вас теперь висит отдельный долгоживущий процесс, который потребляет память, даже когда запросов нет. Serverless-решения тут могут помочь, но добавляют свои подводные камни (холодный старт, ограничения по времени выполнения, стоимость).
Вариант 3 — собрать V8 startup snapshot и встраивать его прямо в Rust-бинарь. Идея в том, что V8 умеет сериализовать состояние своей кучи (heap) в бинарный блоб. Делаем это один раз при сборке, кладём файл внутрь бинаря через include_bytes!, и при старте сервера V8 не парсит и не компилирует встроенный JavaScript — он просто загружает готовый snapshot. Получается мгновенный cold start, никакого отдельного процесса, а изолированные JS-контексты можно плодить в таком количестве, которое реально нужно.
Что такое V8 startup snapshot
При запуске node server.js, Node.js уже использует встроенный V8 startup snapshot для инициализации своих внутренних модулей. Вместо того чтобы при каждом запуске парсить и компилировать fs, path, http, stream, events, util, buffer и создавать с нуля primordials (Object, Function, Array, Promise, Map, Set, Symbol, Error, TypeError и их прототипы), V8 просто десериализует заранее подготовленный бинарный слепок состояния кучи.
Но тут есть проблема: этот встроенный snapshot содержит только сами модули Node.js. Ваши зависимости: React, Express, Prisma — не входят в этот snapshot. Каждый раз при запуске node вынужден:
Парсить и компилировать каждый модуль.
Выполнять их инициализационный код.
Строить граф зависимостей.
Custom startup snapshot позволяет включить наш собственный код и зависимости. Мы выполняем всю эту работу один раз заранее, офлайн, а потом просто используем сериализованное представление специально подготовленного начального состояния V8 heap, которое используется для инициализации нового Isolate или context.
Важное дополнение: в современных версиях Node.js (стабильно начиная с v20) появился флаг --build-snapshot, который позволяет создавать собственные snapshot-ы. Теперь возможно предварительно загрузить тяжёлые зависимости, сделать слепок, и при следующих запусках Node.js будет стартовать мгновенно. При этом нужно помнить, что в snapshot не должно попасть: данные из окружения, сетевые ресурсы, таймеры с истёкшим дедлайном, замыкания с конкретными requestId или userId.
Какие выгоды это даёт
Скорость. V8 использует сложный serializer/deserializer, а современные версии ещё используют lazy deserialization. V8 прямо отмечает, что snapshot содержит, например, executable Code objects, а десериализация может происходить лениво. Подъём одного изолята из 7.5 MB snapshot-а занимает пару миллисекунд. Без него тот же изолят строился бы из парсинга и компиляции десятков JS-файлов.
Защита от ошибок инициализации. Один и тот же snapshot гарантирует одинаковое внутреннее состояние. Он собирается один раз, и если в нём ошибка синтаксиса или инициализации, она всплывёт на этапе сборки snapshot-а, а не в проде.
Переиспользование между изолятами. Если вы держите пул
JsRuntime-ов, каждый новый изолят стартует из одного и того же snapshot-а, не требуя повторных затрат на компиляцию.
Долгое время компиляции
У snapshot-а есть один большой и неприятный минус: его сборка, например, у нас занимает около часа на достаточно мощной машине. Сюда входит компиляция V8 (один раз на целевую платформу), полный warmup всех расширений и сериализация кучи. Это не та операция, которую можно запустить локально по ходу разработки. Из этого следуют жёсткие ограничения:
Snapshot собирается только в CI, на специально выделенном шаге пайплайна, на каждый релиз и на каждый PR, который трогает расширения.
Локально разработчик может не пересобирать snapshot при изменении кода своего приложения, потому что пользовательский код грузится лениво. Но если вы поменяли что-то в самих расширениях (например, добавили новую
op-функцию или поменяли warmup-скрипт), путь только один:just build-snapshotи ждать.CI-инфраструктура дорожает. Snapshot OS/arch-специфичен, поэтому для каждой пары
target × arch(linux-x64, linux-arm64, macos-x64, macos-arm64, windows-x64 — минимум) нужна своя сборка. Часовая сборка на пять платформ — это уже полный рабочий день виртуалок.
Что с этим делать на практике:
Артефакты snapshot-а кешируются в CI по хешу исходников расширений,
Cargo.lockи конфигурации сборки. Если ничего из этого не менялось, пересборка пропускается, и шаг занимает секунды.Локальная разработка идёт вообще без snapshot-а: есть dev-режим, где V8 поднимается обычным cold start. Snapshot включается только в release build.
Время сборки подталкивает нас озаботиться тем, чтобы не тащить в расширения лишнего. Каждое новое расширение в
Cargo.tomlпотенциально удлиняет warmup. В некоторых проектах из-за этого, например, поддержкаcanvasжёстко выключается в snapshot-е.
deno_core
На моей практике работать с «голым» rusty_v8 (FFI-обёрткой над V8) довольно трудоёмко. Как альтернатива могу порекомендовать использовать deno_core Rust-крейт, на котором построен Deno, который отлично подходит для встраивания в любые собственные Rust-приложения. Использование deno_core даёт несколько критически важных преимуществ именно для работы со snapshot-ами:
Готовый API для snapshot-ов. В
deno_coreесть встроенные, хорошо документированные механизмы для создания (create_snapshot) и загрузки snapshot-ов. Не нужно вручную управлять жизненным цикломv8::Isolateиv8::HandleScopeна низком уровне.Система
ops(Operations). Это быстрый и безопасный мост между Rust и JS. Вместо написания сложных C++ аддонов или использованияnapi-rs, мы просто аннотируем Rust-функции макросом#[op2], иdeno_coreавтоматически делает их доступными в JS-контексте. Это идеально ложится на концепцию snapshot-а: мы можем зарегистрировать опции при сборке, а их реальная инициализация произойдёт в runtime.Управление ресурсами (Resource Table).
deno_coreпредоставляет встроенный механизм (Rc<RefCell<...>>) для безопасной передачи файловых дескрипторов, сетевых соединений и других ресурсов между Rust и JS, что важно при работе с пулом изолятов.
Что snapshot даёт на практике: пул изолятов
Главное следствие того, что snapshot делает cold start дешёвым, — вы можете держать не один долгоживущий JS-процесс, а столько изолятов, сколько вам нужно, и поднимать новые по требованию. Каждый Isolate имеет независимое состояние JavaScript. Они не делят между собой ничего. Это даёт три вещи, которых у варианта «поднять микросервис на Node» в таком виде нет:
Параллельность по запросам, а не по воркерам. В Node параллелизм — это либо
cluster.fork()(отдельный процесс с полным cold start), либо worker threads (общая память, общий изолят, общая очередь event loop). Здесь каждый запрос может получить свой изолированный V8-контекст без перезапуска процесса и без конкуренции за один event loop. Один медленный пользовательский код не заблокирует остальные запросы, только свой изолят.Надёжность через изоляцию. Если в одном изоляте случился необработанный
throw, утечка памяти или зависший Promise, то упадёт (или будет перезапущен) только этот изолят. Другие продолжают обслуживать запросы.Горизонтальное масштабирование внутри одного процесса. Мы можем реализовать пул: фиксированный набор изолятов, которые выдаются и возвращаются обратно. Когда все заняты — либо ждём, либо поднимаем новый из того же снимка. Стоимость нового изолята — единицы миллисекунд. Больше не нужно думать: «сколько ядер мы готовы отдать Node».
Тонкости
LAZY_INIT = true≠ «ленивая загрузка модуля». Это про то, что аргументы расширения неизвестны на этапе создания snapshot-а. Само расширение регистрируется всегда, просто его инициализация откладывается до runtime, когда приходят реальные настройки (например, через те самыеops).Snapshot не должен зависеть от env vars, argv, cwd. Если при сборке snapshot-а у вас в env есть
PORT=3000и какое-нибудь расширение прочитает его в init-скрипте, эта константа зашьётся в heap, и все последующие процессы будут видеть «порт 3000» независимо от реального окружения. Поэтому env-переменные применяются уже после создания runtime через отдельный скрипт.Warmup и
is_snapshot = trueидут в паре. Случайно оставленный ESM-скрипт, который читаетprocess.cwd()илиDate.now()в warmup-фазе, даст непереносимый или сломанный snapshot.
Заключение
V8 startup snapshot возможно может дать большой эффект. Вместо того чтобы парсить и компилировать встроенный JavaScript при каждом запуске, мы делаем это один раз при сборке, а в runtime подкладываем уже готовую кучу. Да, это усложняет CI и требует дисциплины при написании bootstrap-кода. Но результат того стоит: не большой вес snapshot-а, единицы миллисекунд на подъём изолята и пул runtime-ов, который позволяет масштабировать JS-вычисления на сервере так, как это было невозможно в классической Node.js-архитектуре.

