Изоляция: 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 | точки монтирования: своя файловая система и корень / |
uts | hostname и доменное имя (можно задать свой) |
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.