Анатомия контейнеризации: namespaces, cgroups и архитектура Docker Engine
Анатомия контейнеризации: namespaces, cgroups и архитектура Docker Engine
Если вы запустите команду top внутри свежего контейнера Ubuntu, вы увидите всего пару процессов. Кажется, будто вы находитесь внутри крошечной, легковесной виртуальной машины со своей собственной операционной системой. Но это величайшая иллюзия в современной IT-индустрии. На самом деле никаких «контейнеров» на уровне ядра Linux не существует.
Для ядра Linux контейнер — это абсолютно обычный процесс, точно такой же, как ваш браузер или текстовый редактор. Разница лишь в том, что к этому процессу применены два механизма изоляции: Namespaces (пространства имён) и Control Groups (контрольные группы, или cgroups).
Чтобы научиться оптимизировать контейнеры и обеспечивать их безопасность на уровне Senior SRE, нам нужно разрушить магию Docker и посмотреть, как он дергает за ниточки операционной системы.
Иллюзия одиночества: Linux Namespaces
Представьте, что вы надели на человека очки виртуальной реальности, в которых он видит пустую комнату, хотя на самом деле находится на оживленном вокзале. Именно это механизм Namespaces делает с процессами. Он ограничивает то, что процесс может видеть.
Существует несколько типов пространств имён, но для понимания сути мы разберем самый наглядный — PID Namespace (пространство идентификаторов процессов).
В обычной Linux-системе каждому процессу выдается уникальный номер — PID (Process ID). Процесс инициализации системы всегда получает PID 1, следующий — PID 2, и так далее. Когда Docker запускает ваш контейнер, он просит ядро Linux создать для этого процесса новое пространство имён PID.
Внутри этого нового пространства процесс уверен, что он тут самый первый и главный, поэтому получает почетный PID 1. Но на уровне хост-системы (вашего сервера) ядро знает правду: для него это просто очередной процесс, которому выдан, скажем, PID 3452.
Контейнеризация — это не виртуализация железа. Это изоляция видимости. Процесс в контейнере использует то же самое ядро ОС, что и хост, просто ядро фильтрует для него системные вызовы, скрывая соседей.
Помимо PID, Docker использует и другие Namespaces:
- Network Namespace: дает процессу собственный сетевой стек, свои IP-адреса и таблицы маршрутизации (поэтому у контейнера есть свой IP).
- Mount Namespace: изолирует точки монтирования файловой системы (процесс видит только файлы образа, а не весь жесткий диск сервера).
- UTS Namespace: позволяет контейнеру иметь собственное имя хоста (hostname).
Укрощение аппетита: Control Groups (cgroups)
Namespaces отлично справляются с тем, чтобы спрятать от процесса остальную систему. Но они никак не ограничивают его жадность. Если процесс с PID 1 внутри контейнера решит занять все 100% процессорного времени или выделить себе 64 ГБ оперативной памяти, он это сделает, и соседние сервисы на сервере упадут.
Здесь на сцену выходит второй столп контейнеризации — cgroups.
Если Namespaces ограничивают то, что процесс видит, то cgroups ограничивают то, что процесс может использовать. Это механизм ядра Linux, который позволяет объединять процессы в иерархические группы и жестко задавать им лимиты на:
- CPU: сколько долей процессорного времени может утилизировать группа.
- Memory: максимальный объем оперативной памяти.
- Block I/O: скорость чтения и записи на диск.
Для SRE-инженера понимание cgroups критически важно из-за механизма OOM Killer (Out Of Memory Killer). Если процесс внутри контейнера попытается превысить лимит памяти, заданный через cgroups, ядро Linux безжалостно убьет этот процесс. В логах Docker вы увидите причину завершения: OOMKilled.
Когда вы пишете в docker run флаг --memory="512m", Docker не создает виртуальную планку RAM на 512 мегабайт. Он просто создает запись в файловой системе cgroups (обычно в /sys/fs/cgroup/memory/docker/<id_контейнера>), указывая ядру: «если сумма памяти процессов в этой группе превысит 512 МБ — убей их».
Архитектура Docker Engine: Кто всем управляет?
Мы выяснили, что контейнер — это процесс, обернутый в Namespaces и cgroups. Но настраивать всё это вручную через системные вызовы Linux невероятно сложно. Нужна программа, которая скачает образ, распакует его, подготовит сеть, настроит cgroups и запустит процесс.
Этим занимается Docker Engine. Со временем его архитектура эволюционировала и разбилась на несколько независимых компонентов, чтобы стать более надежной.
Давайте проследим путь команды docker run nginx сверху вниз:
- Docker Client (CLI): Это утилита
docker, которую вы вызываете в терминале. Она не запускает контейнеры сама, а лишь отправляет REST API запрос к демону. - Docker Daemon (dockerd): Мозг системы. Он принимает запрос от CLI, проверяет, есть ли образ
nginxлокально (если нет — качает из Registry), подготавливает слои файловой системы (об этом в следующей главе) и формирует конфигурацию для запуска. Но самdockerdпроцессы не запускает! Он передает задачу ниже. - containerd: Это менеджер жизненного цикла. Его единственная задача — управлять выполнением контейнеров на конкретном узле. Он независим от Docker (его использует и Kubernetes).
containerdберет конфигурацию отdockerdи готовится к старту. - runc (OCI Runtime): Это низкоуровневая утилита, которая делает грязную работу.
containerdвызываетrunc, передавая ему путь к файлам образа и конфиг.runcобращается к ядру Linux, создает те самые Namespaces и cgroups, запускает процессnginxи... сразу же завершает свою работу. - containerd-shim: Поскольку
runcзавершился, кто-то должен следить за запущеннымnginx, собирать его логи (stdout/stderr) и сообщатьcontainerd, если процесс упадет. Этим занимается крошечный процессshim(прокладка). Для каждого контейнера висит свойshim.
Зачем архитектуру сделали такой сложной и добавили containerd-shim? Ради стабильности production-систем.
Благодаря тому, что контейнеры отвязаны от dockerd и держатся на shim, вы можете обновить Docker Daemon, перезапустить службу dockerd, и ваши запущенные контейнеры не упадут. Они продолжат работать, обслуживая трафик, а когда dockerd поднимется, он переподключится к containerd и восстановит контроль над ними.
Взгляд SRE: Контейнер изнутри хоста
Теперь, когда мы знаем, что контейнер — это обычный процесс, мы можем использовать это для глубокой отладки.
Частая проблема: контейнер потребляет много ресурсов, но внутри него нет утилит вроде strace, htop или tcpdump (хороший образ должен быть минималистичным ради безопасности). Как его дебажить?
Очень просто: найти его реальный PID на хост-системе и использовать инструменты хоста!
Чтобы узнать, под каким PID ядро Linux видит главный процесс вашего контейнера, используйте команду:
docker inspect -f '{{.State.Pid}}' <id_или_имя_контейнера>
Допустим, команда вернула PID 4123. Теперь, находясь на хост-сервере с правами root, вы можете:
- Посмотреть, какие файлы реально открыты процессом:
ls -l /proc/4123/fd - Отследить системные вызовы, на которых тормозит приложение:
strace -p 4123 - Посмотреть потребление ресурсов именно этой cgroup через
systemd-cgtop.
Вы вышли за пределы иллюзии. Вы больше не ограничены инструментами, зашитыми в Docker-образ разработчиком. Как SRE-инженер, вы работаете напрямую с ядром Linux, используя Docker лишь как удобный интерфейс для управления изоляцией.
В следующей главе мы разберем, как именно Docker формирует файловую систему для этого изолированного процесса, почему образы состоят из слоев и как работает магия UnionFS, позволяющая запускать сотни контейнеров из одного образа, не дублируя данные на диске.