Как работает изоляция: namespaces и cgroups

Рейтинг: 79.6% · 15 голосов
Практический курс по Docker: образы, контейнеры, тома, сети, Compose и продакшен. Уроки по главам с обсуждением.
Ответить
Аватара пользователя
Marina_DevOps
Сообщения: 54
Зарегистрирован: 11 май 2026, 05:31

Как работает изоляция: namespaces и cgroups

Сообщение Marina_DevOps »

Оглавление курса (46)
  1. Что такое Docker и какие задачи он решает
  2. Установка Docker и запуск первого контейнера
  3. Образы: слои, теги и реестр Docker Hub
  4. Пишем свой Dockerfile
  5. Тома и хранение данных: volumes и bind mounts
  6. Сети в Docker: связываем контейнеры между собой
  7. Переменные окружения и конфигурация контейнеров
  8. Docker Compose: поднимаем многоконтейнерное приложение
  9. Оптимизация образов: multi-stage сборка, размер и кэш слоёв
  10. Логи, отладка и мониторинг контейнеров
  11. Базовая безопасность контейнеров
  12. Подготовка к продакшену: что важно учесть
  13. Dockerfile глубже: ENTRYPOINT и CMD, HEALTHCHECK, .dockerignore, запуск не от root
  14. Реестры образов: приватные registry, push и pull, теги и digest, imagePullSecrets
  15. BuildKit и buildx: multi-arch сборки, секреты сборки, экспорт кэша
  16. Docker в CI/CD: автосборка, сканирование образов (Trivy, Docker Scout), публикация
  17. Итоговый проект и куда расти: от Dockerfile до прода, обзор оркестрации (Kubernetes, Podman, OCI)
  18. Архитектура Docker: dockerd, containerd, runc и OCI
  19. Как работает изоляция: namespaces и cgroups (вы здесь)
  20. Образы изнутри: слои, OverlayFS и storage driver
  21. Жизненный цикл контейнера и политики перезапуска
  22. Отладка контейнеров: exec, attach, nsenter, debug
  23. Сети Docker глубже: bridge, overlay, macvlan и iptables
  24. Docker Compose глубже: profiles, healthcheck-зависимости, override
  25. Данные и тома глубже: драйверы, бэкап, права
  26. Секреты и конфигурация: как не светить пароли
  27. Ограничение ресурсов: CPU, память, OOM и cgroups
  28. Здоровье и автозапуск: HEALTHCHECK, init и сигналы
  29. Логирование контейнеров: драйверы, ротация, сбор
  30. Мониторинг контейнеров: stats, cAdvisor, Prometheus
  31. Безопасность глубже: rootless, user namespaces, seccomp, capabilities
  32. Supply chain: сканирование, SBOM и подпись образов
  33. Глубокая оптимизация образов: distroless, scratch, кэш
  34. Multi-arch и сборка под ARM: buildx и эмуляция
  35. Реестры в продакшене: Harbor и облачные registry
  36. Docker без Docker: Podman, containerd, nerdctl, Buildah
  37. От Compose к оркестрации: когда контейнеров становится много
  38. Docker Swarm: встроенная оркестрация
  39. Сборка образов в CI без демона: kaniko, BuildKit, Buildah
  40. Траблшутинг Docker: типичные ошибки и как чинить
  41. Производительность и эксплуатация Docker-хоста
  42. Docker Desktop, WSL2 и альтернативы на Windows и Mac
  43. Контейнеризация приложений: бэкенд, фронтенд, БД
  44. Docker и Kubernetes: containerd, миграция, kompose
  45. Лучшие практики и антипаттерны Docker
  46. Сквозной проект: production-ready стек и путь дальше
Запусти контейнер, зайди внутрь через

Код: Выделить всё

docker exec -it
, набери - и увидишь два-три процесса вместо сотен с хоста. Набери

Код: Выделить всё

hostname
- там не имя сервера, а случайный хеш. Сделай - чужая файловая система. Кажется, что Docker создал отдельную виртуальную машину. Не создал. Это тот же самый ядро Linux, тот же планировщик, та же память. Контейнер - это обычный процесс на хосте, которому ядро показывает урезанную картину мира. Никакой магии Docker тут нет: вся изоляция контейнеров - это две фичи ядра, namespaces и cgroups, плюс пара мелочей сверху. Кто понимает этот фундамент, тот не паникует, когда контейнер "съел" память хоста или процесс внутри видит чужие сетевые интерфейсы. Разберём docker под капотом по косточкам и даже соберём мини-контейнер руками.

Namespaces: семь линз, через которые процесс смотрит на систему

Namespace (пространство имён) - это механизм ядра, который оборачивает какой-то глобальный ресурс так, что процессы внутри namespace видят свою изолированную версию этого ресурса. Ключевое слово - "видят". Память физически одна, ядро одно, но каждый процесс приписан к набору namespaces, и через них он смотрит на систему как через линзы. Поменял линзу - поменялась реальность процесса.

Linux namespaces бывают семи типов, и каждый изолирует свою грань:
  • pid - дерево процессов. Внутри своего pid namespace первый процесс получает PID 1 и не видит процессы хоста. Поэтому внутри контейнера твой - это PID 1, хотя на хосте он, скажем, PID 24817.
  • net - сетевой стек целиком: интерфейсы, таблицы маршрутизации, iptables/nftables, порты. У контейнера свой , свой , своя таблица портов. Два контейнера могут оба слушать порт 80 и не конфликтовать - порты в разных net namespaces.
  • mnt - точки монтирования. Это та самая "своя файловая система". Контейнер видит свой корень и свои маунты, не видит хоста.
  • uts - hostname и domainname. Отсюда случайное имя хоста внутри. UTS расшифровывается как Unix Timesharing System, исторический артефакт названия.
  • ipc - межпроцессное взаимодействие: System V IPC, очереди сообщений, разделяемая память POSIX. Чтобы процессы одного контейнера могли общаться через shared memory, а соседний контейнер этого не видел.
  • user - отображение UID/GID. Самый важный для безопасности: позволяет быть root (UID 0) внутри namespace, оставаясь непривилегированным пользователем на хосте. Фундамент rootless.
  • cgroup - изолирует вид на иерархию cgroups, чтобы процесс внутри не видел полный путь своей cgroup на хосте. Не путай namespace cgroup с самим механизмом cgroups - это разные вещи с похожим именем.
Под капотом всё это - три системных вызова. создаёт процесс сразу в новых namespaces (флаги

Код: Выделить всё

CLONE_NEWPID
,

Код: Выделить всё

CLONE_NEWNET
и т.д.).

Код: Выделить всё

unshare()
отвязывает текущий процесс от части namespaces. прицепляет процесс к уже существующему namespace - именно так работает

Код: Выделить всё

docker exec
и . Сами namespaces живут в ядре, а в файловой системе видны как симлинки в

Код: Выделить всё

/proc/PID/ns/
. Пока на namespace кто-то ссылается (живой процесс или bind-mount), он существует; последний ушёл - ядро его уничтожило.

Изображение

Щупаем namespaces руками: unshare и lsns

Хватит теории. Самый честный способ понять linux namespaces - создать их без всякого Docker. Утилита из пакета util-linux делает ровно это.

Код: Выделить всё

# создаём новый pid + mount + uts namespace и запускаем там bash
sudo unshare --pid --mount-proc --uts --fork --mount bash
Теперь внутри этого шелла:

Код: Выделить всё

# меняем hostname - это видно только тут, на хосте имя прежнее
hostname my-mini-container

# смотрим процессы - перемонтировали /proc, видим почти пусто
ps aux
USER  PID %CPU %MEM    VSZ   RSS TTY STAT START TIME COMMAND
root    1  0.0  0.0  10100  3200 pts/0 S  12:01 0:00 bash
root   14  0.0  0.0  11400  3400 pts/0 R+ 12:01 0:00 ps aux
Разбор: наш получил PID 1. Флаг обязателен, потому что первым процессом в новом pid namespace должен стать потомок unshare, а не сам unshare. Флаг

Код: Выделить всё

--mount-proc
перемонтирует , иначе читал бы старый хоста и показывал бы все процессы - namespace был бы, а картинка осталась бы старой. Это типичная ловушка: pid namespace изолирует дерево процессов, но смотрит не в ядро напрямую, а в , поэтому без перемонтирования эффекта не увидишь.

Из другого терминала на хосте посмотри список namespaces:

Код: Выделить всё

lsns -t pid
        NS TYPE NPROCS   PID USER  COMMAND
4026531836 pid     243     1 root  /sbin/init
4026532641 pid       2 24817 root  bash
Первая колонка - инфо-нода (inode) namespace, его уникальный идентификатор. Видишь две строки: основной pid namespace хоста и наш свежесозданный с двумя процессами. Этот же inode светится в

Код: Выделить всё

ls -l /proc/24817/ns/pid
. Именно так Docker и связывает контейнер с его изоляцией - под каждым контейнером лежит набор таких inode.

cgroups v2: учёт и лимиты ресурсов

Namespaces отвечают на вопрос "что процесс видит". cgroups отвечают на вопрос "сколько процесс может взять". Без cgroups контейнер видел бы урезанный мир, но мог бы сожрать всю память и весь CPU хоста - изоляция видимости без изоляции ресурсов бесполезна в продакшене. Это и есть второй столп cgroups docker.

В 2026 году все актуальные дистрибутивы и Docker Engine работают на cgroups v2 - единой иерархии. В отличие от v1 с её зоопарком отдельных деревьев на каждый контроллер, v2 - одно дерево в

Код: Выделить всё

/sys/fs/cgroup
, и каждый узел дерева включает нужные контроллеры. Основные контроллеры:
  • cpu - доля процессорного времени. Файл задаёт квоту в формате "quota period" в микросекундах.
  • memory - оперативная память.

    Код: Выделить всё

    memory.max
    - жёсткий потолок,

    Код: Выделить всё

    memory.current
    - текущее потребление,

    Код: Выделить всё

    memory.high
    - мягкий порог с троттлингом.
  • io - дисковый ввод-вывод по блочным устройствам, .
  • pids - максимум процессов/потоков,

    Код: Выделить всё

    pids.max
    . Защита от fork-бомбы.
Когда ты пишешь

Код: Выделить всё

docker run --memory=512m --cpus=1.5 nginx
, Docker создаёт под этот контейнер cgroup и записывает в её файлы лимиты. Найдём их вживую:

Код: Выделить всё

# узнаём полный id контейнера
docker inspect -f '{{.Id}}' my-nginx

# смотрим cgroup контейнера (путь через systemd-слайс)
ls /sys/fs/cgroup/system.slice/docker-<ID>.scope/

cat /sys/fs/cgroup/system.slice/docker-<ID>.scope/memory.max
536870912
cat /sys/fs/cgroup/system.slice/docker-<ID>.scope/cpu.max
150000 100000
Разбор полей.

Код: Выделить всё

memory.max = 536870912
- это ровно 512 мегабайт в байтах, наш лимит.

Код: Выделить всё

cpu.max = 150000 100000
читается так: за каждый период в 100000 микросекунд процессам этой cgroup разрешено суммарно 150000 микросекунд CPU-времени, то есть 1.5 ядра - наш

Код: Выделить всё

--cpus=1.5
. Если контейнер упрётся в memory.max и не сможет освободить память, ядерный OOM killer прибьёт процесс внутри, и ты увидишь в

Код: Выделить всё

docker inspect
статус

Код: Выделить всё

OOMKilled: true
. Это не баг Docker, это работа cgroup memory controller. Точно так же

Код: Выделить всё

docker stats
не выдумывает цифры - он читает

Код: Выделить всё

memory.current
и счётчики CPU из тех же файлов.

Как Docker собирает контейнер: namespaces плюс cgroups плюс capabilities плюс корень

Теперь склеим картину. Docker сам namespaces и cgroups не создаёт - этим занимается runtime. Docker Engine (демон ) отдаёт команды

Код: Выделить всё

containerd
, тот через шим запускает - низкоуровневый OCI-runtime, который и дёргает , настраивает cgroups и отдаёт управление твоему процессу. Когда говорят "k8s ушёл от dockershim" - речь ровно про это: Kubernetes выкинул прослойку к Docker и работает с containerd напрямую, потому что финальную работу всё равно делает runc, а не Docker.

Из чего runc собирает изоляцию контейнеров:
  • Namespaces - новые pid/net/mnt/uts/ipc (и user в rootless) для нужной видимости.
  • cgroups - cgroup с лимитами cpu/memory/io/pids.
  • pivot_root - смена корня на распакованный образ. Это не древний , а более надёжный

    Код: Выделить всё

    pivot_root
    , от которого тяжелее сбежать; mnt namespace делает смену корня приватной.
  • Capabilities - вместо бинарного "root или не root" Linux дробит привилегии root на ~40 кусочков (capabilities).

    Код: Выделить всё

    CAP_NET_BIND_SERVICE
    - право слушать порты ниже 1024,

    Код: Выделить всё

    CAP_SYS_ADMIN
    - почти всемогущий и опасный,

    Код: Выделить всё

    CAP_CHOWN
    - менять владельца файлов. Docker по умолчанию оставляет контейнеру урезанный набор и выбрасывает опасные. Поэтому контейнерный root - это не хостовый root: ему обрезали capabilities, навесили seccomp-профиль (фильтр системных вызовов) и LSM (AppArmor/SELinux).
Глянь живьём, что осталось от всемогущества:

Код: Выделить всё

docker run --rm alpine sh -c 'cat /proc/1/status | grep CapEff'
CapEff: 00000000a80425fb
# расшифровать набор:
capsh --decode=00000000a80425fb
В выводе ты увидишь конкретный список - там нет

Код: Выделить всё

cap_sys_admin
, нет

Код: Выделить всё

cap_sys_module
. Вот почему даже root внутри дефолтного контейнера не смонтирует произвольную ФС и не загрузит модуль ядра. А

Код: Выделить всё

--privileged
возвращает ВСЕ capabilities, снимает seccomp и отдаёт устройства - это эквивалент "пустить процесс рулить хостом", в проде так не делают без крайней нужды.

User namespace и rootless: root, которого не боишься

Самая интересная линза - user namespace. Она отображает диапазон UID/GID: UID 0 внутри namespace соответствует, например, UID 100000 на хосте. Процесс честно считает себя root, ставит пакеты, владеет файлами образа - но если он сбежит за пределы контейнера, на хосте он окажется бесправным пользователем 100000, который не может ничего.

На этом построен rootless-режим. В классическом Docker сам демон работает от root, и это исторически источник целого класса проблем: дыра в демоне = root на хосте. Rootless Docker (и rootless containerd, и Podman, который rootless by design) запускает и демон, и контейнеры внутри user namespace от обычного пользователя. Маппинг UID берётся из

Код: Выделить всё

/etc/subuid
и

Код: Выделить всё

/etc/subgid
, сеть поднимает

Код: Выделить всё

slirp4netns
в пользовательском пространстве, а за создание namespaces отвечает

Код: Выделить всё

RootlessKit
. Для cgroup-лимитов в rootless нужен cgroups v2 и systemd - иначе ограничивать ресурсы будет нечем.

Честная оговорка: rootless не серебряная пуля. unprivileged user namespaces - это сами по себе большая поверхность атаки ядра, в 2025-2026 регулярно всплывают CVE, эксплуатирующие именно их (поэтому Ubuntu по умолчанию ограничивает unprivileged userns через AppArmor). И в rootless есть свои ограничения: проброс привилегированных портов, некоторые сетевые сценарии, оверлей-ФС не всегда заводится. Но как слой защиты в supply chain и на многопользовательских хостах rootless резко снижает класс "побег = root хоста".

Типичные грабли и антипаттерны
  • ps внутри показывает всё с хоста. Если в pid namespace процессы хоста всё ещё видны - почти всегда забыли перемонтировать (

    Код: Выделить всё

    --mount-proc
    у unshare). Namespace создан, но читает старый .
  • Контейнер ест память хоста. Запустил без

    Код: Выделить всё

    --memory
    - cgroup без

    Код: Выделить всё

    memory.max
    , потолка нет, упрётся в физическую RAM и потащит за собой OOM killer, который может прибить вообще не тот процесс. В проде всегда ставь лимиты.
  • --privileged "чтобы заработало". Классический антипаттерн. Сначала пойми, какой именно capability нужен, и добавь точечно через

    Код: Выделить всё

    --cap-add
    . Девяти из десяти задач хватает одного-двух capability вместо полного privileged.
  • Путаница cgroups v1 и v2. На старом хосте с v1 пути в

    Код: Выделить всё

    /sys/fs/cgroup
    другие (отдельные подкаталоги , ), и часть инструментов/туториалов под v2 не сойдётся. Проверяй:

    Код: Выделить всё

    stat -fc %T /sys/fs/cgroup
    выдаёт

    Код: Выделить всё

    cgroup2fs
    для v2.
  • "Контейнер - это лёгкая VM". Нет. Ядро общее. Уязвимость ядра пробивает изоляцию всех контейнеров сразу - этого у настоящих VM нет. Для враждебного multi-tenant думают про gVisor, Kata Containers (микро-VM) или отдельные ноды.
Мини-лаба: собери изоляцию руками

Повтори на любом Linux с cgroups v2 (проверь

Код: Выделить всё

stat -fc %T /sys/fs/cgroup
):
  • Создай комбинированный namespace:

    Код: Выделить всё

    sudo unshare --pid --mount-proc --uts --net --fork bash
    . Внутри смени

    Код: Выделить всё

    hostname
    , выполни и - убедись, что дерево процессов урезано, а сетевой стек пустой (только в состоянии DOWN). Это твой "контейнер без образа".
  • Из второго терминала найди его:

    Код: Выделить всё

    lsns -t net
    и

    Код: Выделить всё

    lsns -t uts
    , сопоставь inode с

    Код: Выделить всё

    /proc/PID/ns/
    .
  • Сделай свою cgroup и положи туда процесс:

    Код: Выделить всё

    sudo mkdir /sys/fs/cgroup/lab
    echo "100M" | sudo tee /sys/fs/cgroup/lab/memory.max
    echo $$ | sudo tee /sys/fs/cgroup/lab/cgroup.procs
    cat /sys/fs/cgroup/lab/memory.current
    
    Теперь текущий шелл ограничен 100 мегабайтами. Запусти жадный процесс и посмотри, как

    Код: Выделить всё

    memory.current
    растёт и упирается в потолок.
  • Сравни с настоящим Docker:

    Код: Выделить всё

    docker run --rm --memory=100m --pids-limit=20 alpine sh
    , затем найди его scope в

    Код: Выделить всё

    /sys/fs/cgroup/system.slice/
    и прочитай

    Код: Выделить всё

    memory.max
    и

    Код: Выделить всё

    pids.max
    . Убедись, что цифры те же, что ты задал руками. Вывод один: Docker делает ровно то, что ты только что сделал вручную, плюс capabilities, seccomp и pivot_root.
После лабы удали учебную cgroup:

Код: Выделить всё

sudo rmdir /sys/fs/cgroup/lab
(предварительно убрав оттуда процессы).

Контрольные вопросы
  • Почему после

    Код: Выделить всё

    unshare --pid --fork
    без

    Код: Выделить всё

    --mount-proc
    команда всё равно показывает процессы хоста, хотя pid namespace уже создан?
  • Что произойдёт с процессом внутри контейнера, если он превысит

    Код: Выделить всё

    memory.max
    и не сможет освободить память, и где это будет видно в Docker?
  • Чем root внутри дефолтного контейнера отличается от root на хосте - назови минимум два механизма, которые его ограничивают.
  • Как user namespace позволяет процессу быть UID 0 внутри и при этом быть безопасным для хоста, и какой файл задаёт маппинг UID?
Итог

Контейнер - не виртуалка и не магия Docker, а обычный процесс хоста, которому ядро Linux подкрутило две вещи: namespaces (что он видит - pid, net, mnt, uts, ipc, user, cgroup) и cgroups v2 (сколько он может взять - cpu, memory, io, pids). Сверху runc накидывает pivot_root, обрезанные capabilities, seccomp и LSM, а user namespace даёт безопасный rootless. Docker, containerd, Podman - это удобные обёртки над одними и теми же системными вызовами ядра. Понимаешь namespaces и cgroups - понимаешь, почему контейнер ведёт себя так, а не иначе, и чинишь проблемы по сути, а не методом тыка.
👍3 ❤️2 🔥2 😄 🤔
Аватара пользователя
pronswe
Сообщения: 1
Зарегистрирован: 14 май 2026, 05:53

Re: Как работает изоляция: namespaces и cgroups

Сообщение pronswe »

Ого, я думал docker это типа мини виртуалка, а оказывается просто процесс с обрезанным /proc. Сделал unshare руками, у меня bash стал PID 1 - реально щелкнуло как это работает, спасибо
👍1 ❤️1 🔥2 😄 🤔
Аватара пользователя
pythonmain
Сообщения: 1
Зарегистрирован: 17 май 2026, 11:19

Re: Как работает изоляция: namespaces и cgroups

Сообщение pythonmain »

Вопрос по cpu.max - у меня показывает max 100000, это что значит без лимита? И правильно понимаю что если не ставить --memory то контейнер реально может утащить весь хост в OOM?
👍1 ❤️1 🔥1 😄 🤔1
Ответить
← Предыдущая глава
Архитектура Docker: dockerd, containerd, runc и OCI
Следующая глава →
Образы изнутри: слои, OverlayFS и storage driver

Все главы курса «Docker: контейнеризация от основ до продакшена»

Поделиться темой: ✈ Telegram VK
Похожие запросы: Раздача статики и SPA на nginx

Вернуться в «Docker с нуля»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость