Как работают контейнеры: 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)
utshostname и доменное имя
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.
Проверьте себя
1. В чём принципиальное различие между namespaces и cgroups в механизме контейнеров Linux?
Anamespaces ограничивают потребление CPU и памяти, а cgroups изолируют сеть и процессы
Bnamespaces изолируют то, что процесс ВИДИТ (свои процессы, сеть, точки монтирования), а cgroups ограничивают, сколько ресурсов процесс ПОТРЕБЛЯЕТ (CPU, память, I/O)
CЭто два названия одного и того же механизма
Dnamespaces работают только в Docker, а cgroups — только при ручном запуске
2. Почему Docker-контейнер с образом на базе Linux нельзя «по-настоящему» запустить нативно на macOS, и почему он стартует за миллисекунды на самом Linux?
AПотому что контейнер — это процесс на ядре хоста (нет отдельного ядра): на Linux это дёшево и быстро, а на macOS нет Linux-ядра, поэтому Docker держит скрытую Linux-виртуалку
BПотому что macOS не поддерживает файл Dockerfile
CПотому что контейнер — это полноценная виртуальная машина, которую macOS не умеет загружать
DПотому что cgroups запрещены на не-Linux системах лицензией