Перейти к содержимому

Sergei Ozeranskii

Kata Containers: как каждый CI-job получает собственное ядро

Контейнер — это процесс на хосте, изолированный средствами ядра: namespaces, cgroups, capabilities, seccomp. Ядро при этом одно на всех — общее ядро хоста. Для CI это неудобная граница: job выполняет чужой, по сути недоверенный код (шаги сборки, зависимости из реестров, а часто и Docker-in-Docker), и вся эта конструкция стоит на том же ядре, что и соседние job-ы и сам хост. Один эксплойт ядра — и граница контейнера перестаёт что-либо значить.

Kata Containers меняет саму границу: каждый контейнер (в терминах Kubernetes — pod) исполняется внутри отдельной лёгкой виртуальной машины со своим гостевым ядром. Граница изоляции — не набор ядерных примитивов, а аппаратная виртуализация. При этом снаружи Kata выглядит как обычный OCI-runtime: те же образы, тот же containerd, тот же workflow.

Разберём, как это устроено в Kata 3.x, и почему это правильный уровень изоляции для CI.

Слева обычные контейнеры на общем ядре хоста; справа Kata — каждый job в своей micro-VM со своим гостевым ядром поверх hypervisor (KVM).
Общее ядро против отдельного гостевого ядра на каждую нагрузку.

Что такое Kata Containers

Kata Containers (открывается в новой вкладке) — open-source проект (сейчас под управлением OpenInfra / Linux Foundation), который «запускает лёгкую виртуальную машину и использует гостевое Linux-ядро для создания контейнерных нагрузок». Идея — закрыть исторический разрыв между двумя подходами:

  • Контейнеры — быстрые и лёгкие, но делят ядро хоста (слабая граница изоляции).
  • Виртуальные машины — сильная граница (свой гостевой kernel, аппаратная виртуализация), но тяжёлые и медленные в запуске.

Kata берёт свойство VM (отдельное ядро + аппаратная граница) и упаковывает его так, чтобы по весу и скорости запуска приблизиться к контейнеру — отсюда термин Лёгкая виртуальная машина: сильная граница VM (своё ядро, аппаратная виртуализация), но по весу и скорости старта близка к контейнеру..

Kata OCI-совместим. Это тот же стандарт, что у Docker-контейнеров, и он же лежит под CRI (Container Runtime Interface) в Kubernetes. Пересобирать образы не нужно — Kata работает как ещё один runtime под containerd или CRI-O. Для оркестратора это просто другой класс среды выполнения, а не другой формат контейнеров.

Как это устроено внутри

За то, что снаружи это выглядит контейнером, а внутри работает VM, отвечают несколько компонентов.

Hypervisor и гостевая VM

Kata поднимает VM через hypervisor поверх аппаратной виртуализации Kernel-based Virtual Machine — механизм аппаратной виртуализации в ядре Linux, поверх которого работают VMM, поднимаемые Kata.. В Kata 3.x поддерживается несколько Virtual Machine Monitor (hypervisor) — процесс, который создаёт и обслуживает VM. В Kata это QEMU, Cloud Hypervisor, Firecracker или Dragonball., и все они работают через KVM:

VMMЯзыкПрофиль
QEMUCСамый зрелый, максимум архитектур (x86_64, aarch64, ppc64le, s390x) и возможностей
Cloud HypervisorRustСовременный, cloud-ориентированный; изменение CPU/памяти на лету, device passthrough
FirecrackerRustМинималистичный, «серверлесс»: быстрый старт, минимум overhead — ценой части функций (нет проброса virtio-fs, VFIO, hotplug)
DragonballRustВстроен прямо в Rust-runtime Kata: VMM работает в том же процессе, нулевой IPC-overhead

У каждого VMM свои trade-offs между зрелостью, скоростью старта, overhead и набором функций. Выбор конкретного VMM — вопрос эксплуатации; технология позволяет любой из них.

Внутри VM работает своё гостевое ядро и лёгкий гостевой образ. Гостевой rootfs подключается эффективно, через Direct Access — гипервизор отображает гостевой образ прямо в память гостя (zero-copy), экономя память и время загрузки.-маппинг (zero-copy), что сокращает и память, и время старта — micro-VM грузится не как полноценная VM.

kata-agent внутри VM

В гостевой VM живёт kata-agent — долгоживущий процесс на Rust. Он управляет контейнерами внутри VM: создаёт их с нужными cgroups и namespaces (то есть привычная контейнерная изоляция никуда не девается — она просто применяется внутри VM, поверх уже аппаратной границы) и следит за жизненным циклом нагрузки.

Runtime на хосте общается с агентом по протоколу Компактный протокол в духе gRPC для низкоуровневого IPC, без HTTP/2 — рассчитан на минимальные накладные расходы. поверх VSOCK — виртуального сокета между хостом и VM. Обычной сети для управляющего канала не требуется.

Runtime и shim: containerd-shim-kata-v2

Со стороны хоста Kata реализована как Containerd Runtime V2 (Shim API): один долгоживущий процесс-shim на pod вместо запуска runtime на каждую операцию.-runtime — бинарник containerd-shim-kata-v2 (реализует Containerd Runtime V2 / Shim API). Вместо того чтобы вызывать runtime отдельным процессом на каждую операцию, containerd создаёт один сокет и общается с одним экземпляром shim на pod. Именно shim отвечает за запуск hypervisor и его VM и за связь с агентом.

virtio: как VM «дотягивается» до нужного

Гостевая VM изолирована, но контейнеру нужны файлы, сеть и I/O. Их пробрасывают паравиртуальные устройства virtio:

  • virtio-fs — пробрасывает OCI-bundle (rootfs контейнера) в директорию внутри гостевого rootfs.
  • virtio-net / vhost-net — сеть.
  • virtio-block / virtio-scsi — блочные устройства.
  • virtio-vsock — управляющий канал агента.

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

Sandbox-модель: pod — это VM

В терминах Kata один pod = одна VM = один sandbox. Kubernetes может запускать в поде несколько контейнеров — все они делят одну VM; их сопровождает служебный pause-контейнер (sandbox-контейнер), задающий общие namespaces пода. shimv2 обслуживает весь pod одним экземпляром shim — вместо отдельных процессов shim и proxy на каждый контейнер, как в прежней архитектуре.

Изоляция получается двухуровневой:

  • Между подами — аппаратная. Разные поды — это разные VM с разными гостевыми ядрами.
  • Внутри пода — namespaces. Контейнеры одного пода изолированы друг от друга через cgroups и namespaces, которые создаёт гостевое ядро внутри VM.

Для CI это ровно то, что нужно: один job — это один pod, значит одна VM. Соседний job — другая VM с другим ядром; общего у них только физическая машина под hypervisor.

Сеть: как сетевой namespace хоста связан с VM

Оркестратор через CNI создаёт для пода отдельный сетевой namespace и обычно veth-пару: один конец в namespace пода, другой — на хосте. Напрямую подключить veth к VM нельзя — VM нужен TAP-интерфейс. Kata связывает одно с другим прозрачно; способ задаёт модель подключения (Настройка Kata: как интерфейс пода из CNI связывается с TAP-устройством VM — tcfilter, macvtap или none.):

  • tcfilter (по умолчанию) — правила Traffic Control (TC) перенаправляют трафик между veth в namespace пода и TAP-устройством VM. Работает и с ipvlan/macvlan.
  • macvtap — интерфейс пода подключается к VM через MACVTAP-устройство.
  • none — когда VM находится в сетевом namespace хоста.

Ключевое: hypervisor получает отдельный сетевой namespace, а гость видит только отданное ему TAP-устройство. Прямого доступа к NIC хоста у гостя нет — трафик проходит через контролируемую хостом границу TC/TAP.

Сетевой namespace пода с veth-парой из CNI связан с TAP-устройством гостевой VM через TC-filter на хосте; гость видит только TAP, прямого доступа к физическому NIC хоста нет.
Трафик пода доходит до гостя через границу TC/TAP; гость видит только отданное ему TAP-устройство.

Хранилище: virtio-fs, DAX и блочный rootfs

Гостевой rootfs самой VM подключается через DAX: hypervisor отображает гостевой образ в память гостя (на устройство /dev/pmem*) zero-copy — это экономит и память, и время загрузки.

Файлы контейнера пробрасываются иначе. По умолчанию — Паравиртуальная общая ФС: демон virtiofsd на хосте отдаёт rootfs контейнера в гостя; хост и гость видят одно дерево файлов.: на хосте работает демон virtiofsd, который отдаёт OCI-bundle (rootfs контейнера) в директорию внутри гостевого rootfs (kataShared). virtio-fs — это shared-fs модель: хост и гость видят одно и то же дерево файлов.

Альтернатива — блочное устройство: rootfs или том пробрасываются в VM как virtio-block, например через devicemapper snapshotter, который отдаёт образ контейнера как блочное устройство.

Trade-off между двумя моделями:

  • virtio-fs гибче — живой проброс директорий, bind-mount томов, overlay, — но это отдельный демон и слой когерентности кешей между хостом и гостем.
  • блочный rootfs проще и обычно быстрее на «чистом» I/O, но менее гибок и требует блочного snapshotter.

Гостевой образ и kata-agent как init

Гостевые компоненты бывают двух видов:

  • rootfs-образ — полноценная корневая ФС; в роли init либо systemd (гибко, легко кастомизировать), либо сам kata-agent.
  • initrd — образ, распаковываемый в память; init — kata-agent.

Собирается это с флагом AGENT_INIT=yes: тогда kata-agent запускается как init-процесс (PID 1) гостя — без systemd и лишнего userspace. Минимальный вариант, initrd + agent-as-init, — самый быстрый и компактный. Смысл в том, что гостевой userspace сведён к необходимому: ядро, agent и то, что нужно контейнеру. Меньше кода — меньше поверхность атаки и быстрее загрузка.

Память и быстрый старт

Быстрый старт micro-VM складывается из нескольких вещей:

  • минимальное гостевое ядро и урезанный гостевой образ (см. выше);
  • VM templating / factory — заранее поднятая, инициализированная и приостановленная VM-шаблон, из которой новые VM клонируются быстрее, чем загружаются с нуля (enable_template, kata-runtime factory init; режим требует initrd и несовместим с virtio-fs в качестве shared_fs).

С памятью — так же прагматично. У VM есть стартовый объём (default_memory), дальше работает hotplug: когда у контейнера задан лимит памяти, Kata добавляет её гостю на лету — через virtio-mem (enable_virtio_mem) либо memory hotplug / balloon, в зависимости от VMM и архитектуры. Это избавляет от необходимости выделять каждой VM память «с запасом».

Overhead при этом честно остаётся: каждая VM несёт своё ядро и свой стартовый объём памяти — это цена аппаратной границы (см. trade-offs ниже).

Поток: от containerd до процесса в VM

Как Kata встраивается в стандартный конвейер запуска контейнера:

containerd вызывает containerd-shim-kata-v2; тот поднимает hypervisor на KVM и грузит Guest VM с гостевым ядром, kata-agent и процессом контейнера; управление идёт по ttRPC поверх VSOCK, rootfs пробрасывается через virtio-fs.
containerd видит обычный OCI-runtime; под ним поднимается micro-VM.
  1. Оркестратор выбирает Kata — в Kubernetes через RuntimeClass, в CRI-O через аннотации. Никаких изменений в самих образах.
  2. containerd по runtime_type (например io.containerd.kata.v2) вычисляет имя бинарника shim, подставляя префикс: io.containerd.kata.v2containerd-shim-kata-v2. Он создаёт сокет и передаёт его одному экземпляру shim.
  3. shim запускает hypervisor, тот загружает micro-VM из гостевых компонентов (образ + гостевое ядро).
  4. shim вызывает API агента внутри VM (CreateSandbox / создание контейнера) по ttRPC/VSOCK.
  5. kata-agent поднимает процесс контейнера внутри VM — с cgroups и namespaces, из rootfs, проброшенного через virtio-fs.
  6. Job завершился — VM уничтожается вместе со всем содержимым.

Регистрация Kata как runtime handler в containerd — это по сути одна секция в конфиге (иллюстрация из документации Kata, не наш прод-конфиг):

[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata]
  runtime_type = "io.containerd.kata.v2"

А в Kubernetes нагрузку направляют на этот runtime через RuntimeClass:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata
---
apiVersion: v1
kind: Pod
metadata:
  name: untrusted-job
spec:
  runtimeClassName: kata
  containers:
    - name: build
      image: ghcr.io/example/runner:latest

Для того, кто пишет workflow, всё это невидимо: он не знает и не должен знать, что его job исполняется в VM, а не в контейнере.

Почему это правильная граница для недоверенного CI-кода

Модель угроз Kata формулирует цель прямо: не дать недоверенной контейнерной нагрузке получить контроль над хостом, вытащить с него информацию или что-то на нём изменить.

Разница с обычным контейнером принципиальная:

  • Общее ядро исключено. У гостя своё ядро. Эксплойт ядра из недоверенного кода бьёт по гостевому ядру внутри VM, а не по ядру хоста напрямую.
  • Побег из контейнера ≠ доступ к хосту. Вырвавшись из контейнера, код оказывается всё ещё внутри VM. Чтобы дотянуться до хоста или до соседней нагрузки, ему нужно пробить ещё и границу виртуализации — а это принципиально более узкая и жёсткая поверхность.
  • Нет доступа к ФС и сети хоста по умолчанию. Гость не видит файловую систему хоста без явно проброшенного устройства; сеть изолирована через TAP, прямого доступа к NIC хоста нет.
  • Исчерпание ресурсов ограничено рамками VM. Fork-бомба или утечка памяти упираются в квоту своей micro-VM.

Именно поэтому Kata — естественный слой для CI: job-ы по определению исполняют чужой код, и контейнерной границы для этого мало.

Trade-offs — честно

Аппаратная изоляция не бесплатна:

  • Overhead виртуализации. Каждая micro-VM — это отдельное гостевое ядро и память под него; запуск и потребление ресурсов выше, чем у обычного контейнера. Kata и её лёгкие VMM (Firecracker, Dragonball, Cloud Hypervisor) минимизируют это, но не сводят к нулю.
  • Hypervisor — это тоже поверхность атаки. Граница переехала с ядра хоста на hypervisor; уязвимость в QEMU/KVM или в virtio-бэкенде теоретически даёт VM escape. Поэтому имеет значение, какой VMM выбран и как настроены бэкенды (userspace vhost-user безопаснее in-kernel vhost).
  • Гостевое ядро тоже надо обновлять. Своё ядро не делает гостя неуязвимым — его CVE никуда не деваются, просто перестают быть общими с хостом.

Вывод трезвый: Kata не «отменяет» контейнерную безопасность, а добавляет под неё вторую, независимую границу. Это defense-in-depth, а не серебряная пуля.

runc, gVisor и Kata: где проходит граница

Kata удобно позиционировать через два соседних runtime — обычный контейнерный и «песочный».

RuntimeГраница изоляцииOverheadПротив чего защищает
runcnamespaces + cgroups, прямой доступ к ядру хостаминимальныйбазовая изоляция процессов; общее ядро — слабое место
gVisoruserspace-«ядро» Sentry перехватывает системные вызовы приложения и обрабатывает их само; к ядру хоста уходит лишь минимальный, явно ограниченный seccomp-набор syscall, а доступ к ФС — через отдельный процесс Goferсредний (заметная просадка на syscall/I/O)резко сужает kernel attack surface, но это не VM-граница
Kataаппаратная VM со своим гостевым ядром поверх KVMвыше (своё ядро и стартовая память на каждую VM)защищает ядро и инфраструктуру хоста от недоверенной нагрузки

Коротко: runc максимально быстрый и максимально доверяющий; gVisor вклинивает Userspace-«ядро» gVisor: перехватывает системные вызовы приложения и обрабатывает их само, отдавая ядру хоста лишь узкий набор syscall. (userspace-ядро) между приложением и хостом; Kata поднимает полноценную аппаратную границу. Для недоверенного CI-кода важна именно последняя.

Три панели: runc — контейнер-процесс напрямую на ядре хоста; gVisor — приложение через userspace-ядро Sentry с узким набором syscall к ядру хоста и доступом к ФС через Gofer; Kata — контейнер в гостевой VM со своим гостевым ядром поверх hypervisor на KVM.
Одна граница — три уровня жёсткости: от общего ядра до аппаратной VM.

Confidential Containers: когда недоверенным становится и хост

Отдельная возможность технологии — не обязательная часть базовой изоляции — запуск гостя внутри аппаратного Trusted Execution Environment — аппаратно-изолированная и зашифрованная область исполнения (Intel TDX, AMD SEV-SNP, IBM Secure Execution).: Intel TDX, AMD SEV-SNP, IBM Secure Execution. В таком режиме память гостя шифруется и защищена даже от хоста и hypervisor, а Криптографическая проверка того, что нагрузка исполняется в подлинном, ожидаемом TEE — до того, как в него передадут секреты или код. позволяет криптографически проверить окружение до того, как в него передадут нагрузку.

Это инвертирует базовую модель угроз Kata: там хост защищается от недоверенного гостя — здесь, наоборот, гость защищается от недоверенного хоста и оператора. Это направление Confidential Containers. (QEMU — наиболее поддерживаемый VMM для TDX и SEV-SNP.)

Существенная оговорка: всё выше — свойство самой технологии Kata. В этой статье Confidential Containers описаны как возможность, а не как конкретная конфигурация tempus.build.

Как это работает у нас

В tempus.build каждый job раннера исполняется в собственной аппаратной micro-VM на Kata Containers. Недоверенный CI-код — и его Docker-in-Docker — не достаёт до ядра хоста и до VM другого job-а: граница между job-ами аппаратная, а не «доверьтесь контейнеру». VM создаётся под конкретный job и уничтожается по его завершении вместе со всем, что в неё попало.

VM у нас имеет фиксированную форму под конкретный SKU раннера: число vCPU и объём памяти задаются дефолтом гипервизора, а не hotplug’ом, — job получает ровно ту машину, за которую платит, без динамического роста памяти. Бэкенд — QEMU/KVM (совместим с привилегированным Docker-in-Docker внутри VM); Cloud Hypervisor рассматриваем как кандидата на ускорение старта в дальнейшем. Гостевое ядро и гипервизор держим на актуальном security-floor и обновляем при выходе исправлений класса guest→host: вторая граница ценна ровно пока она пропатчена.

Kata — только один из слоёв нашей модели изоляции; как устроены остальные и как самостоятельно проверить образ раннера — на странице «Безопасность и изоляция».

← в блог