Памятник kubelet, Kubernetes != CRI

от автора

Мечта: systemd как execution plane

Все мы знаем старый прикол — заказ пиццы через Kubernetes (это, кстати, реальный проект)… Смешно, потому что показывает, до какой степени создатели Kubernetes оторвались от простого «запустить процесс»: между декларативным состоянием в API-сервере и реальным процессом на хосте у него почти ещё одна целая ОС — CRI, containerd, runc. Но на любом Linux-хосте уже есть менеджер выполнения с cgroups, namespaces, зависимостями, журналом и D-Bus API — systemd… Вас ничего не смущает?

Откройте любую документацию Kubernetes — там будет написано, что это просто Control Plane для оркестрации контейнеров. Но при этом сами создатели теперь предпочитают об этом только говорить: реальная нода обросла отдельной ОС, целой прослойкой между декларативным состоянием и процессом и кучей ограничений, которых на бумаге никто не обещал.

Кто-то ещё помнит священную войну вокруг Docker и systemd в Kubernetes? В 1.20 объявили deprecation dockershim — и в интернете началась паника «Kubernetes бросает Docker», хотя на деле для образов и Dockerfile не менялось ничего, отваливалась только прямая интеграция kubelet → dockerd. Причина была прозаичной: dockershim — это шим над шимом. Docker сам внутри уже работает поверх containerd → runc, а kubelet вдобавок этого держал отдельный CRI-шим, транслирующий вызовы в Docker Engine API. Итоговый путь одного контейнера: kubelet → CRI → dockershim → dockerd → containerd → runc → процесс. Шесть абстракций, чтобы запустить один процесс.

Зачем

Stock kubelet + containerd

Periapsis

RSS демонов в простое

~350 МБ

~74 МБ (1 pawn) — ~130 МБ (30 pawns)

оверхед на под

~15–20 МБ (containerd)

< 1 МБ (нативный юнит)

Подов на хост

110 (жёсткий потолок)

тысячи (зависит от мощности)

Логи

CRI-текстовые файлы

journald

Обновление демона

обычно zero-downtime

zero-downtime

Возьмём мой реальный пример: у меня VPS на 1 CPU и 1 ГБ RAM, kubelet + Cilium + Envoy Proxy, вроде стандартный набор, но на нём еле помещалось, и это — еще без какого-либо workload. Обычный kubelet, обычный CNI, обычный ingress. Просто сама инфра уже съедала почти весь гигабайт до того, как в кластере появлялась хоть одна реальная нагрузка. Для сравнения: на похожей по классу машине (1 vCPU, 2 GB RAM, VPS) у меня весь control plane вместе с рабочей нагрузкой — apiserver, controller-manager, scheduler, kube-proxy, CoreDNS и один nginx — уместились в ~523 МБ, с запасом (далее в разделе «Цифры»).

На мощном железе эту же проблему обычно решают с другой стороны: не убирают прослойку, а наращивают ещё одну. KubeVirt запускает полноценные VM (через QEMU/libvirt) как поды — удобно управлять виртуалками через kubectl, но каждый под тянет за собой настоящее ядро, полноценный boot и сотни МБ RAM только на саму VM, вне зависимости от того, что внутри реально работает. Kata Containers идёт мягче: каждый контейнер получает свой облегчённый microVM ради изоляции сильнее, чем namespaces + cgroups, но тоже не бесплатно — обычно ~130-170 МБ и заметно более медленный старт на контейнер. Оба честно решают проблему изоляции, но ценой ещё одного слоя между декларативным состоянием и процессом.

А что если у вас мощное железо для внутренней инфраструктуры? Или скромный homelab? Вам не нужна VM-изоляция от чужих тенантов, вам просто нужно нарезать одну машину на столько нод, сколько реально нужно — без CRI с одной стороны и без гипервизора с другой. Ровно об этом весь этот пост.

Весь этот набор компромиссов — следствие одного молчаливого ограничения: kubelet считает, что одна нода = одна машина. Нигде в документации Kubernetes это прямо не написано, но зашито в архитектуру kubelet.

Архитектура

Pawns, not nodes

$ kubectl get nodes -o wideNAME               STATUS   ROLES     AGE     VERSION               INTERNAL-IP       EXTERNAL-IP   OS-IMAGE     KERNEL-VERSION                 CONTAINER-RUNTIMEengifire           Ready    primary   2d19h   perigeos://6c446973   192.168.50.2      <none>        Arch Linux   7.0.14-zen1-1-zen (amd64)      systemd://262~develengifire-pawn-01   Ready    pawn      2d19h   perigeos://6c446973   192.168.50.2      <none>        Arch Linux   7.0.14-zen1-1-zen (amd64)      systemd://262~develengifire-scale-1   Ready    pawn      21h     perigeos://6c446973   192.168.50.2      <none>        Arch Linux   7.0.14-zen1-1-zen (amd64)      systemd://262~develengix99            Ready    primary   2d20h   perigeos://7f68f512   192.168.100.200   <none>        Arch Linux   7.0.14-x64v3-xanmod1 (amd64)   systemd://262~develengix99-e2e-1      Ready    pawn      2d20h   perigeos://7f68f512   192.168.100.200   <none>        Arch Linux   7.0.14-x64v3-xanmod1 (amd64)   systemd://262~develengix99-e2e-2      Ready    pawn      2d20h   perigeos://7f68f512   192.168.100.200   <none>        Arch Linux   7.0.14-x64v3-xanmod1 (amd64)   systemd://262~develengix99-scale-1    Ready    pawn      21h     perigeos://7f68f512   192.168.100.200   <none>        Arch Linux   7.0.14-x64v3-xanmod1 (amd64)   systemd://262~develengix99-trail-1    Ready    pawn      2d20h   perigeos://7f68f512   192.168.100.200   <none>        Arch Linux   7.0.14-x64v3-xanmod1 (amd64)   systemd://262~develengix99-trail-2    Ready    pawn      2d20h   perigeos://7f68f512   192.168.100.200   <none>        Arch Linux   7.0.14-x64v3-xanmod1 (amd64)   systemd://262~devel

Один процесс perigeos поднимает N «виртуальных» нод (Pawn) — у каждой свой TLS-сертификат (через CSR API кластера), свой kubelet HTTP endpoint (exec/attach/logs/port-forward), свой pod CIDR, свой cgroup slice и свои лимиты. У каждой Pawn свой бласт-радиус: паника одной Pawn не утащит за собой ни хост-демон, ни соседние Pawns.

Планировщик видит обычные ноды и балансирует, как обычно:

# с тестов$ apsis pawnsNAME        ROLE  PORT   PODScompute-00  pawn  12261  56compute-01  pawn  12262  55...compute-29  pawn  12290  56

30 pawns, ~55 подов на каждой.

Node != host

В отличие от ванильного kubelet, где поднимать потолок выше 110 не рекомендует сам апстрим, у каждой Pawn это конфигурируемый параметр, а не флаг с оговорками — сейчас, например, стоит 256. Потолок количества нод просто перестаёт быть потолком одной машины, и identity (далее Pawn) на одной машине можно завести хоть сто. Я запустил 33 Pawns на двух физических хостах и это не виртуализация и не трюк с вложенными контейнерами (как у kind/k3d, где нода это под в поде): это честных 33 Node объекта, у каждого свой сертификат и свой kubelet-эндпоинт.

Под = машина systemd

$ sudo machinectl | headMACHINE                                                         CLASS     SERVICE        OS        VERSION  ADDRESSESpod-0278b268-569a-43d0-8d01-791c77e90e7e-nginx                  container systemd-nspawn alpine    3.23.4   10.0.65.53…pod-0a272159-351a-4fdf-9738-6010f1f5ad47-nginx                  container systemd-nspawn alpine    3.23.4   10.0.127.141…pod-0b773d9b-e74b-41c0-b301-3169144f43de-nginx                  container systemd-nspawn alpine    3.23.4   10.0.55.182…pod-0fb1dabd-f7c3-461b-8052-413f70dbb2ad-nginx                  container systemd-nspawn alpine    3.23.4   10.0.50.176…pod-1342a134-e0d1-422f-913d-7533d3aca554-nginx                  container systemd-nspawn alpine    3.23.4   10.0.58.116…pod-1ad3491d-5779-4284-afb5-00ab0b480e60-nginx                  container systemd-nspawn alpine    3.23.4   10.0.115.134…pod-1cccc4de-6133-4b3e-8062-6579a219e67f-nginx                  container systemd-nspawn alpine    3.23.4   10.0.116.107…pod-2000e594-376b-45ef-a574-971c29fc1dc2-nginx                  container systemd-nspawn alpine    3.23.4   10.0.23.127…pod-204b0f04-871c-44e0-bcfb-6cc2c215be98-operator               container systemd-nspawn periapsis 0        -

Каждый контейнер — это nspawn-машина из обычных OCI-образов (pull, дедупликация слоёв, P2P-раздача блобов между хостами по TLS). Изоляция ядерная: namespaces + cgroups, тот же механизм, что у runc, но без демона рантайма или shim. Журналы контейнеров едут сразу в journald.

Zero-downtime обновления демона

При рестарте perigeos умирает только сам демон, поды остаются жить. Новый демон стартует, находит их и продолжает с ними работу. Обновление агента не требует drain. После ребута хоста происходит обратное: демон замечает исчезнувшие машины и перезапускает их.

Справедливости ради: containerd v2 умеет то же самое — shim-процессы переживают рестарт демона независимо от него. Разница в том, что perigeos для этого не нужен отдельный shim-процесс на контейнер: супервизор пода — тот же systemd, который управляет любым другим юнитом на хосте, так что «пережить рестарт демона» здесь не отдельный механизм, а естественное следствие того, что под — это обычный юнит.

Цифры

Железо скромное: Xeon E5-2690 v4 (48 ГБ) и мини ПК на Intel N150 (16 ГБ), между ними обычный Ethernet (0.15 мс RTT).

Плотность: 1772 пода, 33 ноды

$ apsis status          # engix99 (Xeon, 30 pawns)Hostname:    engix99Uptime:      1h47mPawns:       30Pods:        1,660Memory:      29 / 48 GBCPU cores:   28Machines:    1,660RSS:         365 MiBLXC veths:   1,665Netns:       1,665$ apsis status          # engifire (N150, 2 pawn)Hostname:    engifireUptime:      1h45mPawns:       2Pods:        112Memory:      2.7 / 16 GBCPU cores:   4Machines:    112RSS:         89 MiBNetns:       112

365 МБ RSS на 1660 подов — это ~220 КБ на под. Для масштаба: один containerd-shim стоит ~15-20 МБ на контейнер, то есть на те же 1660 подов одни только shim’ы весили бы 25-33 ГБ — больше половины всей памяти этого сервера. Здесь этих процессов просто нет.

Под нагрузкой: k6, 1000 VU, 3m30s, ClusterIP -> nginx-whoami на 33 нодах

  THRESHOLDS    errors    rate=0.00%                              < 0.01  PASS    http_req_duration    p(95)=4.05ms                            < 500   PASS    p(99)=10.04ms                           < 1000  PASS  TOTAL RESULTS    checks_total........: 7,507,074   35,740/s    checks_succeeded....: 100.00%    checks_failed.......: 0.00%    HTTP    http_reqs...........: 2,502,358   11,913/s    http_req_duration...: avg=998 us  med=257 us  p(90)=1.48 ms  p(95)=4.05 ms  p(99)=10 ms  max=860 ms    http_req_failed.....: 0.00%    NETWORK    data_received.......: 4.4 GB      21 MB/s    data_sent...........: 170 MB      810 kB/s  running (3m30.0s), 0/1000 VUs, 2,502,358 complete and 0 interrupted iterations

Ноль ошибок. 2.5 миллиона запросов. Медиана 257 мкс при ~12k rps — и это через ClusterIP по реальному datapath (eBPF, VXLAN между хостами), а не loopback-синтетика.

Свежие замеры (09.07.26)

$ sudo apsis statusHostname:    engix99Version:     61a39f25Uptime:      9m26sPawns:       6Pods:        11Kernel:      7.1.3-x64v3-xanmod1Arch:        linux/amd64Go:          go1.26.5-X:nodwarf5Memory:      23998 / 48004 MiBCPU cores:   28Load avg:    1.44 1.85 1.86PSI cpu:     0.0%PSI memory:  0.0%Machines:    8Disk dirs:   106Units:       8RSS:         84 MiBLXC veths:   4Netns:       3Pawns:NAME             ROLE     PORT   IP  PODS  CPU(ms)  MEM(MiB)engix99          primary  12260      5     9348467  1457engix99-e2e-1    pawn     12261      2     170      0engix99-e2e-2    pawn     12262      0     0        0engix99-trail-1  pawn     12263      2     14721    51engix99-trail-2  pawn     12264      2     10335    77engix99-scale-1  pawn     12265      0     0        0

Апрельский 33-нодовый стенд я не пересобирал: рабочий кластер сейчас — 6 нод на тех же двух хостах. Так что это не повтор плотностного бенчмарка, а свежий замер текущей версии.

  • RSS демона в простое: 71.6 МБ (5 pawn) / 67.7 МБ (2 pawn); после 5-минутного load-теста — +4.5 и +2.7 МБ соответственно, никаких утечек памяти.

  • Мое предположение: ~55-60 МБ базы и рантайм плюс ~5-18 МБ на pawn, отсюда и диапазон «70-130 МБ» в таблице из раздела «Зачем».

  • 21 под на 7 нодах, 2910 rps в течение 5 минут: p90 996 мкс, p95 1.36 мс, 0% ошибок, трафик дошёл до всех 21 реплик на всех 7 нодах.

Дешёвый VPS

Дешёвый VPS (1 vCPU, 1.9 ГБ RAM). Полный self-hosted control plane (apiserver + controller-manager + scheduler) плюс kube-proxy, CoreDNS и рабочий под — весь стек целиком:

perigeos, сам демон:          ~102 МБ  (~119 МБ пик)всё, что perigeos управляет:  ~421 МБитого perigeos + нагрузка:    ~523 МБ, ~7% одного ядрахост целиком (free -h):       ~794 МБ / 1.9 ГБ (~42%)

Кластер отвечал на реальные запросы снаружи через NodePort (curl по внешнему IP), DNS резолвился через CoreDNS — обычный kubectl get pods -A показывает всё 1/1 Running.

И совсем маленький хост: Raspberry Pi Zero 2 W

Плата за ~15 долларов: 467 МиБ RAM, arm64, стоковое ядро Raspberry Pi OS. Тот же бинарь perigeos, нода регистрируется в кластере, обычный alpine под создаётся, тянет образ, работает — с обычными событиями в kubectl describe:

$ sudo apsis statusHostname:    engipiPawns:       1Pods:        1Kernel:      6.1.21-v8+Arch:        linux/arm64Memory:      177 / 467 MiBRSS:         59 MiB$ free -h               total        used        free      shared  buff/cache   availableMem:           467Mi       172Mi        87Mi       240Ki   265Mi       294Mi

59 МиБ RSS демона; весь хост вместе с рабочим подом — 172 МиБ из 467, свободно ещё почти 300. kubelet + containerd на эту плату просто не поместились бы вообще. Сноска: под здесь на hostNetwork — Constellation CNI требует ядро 6.6+, а на Pi стоковое 6.1.

Безопасность и что нода умеет сверх kubelet

Плотность — не единственное, что выпадает из отказа от CRI. Дальше — вещи, которые либо про безопасность, либо у стокового kubelet просто отсутствуют.

SecurityContext, но fail-closed

Поддержан полный SecurityContext: runAsUser/runAsGroup/fsGroup/supplementalGroups, runAsNonRoot, allowPrivilegeEscalation → NoNewPrivileges, capabilities add/drop, seccompProfile, sysctls, readOnlyRootFilesystem, privileged. Принцип один: поле, которое нода не может выполнить честно, отклоняется на этапе сборки конфига — Warning Event FailedCreateContainerConfig прямо в kubectl describe. Это не камень в огород kubelet — он такие поля тоже отклоняет. Это страховка против главного соблазна любой реимплементации ноды: молча запустить под без запрошенного ограничения, потому что так проще. Здесь этот путь запрещён как класс: например, seccompProfile: Localhost через systemd не загрузить — значит, под с ним не стартует вообще, а не стартует без профиля.

# runAsUser:1000 runAsGroup:1000 fsGroup:2000 supplementalGroups:[3000,4000], с emptyDir$ kubectl exec demo -- id # exec работает как в kubeletuid=1000 gid=1000 groups=2000,3000,4000$ kubectl exec demo -- stat -c '%U:%G %A' /dataroot:2000 drwxrwsr-x                       # fsGroup владеет томом, setgid выставлен

Root в поде — не root на хосте

userns-маппинг здесь по умолчанию для непривилегированных подов: UID 0 контейнера отображается в непривилегированный высокий UID хоста, так что эскейп из контейнера будет на хосте как nobody. В стоковом kubelet user namespaces до сих пор opt-in.

Containment, который видно

  • resources.limits — это настоящие cgroup-v2 caps (memory.max / cpu.max / pids.max), которые обеспечивает systemd, а не эмулирует рантайм: контейнер, вышедший за лимит, убивается, хост при этом не страдает.

  • Форк-бомбы: per-pod pids.max (periapsis.io/max-pids, дефолт 4096 вместо унаследованных от systemd ~18874). Живьём: под с форк бомбой и max-pids=64 умер об fork() EAGAIN.

  • OOM-детекция: рантайм ловит это и честно ставит OOMKilled. Eviction при node pressure ранжируется как у kubelet: QoS → priority → usage.

Устойчивость, которой у kubelet нет

  • Отказ API-сервера: поды продолжают работать, демон не падает, а когда KAS возвращается — нода сама приходит в Ready (~44 с, без рестарта демона).

  • Взрывной радиус: каждый pawn живёт за panic-firewall — паника одного pawn не роняет ни хост-демон, ни соседние pawn.

P2P-раздача образов

OCI-слои живут в content-addressable store и раздаются между pawn и хостами peer-to-peer: слой, скачанный однажды, дальше расходится по кластеру сам. Вплоть до вырожденного случая — нода тянет приватный (403 от registry) образ целиком от соседа, манифест и слои, без единого запроса в registry:

$ kubectl get eventsNormal  ResolvingManifest  pod/wasix-engifire  Resolving image manifest: ghcr.io/.../wasix-info:latestNormal  PulledFromPeer     pod/wasix-engifire  Layer a03d287ccca9 pulled from peer 192.168.100.200:12261 (h3, tier 0)Normal  Started            pod/wasix-engifire  Container server started

Пиры выбираются по близости (subnet → local → remote), транспорт — HTTP/3 с fallback на TCP на каждого пира. А apsis ingest загружает образ в библиотеку ноды прямо из tar — и он тут же раздаётся P2P, так что образ попадает в кластер вообще без registry.

И два фикса уехали в сам systemd

Часть багов в самом systemd:

  • nspawn: fix EPERM when using —private-users with —network-namespace-path (смержен) — nspawn падал с EPERM, если контейнеру одновременно нужны user namespace (--private-users) и присоединение к уже существующему сетевому неймспейсу (--network-namespace-path): inner child сначала оказывался в новом userns, а setns() в чужой netns оттуда ядро уже не пускало. Для Periapsis это не экзотика, а буквально каждый под: userns-маппинг из раздела выше плюс join в netns, который уже настроил CNI.

  • nspawn: fix .mstack bind mounts being masked by —volatile=overlay (на ревью) — bind-маунты из .mstack-каталога молча исчезали при --volatile=overlay: overlayfs игнорирует активные mount points внутри lowerdir, так что mounts, собранные на этапе assembly, маскировались свежим оверлеем.

Это заодно и ответ на «почему systemd, там же нет всего для контейнеров»: там есть почти всё, а чего нет — туда можно принести ну или пофиксить.

Трейд-офф

Я сознательно (и не только) выбрал OS-изоляцию (общее ядро) вместо аппаратной или VM. Это значит: Periapsis не для враждебных мультитенантных нагрузок. Для доверенной внутренней инфраструктуры, CI/CD, edge и density-first сценариев это правильный размен: изоляция та же, что у обычных контейнеров (namespaces + cgroups + seccomp/caps от nspawn), а налог на microVM отсутствует.

Есть и третья точка на шкале изоляция/плотность — WASM-поды (о них в бонусе): для нагрузки, которой нужен sandbox сильнее namespaces, но без microVM, WASIp3 компонент работает в песочнице wasmtime с deny-by-default профилем. Непроаннотированный под не получает вообще ничего — ни host-интерфейсов, ни сети, ни env, ни файловой системы; каждое разрешение выдаётся явно, а невыданный импорт — это ошибка инстанцирования (подробнее в архитектуре WASI или в последующих статьях). Плюс лимит памяти на уровне рантайма: guest упирается в чистый wasm-trap до того, как за него возьмётся OOM-killer.

Второй пункт: экосистема считает, что нода выглядит как containerd. Стандартный Helm-чарт Cilium не встаёт как есть (Сам Cilium работает без твиков, это init-контейнеры чарта, лезущие хосту в /proc/sys), но у меня все-равно форк. Это постоянная цена за жизнь вне CRI.

С чем сравнивают

Коротко о том, чем это не является — от соседних слоёв стека к главному сравнению, ради которого весь пост.

vs k3s

k3s — компактный control plane (один бинарник, kine вместо etcd), но нода у него та же, что у ванильного Kubernetes: kubelet + containerd + тот же потолок в 110 подов. k3s решает проблему установки кластера, Periapsis решает проблему ноды — но теперь умеет и первое: perigeos сам поднимает свой control plane (apiserver + controller-manager + scheduler) холодным стартом из статических манифестов kubeadm — сам формат манифестов остаётся, убрана необходимость держать k3s/kubeadm запущенным как постоянный оркестратор; bootstrap и adopt происходят автоматически. Это не только homelab: недавно поднял её с нуля на чистом VPS (1 vCPU, 2 ГБ RAM) — вместе с kube-proxy, CoreDNS и рабочей нагрузкой control plane уместился в ~523 МБ, всё поднялось end-to-end и отвечало на реальные запросы снаружи через NodePort. По пути нашлись и сразу были починены пара edge-кейсов — именно так этот проект и взрослеет. Так что граница между «нода» и «control plane» тут не жёсткая: можно взять k3s поверх perigeos, как и раньше, а можно не брать вовсе.

vs LXC/Incus

Incus — системный контейнерный менеджер сам по себе, без Kubernetes сверху: свой CLI, свой API, своя модель профилей и сетей. Periapsis использует похожий изоляционный примитив (namespaces + cgroups через systemd-nspawn, тот же класс, что LXC), но остаётся нодой Kubernetes: тот же API-сервер, планировщик, Deployments, Services, NetworkPolicy, весь существующий инструментарий и экосистема YAML, которую все уже знают. Разница не в изоляции, а в том, что стоит сверху.

vs minikube / kind / k3d

Однонодовый (или под-в-поде, как kind/k3d) кластер на ноутбуке годится, чтобы проверить, что манифест применяется. Он не годится, чтобы проверить то, ради чего вообще нужен Kubernetes: как ведёт себя (anti-)affinity, topology spread, Service/NetworkPolicy, CNI между нодами, PodDisruptionBudget при cordon/drain реальной ноды — да и просто так, кто-то говорил что Kubernetes это только про Enterprise. Один хост Periapsis регистрируется как 30+ независимых pawn, это настоящий многонодовый кластер для тестов, а не имитация одной ноды под разными именами.

vs Knative

У Periapsis есть нативный scale-to-zero, целых два тира:

  • Frozen (periapsis.io/freeze-after) — тот же процесс, та же память, просто снят с CPU. Прогретый JIT, пулы соединений, кэши, загруженные веса модели — всё остаётся тёплым; разморозка — это снятие freeze-флага, процесс продолжает с той же инструкции. RAM это не экономит (честно: freeze не отдаёт память), экономит CPU и время пробуждения.

  • Idled (periapsis.io/idle-after) — процесс останавливается по-настоящему, но под не удаляется: он виден в API-сервере, netns и IP сохранены, образ на месте. Пробуждение — новый процесс в уже существующем окружении.

Общая точка с Knative Serving та же: под, до которого никто не достучался, схлопывается. Но технически это устроено иначе: у Knative Deployment Revision’а уходит на 0 реплик, ReplicaSet удаляет под целиком, а следующий запрос — это полноценный новый под: новый UID, новый IP, планирование и CNI IPAM с нуля.

С моей стороны цифра есть: 10 чистых циклов STZ (замер 06.07.26) — пробуждение из Idled стабильно 1.4-1.6 с, и это тот же самый под: UID, IP и netns сохранены. Пробуждение из Frozen отдельно не замерял — там по устройству нечего мерить, кроме записи в cgroupfs. Цифру Knative рядом сознательно не ставлю: своего воспроизводимого замера у меня нет, а чужие cold-start бенчмарки слишком зависят от конфигурации, чтобы честно сравнивать. По устройству это в любом случае разные операции — у Knative “пробуждение” собирает новый под с нуля, т.е. “заморозка” у них — это удаление.

vs runwasi

runwasi — containerd v2 shim: запускает Wasm-модуль вместо обычного OCI-контейнера, но остаётся внутри CRI/containerd/shim-архитектуры — тот же дополнительный процесс на workload, тот же слой, который весь этот пост как раз выкидывает, просто вместо runc — Wasm-рантайм.

У Periapsis это runtimeClassName: trail — WASIp2/p3 компонент исполняется хостовым рантаймом напрямую, присоединённым к netns пода: без shim, без лишнего процесса на workload. (Ранние отдельные классы wasmtime/wasmedge/wasmer консолидированы в один trail.) Тот же тезис, что и в “Зачем” (нет прослойки между декларативным состоянием и процессом на хосте), только применённый к Wasm, а не к OCI-контейнерам.

vs kubelet

Это, собственно, главное сравнение — ради него весь пост. Все сравнения выше про соседние слои стека (control plane, изоляция без Kubernetes, локальный дев-кластер, scale-to-zero, Wasm-рантайм). Это — про саму ноду.

Путь одного контейнера на стандартной ноде: kubelet → CRI → containerd → containerd-shim → runc → процесс. У каждой прослойки свой процесс, своё состояние, свой рестарт и свои баги. Путь того же контейнера здесь: perigeos → D-Bus → systemd → процесс. Причём perigeos в этой цепочке не супервизор, а декларатор: systemd сам следит за процессами.

Из одного этого сокращения выпадает всё остальное — и почти ничего из этого не пришлось делать, оно случилось само:

  • Логи не собирает агент по текстовым файлам: они уже в journald, потому что под — это юнит.

  • Zero-downtime обновление демона — не механизм, а следствие: поды не дети perigeos, они дети systemd, и рестарт демона их просто не касается.

  • Видимость не требует crictl — apsis, machinectl, journalctl и systemctl показывают всё что нужно.

  • “Нода != хост”: возвращаю оригинальную архитектуру Kubernetes, 30 pawn на одном хосте — это просто 30 Pawns с собственными сертификатами.

И чтобы было совсем честно: kubelet — не ошибка. Он проектировался, когда «нода» значило «железная машина», Docker был единственным рантаймом, а systemd ещё не умел и половины того, что умеет сейчас. Он сделал контейнеры скучной, надёжной технологией — и заслужил памятник.

Просто памятнику пришло время мены.

Бонус: WASM-поды без nspawn

Тот же тезис — убрать прослойку между декларативным состоянием и процессом — работает не только для контейнеров.

Periapsis, в общем-то, всё равно, что и через что запускать: для реконсайлера под — это «то, что должно работать», а чем оно окажется — nspawn-машиной из OCI-образа, хост-процессом или wasm-рантаймом в netns пода, да хоть VM — решает runtimeClassName. Ни одна из выкинутых прослоек не возвращается ни в одном из вариантов.

Под с runtimeClassName: trail — это не OCI-образ с wasm-рантаймом внутри и не containerd-shim (как runwasi), а WASIp2/p3 компонент, исполняемый хостовым рантаймом прямо в netns пода. Pod IP, Services, NetworkPolicy, логи в journald — всё как у обычного пода, только вместо rootfs — один .wasm артефакт, а вместо capability-набора Linux — deny-by-default профиль из “Трейд-оффа”.

Wasm HTTP-сервер держал под k6 ~14 тысяч rps с нулём ошибок (медиана 5.4 мс, 200 VU) — замер от 21.06.26, ещё на раннем WASIX-классе рантайма; нынешний trail — это уже компонентная модель (рекомендую к прочтению) WASIp2/p3, включая настоящие async-экспорты WASI 0.3.

А дальше начинается материал, который в этот пост уже не влезает: компоненты, связываемые через обычный WIT-интерфейс в одном процессе (вызов между «подами» за сотни наносекунд вместо микросекунд kernel IPC), cooperative checkpoint/restore — компонент сериализует своё состояние сам, рантайм переносит его через рестарт — и живой переезд stateful-компонента между хостами, включая x86 → arm64: .wasm не привязан к архитектуре, а чекпоинт — это байты приложения, не нативный стек. Об этом можно делать отдельный пост.

Что можно потрогать

Публичный репозиторий: https://github.com/apsis-io/showcase — бенчмарки с сырыми транскриптами, обзор возможностей и модели pawn, public issue tracker.

Про исходники. Periapsis пока не опубликован; лицензия — BUSL-1.1 (та же модель, что у ряда известных инфраструктурных проектов). По-человечески это значит: свободно для любого внутреннего использования — прод, дев, homelab, research, внутренняя инфраструктура компании; нельзя — продавать это как hosted/managed сервис (SaaS/XaaS), коммерчески бандлить в edge/IoT-продукты и брать деньги за удалённое управление чужой инфрой. Консалтинг на инфраструктуре самого заказчика — можно. Важный нюанс, чтобы никто не боялся зря: запускать на Periapsis свой коммерческий продукт или SaaS (как Fly.io) — можно, это и есть внутреннее использование вашей инфраструктуры; запрещено продавать как услугу сам Periapsis — его хостинг, оркестрацию, управление. И это не навсегда: каждый релиз автоматически переходит под GPL-3.0-or-later не позднее чем через 4 года после выхода. Полный текст лицензии опубликован в showcase-репозитории.

Почему так: я соло-разработчик, и это осознанный размен — настолько открыто, насколько это не превращает проект в бесплатный R&D для чужого managed-сервиса.

Коммерческая лицензия за пределами этих условий и энтерпрайз-вопросы — в личку Хабра или контакты из репозитория.

FAQ (подключаю libastral.so)

  • «Это же велосипед, есть k3s». k3s — компактный control plane (один бинарник, kine вместо etcd). Нода у него та же самая: kubelet + containerd + потолок в 110 подов. Мы решаем разные проблемы и спокойно совместимы — апрельский стенд из «Цифр» работал именно под k3s.

  • «Чем это лучше LXC/Incus?» Тем, что это нода Kubernetes: API-сервер, планировщик, Deployments, Services, NetworkPolicy — весь инструментарий и вся экосистема YAML, которую команда уже знает. Изоляционный примитив похожий, разница — во всём, что сверху.

  • «systemd-nspawn — это несерьёзно». Механизм изоляции тот же, что у runc/docker/containerd: namespaces + cgroups + seccomp/caps. Что правда — в проде nspawn обкатан меньше. Этот риск я знаю и считаю оплаченным: взамен исчезает целый слой со своими процессами, своим состоянием и своими багами.

  • «Сломалась тачка — сломались сразу 100 нод». Нарезка на pawn не создаёт новый failure domain: хост как был единой точкой отказа, так и остался — что с одной нодой, что с тридцатью (см. “Node != host” в архитектуре). Падает хост — падает всё, что на нём крутилось, при любой архитектуре; вопрос в масштабе, а не в наличии проблемы. HA как строилась на нескольких физических хостах (anti-affinity, topology spread), так и строится.

  • «Где исходники?» См. «Что можно потрогать»: showcase-репозиторий с бенчмарками и документацией уже открыт, транскрипты воспроизводимы. Полный текст лицензии там же.

  • «А безопасность мультитенантности?» Первый абзац «Честного трейд-оффа»: Periapsis сознательно не для враждебных тенантов. Общее ядро — значит, недоверенный код, умеющий в kernel-эксплойты (а их нынче развелось), должен жить в microVM-рантайме, а не здесь. Для sandbox сильнее namespaces без microVM есть WASM-путь с deny-by-default (см. бонус).

ссылка на оригинал статьи https://habr.com/ru/articles/1058526/