The runtimePart 2 of 2

Как усыпить тысячу простаивающих приложений: KEDA, CRIU и freezer cgroup v2

Каждое приложение на платформе должно стоить ноль, пока на него никто не смотрит, — и мгновенно оживать, когда кто-то заходит. Мы выбрали контейнеры вместо микро-VM, а затем перепробовали три способа сделать простой бесплатным: масштабирование до нуля через KEDA, checkpoint/restore через CRIU и, наконец, freezer cgroup v2 с вытеснением в swap. Первые два очень по-разному показали нам, в чём на самом деле было ограничение. Это хроника экспериментов, режимов отказа, на которые мы наткнулись, и той архитектуры, что в итоге выжила.

Как усыпить тысячу простаивающих приложений: KEDA, CRIU и freezer cgroup v2

У AI-конструктора приложений жестокий профиль простоя. Пользователи создают приложения так же, как открывают вкладки в браузере: с энтузиазмом, пачками, а потом уходят. В любой момент времени подавляющее большинство приложений на платформе не делают ничего — ни запросов, ни пользователей, ни причины существовать в виде работающих процессов. Но каждое из них — это Vite dev-сервер плюс дерево Node-процессов, удерживающее несколько сотен мегабайт RAM, и владелец ожидает, что оно будет прямо тут, когда он вернётся.

Так что требование двустороннее, и стороны конфликтуют друг с другом:

  1. Простаивающее приложение должно стоить ~ноль. CPU и, что критично, RAM — память это дефицитный ресурс на ноде, набитой сотнями namespace'ов.
  2. Пробуждение должно ощущаться мгновенным. Пользователь кликнул по ссылке на превью. Спиннер, который крутится 30 секунд, читается как «моё приложение сломалось», а не «моё приложение спало».

Чтобы удовлетворить оба требования, мы прошли через одно фундаментальное решение и три рантайм-архитектуры. Решение — контейнеры, не VM — задало пространство вариантов; три архитектуры были реальными реализациями, работавшими на платформе, и каждый отказ сужал это пространство, пока не осталась одна-единственная форма. Эту цепочку стоит пересказать, потому что урок обобщается: самый дешёвый способ заставить что-то перестать потреблять ресурсы — это вообще не останавливать его.

VM или контейнеры: первая развилка на пути

Каждая платформа, которая запускает чужие нагрузки, сталкивается с этим вопросом ровно один раз, и ответ определяет всё, что идёт дальше. Модный ответ в 2026-м — это микро-VM, форма Firecracker / Fly-machines: каждое приложение получает настоящую виртуальную машину со своим ядром, поднимаемую по требованию и уничтожаемую при простое. Это уважаемая архитектура, и стоит точно описать, что она даёт, потому что мы сознательно от неё ушли.

Что VM действительно дают: аппаратную границу изоляции. Паника гостевого ядра, эксплойт побега из контейнера, атака на исчерпание ресурсов ядра — всё это останавливается на гипервизоре. Если ваши тенанты загружают произвольные враждебные бинарники, эта граница практически не подлежит обсуждению.

Чего VM стоят именно для этой нагрузки:

  • Плотность — это бизнес-модель, а RAM не разделяется между ядрами. Тысяча преимущественно простаивающих приложений на ноде работает только тогда, когда их память — забота одного ядра. Каждая микро-VM несёт своё гостевое ядро, свой page cache, свою копию каждого маппинга разделяемых библиотек — память, которую одно общее ядро дедуплицирует между контейнерами бесплатно. Когда дефицитный ресурс это RAM, а флот простаивает на 95%, плата за ядро на каждое приложение — это плата ровно там, где больнее всего.
  • Микро-VM решает не тот холодный старт. 125 мс загрузки Firecracker — это чудо, и для нашей нагрузки это погрешность округления. Время пробуждения сгенерированного приложения определяется прогревом гостя: запуск Node, скан зависимостей Vite, граф модулей, JIT. VM-на-приложение платит за оркестрацию гипервизора и всё равно съедает те самые 10–30 секунд, которые реально важны. Проблема холодного старта, которую нам нужно было решить, живёт над ядром, куда гипервизор дотянуться не может, — но, как мы увидим, ядро может.
  • Агент пишет в живую файловую систему. Вот это уникально для AI-конструктора приложений, и именно это стало решающим. Генерация и превью делят один read-write том (/data/projects): агент пишет файл, Vite работающего пода подхватывает его через HMR, пользователь видит изменение — без пересборки, без образа, без деплоя. Модель эфемерных VM означает запекание образов или синхронизацию файловых систем на каждой итерации, что превращает самый быстрый, самый часто исполняемый цикл платформы — агент пишет код, пользователь его видит — в самый медленный.
  • Kubernetes — это машина управления жизненным циклом, которую нам не пришлось строить. Namespace на каждое приложение даёт нам квоты, limit range, инъекцию секретов, маршрутизацию ingress, RBAC и watch-поток для статуса в реальном времени — всю поверхность day-2-операций — как конфигурацию, а не код. В мире VM оркестраторы тоже есть, но ни одного с такой глубиной готовой экосистемы.

И аргумент, который стал виден только позже, тот самый, к которому идёт весь этот пост: контейнер — это cgroup, а cgroup — это то, над чем ядро может работать. Заморозить его, взвесить, вытеснить, разморозить — единообразно, по всем приложениям на ноде, файловыми записями. VM непрозрачна для хост-ядра по своей конструкции; граница изоляции, которая защищает нагрузку, заодно отрезает её ровно от того типа всефлотовой, опосредованной ядром хирургии над ресурсами, которая и делает экономику тысяч простаивающих приложений рабочей. Suspend/resume для VM существует, но управление тысячей приостановленных образов памяти — это подсистема хранения, которую вы строите и эксплуатируете; управление тысячей замороженных cgroup — это работа уже существующей swap-машинерии ядра.

Компромисс, на который мы пошли, реален, и мы держим его осознанно: общее ядро — более слабая граница изоляции, чем гипервизор. Для этой платформы он ограничен — каждое приложение работает в своём namespace под ResourceQuota и LimitRange, ничто не работает с привилегиями, а нагрузки — это сгенерированный агентом код приложений, действующий по контрактам самой платформы, а не произвольные загруженные бинарники. И его можно пересмотреть без переархитектуры: RuntimeClass в Kubernetes делает усиленные песочницы (в стиле gVisor) заменой на уровне отдельной нагрузки, а не переписыванием платформы. Стало быть, контейнеры. Вопрос превратился в другой: что значит «простой» для контейнера?

Эксперимент 1: масштабирование до нуля (KEDA)

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

apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
spec:
  hosts:
    - {projectId}.{previewDomain}
  scaleTargetRef:
    name: app
    kind: Deployment
    service: web
    port: 80
  replicas:
    min: 0
    max: 1
  scaledownPeriod: 300

Оно работало — в том смысле, что простаивающие приложения не потребляли ничего. И оно жёстко научило нас сути ограничения: масштабирование до нуля смешивает «перестать стоить» с «перестать существовать». Когда число реплик доходит до нуля, под исчезает, так что пробуждение — это полный холодный старт: запланировать под, проверить образ, примонтировать тома, запустить Node, дать Vite обойти граф зависимостей. Для нашей нагрузки это были 10–30 секунд на каждое пробуждение. Каждый раз. Версия этой же проблемы для базы данных получила свой Rust-прокси и танец пробуждения посреди соединения; для пода приложения нет проводного протокола, который можно припарковать, — пользователь смотрит на вкладку браузера.

Вторичные трения тоже были поучительными:

  • Автоскейлер борется с ручным управлением жизненным циклом. Когда пользователь явно жал Resume, мы масштабировали Deployment 0→1 — а KEDA, не наблюдая трафика (пользователь ещё не загрузил превью, потому что его не было), немедленно масштабировал обратно до 0. Решением была хореография: удалить HTTPScaledObject перед увеличением, выждать грейс-период, пока перехватчик зарегистрирует трафик, затем пересоздать его. Любой дизайн, в котором приходится временно отключать систему, чтобы система не отменила твои действия, — это дизайн, подающий жалобу на самого себя.
  • Перехватчик сидит в пути данных. Каждый запрос к каждому превью — включая горячие, бодрствующие — теперь шёл через лишний прокси-хоп, существовавший только ради редкого холодного случая.
  • Состояние пробуждения протекало во фронтенд. Keepalive-пинги отключены для спящего проекта (в этом и смысл), так что UI приходилось особым образом опрашивать переход здоровья после resume и обновлять устаревшие токены превью, выпущенные для предыдущего воплощения пода.

Вердикт был не «KEDA — это плохо»: она хороша в том, для чего предназначена. Вердикт был в том, что удаление пода — неправильный примитив для нагрузки, чьё тёплое состояние (прогретый JIT'ом процесс Node, граф модулей Vite) дорого пересобирать и бесплатно держать. Нам был нужен процесс, переживающий собственную спячку.

Эксперимент 2: checkpoint/restore (CRIU)

Если процесс должен выжить, следующая идея очевидна каждому, кто читал про живую миграцию: CRIU — Checkpoint/Restore In Userspace. Сделать снимок всего дерева процессов на диск (страницы памяти, файловые дескрипторы, TCP-соединения, всё подряд), убить контейнер, а позже восстановить его ровно там, где он был. Стоимость простоя: буквально ноль, даже без swap. Пробуждение: восстановление из локального образа, единицы секунд. На бумаге — идеальный ответ.

Мы построили это всерьёз: привилегированный Rust DaemonSet (criu-agent) на каждой ноде, вызывающий runc поверх состояния containerd у k3s — с nsenter в namespace хоста, потому что runc нужна cgroup-иерархия хоста и настоящий /run:

// criu-agent: checkpoint a project container via the host's runc
let (stdout, stderr, ok) = run_host_cmd(
    "runc",
    &[
        "--root", "/run/k3s/containerd/runc/k8s.io",
        "checkpoint",
        "--image-path", &image_dir,
        "--tcp-established",   // snapshot live TCP, kubelet's API can't
        "--leave-running",
        "--ext-unix-sk",
        "--file-locks",
        &container_id,
    ],
    timeout,
).await;

(Мы намеренно ушли под собственный checkpoint-API kubelet — он поддерживает checkpoint, но не те флаги, что нам нужны, и у него вообще нет restore. Эта асимметрия должна была стать первым намёком.)

Checkpointing работал. Образы ложились на диск, размеры были разумными, флаги справлялись с открытыми сокетами и файловыми блокировками dev-сервера.

Restore — вот где архитектура рухнула — не на баге, а на структурном несоответствии. runc restore воскрешает дерево процессов, но воскрешает его под моделью мира Kubernetes, не сообщая об этом Kubernetes. Kubelet считает, что контейнер, которым он управляет, — это тот, что супервизирует его containerd-shim; восстановленный процесс — это подменыш, с которым у shim нет никаких отношений. Пробы, lifecycle-хуки, kubectl exec, учёт ресурсов, eviction — каждая фича control plane предполагает, что kubelet владеет жизненным циклом процесса, а после restore он этого попросту не делает. Вы восстановили не контейнер; вы восстановили процесс, нацепивший одежду контейнера, и весь слой управления платформы теперь расходится с реальностью. Собственная поддержка checkpoint в upstream-Kubernetes останавливается на «записать форензик-образ» ровно по этой причине — restore-in-place это нерешённая половина.

Мы могли бы бороться — есть люди, героически вшивающие CRIU restore в рантаймы контейнеров, — но платформа, которая хостит тысячи сгенерированных агентом, произвольно странных приложений, не может стоять на фундаменте, где pause/resume, самая часто исполняемая операция жизненного цикла, идёт наперекор оркестратору. CRIU проиграл не потому, что не смог заморозить процесс, а потому, что Kubernetes нельзя было об этом сообщить.

Один артефакт эксперимента, однако, выжил, и он оказался важным: привилегированный Rust DaemonSet на каждой ноде с крошечным HTTP-API оказался ровно той формой, что нужна для нодо-локальной хирургии над жизненным циклом. Мы оставили шасси и заменили двигатель.

Архитектура, которая выжила: freezer cgroup v2 + вытеснение в swap

Вопрос синтеза после двух выбываний пишется сам собой. KEDA провалилась, потому что под переставал существовать. CRIU провалился, потому что Kubernetes не знал, что процесс перестал существовать. Значит: а что, если ничто не перестаёт существовать?

Ядро всё это время держало ответ — и вот тут решение «контейнеры вместо VM» приносит свой дивиденд. Каждый контейнер есть cgroup, а cgroup v2 даёт вам два файла:

  • cgroup.freeze — пишете 1, и ядро замораживает каждую задачу в группе. Не SIGSTOP (который приложения могут заметить и который странно переподвешивает группы процессов) — глубокая заморозка через freezer-подсистему. Ноль CPU, безусловно, для всего дерева.
  • memory.reclaim — просите ядро вытолкнуть страницы cgroup в swap, проактивно, ничего не убивая и даже не будя.

Вместе «пауза» становится четырьмя файловыми записями — без удаления пода, без образа, без restore, без перехватчика:

// freeze-agent: the entire pause operation
fs::write(cgroup.join("memory.swap.max"), b"max")?;   // 1. allow swap
let ram_before = read_memory_current(&cgroup)?;        // 2. for the books
fs::write(cgroup.join("cgroup.freeze"), b"1")?;        // 3. stop the world
fs::write(pod_cgroup.join("memory.reclaim"),           // 4. RAM → swap
          b"999999999999")?;                            //    (best-effort)

В замороженном состоянии след приложения — это ноль CPU и несколько мегабайт невытесняемой памяти ядра; рабочее множество лежит в swap, который есть диск, который есть самое дешёвое на ноде. Разморозка — это обратная операция, и она почти мгновенна: пишете 0 в cgroup.freeze, восстанавливаете memory.swap.max, и процесс возобновляется посреди инструкции. Страницы лениво подгружаются обратно из swap по паттерну доступа реального пользователя — граф модулей Vite, состояние JIT, каждый открытый сокет и таймер: всё на месте, потому что процесс никогда не умирал. 10–30-секундная пересборка, которую масштабирование до нуля выставляло на каждое пробуждение, превратилась в стоимость подкачки страниц, амортизированную на первые несколько взаимодействий.

И — та часть, что CRIU не мог нам дать — Kubernetes ничего не замечает. Под всё это время в состоянии Running. Shim kubelet по-прежнему владеет процессом. Ни одна фича control plane не ломается, потому что с точки зрения оркестратора ничего не произошло. Мы заморозили приложение под моделью Kubernetes, а не против неё. (Liveness-пробы, конечно, заметили бы — поды сгенерированных приложений их просто не несут; собственный idle-sweep платформы и слой здоровья решают, что значит «живой» для замороженного приложения.)

Остальная система сложилась за несколько недель интеграции:

POST /freeze

cgroup.freeze=1
memory.reclaim

default backend

POST /thaw

label adorable/frozen removed

idle sweep
(no keepalive N min)

freeze-agent
(DaemonSet, Rust)

container cgroup

user hits preview

nginx ingress
custom-http-errors

backend wake handler
styled 'waking…' page

project namespace

  • Детектирование простоя намеренно осталось скучным: фронтенд шлёт keepalive-пинг, пока кто-то смотрит проект; sweep замораживает всё, что не пинговало в течение таймаута. Никакого перехвата трафика нигде.
  • Wake-on-HTTP идёт через путь ошибок nginx ingress, а не через инлайновый прокси: запрос к замороженному приложению попадает в custom-http-errors → default backend → wake-обработчик, который размораживает namespace и отдаёт стилизованную страницу «просыпаюсь», перезагружающуюся в живое приложение. У горячего пути ноль добавленных хопов; платит только холодный путь.
  • База данных входит в тот же ритм. Postgres-compute замороженного приложения масштабируется до нуля независимо и просыпается посреди соединения. Состояние заморозки публикуется как метка namespace (adorable/frozen=true), чтобы DB-прокси мог прочитать её напрямую из K8s API — никакого общего состояния в Redis между двумя системами, и бюджет пробуждения у прокси учитывает, что «размораживается всё приложение, а не только мой compute».
  • KEDA была выключена. Freezer покрыл всё, что она делала — детектирование простоя, нулевую стоимость простоя, пробуждение по запросу — с более быстрым пробуждением и двумя движущимися частями меньше в пути запроса.

Что freezer потребовал взамен

Ни один примитив не бесплатен, и честность относительно третьей архитектуры — это то, что делает первые два выбывания осмысленными. Издержки freezer реальны — просто они правильной формы: обязательства по корректности в нашем control plane, а не секунды на часах пользователя.

Freeze/thaw — это многосистемная транзакция, и нам пришлось относиться к ней соответственно. Заморозка трогает cgroup, аннотации ingress, состояние в Redis, метку namespace и взгляд DB-прокси на мир. Применённые частично, комбинации мерзкие — ранняя реализация могла оставить поды замороженными, пока ingress всё ещё направлял на них трафик, если более поздний шаг отказывал, так что заморозка обзавелась цепочкой отката (раскрутить запись в cgroup, если флип маршрутизации провалился). Сам агент усвоил тот же урок в миниатюре: второй запрос на заморозку, приходящий для уже замороженного контейнера, должен быть no-op, возвращающим исходные метаданные — наивная версия перезаписывала своё запомненное исходное значение memory.swap.max контейнера тем max, что записала сама, так что итоговая разморозка «восстанавливала» swap в навсегда-включённое состояние. Идемпотентность в коде жизненного цикла — не любезность; это разница между состоянием и порчей данных.

Состояние, живущее в двух местах, должно перекрёстно сверяться. На вопрос «заморожен ли этот проект?» отвечают Redis (быстро) и взгляд freeze-agent на фактический cgroup (правда). После перезапуска ноды или бэкенда они могут разойтись, так что платформа считает cgroup истиной в последней инстанции и лечит кэш по нему, а не доверяет одной из сторон в одиночку.

И у операций должен быть один владелец. Freeze, thaw, pause (инициированная пользователем остановка), unpause, start, rebuild — шесть подсистем, каждая отращивающая свои вызовы в эту машинерию, — вот как заводились ранние баги. Всё это теперь идёт через единый слой ProjectLifecycle, который владеет порядком переходов, держит пер-проектный лок и единственный имеет право трогать низкоуровневые операции. Freezer — это четыре файловые записи; система вокруг freezer — это упражнение в том, чтобы сделать четыре файловые записи транзакционными по всей распределённой платформе.

Лестница и экономика

Где всё это приземлилось — это лестница жизненного цикла, каждую ступень которой дешевле и медленнее покидать, чем предыдущую:

Состояние CPU RAM Стоимость пробуждения Триггер
Running реальная реальная кто-то смотрит
Frozen 0 ~0 (в swap) подкачка страниц, возобновление за доли секунды таймаут простоя
Stopped 0 0 полный холодный старт пользователь нажал stop
DB asleep 0 0 единицы секунд, посреди соединения независимый idle-sweep

Состояние покоя по умолчанию для каждого приложения на платформе — это заморожено, база спит: фактически бесплатно держать вечно, достаточно быстро будится, чтобы «пусть работает на всякий случай» стало значением по умолчанию вместо месячного счёта. Обратное — приложение, чьи пользователи не вытерпят даже прогрев на подкачке страниц, — это осознанный, оплачиваемый выбор (keep-warm), а не случайность архитектуры.

Одна развилка на пути и три эксперимента, один вывод — и это тот самый, который мы переучиваем заново на каждом слое этой платформы: не уничтожайте тёплое состояние, чтобы потом пересобрать его, — сделайте так, чтобы оно ничего не стоило, и держите его. База данных делает это ветками с копированием при записи и прокси, который будит compute посреди соединения. Приложения делают это freezer'ом ядра и swap-файлом. Дорогой вещью никогда не была пауза. Дорогим было то, что из неё вообще приходилось возвращаться.

Создавай на той самой платформе, о которой эти посты.

Опиши своё приложение простыми словами — Adorable напишет код, настроит базу данных и выкатит его в онлайн.

Начать бесплатно