Перейти до вмісту

Віртуалізація і контейнери

Контейнер запускається за мілісекунди, віртуальна машина за секунди. Контейнер із гігабайтного образу займає мегабайти. docker run alpine на машині з Ubuntu дає робочу Alpine, хоча ядро лишається тим самим.

Магії тут немає, як немає й окремої технології. Контейнери є комбінацією кількох механізмів ядра, кожен з яких існував задовго до Docker. Розібравши їх, ви зможете зібрати контейнер руками, і саме після цього перестанете ставитися до нього як до чорної скриньки.

Передумови. Ядро й системні виклики (модуль 3), процеси (модуль 6), overlayfs (модуль 15), capabilities і seccomp (модуль 16).

Порівняння стеку віртуальних машин і контейнерів: власне ядро в кожній ВМ проти спільного ядра господаря віртуальні машиниконтейнериВМ 1застосунокбібліотекиядро гостявласне, окремеВМ 2застосунокбібліотекиядро гостявласне, окремегіпервізорконтейнер 1застосунокбібліотекиконтейнер 2застосунокбібліотекиконтейнер 3застосунокбібліотекисередовище виконання контейнеріводне ядро господаря на всіхапаратураапаратураізоляція апаратна, старт — секундиізоляція засобами ядра, старт — мілісекунди
Ліворуч ядер стільки ж, скільки машин. Праворуч — одне на всіх. Звідси і вся різниця у швидкості, розмірі та в тому, наскільки міцна ізоляція.

Віртуалізація створює ілюзію цілого комп’ютера. Гостьова ОС має власне ядро й вважає, що керує апаратурою.

Контейнеризація створює ілюзію окремої системи в межах одного ядра. Процеси контейнера є звичайними процесами господаря, просто з обмеженим поглядом на світ.

Наслідки виходять протилежні за всіма параметрами. Віртуальна машина ізольована міцніше, але важча. Контейнер легкий, зате ділить ядро, а отже уразливість у ядрі стосується всіх контейнерів одразу.

Гіпервізор першого типу, bare metal, працює прямо на апаратурі й сам є мінімальною операційною системою. Такими є VMware ESXi, Xen і Hyper-V, і на них побудовані хмари.

Другий тип, hosted, працює програмою всередині звичайної ОС; це VirtualBox чи VMware Workstation. Поставити простіше, накладних витрат більше.

Межа між типами розмита. KVM, наприклад, перетворює ядро Linux на гіпервізор першого типу, лишаючись при цьому звичайним модулем звичайної системи.

Апаратна віртуалізація (Intel VT-x, AMD-V) додала режим, у якому гостьове ядро виконує привілейовані інструкції безпосередньо, а гіпервізор перехоплює тільки те, що справді потребує втручання. До неї гостьовий код доводилося перекладати на льоту, і коштувало це дорого.

EPT робить те саме для пам’яті: апаратура транслює гостьову фізичну адресу у справжню фізичну й знімає з гіпервізора найдорожчу частину роботи (модуль 11).

Паравіртуалізація йде іншим шляхом. Гостьова ОС знає, що вона віртуальна, і замість прикидатися звертається до гіпервізора напряму. Драйвери virtio влаштовані саме так, тому й швидші за емуляцію справжніх пристроїв.

З чого зроблено контейнер

Section titled “З чого зроблено контейнер”

Сутності «контейнер» у ядрі не існує. Є процес, до якого застосували чотири незалежні механізми.

  1. Простори імен (namespaces) обмежують те, що процес бачить. Окремі простори для PID, точок монтування, мережі, користувачів, імені вузла, міжпроцесного зв’язку й cgroups. У власному просторі PID процес бачить себе під номером 1 і не бачить процесів господаря.

  2. Контрольні групи (cgroups) обмежують те, що процес споживає: процесорний час, пам’ять, пропускну здатність вводу-виводу. Саме cgroups робить OOM killer передбачуваним для конкретної групи (модуль 12).

  3. overlayfs дає кореневу файлову систему зі спільних шарів (модуль 15). Тому контейнери з одного образу не займають місця повторно.

  4. Capabilities і seccomp прибирають зайві привілеї й системні виклики (модуль 16).

Docker, Podman і containerd не додають до цього нічого принципового. Вони пакують образи, керують мережею й життєвим циклом, а ізоляцію роблять ті самі чотири механізми, доступні з командного рядка кожному.

Образ складається з шарів файлової системи й маніфесту з метаданими, де кожен шар є різницею відносно попереднього. Адресуються шари за хешем вмісту, тому однакові зберігаються один раз на всіх.

OCI стандартизує формат образу й середовища виконання, завдяки чому образ, зібраний Docker, запускається під Podman чи containerd.

З архітектури шарів випливає цілком практичний наслідок: порядок команд у Dockerfile визначає швидкість збірки. Якщо копіювати код перед встановленням залежностей, кеш шару із залежностями ставатиме недійсним при кожній зміні коду.

Контейнери швидкі, зате ділять ядро. Віртуальні машини ізольовані, зате важкі. microVM намагається взяти обидві переваги: Firecracker запускає віртуальну машину з мінімальним набором емульованих пристроїв за десятки мілісекунд. Так працюють хмарні функції, де на одній машині виконується код різних клієнтів і спільне ядро неприйнятне.

WSL2 запускає повноцінне ядро Linux у полегшеній віртуальній машині поверх Hyper-V, з інтеграцією у файлову систему й мережу Windows. Перша версія WSL намагалася транслювати системні виклики Linux у виклики Windows і зіткнулася з тим, наскільки повно доводиться відтворювати чуже ABI.

Terminal window
lsns 2>/dev/null | head

Простори імен у системі: тип, кількість процесів і хто в ньому. На машині з контейнерами їх буде багато.

Terminal window
ls -l /proc/self/ns/

Простори імен поточного процесу. Числа в дужках — ідентифікатори; два процеси в одному просторі мають однакове число.

Terminal window
unshare --pid --fork --mount-proc sh -c 'echo "мій PID: $$"; ps -e'

Власний простір PID: оболонка бачить себе під номером 1 і не бачить жодного процесу господаря. Це той самий механізм, що дає контейнеру його PID 1.

Terminal window
unshare --net sh -c 'ip link show'

Власний простір мережі: лише інтерфейс lo, жодної мережі назовні. Docker з’єднує такий простір із господарем через пару віртуальних інтерфейсів.

Terminal window
cat /sys/fs/cgroup/cgroup.controllers
systemd-cgls --no-pager 2>/dev/null | head -15

Доступні контролери cgroups v2 і дерево груп. systemd кладе кожну службу у власну групу — це та сама технологія, що й у контейнерах.

Terminal window
systemd-run --user --scope -p MemoryMax=64M sh -c 'echo обмежено 64 МіБ; cat /sys/fs/cgroup/$(cat /proc/self/cgroup | cut -d: -f3)/memory.max 2>/dev/null'

Запуск процесу з обмеженням пам’яті без жодного контейнера.

Terminal window
lsmod | grep -E '^kvm'; ls -l /dev/kvm 2>/dev/null
grep -o -E 'vmx|svm' /proc/cpuinfo | sort -u

Підтримка апаратної віртуалізації: vmx для Intel, svm для AMD.

Terminal window
systemd-detect-virt

Чи система сама працює у віртуальному середовищі — і в якому саме.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Контейнер — це легка віртуальна машина». Це процес господаря з обмеженим поглядом на світ. Власного ядра він не має, тому й ізоляція в нього слабша, ніж у віртуальної машини.

«Docker — це технологія ізоляції». Ізоляцію дають namespaces, cgroups, overlayfs, seccomp і capabilities. Docker пакує їх у зручний інтерфейс.

«У контейнері інша операційна система». Інший там простір користувача. Ядро одне, і це ядро того дистрибутива, який стоїть на господарі.

«Root у контейнері безпечний». Без простору імен користувачів це той самий root господаря, тільки з обрізаними capabilities. Прогалина в ядрі перетворює його на повний root машини.

«Контейнери ізолюють від уразливостей ядра». Радше навпаки: ядро спільне, тому уразливість у ньому стосується всіх контейнерів одразу. Саме через це й існують microVM.

«Порядок команд у Dockerfile — справа смаку». Шари кешуються за порядком, тож копіювання коду перед встановленням залежностей робить кеш недійсним при кожній зміні коду.

Перевір себе

1. Чому образ Alpine запускається на господарі з Ubuntu?
2. Який механізм дає процесу власний PID 1 і приховує процеси господаря?
3. Який механізм обмежує контейнеру памʼять і процесорний час?
4. Що дала апаратна віртуалізація (VT-x, AMD-V)?
5. Чому для запуску коду різних клієнтів на одній машині обирають microVM, а не контейнери?
6. Чому контейнер із гігабайтного образу займає лише мегабайти?

B4 — контейнер руками. unshare для просторів імен, pivot_root для кореня, overlayfs для шарів, cgroups для обмежень, seccomp для фільтра викликів — без Docker. Мета — довести, що ізоляція складається з окремих механізмів, кожен з яких можна ввімкнути й вимкнути.

B3 — cgroups і OOM. Обмежити пам’ять і простежити спрацювання OOM killer у межах групи.