Namespaces и cgroups — примитивы контейнера
Namespaces изолируют глобальные ресурсы ядра на группу процессов (pid, net, mnt, uts, ipc, user, cgroup). В сочетании с cgroups для лимитов и корневой файловой системой они образуют полный примитив контейнера. Никакой магии — именно здесь начинается трек Docker.
Каждый senior-инженер, отлаживавший проблему с сетью контейнера, контейнер, поедающий CPU хоста, или эскалацию привилегий внутри контейнера, рано или поздно приходит к одному и тому же осознанию: контейнер — это не виртуальная машина, не песочница и не магия. Это процесс (или дерево процессов), которому ядро показывает другую картину мира. Процесс думает, что он один на машине, что у него PID 1, собственный сетевой стек, и видит конкретное дерево директорий как свой корень. Ядро достигает этого двумя механизмами: namespaces (разрезают глобальные ресурсы на изолированные представления) и cgroups (enforc-ат лимиты ресурсов). Корневая файловая система завершает картину. Эти три вещи вместе — именно то, что Docker, containerd и каждый другой контейнерный рантайм собирает при запуске контейнера. После этого урока никакой тайны не остаётся — и трек Docker начинается именно здесь.
После этого урока ты сможешь назвать семь типов namespace и что каждый изолирует, использовать unshare для создания нового namespace и nsenter для входа в существующий, объяснить, как cgroups v2 ограничивают CPU и память процесса, и описать контейнер как namespaces + cgroups + корневая файловая система — без оставшихся чёрных ящиков.
Семь типов namespace. Namespace оборачивает глобальный ресурс ядра так, что процессы внутри видят собственную изолированную копию. Linux предоставляет семь типов namespace.
# Список namespace для текущего процесса оболочки:
ls -la /proc/$$/ns/
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 cgroup -> cgroup:[4026531835]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 ipc -> ipc:[4026531839]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 mnt -> mnt:[4026531840]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 net -> net:[4026531992]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 pid -> pid:[4026531836]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 user -> user:[4026531837]
# lrwxrwxrwx 1 user user 0 Jun 22 09:00 uts -> uts:[4026531838]
# Два процесса с одинаковым inode-номером для типа namespace
# находятся в ОДНОМ namespace. Разные inode = разные namespace.
| Namespace | Флаг | Что изолирует |
|---|---|---|
| pid | CLONE_NEWPID | Пространство PID — PID 1 внутри может быть любым процессом |
| net | CLONE_NEWNET | Сетевые интерфейсы, таблица маршрутизации, правила iptables |
| mnt | CLONE_NEWNS | Таблица монтирования — какие файловые системы видны |
| uts | CLONE_NEWUTS | Имя хоста и NIS-домен |
| ipc | CLONE_NEWIPC | System V IPC (семафоры, очереди сообщений, разделяемая память) |
| user | CLONE_NEWUSER | Маппинг UID/GID — root внутри маппится на непривилегированный UID снаружи |
| cgroup | CLONE_NEWCGROUP | Представление иерархии cgroup для процесса |
Создание namespace через unshare. unshare запускает новый процесс в новом namespace без какого-либо контейнерного рантайма. Это и есть голая возможность ядра.
# Создать новый UTS namespace и изменить hostname внутри:
sudo unshare --uts /bin/bash
# Внутри нового namespace:
hostname container-test
hostname
# container-test
# Открыть другой терминал на хосте и проверить:
hostname
# myserver ← hostname хоста не изменился
# Создать новый PID namespace — оболочка становится PID 1 внутри:
sudo unshare --pid --fork --mount-proc /bin/bash
# Внутри:
echo $$
# 1 ← эта оболочка — PID 1 в новом namespace
ps aux
# USER PID %CPU %MEM VSZ RSS TTY STAT TIME COMMAND
# root 1 0.0 0.0 4236 3492 pts/1 S 0:00 /bin/bash
# root 2 0.0 0.0 7484 2992 pts/1 R+ 0:00 ps aux
# Видны только два процесса — 200+ процессов хоста скрыты
# Создать новый network namespace (изолированный сетевой стек):
sudo unshare --net /bin/bash
# Внутри:
ip link
# 1: lo: <LOOPBACK> mtu 65536 ... ← только loopback, нет eth0, нет docker0unshare с несколькими флагами комбинирует namespace: --pid --net --uts --ipc --mount — это примерно то, что делает контейнерный рантайм при старте контейнера, минус смена корня.
Вход в существующие namespace через nsenter. nsenter подключает процесс к namespace уже запущенного процесса. Именно так отлаживают контейнеры с хоста без docker exec.
# Найти PID контейнера на хосте:
# (В Docker: docker inspect <container> --format '{{.State.Pid}}')
CPID=12345
# Войти в сетевой namespace контейнера для инспекции снаружи:
sudo nsenter --net --target $CPID ip addr
# 1: lo: <LOOPBACK,UP>
# 15: eth0@if16: <BROADCAST,MULTICAST,UP> ...
# inet 172.17.0.3/16
# Войти в PID namespace контейнера для просмотра его дерева процессов:
sudo nsenter --pid --target $CPID ps aux
# Войти во ВСЕ namespace контейнера (эквивалент raw exec):
sudo nsenter --target $CPID --mount --uts --ipc --net --pid -- /bin/bash
# Ты теперь внутри полного окружения контейнера без Docker
# Именно так работает 'docker exec' под капотом —
# он вызывает nsenter для входа в namespace контейнера.nsenter незаменим, когда контейнер не имеет оболочки (образы FROM scratch, distroless) — входишь в его сетевой namespace с хоста, но запускаешь инструменты из файловой системы хоста.
cgroups v2: лимиты ресурсов на группу процессов. Namespaces контролируют что процесс видит. cgroups контролируют сколько ресурсов хоста он может использовать. Урок 08 покрыл cgroups в глубину; здесь мы связываем их с картиной контейнера.
# Иерархия cgroup v2 находится в /sys/fs/cgroup (единая иерархия):
ls /sys/fs/cgroup/
# cgroup.controllers memory.current memory.max system.slice user.slice
# Найти, к какому cgroup принадлежит PID контейнера:
cat /proc/$CPID/cgroup
# 0::/system.slice/docker-abc123.scope
# Инспектировать лимит памяти для этого cgroup:
cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max
# 536870912 ← лимит 512 МБ, установленный Docker
# Инспектировать лимит CPU:
cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.max
# 100000 1000000 ← квота 100мс на период 1000мс = 10% одного CPU
# Создать cgroup вручную и ограничить его (показывает сырой механизм, который использует Docker):
sudo mkdir /sys/fs/cgroup/demo
echo 268435456 | sudo tee /sys/fs/cgroup/demo/memory.max # 256 МБ
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs # добавить эту оболочку
# Эта оболочка теперь не может выделить более 256 МБ суммарно.
# OOM killer (из урока 08) сработает при попытке.Флаги Docker --memory, --cpus и --cpu-shares записывают именно эти файлы cgroup. Другого механизма нет.
Уравнение контейнера: namespaces + cgroups + rootfs. Контейнерный рантайм выполняет три действия последовательно при запуске контейнера:
# 1. NAMESPACES — создать изолированные представления ядра:
# --pid: новое пространство PID (процесс контейнера — PID 1)
# --net: новый сетевой стек (veth-пара к bridge хоста)
# --mnt: новая таблица монтирования
# --uts: новое имя хоста
# --ipc: новое IPC-пространство
# --user: (опционально) переназначение UID для rootless-контейнеров
# 2. CGROUPS — применить лимиты ресурсов:
# memory.max → --memory в Docker
# cpu.max → --cpus в Docker
# pids.max → ограничение fork bomb
# 3. ROOTFS — предоставить другую файловую систему как корень:
# pivot_root() или chroot() в стек слоёв образа контейнера
# Образ контейнера — просто дерево директорий (tar-слои)
# overlayfs объединяет read-only слои образа с записываемым верхним слоем
# Собрать минимальный контейнер вручную (образовательно — не для продакшна):
sudo unshare --pid --fork --mount-proc --net --uts \
chroot /path/to/rootfs /bin/sh
# Это контейнер. Без демона Docker, без containerd, без магии.
# Единственное отличие от настоящего контейнерного рантайма:
# - нет назначения cgroup (нет лимитов ресурсов)
# - нет veth-пары (нет сетевой связности)
# - нет управления образами / слоями
# Настоящий рантайм автоматизирует эти три шага, добавляет управление
# жизненным циклом, дистрибуцию образов и API управления.
# Проверить с хоста, что у "контейнера" свой PID namespace:
ls -la /proc/<pid-контейнера>/ns/pid
# pid:[4026532345] ← другой inode = другой namespace
ls -la /proc/$$/ns/pid
# pid:[4026531836] ← PID namespace хостаЭто мост: трек Docker начинается с docker run, но теперь ты знаешь, что под капотом он вызывает clone() с флагами namespace, записывает лимиты cgroup и вызывает pivot_root() в стек слоёв образа. Каждая сессия отладки контейнера — docker exec, nsenter, лимиты памяти cgroup, инспекция сетевого namespace — обретает смысл, когда видишь три примитива.
Отладка: у контейнера нет интернета, но у хоста есть. Диагностика с хоста через инструменты namespace.
# Контейнер запущен, но curl внутри падает:
# docker exec myapp curl https://example.com
# curl: (6) Could not resolve host: example.com
# Шаг 1: получить PID контейнера на хосте:
CPID=$(docker inspect myapp --format '{{.State.Pid}}')
echo $CPID
# 8821
# Шаг 2: войти в сетевой namespace контейнера и инспектировать оттуда:
sudo nsenter --net --target $CPID ip route
# default via 172.17.0.1 dev eth0
# 172.17.0.0/16 dev eth0 proto kernel
# Шаг 3: проверить доступность шлюза по умолчанию:
sudo nsenter --net --target $CPID ping -c1 172.17.0.1
# 64 bytes from 172.17.0.1: icmp_seq=0 ttl=64 time=0.15 ms
# Шлюз доступен — подозреваем DNS
# Шаг 4: проверить разрешение DNS из namespace:
sudo nsenter --net --target $CPID cat /etc/resolv.conf
# nameserver 127.0.0.11 ← встроенный DNS-резолвер Docker
sudo nsenter --net --target $CPID nslookup example.com 127.0.0.11
# ;; connection timed out; no servers could be reached
# Шаг 5: проверить ip_forward на ХОСТЕ:
sysctl net.ipv4.ip_forward
# net.ipv4.ip_forward = 0 ← нашли
# Хост потерял ip_forward (перезагрузка без постоянного sysctl).
# Встроенный DNS Docker на 127.0.0.11 пересылает запросы через NAT хоста —
# что требует ip_forward.
# Исправление:
sudo sysctl -w net.ipv4.ip_forward=1
sudo tee /etc/sysctl.d/99-docker-forwarding.conf <<'EOF'
net.ipv4.ip_forward = 1
EOF
# Повторить из контейнера:
# docker exec myapp curl https://example.com
# <!DOCTYPE html>... ← работаетПервопричина: два урока сходятся — ip_forward (урок sysctl) потерян при перезагрузке, диагностирован через nsenter для входа в сетевой namespace контейнера прямо с хоста. Это стандартный playbook для проблем с сетью контейнеров.
▸Почему это работает
На macOS нет Linux namespaces — Docker Desktop запускает Linux VM. При выполнении docker run на macOS Docker Desktop запускает лёгкую Linux VM (через Apple Hypervisor framework). Ядро Linux внутри этой VM предоставляет namespaces и cgroups. Docker CLI на macOS — просто удалённый клиент, общающийся с демоном внутри VM. Это значит: nsenter с хоста macOS не работает (на macOS нет Linux namespaces), /proc не существует, а параметры sysctl для Docker (net.ipv4.ip_forward и др.) живут внутри VM, а не на Mac. Трек Docker работает полностью с VM; это различие важно при отладке продакшн Linux-хостов в сравнении с локальной средой разработки на macOS.
▸Частая ошибка
Считать root в user namespace равным root на хосте. Когда контейнер использует user namespace (rootless-контейнеры, умолчание Podman), UID 0 внутри контейнера маппится на непривилегированный UID на хосте (например, UID 100000). Процесс, вырвавшийся из mount namespace контейнера, но остающийся в его user namespace, имеет на хосте UID 100000 — не root. В этом и состоит ценность user namespaces для безопасности. Однако многие контейнерные рантаймы (классический Docker) по умолчанию НЕ используют user namespaces — UID 0 внутри маппится напрямую на UID 0 хоста. В таких развёртываниях побег из контейнера даёт доступ root на хосте. Всегда проверяй, использует ли твой рантайм user namespaces, если безопасность имеет значение.
Ты запускаешь `sudo unshare --pid --fork --mount-proc /bin/bash` и видишь только два процесса в `ps aux`. На хосте при этом 200 запущенных процессов. Какой механизм ядра создаёт это отличие, и что добавить, чтобы у этой оболочки было своё имя хоста, отдельное от хоста?
Контейнеры Linux построены из трёх примитивов ядра без какой-либо дополнительной магии. Namespaces дают группе процессов изолированное представление семи глобальных ресурсов: pid (идентификаторы процессов), net (сетевой стек), mnt (таблица монтирования), uts (hostname), ipc (IPC-объекты), user (маппинг UID/GID), cgroup (представление иерархии cgroup). unshare создаёт новый namespace; nsenter входит в существующий — полезно для отладки контейнеров с хоста без docker exec. cgroups v2 enforc-ят лимиты ресурсов на группу процессов через файлы в /sys/fs/cgroup/: memory.max, cpu.max, pids.max — именно то, что записывают флаги Docker --memory и --cpus. Корневая файловая система (стек слоёв образа, смонтированный через overlayfs с pivot_root) завершает контейнер. Каждый контейнерный рантайм — Docker, containerd, Podman — это API и менеджер жизненного цикла поверх этих трёх примитивов. Трек Docker начинается здесь: docker run вызывает clone() с флагами namespace, записывает файлы cgroup и делает pivot_root в образ. Ничего другого под капотом нет.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.