Виртуализация и контейнеры: KVM, Xen, QEMU, jails, zones, OCI-рантаймы
Есть один вопрос, который стоит задать себе прежде, чем разбираться в KVM, Docker или jails: что именно мы пытаемся размножить?
Операционная система уже умеет делать вид, что каждому процессу принадлежит вся машина. Виртуальная память врёт про адресное пространство, планировщик врёт про процессор, файловые дескрипторы врут про устройства. Процесс — это и есть виртуальная машина, только очень специфическая: её «архитектура» — не x86, а набор системных вызовов Linux.
Проблема в том, что этой абстракции иногда мало. Процессу видны чужие PID, общий /etc, единственная версия ядра, единый сетевой стек, uid 0 со всеми последствиями. И тогда возникают два принципиально разных ответа:
- Виртуализация размножает железо. Гость видит поддельный процессор, поддельную память, поддельный диск — и запускает на них своё полноценное ядро. Граница проходит по архитектуре CPU.
- Контейнеризация размножает представления ядра о мире. Гость видит свой корень ФС, своё дерево PID, свой сетевой стек — но исполняется тем же самым ядром. Граница проходит по коду ядра.
Всё остальное в этой статье — детали, следствия и компромиссы между этими двумя ответами. Мы разберём, как виртуализация вообще стала возможна на x86, напишем гипервизор на C в тридцать строк, соберём контейнер вручную из системных вызовов, посмотрим на jails и zones — и в конце поймём, почему индустрия в итоге пришла к гибридам вроде Firecracker.
Про сам механизм изоляции — namespaces, cgroups, capabilities, seccomp — подробно говорилось в статье Безопасность и изоляция; здесь мы смотрим, как из этих кирпичей строятся продукты.
Спектр изоляции: единственная схема, которую надо запомнить
Смотреть на эту картинку нужно так: всё, что находится выше границы, придётся оплатить памятью и временем старта; всё, что ниже — это то, что атакующему нужно сломать, чтобы выйти наружу.
У классической VM граница проходит по аппаратуре — атакующему нужно найти дыру в эмуляции устройства (VENOM, CVE-2015-3456, — переполнение в контроллере флоппи-дисковода QEMU, который был включён даже там, где флоппи не использовался) или в самом гипервизоре. У контейнера граница — это код ядра, обслуживающий ~350 системных вызовов, и любая уязвимость в нём (Dirty Pipe, CVE-2022-0847) означает выход.
Цена за это симметричная: VM тащит с собой полноценное ядро и rootfs, контейнер — только файлы приложения.
Обратите внимание на пустоту в левом верхнем углу. Она принципиальна: сильная граница требует дублирования состояния, а дублирование стоит ресурсов. Firecracker и Kata — попытки подобраться к этому углу как можно ближе.
Теория: почему x86 нельзя было виртуализировать до 2005 года
В 1974 году Джералд Попек и Роберт Голдберг опубликовали работу «Formal Requirements for Virtualizable Third Generation Architectures» (CACM 17(7)), где сформулировали три требования к VMM:
- Эквивалентность. Программа под гипервизором ведёт себя так же, как на голом железе (с точностью до тайминга и доступных ресурсов).
- Контроль ресурсов. VMM полностью управляет доступом к ресурсам; гость не может забрать то, что ему не выдали.
- Эффективность. Подавляющее большинство инструкций исполняется процессором напрямую, без вмешательства.
И главную теорему: VMM можно построить, если множество чувствительных инструкций является подмножеством привилегированных. «Чувствительная» — та, чьё поведение зависит от режима или конфигурации системы, или та, что меняет эту конфигурацию. Если каждая такая инструкция, выполненная в непривилегированном режиме, вызывает ловушку, то VMM просто ловит их все и эмулирует — техника trap-and-emulate.
x86 этому условию не удовлетворял. В работе Робина и Ирвина (USENIX Security 2000) перечислены 17 инструкций, чувствительных, но не привилегированных. Классические примеры:
POPF— в кольце 3 попытка изменить флагIF(разрешение прерываний) молча игнорируется, без исключения. Гостевое ядро, думающее, что запретило прерывания, ничего не заметит.SGDT,SIDT,SLDT,SMSW— читают системные регистры и разрешены в кольце 3. Гость увидит настоящую таблицу дескрипторов хоста и поймёт, что его обманывают.PUSHF— положит в стек настоящийEFLAGSс настоящимCPL.
Отсюда родились три исторических обходных пути:
Бинарная трансляция (VMware, 1999). Гостевой код сканируется и переписывается на лету: опасные инструкции заменяются на вызовы гипервизора, всё остальное исполняется напрямую. Кэш транслированных блоков делает амортизированную стоимость приемлемой. Дорого в реализации, но работает на любом x86. Показательно, что в Adams & Agesen, ASPLOS 2006 хорошо оптимизированная бинарная трансляция оказалась быстрее первого поколения аппаратной виртуализации — VM exit тогда стоил тысячи тактов.
Паравиртуализация (Xen, 2003). Радикально проще: не притворяться железом вообще. Гостевое ядро модифицируется так, чтобы вместо привилегированных инструкций делать гипервызовы (hypercall) — тот же принцип, что системный вызов, только на уровень ниже. В «Xen and the Art of Virtualization» (SOSP 2003) показано, что накладные расходы падают до единиц процентов. Минус очевиден: нужен патченый гость, и Windows так не запустить.
Аппаратная поддержка (Intel VT-x, 2005; AMD-V, 2006). Процессор получил новый ортогональный режим: VMX root (гипервизор) и VMX non-root (гость). Гость исполняется в своём кольце 0 — с точки зрения кода всё «как на железе», — но заранее заданные события выбрасывают его в гипервизор. Проблема чувствительных инструкций исчезла как класс.
VM exit: главный такт аппаратной виртуализации
Всё состояние виртуального процессора живёт в структуре в памяти: VMCS у Intel (Virtual Machine Control Structure) или VMCB у AMD. Гипервизор заполняет её и выполняет VMLAUNCH; процессор загружает регистры гостя и переключается в non-root. Обратный переход — VM exit — происходит по причине, записанной в поле exit_reason.
загрузка VMCS GuestMode --> GuestMode: обычные инструкции
исполняются напрямую, 0 накладных GuestMode --> HostKernel: VM exit
(EPT violation, CPUID, HLT, MSR, внешнее прерывание) HostKernel --> GuestMode: обработано внутри ядра
(теневой APIC, EPT-фолт, MSR) HostKernel --> HostUser: KVM_EXIT_IO / KVM_EXIT_MMIO
нужна модель устройства HostUser --> HostKernel: устройство сэмулировано,
снова KVM_RUN HostKernel --> [*]: KVM_EXIT_SHUTDOWN
Ключевая мысль для инженера: производительность виртуализации — это в первую очередь количество VM exit’ов на единицу работы. Один round-trip exit → обработка в ядре → resume стоит порядка 1000–2000 тактов на современных Intel/AMD (можно померить самому через kvm-unit-tests, тест vmexit). Если exit пробивается до user space (QEMU), счёт идёт на десятки микросекунд.
Отсюда вся история оптимизаций последних 15 лет — про устранение выходов:
| Проблема | Наивное решение | Аппаратное решение |
|---|---|---|
| Гость меняет свои таблицы страниц | shadow page tables, exit на каждый write в PTE | EPT / NPT — вторая ступень трансляции в MMU: GVA → GPA → HPA |
| Смена адресного пространства сбрасывает TLB | полный flush на каждый exit | VPID / ASID — теги в TLB, записи гостя и хоста сосуществуют |
| Гость пишет в свой APIC при каждом прерывании | exit на каждый доступ к APIC | APICv / AVIC, posted interrupts — прерывание доставляется в гостя без выхода |
| Устройство эмулируется программно | exit на каждый MMIO-регистр | virtio (batching), VFIO (прямой проброс), SR-IOV |
Проверить, что доступно на вашем железе:
$ grep -o -E '(vmx|svm|ept|npt|vpid|tsc_deadline_timer)' /proc/cpuinfo | sort -u
ept
tsc_deadline_timer
vmx
vpid
$ ls -l /dev/kvm
crw-rw---- 1 root kvm 10, 232 /dev/kvm
$ systemd-detect-virt # а мы сами не внутри VM?
kvm
$ systemd-detect-virt --container
none
Изнутри гостя факт виртуализации виден через CPUID: бит 31 в ECX листа 1 плюс сигнатура вендора в листе 0x40000000 (KVMKVMKVM, Microsoft Hv, XenVMMXenVMM, VMwareVMware). Никакой «невидимой» виртуализации в общем случае не бывает, и антифрод-системы этим активно пользуются.
KVM своими руками: гипервизор в тридцать строк
Самое полезное упражнение для понимания — увидеть, что KVM не «программа», а набор ioctl’ов над файловым дескриптором. Ядро даёт три уровня дескрипторов: системный (/dev/kvm), машинный (VM) и процессорный (vCPU). Всё остальное — QEMU, libvirt, Firecracker — это обвязка вокруг цикла ioctl(KVM_RUN).
Ниже — работающий гипервизор, который запускает 16-битный код в «реальном режиме» и печатает символ через порт ввода-вывода. Идея примера восходит к статье Джоша Триплетта в LWN; канонический справочник — Documentation/virt/kvm/api.rst.
#include <fcntl.h>
#include <linux/kvm.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>
int main(void) {
/* Гостевой код: mov al,'K'; out 0x3f8,al; hlt — и так в цикле по byte */
const unsigned char code[] = {
0xb0, 'K', /* mov al, 'K' */
0xe6, 0xf8, /* out 0xf8, al — порт 0x3f8 усечён до 8 бит */
0xf4 /* hlt */
};
int kvm = open("/dev/kvm", O_RDWR | O_CLOEXEC);
int vmfd = ioctl(kvm, KVM_CREATE_VM, 0UL);
/* Выделяем «физическую память гостя» — обычный анонимный маппинг хоста */
void *mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
memcpy(mem, code, sizeof(code));
/* Отдаём её гостю: GPA 0x1000 отображается на этот кусок хоста.
Именно эту таблицу ядро превращает в EPT. */
struct kvm_userspace_memory_region region = {
.slot = 0,
.guest_phys_addr = 0x1000,
.memory_size = 0x1000,
.userspace_addr = (unsigned long)mem,
};
ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, ®ion);
int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0UL);
/* Область обмена с ядром: сюда KVM кладёт причину выхода */
int run_size = ioctl(kvm, KVM_GET_VCPU_MMAP_SIZE, NULL);
struct kvm_run *run = mmap(NULL, run_size, PROT_READ | PROT_WRITE,
MAP_SHARED, vcpufd, 0);
/* Начальное состояние регистров: CS:IP = 0x1000:0 в реальном режиме */
struct kvm_sregs sregs;
ioctl(vcpufd, KVM_GET_SREGS, &sregs);
sregs.cs.base = 0x1000; sregs.cs.selector = 0;
ioctl(vcpufd, KVM_SET_SREGS, &sregs);
struct kvm_regs regs = { .rip = 0, .rflags = 0x2 };
ioctl(vcpufd, KVM_SET_REGS, ®s);
for (;;) {
ioctl(vcpufd, KVM_RUN, NULL); /* ← здесь процессор уходит в гостя */
switch (run->exit_reason) {
case KVM_EXIT_IO:
/* Гость выполнил OUT. Мы — «устройство». Данные лежат
по смещению run->io.data_offset от начала структуры. */
putchar(*((char *)run + run->io.data_offset));
fflush(stdout);
break;
case KVM_EXIT_HLT:
printf("\nгость остановился\n");
return 0;
default:
fprintf(stderr, "неожиданный exit_reason=%d\n", run->exit_reason);
return 1;
}
}
}
$ gcc -O2 -o tinyvm tinyvm.c && ./tinyvm
K
гость остановился
Что из этого следует практически:
- Память гостя — это обычная память процесса хоста. Отсюда работают
mmap,madvise(MADV_HUGEPAGE), KSM (дедупликация страниц между гостями), балунинг, аovercommitхоста автоматически распространяется на гостей. - Эмуляция устройств живёт в user space — и потому падение QEMU убивает только одну VM, а seccomp-фильтр вокруг QEMU реально ограничивает последствия его взлома.
- Всё, что часто, ядро обрабатывает само: in-kernel irqchip, PIT, HPET. Наружу пробиваются только редкие или сложные вещи.
QEMU, virtio и проброс железа
QEMU — это два разных продукта под одним именем. В режиме TCG (Tiny Code Generator) он честный эмулятор с динамической трансляцией, умеющий ARM на x86 и наоборот, — медленный, но универсальный. В режиме -accel kvm он превращается в модель устройств для KVM: исполнение инструкций забирает процессор, а QEMU обслуживает лишь выходы.
$ qemu-system-x86_64 \
-accel kvm -cpu host -smp 4 -m 4G \
-drive if=virtio,file=disk.qcow2,cache=none,aio=io_uring \
-netdev tap,id=n0,vhost=on -device virtio-net-pci,netdev=n0 \
-nographic -kernel vmlinuz -append "console=ttyS0 root=/dev/vda1"
Каждый флаг здесь — компромисс:
-cpu hostпробрасывает все флаги CPU (быстро, но ломает live-миграцию на другую модель процессора; в кластере используют именованные модели вроде-cpu Skylake-Server-v4).cache=noneозначаетO_DIRECT: минуем page cache хоста, чтобы не платить память дважды и не терять данные при сбое хоста.aio=io_uring— асинхронный ввод-вывод через io_uring вместо потоков (см. Ввод-вывод и драйверы).
virtio — самая важная деталь. Эмулировать реальную сетевую карту e1000 значит выходить в user space на каждую запись в регистр: сотни тысяч exit’ов в секунду. virtio — это открытый стандарт OASIS паравиртуализованного устройства: гость и хост делят кольцевые буферы (virtqueue) в памяти гостя, гость складывает туда дескрипторы и «звонит» один раз на пачку. Классическая паравиртуализация, только не для CPU, а для I/O.
Дальше стек оптимизаций идёт вглубь:
| Механизм | Где обрабатывается пакет | Когда применять |
|---|---|---|
virtio-net в QEMU |
user space хоста | по умолчанию, простота |
vhost-net |
ядро хоста, QEMU не участвует в датапасе | обычный прод, ×2–3 к пропускной способности |
vhost-user |
другой процесс (OVS-DPDK, VPP) | NFV, миллионы pps |
vDPA |
железо говорит на virtio напрямую | новые SmartNIC |
VFIO / PCI passthrough |
устройство отдано гостю целиком | GPU, NVMe, low-latency |
| SR-IOV | одна карта → N виртуальных функций | облако, много гостей на одну NIC |
Проброс через VFIO требует IOMMU (intel_iommu=on или amd_iommu=on) и уважает IOMMU-группы — минимальную гранулярность изоляции DMA:
$ for g in /sys/kernel/iommu_groups/*/devices/*; do
echo "группа ${g%%/devices*}: $(basename $g)"
done | head -3
группа /sys/kernel/iommu_groups/1: 0000:00:01.0
группа /sys/kernel/iommu_groups/14: 0000:01:00.0
группа /sys/kernel/iommu_groups/14: 0000:01:00.1
# отдать GPU (видео + аудио функции — они в одной группе, только вместе)
$ echo vfio-pci > /sys/bus/pci/devices/0000:01:00.0/driver_override
$ echo 0000:01:00.0 > /sys/bus/pci/drivers_probe
Без IOMMU проброс невозможен в принципе: устройство с правами DMA прочитало бы всю физическую память хоста. Это, кстати, ровно тот механизм, который делает Thunderbolt/DMA-атаки нетривиальными на современных ноутбуках.
Xen и семейство гипервизоров других ОС
Xen устроен иначе, чем KVM, и это различие концептуальное. KVM — модуль внутри Linux: ядро Linux и есть гипервизор, а VM — просто процесс. Xen — отдельный микрогипервизор, который грузится первым и запускает Linux как dom0 — привилегированную гостевую ОС с драйверами и инструментами управления. Остальные гости — domU.
драйверы, планировщик, ФС"] LK --> Q1["QEMU (процесс)"] --> G1["Гость 1"] LK --> Q2["QEMU (процесс)"] --> G2["Гость 2"] LK --> P1["Обычные процессы хоста
nginx, sshd"] end subgraph XEN["Модель Xen: гипервизор под всеми"] direction TB HW2["Оборудование"] --> XH["Xen: ~50 тыс. строк
только CPU, память, планировщик"] XH --> D0["dom0: Linux/NetBSD
все драйверы, xl/xenstore,
бэкенды blkback/netback"] XH --> U1["domU PVH
фронтенды blkfront/netfront"] XH --> U2["domU HVM
+ QEMU в stubdomain"] D0 -.кольцевые буферы
через grant tables.-> U1 D0 -.-> U2 end
Практический смысл: у Xen меньше доверенная база (гипервизор не содержит драйверов устройств и файловых систем), но сложнее эксплуатация и живее вопрос совместимости. Режимы гостей эволюционировали:
- PV — классическая паравиртуализация без VT-x, гость исполняется в кольце 3 (x86-64), гипервызовы вместо привилегированных инструкций. После Meltdown/Spectre PV-гости оказались источником целого класса уязвимостей, и проект объявил PV нежелательным режимом.
- HVM — полная аппаратная виртуализация с эмуляцией устройств QEMU (в идеале — в изолированном stubdomain).
- PVH — современный компромисс и режим по умолчанию: аппаратная виртуализация CPU и памяти, но никакой эмуляции legacy-железа, только virtio/PV-устройства. По сути та же идея, что и microVM.
В других семействах ОС:
- FreeBSD —
bhyve(с 10.0, 2014): гипервизор в ядре (vmm.ko) плюс user-spacebhyve(8). Изначально требовал EPT/NPT — никакой бинарной трансляции, только чистая аппаратная схема. Умеет virtio, VNC, passthrough черезppt. Именно bhyve стал основой виртуализации в SmartOS и TrueNAS. - OpenBSD —
vmm(4)+vmd(8)(с 5.9/6.0). Демонстративно минималистичен: только OpenBSD и Linux в гостях, никакого проброса устройств, никакой поддержки вложенности. Философия «меньше кода — меньше дыр» в чистом виде. - NetBSD — NVMM (Maxime Villard, 2018): интересен архитектурно тем, что вся эмуляция инструкций вынесена в библиотеку
libnvmmв user space, а ядро отвечает только за переключение режимов. QEMU умеет-accel nvmm; тот же NVMM портирован в DragonFly BSD. - macOS/XNU —
Hypervisor.framework(низкоуровневый API поверх VT-x/Apple Silicon) иVirtualization.framework(высокоуровневый, с virtio и запуском Linux в пару вызовов). На Apple Silicon поверх этого работает Rosetta для трансляции x86-64 Linux-бинарников внутри ARM-гостя. - Windows NT — Hyper-V архитектурно ближе к Xen: гипервизор грузится раньше NT-ядра, а «хостовая» Windows становится root partition. Это же делает возможным VBS/HVCI — защиту, при которой критичные структуры ядра лежат в отдельном VTL-мире, недоступном даже скомпрометированному ядру. WSL2 — не эмулятор, а полноценная утилитарная VM с настоящим ядром Linux; отсюда его свойства: честные системные вызовы, но медленный доступ к
/mnt/cчерез 9P/virtiofs. Сторонние VMM (QEMU, VirtualBox) работают через APIWHPX, когда Hyper-V включён.
Вложенная виртуализация (L0 → L1 → L2) поддерживается почти везде, но с оговоркой: L1-гипервизор не имеет доступа к VMCS железа, и L0 вынужден строить «теневую» VMCS и теневые EPT. Работает, но заметно медленнее.
$ cat /sys/module/kvm_intel/parameters/nested
Y
Контейнеры: их не существует
Самое важное утверждение раздела: в ядре Linux нет объекта «контейнер». Нет структуры struct container, нет системного вызова create_container(). Контейнер — это соглашение о том, как одновременно применить к процессу семь независимых механизмов:
- namespaces — что процесс видит (mnt, pid, net, uts, ipc, user, cgroup, time);
- cgroups v2 — сколько ресурсов он может взять;
- capabilities — что ему разрешено делать от имени root;
- seccomp-bpf — какие системные вызовы вообще доступны;
- LSM (AppArmor/SELinux) — мандатные правила поверх всего;
- pivot_root + overlayfs — свой корень файловой системы;
no_new_privs— запрет повышения привилегий через setuid.
Убрать любой пункт — и «контейнер» останется, но перестанет быть изоляцией. Именно поэтому фраза «контейнеры не являются границей безопасности» технически неточна: границей не является любая частичная конфигурация, а полная — вполне рабочая граница, просто более хрупкая, чем гипервизор.
Посмотрим на всё это глазами ядра:
$ lsns -t pid,net,mnt
NS TYPE NPROCS PID USER COMMAND
4026531835 mnt 241 1 root /sbin/init
4026531840 pid 241 1 root /sbin/init
4026531992 net 241 1 root /sbin/init
4026532310 mnt 1 8412 165536 nginx: master process
4026532311 pid 1 8412 165536 nginx: master process
4026532313 net 1 8412 165536 nginx: master process
$ ls -l /proc/8412/ns/
lrwxrwxrwx 1 ... net -> 'net:[4026532313]'
lrwxrwxrwx 1 ... pid -> 'pid:[4026532311]'
lrwxrwxrwx 1 ... user -> 'user:[4026531837]' # ← тот же, что у хоста!
$ cat /proc/8412/uid_map
0 165536 65536 # root в контейнере = uid 165536 на хосте
Последние две строки — самое ценное диагностическое действие при разборе инцидентов: если user-namespace совпадает с хостовым, то uid 0 внутри контейнера — это настоящий uid 0, и вся защита держится только на capabilities и seccomp.
Контейнер вручную: bash и C
Проще всего убедиться в «конструкторной» природе контейнеров, собрав его руками:
# минимальный «контейнер» без единой строки Go
$ unshare --user --map-root-user --pid --mount --net --uts --ipc --fork --mount-proc \
/bin/bash
# hostname isolated-box
# ps aux
USER PID %CPU %MEM COMMAND
root 1 0.0 0.0 /bin/bash # мы PID 1
root 9 0.0 0.0 ps aux
# id
uid=0(root) gid=0(root) groups=0(root)
# cat /proc/self/uid_map
0 1000 1 # но «снаружи» мы всё тот же uid 1000
# ip link
1: lo: <LOOPBACK> mtu 65536 state DOWN # сеть пустая: net-namespace свой
То же самое на C — видно, что вся «магия» умещается в один clone(2):
#define _GNU_SOURCE
#include <sched.h>
#include <sys/mount.h>
#include <sys/wait.h>
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
static char stack[1024 * 1024];
static int child(void *arg) {
(void)arg;
sethostname("box", 3);
/* Обязательно: делаем распространение маунтов приватным, иначе
наши mount()'ы уедут обратно на хост через shared subtrees. */
mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL);
/* Своя /proc — без неё ps покажет процессы хоста,
потому что procfs привязан к pid-namespace на момент монтирования. */
mount("proc", "/proc", "proc", 0, NULL);
/* В реальном рантайме здесь: pivot_root в rootfs образа,
затем capset(), seccomp(), setgroups/setresuid и только потом execve. */
execlp("/bin/sh", "sh", NULL);
return 1;
}
int main(void) {
int flags = CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWNET |
CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD;
pid_t pid = clone(child, stack + sizeof(stack), flags, NULL);
if (pid < 0) { perror("clone"); return 1; }
printf("на хосте это PID %d, внутри — PID 1\n", pid);
waitpid(pid, NULL, 0);
return 0;
}
$ sudo ./mini-container
на хосте это PID 34117, внутри — PID 1
Обратите внимание на MS_PRIVATE: это одна из самых частых ошибок в самописных рантаймах. Без неё монтирования утекают на хост из-за shared subtrees — механизма, подробно описанного в Documentation/filesystems/sharedsubtree.rst.
Второй нюанс — pivot_root, а не chroot. chroot меняет только корень разрешения путей и обходится тривиально (fchdir по дескриптору каталога, открытому до чейнджа, плюс chdir("..")). pivot_root(2) меняет корень всего mount-namespace, и старый корень затем отмонтируется — вернуться некуда.
cgroups v2: ограничение ресурсов
Namespaces отвечают на вопрос «что видно», cgroups — «сколько можно». В v2 иерархия единая (в v1 их было по одной на контроллер, что и породило дыру CVE-2022-0492 через release_agent):
$ mount | grep cgroup2
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)
$ mkdir /sys/fs/cgroup/demo
$ echo "+cpu +memory +io +pids" > /sys/fs/cgroup/cgroup.subtree_control
$ echo "200000 100000" > /sys/fs/cgroup/demo/cpu.max # 2 CPU: 200 мс квоты на 100 мс периода
$ echo "512M" > /sys/fs/cgroup/demo/memory.max
$ echo "400M" > /sys/fs/cgroup/demo/memory.high # мягкий порог: тротлинг вместо OOM
$ echo "100" > /sys/fs/cgroup/demo/pids.max # защита от fork-бомбы
$ echo $$ > /sys/fs/cgroup/demo/cgroup.procs
$ cat /sys/fs/cgroup/demo/memory.pressure
some avg10=12.43 avg60=3.21 avg300=0.71 total=1843291
full avg10=4.10 avg60=1.02 avg300=0.20 total=612004
Практические выводы, за которые обычно платят инцидентами:
cpu.max— это не «два ядра», а квота на период. Многопоточное приложение с 32 потоками на 32-ядерной машине израсходует квоту за 3 мс и будет стоять 97 мс. Классический симптом — «CPU utilization 20%, а p99 полсекунды». Смотритеnr_throttledиthrottled_usecвcpu.stat.- JVM, Go и glibc читают cgroup-лимиты по-разному. Современная JVM (
UseContainerSupport) видитmemory.max; Go до 1.25 не подстраивалGOMAXPROCSподcpu.maxавтоматически — отсюда классические тормоза Go-сервисов в Kubernetes с низким CPU limit. - PSI (
*.pressure) — лучший сигнал для автоскейлинга, чем загрузка CPU: он измеряет время, потерянное на ожидание ресурса. См. работу Йохансена о PSI. - OOM внутри cgroup убивает процесс внутри cgroup, а не самый жирный на хосте; в логах это
memory-cgroup out of memory, иdmesgпокажет, какая именно группа.
OCI: что происходит при docker run
Docker давно перестал быть монолитом. Индустрия договорилась о трёх спецификациях Open Container Initiative: image-spec (как выглядит образ), runtime-spec (как выглядит бандл — config.json + rootfs), distribution-spec (как образ качается из реестра). Благодаря им runc заменяется на crun или gVisor одной строкой конфига.
распаковать в snapshotter C->>C: собрать OCI-бандл: config.json + rootfs C->>S: запустить shim (переживёт рестарт containerd) S->>R: runc create
Ключевые следствия этой схемы:
- runc не остаётся резидентным. Он делает
execveи исчезает. «Демон», переживающий контейнер, — это shim; он держит stdio, ждётSIGCHLDи позволяет перезапустить containerd, не убивая контейнеры. - Приложение — PID 1 со всеми обязанностями: жать зомби и разбирать сигналы. Ядро не шлёт
SIGTERMпо умолчанию процессу с PID 1 в его namespace — вернее, шлёт, но необработанный сигнал с дефолтным обработчиком игнорируется для PID 1. Отсюдаdocker stop, ждущий 10 секунд и убивающийSIGKILL. Лечится флагом--init(tini) или обработчиком в приложении. - Реализаций runtime-spec несколько:
runc(Go, эталон),crun(C, старт заметно быстрее и памяти меньше — важно для serverless и плотных нод),youki(Rust),runsc(gVisor),kata-runtime(VM под видом контейнера).
Всё это можно потрогать без Docker:
$ mkdir -p bundle/rootfs && cd bundle
$ podman export $(podman create alpine) | tar -C rootfs -xf -
$ runc spec # сгенерировать эталонный config.json
$ jq '.process.args' config.json
["sh"]
$ jq '.linux.namespaces[].type' config.json
"pid" "network" "ipc" "uts" "mount"
$ sudo runc run demo
/ # ps
PID USER COMMAND
1 root sh
Образы и overlayfs
Образ — это упорядоченный список tar-архивов (слоёв), адресуемых по SHA-256, плюс JSON-конфиг. Union-файловая система склеивает их в один корень:
$ mount -t overlay overlay \
-o lowerdir=/layers/base:/layers/pkgs,upperdir=/rw,workdir=/work \
/merged
Порядок в lowerdir — справа налево по старшинству: самый левый слой выигрывает. Удаление файла из нижнего слоя реализуется whiteout’ом — символьным устройством 0/0 в верхнем слое; скрытие целого каталога — xattr trusted.overlay.opaque="y".
Отсюда практические правила, которые обычно узнают дорогой ценой:
- Удаление в следующем слое не уменьшает образ.
RUN apt-get install …и отдельныйRUN rm -rf /var/lib/apt/lists/*дадут образ, где кэш apt лежит физически, просто невидим. Нужно объединять в одну инструкцию или использовать multi-stage build. - Первая запись в большой файл — это полное копирование (copy-up). SQLite-база на 4 ГБ внутри слоя образа при первой записи копируется целиком. Базы, логи и любые изменяемые данные должны жить на томе.
- Page cache общий для одинаковых нижних слоёв. Сто контейнеров из одного образа делят одни и те же страницы
libc.so— по этой причине плотность контейнеров на порядок выше плотности VM. - Максимум 500 нижних слоёв (у overlay2 в Docker — 128); превышение даёт загадочную ошибку
invalid argumentпри монтировании.
Rootless: контейнеры без root
Самая недооценённая часть современного стека. user-namespace позволяет непривилегированному пользователю получить внутри uid 0 — не настоящий, а отображённый. podman и nerdctl пользуются этим по умолчанию:
$ cat /etc/subuid
alice:100000:65536 # alice владеет диапазоном 100000..165535
$ podman run --rm -it alpine id
uid=0(root) gid=0(root)
# а снаружи, на хосте:
$ ps -o user,pid,cmd -C sleep
USER PID CMD
100000 14210 sleep 300
Ограничения честные: нельзя биндиться на порты <1024 без net.ipv4.ip_unprivileged_port_start, сеть идёт через user-space стек (slirp4netns, современный pasta) с меньшей пропускной способностью, overlayfs — либо через fuse-overlayfs, либо через непривилегированный overlay (ядро 5.11+). Но поверхность атаки радикально меньше: даже полный побег из контейнера даёт права обычного пользователя.
Jails, zones и другие семейства
Контейнеры Linux собраны из независимых механизмов и потому конфигурируемы (и потому же легко конфигурируются неправильно). В BSD и Solaris пошли по противоположному пути: изоляция — это один цельный объект ядра.
FreeBSD jails появились в 1999 году, за десятилетие до Docker, из статьи Пола-Хеннинга Кампа «Jails: Confining the omnipotent root» (SANE 2000). Идея: jail(2) создаёт метку, а дальше все проверки в ядре учитывают её — процесс в jail не видит чужие процессы, не может смонтировать ФС, не может изменить сетевую конфигурацию, не может выйти из своего поддерева ФС. Никакой сборки из кубиков — либо вы в jail, либо нет.
# /etc/jail.conf
web {
path = "/jails/web";
host.hostname = "web.local";
vnet; # свой полноценный сетевой стек, а не одна привязка IP
vnet.interface = "epair0b";
allow.raw_sockets = 0;
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
persist;
}
$ service jail start web
$ jls
JID IP Address Hostname Path
1 - web.local /jails/web
$ jexec web sh # аналог docker exec
$ sysctl security.jail.param.allow.mount
security.jail.param.allow.mount: 0
Отличия от Linux, важные на практике: VNET даёт jail настоящий независимый сетевой стек (аналог net-namespace, но появился раньше); jails иерархичны с версии 8.0 — jail может создавать вложенные jails; ZFS-датасеты делегируются в jail целиком (zfs set jailed=on), давая снапшоты и клоны там, где Linux использует overlayfs. Экосистема: iocage, bastille, cbsd. О контексте BSD-семейства — в статье Семейство BSD.
Solaris/illumos zones (2004) — ещё более цельная модель. Есть global zone (собственно система) и non-global zones. Особенности, до которых Linux добирался годами:
- Brands — трансляция ABI:
lx-брендированная зона исполняет немодифицированные Linux-бинарники поверх illumos-ядра, транслируя системные вызовы. Это ровно та идея, что позже появится в WSL1 и в FreeBSD linuxulator. - Crossbow — виртуальные сетевые карты (
dladm create-vnic) с аппаратным разделением очередей: exclusive-IP зоны получили полноценную сеть ещё в 2008-м. - Fair Share Scheduler + resource pools — управление ресурсами не поверх, а внутри планировщика.
- ZFS-клоны как основа мгновенного клонирования зон.
# illumos / OmniOS
$ zonecfg -z app
zonecfg:app> create -b
zonecfg:app> set zonepath=/zones/app
zonecfg:app> add net; set physical=app0; set allowed-address=10.0.0.10/24; end
zonecfg:app> add capped-memory; set physical=2g; end
zonecfg:app> commit; exit
$ zoneadm -z app install && zoneadm -z app boot
$ zlogin app
Другие: в macOS изоляция строится не на «контейнерах», а на sandbox-профилях (sandbox_init, Seatbelt) плюс App Sandbox с entitlements; Docker Desktop там — это VM через Virtualization.framework. В Windows есть два режима: process isolation (Windows Server Containers на job objects, silos и Server Silo — аналог namespaces) и Hyper-V isolation (каждый контейнер в утилитарной VM). Ещё есть Windows Sandbox и AppContainer — модель для UWP-приложений.
Эволюция: как пришли к гибридам
Главный поворот здесь — 2018 год. Стало понятно, что для мультиарендных нагрузок контейнера мало, а VM слишком тяжела, — и появились три разных ответа:
Firecracker (AWS, Rust) — VMM на 50 тысяч строк вместо миллиона в QEMU. Никакого BIOS, PCI, ACPI, USB: только virtio-net, virtio-block, virtio-vsock, серийный порт и клавиатурный контроллер для reset. Ядро гостя грузится напрямую по boot-протоколу Linux. Результат из статьи NSDI 2020: менее 5 МБ оверхеда на microVM, старт до init за ~125 мс, тысячи microVM на хост. Плюс jailer — обёртка, сажающая сам VMM в chroot, namespaces, cgroup и seccomp. На этом работают AWS Lambda и Fargate.
Kata Containers — тот же приём, но прозрачный для Kubernetes: реализует CRI/OCI-интерфейс, а внутри поднимает microVM (QEMU, Cloud Hypervisor или Firecracker) с агентом. kubectl не видит разницы, RuntimeClass выбирает изоляцию per-pod.
gVisor (Google) — совсем другой ход: не добавить аппаратную границу, а переписать Linux в user space. Sentry на Go реализует несколько сотен системных вызовов, свой VFS, свой сетевой стек (netstack), свои futex и /proc. Обращения приложения перехватываются (сейчас — механизмом systrap, раньше ptrace или KVM) и обрабатываются в Sentry, а к хосту уходит лишь узкий allowlist. Цена — заметно более дорогой системный вызов: CPU-bound нагрузки почти не страдают, а syscall-интенсивные (веб-серверы с высоким RPS, компиляция) проседают ощутимо.
Тут же стоит упомянуть unikernels — противоположный предел: приложение и библиотечная ОС компилируются в один образ без разделения ядро/user space вообще (MirageOS, ASPLOS 2013, Unikraft, OSv). Изоляция чистая (только гипервизор), образ — мегабайты, старт — миллисекунды. Не взлетело массово по прозаичной причине: нет ssh, нет strace, нет gdb, отлаживать нечем.
Заблуждения, которые дорого стоят
«Контейнер — это лёгкая виртуальная машина». Нет. Одно ядро на всех. Отсюда: нельзя запустить FreeBSD-контейнер на Linux; sysctl внутри контейнера меняет настройки хоста (кроме namespace-aware, вроде части net.*); uname -r покажет ядро хоста; загрузка модулей ядра «внутри контейнера» — это загрузка в хост.
«Alpine-образ на 5 МБ означает 5 МБ памяти». Размер образа — это диск, а не RAM. При этом musl вместо glibc даёт другой аллокатор и другой резолвер DNS: известны проседания производительности многопоточных приложений и странности с поиском в search-доменах.
«docker run --memory=1g — это как ulimit». Это cgroup-лимит на сумму RSS + page cache + kernel memory группы. Приложение, активно читающее файлы, упирается в лимит из-за кэша; ядро сначала попытается вытеснить кэш, и только потом сработает OOM. Смотреть надо memory.current и memory.stat, а не RSS процесса.
«В контейнере нет root, значит безопасно». Проверьте /proc/PID/uid_map. Без user-namespace uid 0 внутри — настоящий root ядра, ограниченный лишь capabilities. Побеги через --privileged, монтирование /var/run/docker.sock, CAP_SYS_ADMIN или hostPID — не экзотика, а типовые находки любого аудита. Реальные CVE: CVE-2019-5736 (перезапись /proc/self/exe рантайма), CVE-2022-0492 (release_agent в cgroup v1), CVE-2024-21626 (утёкший дескриптор рабочего каталога в runc).
«Виртуализация даёт линейное замедление». Не даёт. CPU-bound код в VM работает на 97–99% нативной скорости — он просто исполняется процессором. Проседают вещи, порождающие выходы: интенсивный I/O без virtio, частые прерывания, TLB-нагрузка (EPT удваивает глубину page walk — до 24 обращений вместо 4 при промахе; отсюда важность huge pages в гостях). Классическое измерение — Felter et al., IEEE ISPASS 2015.
«Overcommit памяти в гипервизоре бесплатен». Баллон (virtio-balloon), KSM и free page reporting позволяют переподписку, но KSM тратит CPU на сканирование и открывает side-channel-атаки между гостями, а баллон требует кооперации гостя. В облаках память обычно не переподписывают именно поэтому.
Как выбирать на практике
или другая ОС?"} B -- да --> VM["Полная VM
QEMU/KVM, bhyve, Hyper-V"] B -- нет --> C{"Код чужой
или недоверенный?"} C -- "да, мультиарендность" --> D{"Важен старт
и плотность?"} D -- "да, serverless" --> FC["microVM: Firecracker,
Cloud Hypervisor"] D -- "нет, важна прозрачность" --> K["Kata Containers
или gVisor"] C -- "нет, свой код" --> E{"Нужны сырые сокеты,
модули, GPU?"} E -- да --> F["Контейнер + узкие capabilities
+ device plugin
или отдельная VM"] E -- нет --> G{"Есть право на root
на хосте?"} G -- нет --> H["Rootless podman
user namespaces + pasta"] G -- да --> I["Обычный OCI-контейнер:
runc/crun + seccomp + AppArmor
+ read-only rootfs"] VM --> Z["Проброс железа: VFIO/SR-IOV
Huge pages в гостях
CPU pinning для латентности"]
Небольшой набор дефолтов, который редко бывает неправильным:
# Kubernetes: минимальный набор, который стоит включать всегда
securityContext:
runAsNonRoot: true
runAsUser: 10001
readOnlyRootFilesystem: true # запись — только в явные тома
allowPrivilegeEscalation: false # = no_new_privs
capabilities:
drop: ["ALL"] # и добавлять по одной, если приспичит
seccompProfile:
type: RuntimeDefault # блокирует ~45 опасных вызовов
resources:
requests: { cpu: "500m", memory: "512Mi" }
limits: { memory: "512Mi" } # memory — да; cpu limit — часто вредит из-за throttling
Отдельно про диагностику. Когда «в контейнере не работает, а на хосте работает», порядок проверки почти всегда один:
$ strace -f -e trace=all -o /tmp/t.log ./app # чаще всего EPERM от seccomp
$ grep EPERM /tmp/t.log | head
$ grep Seccomp /proc/self/status # 2 = filter mode
$ capsh --print | grep Current # какие capabilities реально остались
$ cat /sys/fs/cgroup/cpu.stat # nr_throttled растёт? это ваш p99
$ dmesg | grep -iE 'apparmor|audit|oom' # LSM отказал или OOM-killer сработал
Инструменты наблюдаемости — perf, bpftrace, ftrace — работают в контейнерах с оговорками (нужны CAP_SYS_ADMIN/CAP_BPF и доступ к символам хоста); подробности в статье Наблюдаемость и производительность.
Мини-итог
- Виртуализация размножает железо, контейнеры — представления ядра. Всё остальное следует из этого выбора.
- x86 стал виртуализуемым только с VT-x/AMD-V; до этого приходилось выкручиваться бинарной трансляцией или паравиртуализацией.
- Производительность VM определяется числом VM exit’ов; EPT/NPT, virtio, vhost, VFIO и SR-IOV — это всё способы их убрать.
- KVM — это ioctl-цикл над
/dev/kvm; память гостя — обычныйmmapпроцесса хоста, и это открывает весь арсенал memory management хоста. - Контейнера как объекта ядра не существует: это namespaces + cgroups + capabilities + seccomp + LSM + pivot_root, собранные вместе рантаймом по спецификации OCI.
- В BSD и Solaris изоляция сделана цельным объектом (jail, zone) — менее гибко, но труднее сконфигурировать небезопасно.
- Гибриды (Firecracker, Kata, gVisor) существуют потому, что для чужого кода границы контейнера мало, а полноценная VM слишком дорога.
Что почитать
- Documentation/virt/kvm/api.rst — исчерпывающее описание KVM API; читается лучше большинства книг.
- Popek G., Goldberg R. Formal Requirements for Virtualizable Third Generation Architectures, CACM, 1974.
- Barham P. et al. Xen and the Art of Virtualization, SOSP 2003.
- Adams K., Agesen O. A Comparison of Software and Hardware Techniques for x86 Virtualization, ASPLOS 2006.
- Agache A. et al. Firecracker: Lightweight Virtualization for Serverless Applications, NSDI 2020.
- Kamp P.-H., Watson R. Jails: Confining the omnipotent root, SANE 2000.
- OCI runtime-spec и image-spec — короткие и обязательные к прочтению.
- Documentation/filesystems/overlayfs.rst и cgroup-v2.rst.
- man-страницы:
namespaces(7),user_namespaces(7),cgroups(7),clone(2),unshare(2),setns(2),pivot_root(2),capabilities(7),seccomp(2); во FreeBSD —jail(8),jail(2),bhyve(8),vnet(9). - gVisor: What is gVisor? — хорошее описание того, как выглядит Linux ABI, реализованный заново.
- Sorensen A., Tanenbaum A. Modern Operating Systems, гл. 7 «Virtualization and the Cloud» — учебное изложение.
Что дальше
Мы прошли путь снизу доверху: от загрузки железа до способов размножить целые операционные системы на одной машине. Логичный финал трека — попробовать написать ОС самому и увидеть, как все эти механизмы выглядят изнутри, когда ты сам их реализуешь.
Как пишут свою ОС: от bootloader до планировщика и что читать дальше