Изоляция: namespaces и cgroups в контексте Docker

Два механизма ядра Linux, на которых держится вся контейнеризация: один изолирует, другой лимитирует.

Namespaces ограничивают, что контейнер видит; cgroups ограничивают, сколько ресурсов он потребляет. Docker лишь оркестрирует эти возможности ядра.

Зачем это знать на практике

Эти два механизма объясняют, почему внутри контейнера процесс видит себя как PID 1, почему ps в контейнере не показывает процессы хоста и почему контейнер с утечкой памяти убивается с кодом 137, а не валит весь сервер. Понимание namespaces и cgroups помогает осознанно ставить лимиты (--memory, --cpus), отлаживать сетевые проблемы и не делать ошибочных предположений о безопасности.

Namespaces: изоляция видимости

Namespace — это «обёртка» вокруг некоторого глобального ресурса ядра, дающая процессам внутри неё собственную, изолированную версию этого ресурса. Docker создаёт для каждого контейнера набор namespaces:

NamespaceЧто изолирует
pidдерево процессов: внутри свой PID 1, чужих процессов не видно
netсеть: свои интерфейсы, IP, таблица маршрутов, порты
mntточки монтирования: своя файловая система и корень /
utshostname и доменное имя (можно задать свой)
ipcмежпроцессное взаимодействие: очереди сообщений, разделяемая память
userотображение UID/GID: root в контейнере ≠ root на хосте

Именно поэтому внутри контейнера первый процесс имеет PID 1 (pid namespace), у него собственный eth0 со своим IP (net), отдельный hostname (uts) и собственный корень из слоёв образа (mnt). При этом на хосте тот же процесс — обычный PID, скажем, 24517.

# внутри контейнера nginx — PID 1
docker run -d --name web nginx
docker exec web ps -e
#   PID TTY   TIME CMD
#     1 ?     00:00 nginx
#    31 ?     00:00 nginx

# тот же процесс на хосте — большой PID
ps -ef | grep 'nginx: master'

Namespaces — это записи в ядре. Их можно увидеть как символические ссылки в /proc/<pid>/ns/; одинаковая цель ссылки означает общий namespace.

# сравнить net namespace процесса контейнера и процесса хоста
ls -l /proc/1/ns/net          # хост
ls -l /proc/<pid_в_контейнере>/ns/net   # цель другая → другой net namespace

На namespaces строятся и удобные режимы Docker: --network host отключает сетевой namespace (контейнер делит сеть с хостом), --pid host — отключает pid namespace (виден весь хост), а --userns-remap включает user namespace для безопасности, чтобы root внутри контейнера соответствовал непривилегированному пользователю на хосте.

Namespaces можно не только создавать, но и переиспользовать: команда docker exec работает именно так — она запускает новый процесс внутри уже существующих namespaces контейнера, поэтому видит те же процессы, ту же сеть и ту же файловую систему. А флаг --network container:web подключает новый контейнер к сетевому namespace другого: оба получают общий localhost и набор портов. Это не магия Docker, а прямое следствие того, что namespace — самостоятельный объект ядра, к которому можно присоединиться.

cgroups: ограничение ресурсов

Namespaces ничего не говорят о количестве ресурсов: без лимитов один контейнер способен съесть всю память и все ядра. За это отвечает второй механизм — cgroups (control groups). Он группирует процессы и навешивает на группу лимиты и учёт по CPU, памяти, дисковому и сетевому I/O.

# ограничить контейнер: 512 МБ памяти и 1.5 ядра CPU
docker run -d --name api --memory 512m --cpus 1.5 myapp

# текущее потребление по cgroup-учёту
docker stats --no-stream api
# NAME  CPU %   MEM USAGE / LIMIT   MEM %
# api   12.3%   210MiB / 512MiB     41.0%

Лимит CPU задаётся как доля процессорного времени (--cpus 1.5 ≈ полтора ядра), лимит памяти — жёсткой границей (--memory). Что произойдёт при превышении, зависит от ресурса. CPU-лимит просто тормозит (throttling): процесс не убивают, ему дают меньше времени. А вот выход за лимит памяти приводит к тому, что ядро убивает процесс через OOM-killer (Out Of Memory).

Как это работает под капотом

Когда runc запускает контейнер, он делает системный вызов clone() с флагами создания нужных namespaces, затем помещает процесс в созданную cgroup и записывает в её файлы значения лимитов. В современных системах используется cgroups v2 — единая иерархия в /sys/fs/cgroup/. Лимиты и счётчики — это обычные файлы: например, лимит памяти лежит в memory.max, а текущее потребление — в memory.current. Docker всего лишь пишет в них нужные числа от вашего имени.

# заглянуть в cgroup-файлы контейнера (cgroups v2)
cat /sys/fs/cgroup/.../memory.max      # жёсткий лимит памяти в байтах
cat /sys/fs/cgroup/.../memory.current  # сколько занято сейчас

Если процесс пытается превысить memory.max и память нельзя освободить, ядро отправляет ему сигнал — контейнер падает, а docker inspect покажет OOMKilled: true и код выхода 137.

Частые ошибки

  • Считать контейнер безопасной песочницей по умолчанию. Без user namespace root в контейнере — это во многом root на хосте. Для изоляции включают --userns-remap и снимают лишние capabilities.
  • Запускать контейнеры без лимитов памяти в продакшене. Утечка в одном контейнере без --memory способна выесть всю память узла и уронить соседей.
  • Не узнавать OOM по коду 137. Код выхода 137 = 128 + 9 (SIGKILL) — почти всегда это OOM-killer из-за превышения лимита памяти, а не «баг приложения».
  • Путать роли двух механизмов. namespaces дают изоляцию (что видно), cgroups — лимиты (сколько можно); одно не заменяет другое.

Итоги

  • Контейнер изолируется через namespaces (pid, net, mnt, uts, ipc, user) — каждый разделяет свой класс ресурсов ядра.
  • Из-за pid namespace главный процесс контейнера видит себя как PID 1, а на хосте это обычный большой PID.
  • cgroups ограничивают и учитывают CPU, память и I/O; --cpus и --memory пишут лимиты в cgroup-файлы.
  • Превышение CPU-лимита тормозит процесс, превышение лимита памяти ведёт к OOM-kill и коду выхода 137.
Проверьте себя
1. В чём принципиальное различие ролей namespaces и cgroups?
Anamespaces ограничивают видимость ресурсов, cgroups — их потребление
Bnamespaces лимитируют память, cgroups изолируют сеть
CЭто синонимы для одного механизма ядра
Dnamespaces работают только в Docker, cgroups — только в Kubernetes
2. Почему главный процесс внутри контейнера имеет PID 1, хотя на хосте это процесс с большим номером?
ADocker подменяет вывод ps
BИз-за pid namespace у контейнера собственное дерево процессов с нумерацией от 1
CКонтейнер запускается раньше всех процессов хоста
DЭто случайное совпадение номеров
3. Контейнер с лимитом --memory 256m превысил его. Что, скорее всего, произойдёт и какой будет код выхода?
AЛимит проигнорируется, контейнер продолжит работу
BКонтейнер только притормозит, как при CPU-лимите
COOM-killer ядра убьёт процесс, код выхода 137
DDocker автоматически увеличит лимит