Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → cgroups и namespaces: основы контейнеров

Как устроен контейнер: cgroups и namespaces без магии

Namespaces изолируют, cgroups ограничивают: как ядро Linux собирает «контейнер», что реально делают docker run и лимиты, и как диагностировать контейнер без магии.

Прямой ответ

Контейнер — не виртуальная машина, а обычный процесс ядра Linux с двумя добавками: namespaces (изоляция взгляда — процесс видит «свои» PID/сеть/файлы/хостнейм, не чужие) и cgroups (ограничение ресурсов — сколько CPU/памяти/IO ему положено); всё остальное (образы, слои, API) — удобная обёртка поверх (unshare/chroot-наследство — «контейнеризация» была в UNIX-идеях десятилетиями — ядро лишь довело до продукта); практическое следствие — общее ядро (уязвимость ядра — граница всех «контейнеров» сразу — отсюда rootless/USERNS-hardening — безопасность контейнеров; VM-изоляция — другой класс — gVisor/Firecracker — когда нужен он). Namespaces (главные семь): PID (внутри — своя нумерация процессов, PID 1 — ваш entrypoint — сигналы/зомби — «init-проблема» контейнеров: PID 1 не обрабатывает сигналы по умолчанию → zombie-накопление — docker run --init/tini — сигналостойкий entrypoint), NET (своя сетевая карта/таблицы/порты — «порт 8000 внутри» не виден снаружи без публикации — сети docker; loopback свой — поднять lo обязательно), MNT (своя точка монтирования — корень из образа; volume — bind-mount в эту namespace — хранение), UTS (свой hostname — «контейнер думает, что он сервер app-1»), IPC (своя System V IPC/POSIX-очереди — разделяемая память между процессами внутри), USER (uid-маппинг: внутренний root = внешний непривилегированный — основа rootless — самый важный для безопасности), Cgroup/Time (вид дерева cgroup / сдвиг часов — реже трогаем); lsns/ls /proc/<pid>/ns — увидеть namespaces живого процесса; unshare — «собрать контейнер руками»: unshare --pid --fork --mount-proc bash — bash в своём PID-пространстве (5 строк, магия исчезает — лучший учебный опыт — после него docker run читается как спецификация). cgroups — иерархия контроля ресурсов: память (memory.max — жёсткий лимит — OOM kill при превышении: «контейнер умер без логов, exit 137» — ядро убило по памяти — диагностика — лимит контейнера ≠ память хоста, приложение должно знать свой лимит — Java/JVM-флаги, Go — обычно само — кэши/пулы под лимит); CPU (cpu.max — квота: «2 CPU» = 200 000 мкс CPU-времени на 100 000 мкс период — троттлинг при исчерпании (не «медленнее плавно», а стоп-старт — латентность пилит — container_cpu_cfs_throttled_periods_total в k8s — лимиты — limits по CPU часто вреднее пользы); IO (io.max — пропускная/IOPS по устройствам), pids (лимит процессов — fork-бомба не убьёт хост — --pids-limit); cgroup v2 — единая иерархия (v1 — раздельные контроллеры — наследие; современные дистрибутивы/docker на v2 — stat -fc %T /sys/fs/cgroup — cgroup2fs — проверка); «контейнер видит лимит» — /sys/fs/cgroup внутри — приложения, читающие «память машины» (/proc/meminfo — хостовое!) — врут себе — лимит из cgroup (JVM/Gunicorn-тюнинг — частый баг «в контейнере workers по хостовым ядрам» — процессы).

docker run как спецификация namespaces/cgroups

docker run -m 512m --cpus 1.5 --pids-limit 256 --network mynet -v data:/data --hostname app1 img разворачивается: -m 512m → memory.max=536870912 (OOM-граница), --cpus 1.5 → cpu.max «150000 100000» (квота), -v data:/data → bind/том в MNT-namespace, --network mynet → veth-пара в NET-namespace (один конец в контейнере (eth0), другой — в мосту хоста — NAT/порт-форвардинг — «publish 8000» = iptables-правило на хосте), --hostname → UTS, --pids-limit → pids.max; docker inspect — разделы HostConfig (ресурсы) и конфиг — «паспорт» контейнера (что реально включено — читать перед прод-деплоем — «думали лимит есть» — харднинг-чеклист); docker stats — живые cpu/mem/io/pids (cgroup-метрики — источник — Prometheus cAdvisor собирает то же — метрики контейнеров); rootless mode (userns + rootless daemon — процесс демона от вашего uid — компромисс ядра на хосте (вложенная userns) — локальный стек — ноутбуку достаточно); «контейнер = процесс» проверяется руками: ps aux | grep <entrypoint> на хосте — ваш «контейнерный» процесс как обычный (pid другой namespace'ной нумерации) — docker top — мост между двумя взглядами (хостовым и контейнерным PID).

Практический сценарий: контейнер «сам умер» — разбор без магии

Исходник: прод, под в k8s периодически перезапускается, логи приложения обрываются без ошибки, kubectl describe — «OOMKilled», exit 137 — команда: «приложение падает» — дебажат код, ничего нет (и правильно — код не падал, убило ядро). Диагностика (полчаса, по слоям): (1) подтверждение OOM — kubectl get pod -o yaml → lastState.terminated.reason=OOMKilled — cgroup memory.max исчерпан (лимит пода 512 МБ — requests/limits); (2) «сколько нужно на самом деле» — container_memory_working_set_bytes в Grafana — рабочий набор растёт до ~520 МБ в пиках (кэш страниц приложения + пул соединений — приложение честно растёт, лимит впритык); (3) нюанс, который не нашли бы без cgroup-понимания — приложение читало «свободную память» из /proc/meminfo — хостовые 64 ГБ (namespace не изолирует /proc/meminfo по умолчанию!) — внутренний autosize кэша раздувался под «хост» — фикс: лимит передаём приложению env'ом (CACHE_MAX_MB=400) — кэш в границах cgroup, не «по ощущениям из proc»; (4) лимит пода 512 → 768 МБ (working set + запас 30% — расчёт по метрикам, не на глаз); (5) попутно — pids: docker inspect/pod-spec — pids-limit не задан (дефолт ядра — тысячи) — ставим 512 (защита от «пул потоков утёк» — наблюдали в профилях ранее — лимит превратит утечку в явную ошибку, не в смерть хоста); (6) CPU-квота — cpu.max 50000/100000 («0.5 CPU») при батчевой обработке — троттлинг (throttled_periods растёт — p99 рваный) — CPU-limit сняли (оставили request — почему limits по CPU — зло — бурст важнее предсказуемости для batch); (7) проверка руками на стенде — docker run -m 512m --cpus 0.5 ... + stress-ng/нагрузочный профиль — воспроизвели OOM/троттлинг локально за минуты (тест-контейнеры — «инцидент как тест»); результат: OOM-рестарты ноль за месяц, p99 ровный; вывод в runbook: «exit 137 = cgroup OOM, не «краш приложения» — смотреть working set против лимита и то, что приложение думает о своей памяти» — следующий дежурный закрыл бы за 10 минут; понимание cgroups/namespaces здесь — не академия: без него «/proc/meminfo врёт в контейнере» и «137 = OOM kill» — магия, с ним — спецификация.

FAQ

Контейнер против виртуальной машины — в чём принципиальная разница? VM — гипервизор + гостевое ядро (полная изоляция, сотни МБ оверхеда, секунды старта); контейнер — процесс общего ядра + namespaces/cgroups (изоляция weaker — ядро общее: CVE ядра — граница всех контейнеров хоста, оверхед ~ноль, старт миллисекунды); выводы: один доверенный код на хосте (ваш) — контейнеры достаточно; мультиарендность с чужим кодом — VM-граница или sandboxed-рантаймы (gVisor/Firecracker — «контейнер в микро-VM» — hardening-выбор); user namespace (rootless) — приближает контейнер к VM-изоляции по uid-границе, но ядро всё ещё общее — «контейнер = процесс» — главное ментальное правило.

Что такое PID 1 в контейнере и почему с ним проблемы? В PID-namespace контейнера entrypoint получает PID 1 — а у PID 1 в Linux особые обязанности (обработка сигналов по умолчанию, усыновление/похороны зомби-детей); обычный python/node как PID 1: SIGTERM игнорируется (нет обработчика по умолчанию у PID 1 — graceful shutdown не работает — «docker stop ждёт 10 с и SIGKILL») + зомби-дети копятся (shell-скрипт entrypoint не жнёт); решения: --init (tini как PID 1 — docker-флаг) или явный tini в образе (ENTRYPOINT ["tini","--"]) + приложение ловит SIGTERM (graceful); k8s — shareProcessNamespace/tini-образы — та же механика; проверка: docker exec ps 1 — кто реально PID 1 в вашем контейнере (часто — /bin/sh — сюрприз).

Почему приложение в контейнере «не видит» лимит памяти/CPU? /proc/meminfo и /proc/cpuinfo — хостовые (namespace их не подменяет — исторически), а cgroup-лимиты живут в /sys/fs/cgroup — приложения, определяющие ресурсы по proc (кэши, пулы, воркеры «по числу ядер») — настраиваются под хост (64 ГБ/32 ядра) и OOM'ятся в 512 МБ; решения: языковые runtime'ы cgroup-aware (JVM UseContainerSupport — по умолчанию в новых, Go runtime — сам, .NET — сам), Python — передавать лимит env'ом и считать от него (workers = f(CPU_LIMIT)), библиотеки-помощники (читают cgroup-файлы); проверка — cat /sys/fs/cgroup/memory.max внутри контейнера против /proc/meminfo — разница и есть баг-класс; lxcfs — костыль «proc как cgroup» — реже нужен в 2026 (runtime'ы научились).

Как увидеть namespaces/cgroups своего контейнера руками? Namespaces: ls -l /proc/<pid>/ns/ (pid.* — иноды — одинаковый инод = одна namespace — сравнение «контейнерных» и хостовых процессов), lsns -t pid (обзор по хосту — какие namespaces вообще живут), nsenter -t <pid> -n ip addr (войти в NET-namespace процесса — «посмотреть сеть глазами контейнера» — незаменимо при «порт не пингуется»); cgroups: cat /proc/<pid>/cgroup (путь в иерархии v2), cat /sys/fs/cgroup/<путь>/memory.max|memory.current|cpu.stat (лимиты/текущее/троттлинг — источник правды), systemd-cgls/systemd-cgtop (дерево+топ по cgroups — «кто ест» по-контейнерно); docker: docker inspect (конфиг), docker stats (живое), docker exec cat /proc/1/cgroup (взгляд изнутри); этот набор (lsns/nsenter/cgroup-файлы) — «рентген» контейнера — диагностическая культура — магия уходит, остаётся инженерия.

Источники: man 7 namespaces, документация ядра cgroup-v2, docker run reference — ссылки в шапке.