Как работают контейнеры: namespaces и cgroups
Контейнер — это не «лёгкая виртуалка», а обычный процесс, которому ядро Linux наврало про окружающий мир. Разберём, как именно.
Контейнер — это процесс (или группа процессов) на общем ядре хоста, изолированный двумя механизмами ядра Linux: namespaces ограничивают, что процесс видит, а cgroups ограничивают, сколько ресурсов он потребляет.
Многие думают, что Docker — это виртуальная машина. Это не так: внутри контейнера нет отдельного ядра и нет эмуляции железа. Есть один общий Linux-хост, а Docker лишь складывает воедино несколько штатных возможностей ядра. Понимание этого объясняет на практике всё: почему контейнеры стартуют за миллисекунды, почему контейнер с одним ядром не запустишь на другом, почему процесс внутри имеет PID 1 и почему «съевший» память контейнер может быть убит, не уронив соседей.
Предок изоляции: chroot
Историческое начало — chroot, появившийся ещё в 1979 году. Он подменяет процессу корень файловой системы: то, что для процесса выглядит как /, на самом деле является вложенным каталогом хоста. Процесс физически не может «выйти» выше этой точки и обращаться к остальной системе по путям:
# упрощённо: дать процессу свой корень
sudo chroot /srv/jail /bin/bash
# внутри `ls /` покажет содержимое /srv/jail, а не настоящего корня
Но chroot изолирует только файловую систему. Процессы хоста, его сеть, список пользователей — всё это «джейл» по-прежнему видит. namespaces — это обобщение идеи chroot на все остальные ресурсы системы.
Namespaces: что процесс видит
Namespace — это изолированный экземпляр какого-то одного класса глобальных ресурсов ядра. Процессы в одном namespace видят свой набор ресурсов и не подозревают о тех, что снаружи. Основные типы:
| Namespace | Что изолирует |
pid | дерево процессов: внутри свой PID 1, чужих процессов не видно |
net | сетевой стек: свои интерфейсы, IP, порты, таблица маршрутов |
mnt | точки монтирования: своя файловая иерархия (наследник идеи chroot) |
uts | hostname и доменное имя |
ipc | межпроцессное взаимодействие (очереди сообщений, разделяемая память) |
user | отображение UID/GID: root внутри может быть обычным пользователем снаружи |
Самый показательный — pid. Внутри своего pid-namespace главный процесс получает PID 1 (как init у целой системы) и не видит ни одного процесса хоста. Именно поэтому ps в контейнере показывает две-три строки вместо сотен. А user-namespace — основа «rootless»-контейнеров: процесс может быть root (UID 0) внутри, оставаясь непривилегированным пользователем снаружи, что резко снижает риск побега.
Демонстрация: контейнер руками через unshare
Чтобы убедиться, что за магией Docker стоит обычный системный вызов, создадим изоляцию вручную утилитой unshare (она вызывает unshare()/clone() ядра). Дадим процессу собственные pid- и mount-namespace:
# свой PID-namespace + свой /proc, новый hostname
sudo unshare --pid --fork --mount-proc --uts bash
# теперь МЫ ВНУТРИ. Проверим изоляцию:
hostname container01 # меняем hostname — хост не затронут
ps aux # видно лишь bash (PID 1) и ps — процессов хоста нет
echo $$ # PID нашей оболочки внутри namespace
Вывод: (примерный)
USER PID %CPU %MEM COMMAND root 1 0.0 0.1 bash root 14 0.0 0.1 ps aux
Изнутри это выглядит как отдельная система, хотя мы по-прежнему на том же ядре. Команда lsns с хоста перечислит все существующие namespaces и процессы в них — так же это делают инструменты вокруг Docker:
lsns # все namespaces системы
ls -l /proc/$$/ns/ # ссылки на namespaces текущего процесса
Cgroups: сколько процесс потребляет
Namespaces прячут ресурсы, но сами по себе не ограничивают аппетит: процесс в namespace всё ещё может занять весь процессор и всю память хоста. За лимиты отвечают cgroups (control groups) — иерархия групп процессов, для каждой из которых ядро учитывает и ограничивает CPU, память, ввод-вывод диска и число процессов. Современные системы используют cgroups v2 с единым деревом в /sys/fs/cgroup:
# посмотреть, какие контроллеры доступны
cat /sys/fs/cgroup/cgroup.controllers
# создать группу и ограничить ей память до 100 МБ
sudo mkdir /sys/fs/cgroup/demo
echo "100M" | sudo tee /sys/fs/cgroup/demo/memory.max
# ограничить CPU: 20000 микросекунд работы на каждые 100000 (= 20% одного ядра)
echo "20000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max
# поместить процесс в группу — записать его PID
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs
Теперь оболочка и все её потомки не получат больше 100 МБ ОЗУ и 20% ядра. Если процесс в группе превысит memory.max — ядро убьёт его OOM-killer'ом, не трогая остальную систему. Именно это стоит за флагами Docker:
# Docker просто транслирует это в cgroups за вас:
docker run --memory=100m --cpus=0.2 nginx
Как это работает под капотом
Сложить из этих кирпичей контейнер — задача низкоуровневого рантайма (runc), который Docker вызывает под капотом. Алгоритм запуска такой: ядро создаёт новый процесс системным вызовом clone() с флагами нужных namespaces (CLONE_NEWPID, CLONE_NEWNS, CLONE_NEWNET и т.д.) — так процесс рождается уже в изоляции; затем runc монтирует образ как корень через mount-namespace, помещает процесс в подготовленную cgroup для лимитов, настраивает виртуальный сетевой интерфейс в net-namespace и, наконец, через execve запускает вашу команду как PID 1. Никакой эмуляции на этом пути нет — всё это обычные системные вызовы к ядру хоста. Отсюда два следствия: контейнер стартует почти мгновенно (создать процесс дёшево, в отличие от загрузки целой ОС), и его нельзя запустить на чужом ядре — образ с Linux-бинарями исполняет именно ядро Linux-хоста (поэтому на macOS/Windows Docker втихую держит лёгкую Linux-виртуалку).
Частые ошибки
- Считают контейнер виртуальной машиной и ждут отдельного ядра или другой ОС — внутри то же ядро хоста.
- Путают роли: namespaces дают изоляцию видимости, cgroups — лимиты ресурсов; одно без другого неполноценно (изолированный процесс всё равно съест всю память без cgroup).
- Не задают
--memory/--cpusи удивляются, что один контейнер положил соседей по нагрузке — без cgroup-лимита он не ограничен. - Думают, что
rootвнутри контейнера безопасен «по умолчанию» — без user-namespace это тот же root на ядре хоста; rootless-режим и user-namespace существуют именно для этого. - Запускают тяжёлую программу как PID 1 без обработчика сигналов и удивляются, что контейнер не реагирует на остановку — PID 1 имеет особую семантику сигналов.
Итоги
- Контейнер — это процесс на общем ядре хоста, а не виртуальная машина; отсюда мгновенный старт и привязка к ядру Linux.
- Namespaces изолируют видимость: pid (дерево процессов), net (сеть), mnt (файлы), uts (hostname), ipc, user (UID/GID).
- cgroups ограничивают потребление: память (
memory.max), CPU (cpu.max), ввод-вывод, число процессов. chroot— исторический предок, изолировал только файловую систему;unshareиlsnsпозволяют пощупать namespaces руками.- Флаги Docker (
--memory,--cpus) — это просто удобная обёртка над cgroups; рантаймruncсобирает контейнер изclone()+ mount + cgroup.