Курс Лекція 2
▶ СлайдиСРС 03+04⌨️ команди 15 · 8 нових
Конспект · Лекція 2

ПРОСТОРИ ІМЕН, ГРУПИ КЕРУВАННЯ (CGROUPS), ПРИВІЛЕЇ

Ізоляція ядром без віртуалізації заліза

Ключова ідея. Контейнер — це не окрема ОС, а звичайний процес, якому ядро підмінило видимість (namespaces) і обмежило апетит (cgroups).
Демо. unshare --pid --fork --mount-proc bash — запустити той самий bash-бінарник у власному PID-просторі й показати, що всередині він бачить себе як PID 1, а процеси хоста зникли.
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття

Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР2. Матеріали публікуються в Google Classroom разом із лекцією.

📚 ТЕОРЕТИЧНІ ВІДОМОСТІ

Вступ: контейнер без віртуалізації заліза

Минулого разу ми з'ясували, що процес — це активна абстракція ядра: PID, адресний простір, дескриптори, права. Сьогодні ми відповідаємо на питання, яке напрошується само: якщо контейнер — це «просто процес», то як тоді всередині нього ps aux показує лише один-два рядки, а не сотні процесів хоста? Чому контейнер не бачить файлів хоста, хоча працює на тому самому ядрі? І чому один контейнер не може захопити всю оперативну пам'ять машини?

Відповідь — три незалежні механізми ядра Linux, які Docker комбінує, але не винаходить: простори імен (namespaces) визначають, що процес бачить, групи керування (cgroups) визначають, скільки процес може споживати, а capabilities визначають, що процесу дозволено робити. Жоден з цих механізмів не є специфічним для Docker — це базові примітиви ядра, доступні будь-якій програмі через звичайні системні виклики. Розуміння цього — ключ до того, щоб контейнери перестали здаватись магією.

Сім просторів імен ядра Linux

Простір імен — це механізм ядра, який дозволяє групі процесів мати власну, ізольовану «версію» певного глобального ресурсу ядра. 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(), unshare(), setns(): три способи роботи з просторами імен

Три системні виклики покривають увесь життєвий цикл роботи з просторами імен:

  • 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-простір напряму.

cgroups v2 — контроль апетиту

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 для великих сторінок пам'яті.

Linux capabilities: дроблення прав root

Класична модель 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 namespaces: подвійний захист

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 робить компрометацію контейнера значно менш цінною для атакуючого.

Разом: з чого складається контейнер

Жоден з трьох механізмів окремо не дає повної ізоляції:

  • namespaces відповідають на питання «що процес бачить» — власний PID, мережу, файлову систему, hostname, IPC, UID-мапінг, cgroup-ієрархію;
  • cgroups відповідають на питання «скільки процес може споживати» — CPU, пам'ять, I/O, кількість процесів;
  • capabilities відповідають на питання «що процесу дозволено робити», навіть якщо він формально root усередині свого простору імен.

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-простір насправді новий.
  • cgroups v1 vs v2 — старі системи (і деякі дистрибутиви) досі можуть мати змішану або v1-ієрархію; шляхи /sys/fs/cgroup/memory/... (v1) відрізняються від єдиної ієрархії v2.
  • --privileged — не «більше прав», а «майже всі прапорці ISOLATION вимкнено»: він одночасно дає всі capabilities, вимикає seccomp-профіль і відкриває доступ до пристроїв хоста — це надзвичайно широкий дозвіл, а не точкове налаштування.
  • root у контейнері без user namespace — реальний root на хості, якщо процес зуміє вирватись за межі mount/PID namespace через вразливість.
  • cgroups memory.max ≠ гарантія OOM усередині контейнера: OOM killer у cgroup вбиває конкретний процес усередині групи, але сама група (і контейнер) може продовжити працювати в деградованому стані.

Підсумок

Контейнер — не окрема машина й не легка віртуальна машина, а звичайний Linux-процес, якому через сім видів просторів імен підмінили видимість світу, через cgroups v2 обмежили споживання ресурсів, а через capabilities роздробили права root на окремі дозволи. clone(), unshare() і setns() — три системні виклики, що покривають увесь життєвий цикл роботи з просторами імен, включно з тим, як docker exec приєднується до вже запущеного контейнера. User namespace додає ще один рівень захисту, відображаючи UID 0 усередині на непривілейований UID зовні. Наступна лекція переходить від запущеного контейнера (runtime) до самого образу: з чого він складається, чому важить саме стільки й що таке шари.

Розбір за слайдами

План лекції

Фокус: ізоляція ядром

Віртуальна машина ↔ контейнер

Віртуальна машина
  • Гіпервізор віртуалізує залізо
  • Окреме ядро гостьової ОС
  • Старт — секунди, образ — гігабайти
  • Ізоляція — на межі заліза
vs
Контейнер
  • Звичайний процес на ядрі хоста
  • Ядро одне — спільне з хостом
  • Старт — мілісекунди, образ — мегабайти
  • Ізоляція — namespaces + cgroups + capabilities

Контейнеру змінили «видимість світу», а не дали окрему машину

Простори імен (*namespaces*): що ізолює кожен

PIDвласна нумерація процесів; усередині — PID 1
NETвласний мережевий стек: інтерфейси, IP, маршрути
MNTвласне дерево точок монтування
UTSвласне ім'я хоста (hostname)
IPCчерги повідомлень, семафори, спільна пам'ять System V
USERвідображення UID: root усередині ≠ root на хості

Кожен простір — окрема «версія» глобального ресурсу ядра

Сьомий простір і успадкування

КОНТЕЙНЕР = ПРОЦЕС + 7 ПРОСТОРІВ ІМЕН ХОСТ · спільне ядро КАПСУЛА (PID 1) PIDMNTNETUTSIPCUSERCGROUP свій погляд на систему — але ядро одне на всіх

Три системні виклики для просторів імен

ВикликЩо робитьХто використовує
clone(flags)новий процес одразу в нових просторахdocker run → runc
unshare(flags)відв'язує наявний процес від поточного просторуутиліта unshare(1)
setns(fd, type)приєднує процес до чужого просторуnsenter, docker exec

docker exec — це setns() у простори вже запущеного контейнера, не новий clone()

unshare: власний PID-простір

bash
sudo unshare --pid --fork --mount-proc bash
# усередині:
ps aux
echo $$

Той самий /bin/bash, але echo $$ покаже PID 1 — процеси хоста невидимі

Що робить unshare --pid --fork --mount-proc bash

  1. 1
    unshare(CLONE_NEWPID)поточний процес відв'язано у новий PID-простір
  2. 2
    fork()перша дитина в новому просторі стає PID 1
  3. 3
    mount -t procновий /proc, інакше ps читав би /proc хоста
  4. 4
    execve(bash)той самий бінарник — інша картина світу
  5. 5
    echo $$ → 1процеси хоста невидимі; exit повертає назад

Без --mount-proc ps показує «чужі» процеси — типова помилка

Живе демо: unshare проти хоста

  1. На хості: ps aux | wc -l (десятки процесів)
  2. sudo unshare --pid --fork --mount-proc bash
  3. Усередині: ps aux — видно тільки bash і ps
  4. echo $$ — PID дорівнює 1
  5. exit — повернення в звичайний простір, хост незмінний

Очікувано: Один і той самий бінарник bash поводиться по-різному залежно від простору імен, у якому він запущений

setns(): як docker exec заходить у контейнер

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 aux

nsenter обгортає setns() — заходить у namespaces процесу за його PID

Простори імен ↔ групи керування

namespaces
  • Що процес БАЧИТЬ
  • Власні PID, мережа, ФС, hostname
  • Не обмежують кількість ресурсів
  • Механізм: clone / unshare / setns
+
cgroups
  • Скільки процес МОЖЕ спожити
  • CPU, пам'ять, I/O, кількість процесів
  • Не ховають нічого від процесу
  • Механізм: файли у /sys/fs/cgroup

Без cgroups ізольований процес міг би з'їсти всю пам'ять хоста

cgroups v2 — контроль апетиту

CGROUPS v2 · ЛІМІТИ РЕСУРСІВ CPU 40% MEMORY 512M / 2G IO 10 MB/s PIDS 128 ядро гарантує: процес не зʼїсть більше, ніж дозволено

Контролери cgroups v2

КонтролерФайлПрикладОзначає
cpucpu.max50000 10000050 мс з кожних 100 мс — половина ядра
memorymemory.max104857600100 МБ; далі — OOM killer у групі
ioio.max8:0 rbps=1048576010 МБ/с читання з диска
pidspids.max128захист від fork-бомби

Значення — звичайний текст; ліміт змінюється echo у файл

cgroups наживо

bash
docker run -d --name b7 --memory=100m --cpus=0.5 nginx
cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max
docker stats --no-stream

docker run --memory / --cpus транслюється у файли cgroups v2

cgroups v2: файли лімітів зсередини

bash
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 = половина ядра

Привілеї: root у контейнері ≠ root на хості

USER-простір: чому root у контейнері небезпечний

USER-ПРОСТІР: ХТО ТАКИЙ ROOT УСЕРЕДИНІ UID У КОНТЕЙНЕРІ uid 0 · root uid 1000 · app процес щиро вважає себе root UID НА ХОСТІ uid 0 · root усе можна uid 100000 · nobody нічого вирішує лише ця колонка 0 → 0 0 → 100000 БЕЗ userns типовий Docker --userns-remap вмикається вручну vs root у капсулі, що змонтувала /etc хоста, — це root над /etc хоста

Docker не вмикає USER-простір за замовчуванням — це і є та «сьома брама», що лишається відчиненою

Capabilities: приклади

CapabilityЩо дозволяєDocker за замовч.
CAP_NET_BIND_SERVICEпорт < 1024 без повного rootтак
CAP_CHOWNзмінювати власника файлівтак
CAP_KILLсигнали чужим процесамтак
CAP_NET_ADMINінтерфейси, маршрути, фаєрволні
CAP_SYS_PTRACEстежити за чужими процесамині
CAP_SYS_ADMINmount та десятки адмін-операційні

--cap-add / --cap-drop — точково; --privileged — вимикає майже все одразу

Привілеї в цифрах

~40
capabilities у ядрі Linux
замість бінарного root / не-root
14
лишає Docker за замовчуванням
CAP_SYS_ADMIN, CAP_NET_ADMIN — відкинуті
0 → 100000
UID при userns-remap
root усередині — звичайний користувач зовні
1
прапорець --privileged
усі caps + вимкнений seccomp + пристрої хоста

Разом: з чого складається "контейнер"

процес звичайний namespaces ізоляція cgroups + caps ліміти/права КОНТЕЙНЕР все разом З ЧОГО СКЛАДАЄТЬСЯ «КОНТЕЙНЕР»

Як docker run збирає контейнер

docker runCLI → dockerd
containerd → runcOCI-рантайм
clone(flags)нові простори імен
cgroups + capsзапис лімітів, скидання прав
execve()процес контейнера, PID 1

Три механізми незалежні й не знають один про одного — комбінує їх runc

Кожна програма і кожен користувач системи мають працювати з найменшим набором привілеїв, необхідним для виконання роботи. — Джером Зальцер, Майкл Шредер — принцип найменших привілеїв (1975)

Наперед: з чого складається образ

Підводні камені: namespaces і привілеї

Підсумок

Джерела

🥚 ПАСХАЛКА
У прикладах контейнер названо b7, а процес усередині нього завжди бачить себе як PID 1. Двосимвольне ім'я обране не випадково — воно ще трапиться.
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 15 · 8 нових

Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.

sudoнове
Виконує команду від імені адміністратора (root); потрібно для змін у ядрі — просторів імен, мережевих інтерфейсів.
unshare
Запускає програму в нових просторах імен (namespaces) ядра — те, з чого «зшитий» контейнер (unshare --pid --fork --mount-proc bash).
ps
Список процесів; ps aux — усі процеси з користувачем, PID і командою (у контейнері видно лише його власні).
echo
Друкує рядок або значення змінної (наприклад echo $$ — PID поточної оболонки).
docker run
Створює й запускає контейнер з образу (docker run -it ubuntu:24.04 bash — інтерактивно з терміналом; -d — у фоні; --rm — видалити після виходу; -p 8080:80 — прокинути порт).
docker
Клієнт Docker: усі дії з образами й контейнерами — через його підкоманди (docker <підкоманда> --help — довідка).
ls
Показує список файлів у каталозі; ls -la — з правами, власником, розміром і прихованими файлами.
docker inspectнове
Друкує повний JSON-опис контейнера чи образу; -f '{{.State.Pid}}' — витягнути одне поле.
nsenterнове
Входить у простори імен уже запущеного процесу (nsenter --target <PID> --pid --net ps aux) — заглянути всередину контейнера без Docker.
cat
Виводить вміст файлу в термінал повністю (від concatenate); зручно для коротких файлів і /proc/....
docker statsнове
Живий моніторинг CPU/пам'яті контейнерів (--no-stream — один знімок).
ipнове
Керує мережею Linux: інтерфейси, адреси, маршрути, мережеві простори імен (ip netns add demo).
capshнове
Показує й змінює можливості (capabilities) процесу — на що саме root у контейнері має право (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.