Ізоляція ядром без віртуалізації заліза
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР2. Матеріали публікуються в Google Classroom разом із лекцією.
Минулого разу ми з'ясували, що процес — це активна абстракція ядра: PID, адресний
простір, дескриптори, права. Сьогодні ми відповідаємо на питання, яке напрошується
само: якщо контейнер — це «просто процес», то як тоді всередині нього ps aux
показує лише один-два рядки, а не сотні процесів хоста? Чому контейнер не бачить
файлів хоста, хоча працює на тому самому ядрі? І чому один контейнер не може
захопити всю оперативну пам'ять машини?
Відповідь — три незалежні механізми ядра Linux, які Docker комбінує, але не винаходить: простори імен (namespaces) визначають, що процес бачить, групи керування (cgroups) визначають, скільки процес може споживати, а capabilities визначають, що процесу дозволено робити. Жоден з цих механізмів не є специфічним для Docker — це базові примітиви ядра, доступні будь-якій програмі через звичайні системні виклики. Розуміння цього — ключ до того, щоб контейнери перестали здаватись магією.
Простір імен — це механізм ядра, який дозволяє групі процесів мати власну, ізольовану «версію» певного глобального ресурсу ядра. Linux нині підтримує сім класичних видів просторів імен:
| Простір імен | Що ізолює | Приклад ефекту |
|---|---|---|
| PID | нумерацію процесів | усередині процес бачить себе як PID 1 |
| NET | мережевий стек | власні інтерфейси, IP-адреси, таблиці маршрутизації |
| MNT | дерево точок монтування | власний вигляд файлової системи |
| UTS | ім'я хоста й домену | hostname усередині ≠ hostname хоста |
| IPC | черги повідомлень, семафори, спільну пам'ять System V | процеси поза простором не бачать чужі IPC-об'єкти |
| USER | відображення UID/GID | root (UID 0) усередині може бути звичайним користувачем на хості |
| CGROUP | видимість власної ієрархії cgroup | процес бачить своє піддерево як корінь /sys/fs/cgroup |
Кожен новий процес за замовчуванням успадковує простори імен свого батька. Але ядро дозволяє явно попросити нові, «чисті» простори — саме це і відбувається, коли Docker запускає контейнер.
NET namespace — один з найпоказовіших прикладів того, наскільки глибоко заходить
ізоляція. Новостворений NET namespace стартує лише з loopback-інтерфейсом (lo),
без жодного доступу до зовнішнього світу — навіть до Wi-Fi чи Ethernet хоста. Щоб
з'єднати такий простір із рештою мережі, використовують віртуальну пару інтерфейсів
veth (virtual Ethernet): один кінець лишається у вихідному просторі імен
(зазвичай підключений до моста docker0 на хості), другий переноситься у новий
простір. Пакет, надісланий з одного кінця пари, миттєво з'являється на іншому — це
і є той «віртуальний кабель», яким Docker з'єднує контейнер з мережею хоста.
sudo ip netns add demo
sudo ip link add veth-host type veth peer name veth-ns
sudo ip link set veth-ns netns demo
sudo ip netns exec demo ip addr add 10.0.0.2/24 dev veth-ns
Саме тому кожен контейнер отримує власну IP-адресу, власну таблицю маршрутизації і власний набір правил файрвола (iptables/nftables), хоча фізично працює через той самий мережевий інтерфейс хоста.
Три системні виклики покривають увесь життєвий цикл роботи з просторами імен:
clone(flags) — створює новий процес одразу в нових просторах імен. Прапорці
на кшталт CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWNS (mount), CLONE_NEWUTS,
CLONE_NEWIPC, CLONE_NEWUSER, CLONE_NEWCGROUP вмикають потрібні простори саме
для процесу, що народжується.unshare(flags) — «відв'язує» вже наявний процес від поточного простору імен
і переводить його в новий. Саме на цьому побудована утиліта командного рядка
unshare(1).setns(fd, nstype) — приєднує поточний процес до вже існуючого простору імен
іншого процесу (через файловий дескриптор /proc/<pid>/ns/*). Саме так docker exec «заходить» усередину вже запущеного контейнера — не створює новий простір, а
приєднується до наявного.sudo unshare --pid --fork --mount-proc bash
# усередині:
ps aux # видно лише bash і ps
echo $$ # 1
Прапорець --mount-proc тут критичний: без нього /proc усередині нового PID-
простору лишається старим /proc хоста, і ps показуватиме процеси хоста, бо ps
читає саме /proc, а не сам PID-простір напряму.
Namespaces вирішують питання видимості, але нічого не обмежують кількісно: без додаткового механізму один процес усередині «ізольованого» контейнера міг би виділити всю оперативну пам'ять хоста або завантажити всі ядра CPU на 100%. Цю прогалину закривають control groups (cgroups).
У сучасному Linux (cgroups v2, єдина уніфікована ієрархія) кожна група ресурсів —
це каталог у /sys/fs/cgroup/, а ліміти й статистика — звичайні файли всередині
нього:
docker run -d --name web --memory=100m --cpus=0.5 nginx
# Docker транслює це у файли cgroups v2, приблизно:
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max # 104857600
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/cpu.max # 50000 100000
docker stats --no-stream
memory.max — жорсткий ліміт: перевищення викликає OOM killer саме для процесів
цієї групи, не чіпаючи решту хоста. cpu.max записаний як пара quota period (у
мікросекундах): 50000 100000 означає «50 мс процесорного часу з кожних 100 мс»,
тобто половину одного ядра. cgroups v2 також контролюють I/O (io.max), кількість
процесів (pids.max) — важливий захист від fork-бомб усередині контейнера — і
hugetlb для великих сторінок пам'яті.
Класична модель Unix знає лише два стани: звичайний користувач і root (UID 0), який може все. Це занадто грубо для контейнерів: якщо процес усередині контейнера працює від root, а привілеї не роздроблені, компрометація контейнера дає атакуючому теоретично необмежені можливості в межах простору імен.
Capabilities дроблять «суперправа root» на близько 40 окремих дозволів. Кілька показових прикладів:
CAP_NET_BIND_SERVICE — дозволяє прив'язатися до порту < 1024 без повного root;CAP_SYS_ADMIN — «звалище» найнебезпечніших адміністративних операцій (mount,
деякі namespace-операції) — практично еквівалент часткового root, уникати за
замовчуванням;CAP_NET_ADMIN — конфігурація мережевих інтерфейсів, маршрутів, фаєрвола;CAP_CHOWN, CAP_DAC_OVERRIDE — обхід перевірок власності й прав доступу до
файлів.Docker за замовчуванням запускає контейнери з обмеженим набором приблизно 14 capabilities (з ~40 доступних), явно відкидаючи найнебезпечніші. Побачити активний набір можна командою:
docker run --rm alpine sh -c "apk add -q libcap && capsh --print"
# Current: = cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,...
Прапорці --cap-add і --cap-drop дозволяють точково додати чи прибрати конкретну
можливість без надання повного --privileged, який вимикає майже всі обмеження й
має використовуватись лише як крайній випадок (наприклад, для Docker-in-Docker).
USER namespace — окремий і особливо важливий випадок. Він дозволяє відобразити UID всередині простору імен на інший UID зовні. Класичний сценарій: процес усередині контейнера бачить себе як UID 0 (root) — і всередині простору імен це справді root з повним набором прав над файлами й процесами цього простору. Але зовні, з погляду хоста, той самий процес насправді виконується від непривілейованого UID (наприклад, 100000). Якщо атакуючий вирветься за межі namespace-ізоляції (через баг ядра чи неправильно змонтований шлях), на хості він матиме права звичайного користувача, а не root.
# Docker daemon.json: увімкнути user namespace remapping
{ "userns-remap": "default" }
Це — саме той «подвійний захист», про який ідеться в анонсі лекції: навіть якщо namespaces і capabilities десь дадуть слабину, user namespace робить компрометацію контейнера значно менш цінною для атакуючого.
Жоден з трьох механізмів окремо не дає повної ізоляції:
Docker (і containerd, runc під ним) — це, по суті, зручна оркестрація виклику
clone() з правильним набором прапорців, запис лімітів у файли cgroups v2 і
виставлення набору capabilities — усе перед тим, як зрештою викликати execve() над
процесом усередині контейнера.
Важливо, що жоден з трьох механізмів не «знає» про існування двох інших: ядро надає
cgroups тому самому процесу незалежно від того, чи має він власний PID namespace, і
namespaces працюють так само для процесу з повним набором capabilities чи без нього.
Docker (через runc, низькорівневий OCI-рантайм) просто послідовно застосовує всі три
механізми до одного й того самого clone()-виклику — саме ця комбінаторність і дає
враження «окремого контейнера», хоча насправді це один процес, до якого застосували
три незалежні, ортогональні обмеження ядра.
unshare --pid без --mount-proc лишає /proc від хоста — ps покаже
«чужі» процеси, бо читає старий /proc, хоча PID-простір насправді новий./sys/fs/cgroup/memory/... (v1) відрізняються від
єдиної ієрархії v2.--privileged — не «більше прав», а «майже всі прапорці ISOLATION вимкнено»:
він одночасно дає всі capabilities, вимикає seccomp-профіль і відкриває доступ до
пристроїв хоста — це надзвичайно широкий дозвіл, а не точкове налаштування.Контейнер — не окрема машина й не легка віртуальна машина, а звичайний Linux-процес,
якому через сім видів просторів імен підмінили видимість світу, через cgroups v2
обмежили споживання ресурсів, а через capabilities роздробили права root на окремі
дозволи. clone(), unshare() і setns() — три системні виклики, що покривають
увесь життєвий цикл роботи з просторами імен, включно з тим, як docker exec
приєднується до вже запущеного контейнера. User namespace додає ще один рівень
захисту, відображаючи UID 0 усередині на непривілейований UID зовні. Наступна
лекція переходить від запущеного контейнера (runtime) до самого образу: з чого він
складається, чому важить саме стільки й що таке шари.
Фокус: ізоляція ядром
Контейнеру змінили «видимість світу», а не дали окрему машину
Кожен простір — окрема «версія» глобального ресурсу ядра
| Виклик | Що робить | Хто використовує |
|---|---|---|
clone(flags) | новий процес одразу в нових просторах | docker run → runc |
unshare(flags) | відв'язує наявний процес від поточного простору | утиліта unshare(1) |
setns(fd, type) | приєднує процес до чужого простору | nsenter, docker exec |
docker exec — це setns() у простори вже запущеного контейнера, не новий clone()
sudo unshare --pid --fork --mount-proc bash
# усередині:
ps aux
echo $$Той самий /bin/bash, але echo $$ покаже PID 1 — процеси хоста невидимі
Без --mount-proc ps показує «чужі» процеси — типова помилка
Очікувано: Один і той самий бінарник bash поводиться по-різному залежно від простору імен, у якому він запущений
PID=$(docker inspect -f '{{.State.Pid}}' b7)
ls -l /proc/$PID/ns/
# pid -> pid:[4026532xxx] net -> net:[4026532yyy] ...
nsenter --target $PID --pid --net --mount ps auxnsenter обгортає setns() — заходить у namespaces процесу за його PID
Без cgroups ізольований процес міг би з'їсти всю пам'ять хоста
| Контролер | Файл | Приклад | Означає |
|---|---|---|---|
| cpu | cpu.max | 50000 100000 | 50 мс з кожних 100 мс — половина ядра |
| memory | memory.max | 104857600 | 100 МБ; далі — OOM killer у групі |
| io | io.max | 8:0 rbps=10485760 | 10 МБ/с читання з диска |
| pids | pids.max | 128 | захист від fork-бомби |
Значення — звичайний текст; ліміт змінюється echo у файл
docker run -d --name b7 --memory=100m --cpus=0.5 nginx
cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max
docker stats --no-streamdocker run --memory / --cpus транслюється у файли cgroups v2
cat /sys/fs/cgroup/.../memory.max # 104857600 (100 МБ)
cat /sys/fs/cgroup/.../cpu.max # 50000 100000 (0.5 CPU)
cat /sys/fs/cgroup/.../pids.max # захист від fork-бомбcpu.max записаний як "quota period" у мікросекундах: 50000/100000 = половина ядра
Docker не вмикає USER-простір за замовчуванням — це і є та «сьома брама», що лишається відчиненою
| Capability | Що дозволяє | Docker за замовч. |
|---|---|---|
CAP_NET_BIND_SERVICE | порт < 1024 без повного root | так |
CAP_CHOWN | змінювати власника файлів | так |
CAP_KILL | сигнали чужим процесам | так |
CAP_NET_ADMIN | інтерфейси, маршрути, фаєрвол | ні |
CAP_SYS_PTRACE | стежити за чужими процесами | ні |
CAP_SYS_ADMIN | mount та десятки адмін-операцій | ні |
--cap-add / --cap-drop — точково; --privileged — вимикає майже все одразу
Три механізми незалежні й не знають один про одного — комбінує їх runc
Кожна програма і кожен користувач системи мають працювати з найменшим набором привілеїв, необхідним для виконання роботи. — Джером Зальцер, Майкл Шредер — принцип найменших привілеїв (1975)
Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.
sudoновеunshareunshare --pid --fork --mount-proc bash).psps aux — усі процеси з користувачем, PID і командою (у контейнері видно лише його власні).echoecho $$ — PID поточної оболонки).docker rundocker run -it ubuntu:24.04 bash — інтерактивно з терміналом; -d — у фоні; --rm — видалити після виходу; -p 8080:80 — прокинути порт).dockerdocker <підкоманда> --help — довідка).lsls -la — з правами, власником, розміром і прихованими файлами.docker inspectнове-f '{{.State.Pid}}' — витягнути одне поле.nsenterновеnsenter --target <PID> --pid --net ps aux) — заглянути всередину контейнера без Docker.cat/proc/....docker statsнове--no-stream — один знімок).ipновеip netns add demo).capshновеcapsh --print).hostnameновеdocker execновеdocker exec -it storage sh).💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.