Пространства имён и контрольные группы
Предпосылки
процессы (fork, clone, PID), планировщик (CFS, vruntime), файловые системы (mount, VFS), управление памятью (overcommit, OOM killer).
← Загрузка системы | Контейнеры →
Ядро работает, systemd запустил сервисы. Но все процессы на сервере делят одно пространство PID, одну сеть, одни ресурсы. Утечка памяти в одном сервисе может убить соседний через OOM killer — потому что ядро не различает, кому принадлежит какой процесс. Для изоляции внутри одной ОС нужны механизмы, которые разделят общую картину мира на независимые области.
На сервере с 16 ГБ RAM работают два клиента. Клиент A запустил процесс конвертации видео, который начал потреблять всю доступную память. Когда свободная RAM закончилась, ядро активировало OOM (Out of Memory) killer — и он выбрал процесс клиента B (веб-приложение), потому что тот занимал больше всего RSS (Resident Set Size) в момент проверки. Клиент B потерял сервис из-за чужого процесса, хотя сам потреблял стабильные 2 ГБ.
Проблема глубже, чем кажется. Клиент A вызвал kill -9 на PID 1823 — и убил процесс клиента B, потому что оба делят общее пространство PID (Process ID), а между ними нет изоляции по UID/capabilities: зная PID и имея те же привилегии, A может послать сигнал B. Клиент A запустил веб-сервер на порту 80 — и клиент B не может занять тот же порт, хотя оба платят за «выделенный сервер». Клиент A видит через /proc все процессы на машине: PID, аргументы командной строки, переменные окружения.
Для реальной изоляции нужны два механизма: пространства имён (namespaces) скрывают ресурсы одного клиента от другого, контрольные группы (cgroups) ограничивают потребление, чтобы один клиент не мог израсходовать ресурсы, принадлежащие другому.
Пространства имён: что процесс видит
Пространство имён (namespace) ограничивает видимость. Процесс внутри пространства имён видит только ресурсы этого пространства — остальные для него не существуют. Ядро поддерживает восемь типов пространств имён, каждый отвечает за свой класс ресурсов.
PID namespace: собственное дерево процессов
Без PID namespace все процессы на сервере видят друг друга через /proc и ps. Клиент A может отправить сигнал процессу клиента B, зная его PID.
PID namespace создаёт отдельное дерево процессов. Первый процесс внутри нового PID namespace получает PID 1 — он становится локальным init для этого пространства. Все процессы, порождённые внутри, получают PID из собственного счётчика: 1, 2, 3, и так далее. Процесс с PID 1 внутри пространства имён клиента A на уровне хоста имеет совершенно другой PID — допустим, 4527.
Хост (PID namespace корневой):
PID 1 (systemd)
PID 4527 (init клиента A) PID 5102 (init клиента B)
PID 4528 (worker A) PID 5103 (webapp B)
PID 4529 (ffmpeg A) PID 5104 (postgres B)
Клиент A видит: Клиент B видит:
PID 1 (init) PID 1 (init)
PID 2 (worker) PID 2 (webapp)
PID 3 (ffmpeg) PID 3 (postgres)Клиент A вызывает kill -9 2 — убивает собственный worker, а не процесс клиента B. У него нет ни возможности узнать PID 5103, ни послать ему сигнал. Процессы клиента B для него не существуют.
PID 1 внутри namespace выполняет роль init: собирает зомби-процессы (orphaned children), получает сигналы при завершении потомков. Если PID 1 внутри namespace завершается, ядро уничтожает все процессы в этом пространстве — аналог выключения машины.
Mount namespace: собственная файловая система
Процессы клиентов A и B в общем mount namespace видят одну и ту же файловую систему. Клиент A может прочитать /home/clientB/config.yml и увидеть чужие ключи.
Mount namespace даёт процессу собственное дерево монтирования. mount и umount внутри одного mount namespace не влияют на другие. Клиент A видит / как корень своего chroot-окружения, клиент B — свой. Каждый может установить свою версию библиотек, свой /etc/resolv.conf, свой набор сертификатов в /etc/ssl/.
Network namespace: собственный сетевой стек
Клиент A и клиент B оба хотят слушать порт 80. В общем network namespace это невозможно: bind() на 0.0.0.0:80 вернёт EADDRINUSE.
Network namespace создаёт изолированный сетевой стек: собственный набор интерфейсов, собственные таблицы маршрутизации, собственные правила iptables, собственный пул портов. Новый network namespace при создании содержит единственный интерфейс — lo (loopback), и тот в состоянии DOWN.
Для связи с внешним миром ядро создаёт veth pair (virtual Ethernet pair — пара виртуальных Ethernet-интерфейсов). Один конец пары помещается в network namespace контейнера, другой — в хостовой namespace, обычно подключённый к bridge-интерфейсу (программный коммутатор, соединяющий несколько виртуальных интерфейсов в одну L2-сеть). Пакет, отправленный в один конец veth, мгновенно появляется на другом — как виртуальный кабель между двумя пространствами.
flowchart LR subgraph A["Namespace A"] EA["eth0<br>10.0.1.2"] end subgraph Host["Хост"] BR["docker0<br>(bridge)"] PHY["eth0 (физический)<br>203.0.113.10"] BR --- PHY end subgraph B["Namespace B"] EB["eth0<br>10.0.1.3"] end EA ---|"veth"| BR BR ---|"veth"| EB
Оба клиента слушают порт 80 внутри своего namespace. На хосте iptables/DNAT (Destination NAT) перенаправляет входящие соединения: 203.0.113.10:8080 → 10.0.1.2:80 (клиент A), 203.0.113.10:8081 → 10.0.1.3:80 (клиент B).
UTS namespace: собственное имя хоста
UTS namespace (Unix Time-Sharing) изолирует имя хоста и доменное имя. Клиент A вызывает hostname client-a-web — и это не влияет на hostname клиента B. Без UTS namespace вызов sethostname() изменил бы имя для всей машины.
User namespace: root внутри, nobody снаружи
Клиент A хочет устанавливать пакеты через apt install — для этого нужен root. Но реальный root на хосте — прямая угроза: побег из контейнера с привилегиями root даёт полный контроль над сервером.
User namespace создаёт отображение UID (User ID) / GID (Group ID): пользователь с UID 0 (root) внутри namespace отображается на непривилегированного пользователя на хосте — например, UID 100000. Процесс внутри namespace может выполнять привилегированные операции (mount, создание сетевых интерфейсов, изменение hostname) в пределах своего namespace, но на хосте он остаётся обычным пользователем. Файл /proc/<pid>/uid_map хранит таблицу маппинга:
# внутри namespace: на хосте: диапазон:
0 100000 65536Это означает: UID 0-65535 внутри namespace соответствуют UID 100000-165535 на хосте.
Остальные типы пространств имён
IPC (Inter-Process Communication) namespace изолирует очереди сообщений System V, семафоры и разделяемую память. Без него shmget() одного клиента может столкнуться с ключами другого.
Cgroup namespace скрывает структуру контрольных групп хоста. Процесс внутри cgroup namespace видит свою cgroup как корневую — он не знает о существовании других cgroup на машине. /proc/self/cgroup показывает / вместо /system.slice/docker-abc123.scope.
Time namespace (появился в Linux 5.6, 2020) позволяет процессу видеть другое значение CLOCK_MONOTONIC и CLOCK_BOOTTIME. Это нужно при миграции контейнеров между серверами: контейнер, запущенный на машине с uptime 30 дней, переезжает на свежий сервер — без time namespace clock_gettime(CLOCK_MONOTONIC) резко изменится, что может сломать таймеры внутри приложения.
Создание пространств имён: системные вызовы
Три системных вызова управляют пространствами имён: clone(), unshare() и setns().
clone() создаёт новый процесс (как fork()) и одновременно помещает его в новые пространства имён. Флаги CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWNS (mount), CLONE_NEWUTS, CLONE_NEWUSER, CLONE_NEWIPC, CLONE_NEWCGROUP, CLONE_NEWTIME указывают, какие пространства создать. Можно комбинировать:
/* Создать процесс в новых PID, mount и network namespace */
clone(child_fn, stack_top,
CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | SIGCHLD,
arg);unshare() создаёт новые пространства имён для уже существующего процесса, не создавая дочерний. Текущий процесс «отсоединяется» от пространства имён родителя и получает собственную копию:
/* Текущий процесс получает собственный mount namespace */
unshare(CLONE_NEWNS);
/* Теперь mount/umount не влияют на родителя */
mount("tmpfs", "/tmp", "tmpfs", 0, "size=100M");setns() присоединяет текущий процесс к уже существующему пространству имён другого процесса. Пространства имён процесса представлены как файлы в /proc/<pid>/ns/:
ls -l /proc/4527/ns/
# lrwxrwxrwx pid -> pid:[4026532198]
# lrwxrwxrwx net -> net:[4026532201]
# lrwxrwxrwx mnt -> mnt:[4026532199]
# ...Каждый файл — символическая ссылка с inode-номером пространства имён. Два процесса с одинаковым номером находятся в одном пространстве.
/* Присоединиться к network namespace процесса 4527 */
int fd = open("/proc/4527/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET);
close(fd);
/* текущий процесс теперь видит сетевые интерфейсы контейнера */Важная деталь: для PID namespace setns() работает иначе, чем для остальных. Вызывающий процесс не меняет свой PID namespace — изменение вступает в силу только для его дочерних процессов. Поэтому docker exec делает setns() для всех namespace (PID, mount, network, UTS, IPC, cgroup), а затем fork() + exec() — именно дочерний процесс оказывается внутри PID namespace контейнера и получает PID в его пространстве.
Утилиты командной строки: unshare и nsenter
Системные вызовы unshare() и setns() обёрнуты в одноимённые утилиты. unshare создаёт новые пространства имён и запускает в них команду:
# Создать PID + mount namespace, запустить bash
sudo unshare --pid --mount --fork bash
# Внутри нового namespace:
echo $$
# 1 <-- PID 1 внутри namespace
ps aux
# Показывает процессы хоста — procfs ещё от старого mount namespace.
# Перемонтируем /proc:
mount -t proc proc /proc
ps aux
# PID COMMAND
# 1 bash
# Только собственные процессыnsenter — аналог setns() для командной строки. Входит в пространства имён указанного процесса:
# Войти во все namespace процесса с PID 4527
sudo nsenter --target 4527 --all bash
# Войти только в network namespace
sudo nsenter --target 4527 --net ip addr show
# Покажет интерфейсы контейнера, а не хостаДиагностический приём: если контейнер запущен на базе distroless-образа (образ без оболочки, пакетного менеджера и стандартных утилит — только приложение и его зависимости), nsenter --target <PID> --net ss -tlnp покажет сетевые соединения контейнера с хоста.
Контрольные группы: сколько процесс может потратить
Пространства имён решили проблему видимости: клиент A не видит процессы клиента B. Но клиент A по-прежнему может потребить все 16 ГБ RAM — и OOM killer убьёт процессы клиента B, потому что лимита на потребление нет. Пространства имён ограничивают видимость, но не потребление. Для этого нужен другой механизм — контрольные группы (cgroups, control groups).
Cgroup — именованная группа процессов с количественными лимитами на ресурсы. Процессы внутри cgroup могут использовать CPU, память, дисковый I/O — но не больше установленного предела.
Контроллер памяти: memory.max и OOM внутри группы
Возвращаемся к сценарию: клиент A запускает конвертацию видео, которая пожирает память. Создадим cgroup с жёстким лимитом 2 ГБ:
# Cgroups v2: создать группу client-a
mkdir /sys/fs/cgroup/client-a
# Установить лимит памяти 2 ГБ
echo 2G > /sys/fs/cgroup/client-a/memory.max
# Поместить процесс в группу
echo 4527 > /sys/fs/cgroup/client-a/cgroup.procs
# Проверить текущее потребление
cat /sys/fs/cgroup/client-a/memory.current
# 847249408 (~808 МБ)Когда процессы внутри cgroup client-a попытаются суммарно превысить 2 ГБ, ядро активирует OOM killer — но только внутри этой cgroup. Убит будет процесс клиента A, не клиента B. Хост и другие cgroup не затронуты. Это принципиальное отличие от системного OOM killer, который выбирает жертву среди всех процессов на машине.
memory.current показывает фактическое потребление. memory.high (мягкий лимит) замедляет выделение памяти через throttling (искусственное торможение — ядро вставляет задержки в путь выделения), давая приложению шанс освободить память до того, как будет достигнут жёсткий memory.max. memory.events содержит счётчики: сколько раз был достигнут high, сколько раз сработал OOM.
Контроллер CPU: cpu.max и квоты
Клиент A запустил ffmpeg, который нагружает все 8 ядер на 100%. Клиент B получает крохи CPU.
cpu.max задаёт квоту в формате $QUOTA $PERIOD — сколько микросекунд из каждого периода cgroup может использовать CPU:
# Клиент A: максимум 200 мс из каждых 100 мс
# (эквивалент 2 полных ядер из 8)
echo "200000 100000" > /sys/fs/cgroup/client-a/cpu.maxЗначение 200000 100000 означает: за каждые 100 мс (100 000 мкс) процессы в этой cgroup суммарно получат не более 200 мс (200 000 мкс) процессорного времени. Поскольку за 100 мс реального времени 8 ядер дают 800 мс CPU-времени, квота 200 мс — это 25% всех CPU-ресурсов, или эквивалент двух полных ядер.
cpu.weight (диапазон 1-10000, по умолчанию 100) работает иначе — это относительный вес при конкуренции. Если у клиента A вес 100 и у клиента B вес 300, при полной загрузке B получит втрое больше CPU. Но если B не использует свою долю — A может забрать свободное. cpu.max — жёсткий потолок, cpu.weight — пропорциональное деление.
Контроллер I/O: io.max и полоса пропускания
Клиент A записывает на диск результаты конвертации — 500 МБ/с непрерывно. Дисковая подсистема перегружена, запросы клиента B на чтение базы данных ждут в очереди. io.max ограничивает I/O по числу IOPS (I/O Operations Per Second) и полосе пропускания, привязывая лимит к конкретному устройству:
# Узнать major:minor номер устройства
lsblk -o NAME,MAJ:MIN
# sda 8:0
# Ограничить запись клиента A: максимум 50 МБ/с, 1000 IOPS на запись
echo "8:0 rbps=max wbps=52428800 riops=max wiops=1000" \
> /sys/fs/cgroup/client-a/io.maxCgroups v1 и v2: два поколения
Cgroups v1 (Linux 2.6.24, 2008) использовали отдельную иерархию для каждого контроллера. Контроллер памяти монтировался в /sys/fs/cgroup/memory/, CPU — в /sys/fs/cgroup/cpu/, I/O — в /sys/fs/cgroup/blkio/. Процесс мог находиться в cgroup A для памяти и в cgroup B для CPU. Такая модель усложняла управление: чтобы ограничить все ресурсы одного клиента, приходилось создавать и синхронизировать группы в нескольких иерархиях.
Cgroups v2 (Linux 4.5, 2016; по умолчанию в systemd с ~2020) используют единую иерархию. Все контроллеры — memory, cpu, io, pids — работают в одном дереве /sys/fs/cgroup/. Процесс принадлежит одной cgroup, и все лимиты задаются в ней:
/sys/fs/cgroup/ (корневая cgroup)
+-- client-a/ (cgroup клиента A)
| memory.max = 2G
| cpu.max = 200000 100000
| io.max = 8:0 wbps=52428800
| cgroup.procs = 4527, 4528, 4529
|
+-- client-b/ (cgroup клиента B)
memory.max = 4G
cpu.max = 400000 100000
cgroup.procs = 5102, 5103, 5104В cgroups v2 появился механизм PSI — pressure stall information (информация о простоях из-за нехватки ресурсов): файлы cpu.pressure, memory.pressure, io.pressure показывают, какую долю времени процессы в cgroup ожидают ресурс. Значение some avg10=25.00 в memory.pressure означает: за последние 10 секунд 25% времени хотя бы один процесс в cgroup ожидал память. Проверить, какая версия используется:
mount | grep cgroup
# cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec)Если вывод содержит cgroup2 — система работает на v2. Если несколько строк с cgroup (memory, cpu, blkio отдельно) — это v1.
systemd и контрольные группы
На современных серверах с systemd каждый сервис автоматически получает собственную cgroup. При systemctl start nginx systemd создаёт cgroup /system.slice/nginx.service, запускает main-процесс внутри неё, и все дочерние процессы наследуют эту cgroup.
systemd-cgls
# Control group /:
# -.slice
# +-- system.slice
# | +-- nginx.service
# | | +-- 1234 nginx: master process
# | | +-- 1235 nginx: worker process
# | | +-- 1236 nginx: worker process
# | +-- postgresql.service
# | +-- 1300 /usr/lib/postgresql/16/bin/postgres
# +-- user.slice
# +-- user-1000.slice
# +-- session-1.scope
# +-- 2000 bash
# +-- 2001 vimsystemd-cgtop работает как top, но показывает потребление по cgroup: CPU, память, I/O для каждого сервиса.
Лимиты задаются в unit-файле:
[Service]
MemoryMax=2G
CPUQuota=200%
IOWriteBandwidthMax=/dev/sda 50MCPUQuota=200% означает эквивалент двух полных ядер — тот же лимит, что и echo "200000 100000" > cpu.max, но в удобной нотации. MemoryMax=2G транслируется в memory.max.
Важное следствие: systemctl stop nginx отправляет сигнал main-процессу, а затем убивает все процессы в cgroup сервиса. Даже если nginx форкнул дочерний процесс, который отсоединился от сессии (демонизировался), он всё равно остаётся в cgroup и будет завершён. До cgroups демонизированные процессы «убегали» от init-скрипта — kill $(cat /var/run/nginx.pid) не затрагивал потомков, которые могли продолжать висеть в памяти.
OOM внутри cgroup: взрыв в изолированном отсеке
Вернёмся к исходной проблеме. Клиент A запустил процесс, который потребляет всю память. Без cgroups OOM killer выбирает жертву среди всех процессов на машине, ранжируя по oom_score — комбинации RSS, oom_score_adj и других факторов. Жертвой может стать любой процесс, включая базу данных клиента B.
С cgroups v2 OOM killer работает в рамках cgroup. Когда суммарное потребление памяти процессами в cgroup client-a достигает memory.max = 2 ГБ, ядро пытается высвободить память внутри группы (page reclaim, writeback). Если не удаётся — ядро вызывает OOM killer, который выбирает жертву исключительно среди процессов этой cgroup. Процессы клиента B в cgroup client-b не рассматриваются.
# После OOM в cgroup client-a:
dmesg | tail
# Memory cgroup out of memory: Killed process 4529 (ffmpeg)
# total-vm:4521984kB, anon-rss:2097152kB
# Процессы клиента B продолжают работать:
cat /sys/fs/cgroup/client-b/cgroup.procs
# 5102
# 5103
# 5104Счётчик memory.events фиксирует события:
cat /sys/fs/cgroup/client-a/memory.events
# low 0
# high 12
# max 3
# oom 1
# oom_kill 1oom 1 — OOM killer вызван один раз. high 12 — мягкий лимит был достигнут 12 раз (если настроен memory.high). Эти счётчики — основа мониторинга: если high растёт, приложение подходит к лимиту, и стоит либо оптимизировать потребление, либо увеличить квоту.
Пространства имён + контрольные группы = строительные блоки контейнера
Итог сценария. Клиент A и клиент B на одном физическом сервере:
PID namespace — A не видит процессы B, не может послать им сигнал. Mount namespace — A видит собственную файловую систему с собственными библиотеками. Network namespace — оба слушают порт 80, каждый в своём сетевом стеке, связаны с хостом через veth pair. User namespace — root внутри пространства A не имеет привилегий на хосте. Cgroup memory.max = 2 ГБ — OOM killer при превышении убивает процессы A, а не B. Cgroup cpu.max — A не может занять все ядра.
Всё это — механизмы ядра Linux, доступные через системные вызовы и псевдофайловую систему /sys/fs/cgroup/. Их можно комбинировать вручную: unshare + mount + echo PID > cgroup.procs. На практике эту сборку автоматизируют контейнерные среды: Docker вызывает clone() с нужным набором флагов CLONE_NEW*, создаёт cgroup, настраивает veth pair, монтирует файловую систему из образа — и получается то, что называется «контейнером». Контейнер — не отдельная сущность ядра, а комбинация пространств имён и контрольных групп, скреплённых вместе.
Sources
- Michael Kerrisk, 2013-2016, Namespaces in operation — LWN.net article series: https://lwn.net/Articles/531114/
man 7 namespaces: https://man7.org/linux/man-pages/man7/namespaces.7.htmlman 7 cgroups: https://man7.org/linux/man-pages/man7/cgroups.7.html
← Загрузка системы | Контейнеры →