1. Архитектура Docker и жизненный цикл контейнеров
Архитектура Docker и жизненный цикл контейнеров
В 2013 году на конференции PyCon Соломон Хайкс представил проект, который навсегда изменил ландшафт ИТ-индустрии. До появления Docker запуск приложения в разных средах напоминал попытку перевезти россыпь разнокалиберных товаров на телегах: что-то терялось, что-то билось из-за разницы в дорожном покрытии (версиях библиотек и ОС). Docker предложил концепцию «стандартного контейнера», аналогичную морским грузоперевозкам. Но за внешней простотой команды docker run скрывается сложная инженерная экосистема, использующая глубокие механизмы ядра Linux для обеспечения изоляции и эффективности.
Фундамент изоляции: пространства имен и контрольные группы
Многие ошибочно называют контейнеры «легковесными виртуальными машинами». Это технически неверно. Виртуальная машина (ВМ) эмулирует аппаратное обеспечение и запускает полноценную гостевую операционную систему со своим ядром. Контейнер же — это обычный процесс в основной ОС, который «думает», что он изолирован. Эта иллюзия поддерживается двумя ключевыми технологиями ядра Linux: Namespaces (Пространства имен) и Cgroups (Контрольные группы).
Namespaces: границы видимости
Пространства имен определяют, что процесс может видеть. Когда вы запускаете контейнер, Docker создает набор пространств имен для этого процесса:
* PID Namespace: Изолирует идентификаторы процессов. Внутри контейнера ваше приложение имеет PID 1, хотя в основной системе это может быть PID 4502. Если PID 1 (процесс-родитель контейнера) завершается, контейнер останавливается.
* NET Namespace: Предоставляет контейнеру собственный сетевой стек: IP-адреса, таблицы маршрутизации, порты. Это позволяет двум контейнерам одновременно слушать порт 80 без конфликтов.
* MNT Namespace: Изолирует точки монтирования файловой системы. Контейнер видит свою корневую файловую систему (/), которая никак не пересекается с корнем хоста.
* UTS Namespace: Позволяет контейнеру иметь собственное имя узла (hostname) и доменное имя.
* IPC Namespace: Изолирует средства межпроцессного взаимодействия (очереди сообщений, разделяемую память).
* USER Namespace: Позволяет сопоставлять идентификаторы пользователей внутри контейнера с другими идентификаторами на хосте. Например, процесс, запущенный от имени root (UID 0) в контейнере, может соответствовать непривилегированному пользователю на хосте, что критически важно для безопасности.
Cgroups: лимиты потребления
Если Namespaces отвечают за то, что процесс видит, то Control Groups (cgroups) отвечают за то, сколько ресурсов он может потребить. Без cgroups один «прожорливый» контейнер мог бы занять всю оперативную память или CPU, вызвав отказ всей системы (Kernel Panic или срабатывание OOM Killer).
Docker использует cgroups для ограничения:
Клиент-серверная архитектура Docker
Docker не является монолитным приложением. Это распределенная система, состоящая из нескольких компонентов, взаимодействующих по REST API.
Docker Daemon (dockerd)
Это «сердце» системы. Демон работает в фоновом режиме на хост-машине и управляет всеми объектами: образами, контейнерами, сетями и томами. Именно он слушает запросы от клиента и выполняет тяжелую работу по взаимодействию с ядром ОС.
Docker Client (docker)
Интерфейс командной строки (CLI), с которым взаимодействует пользователь. Когда вы вводите docker build, клиент отправляет команду демону dockerd. Важно понимать, что клиент и демон могут находиться на разных машинах. Вы можете управлять удаленным сервером Docker со своего ноутбука, просто настроив переменную окружения DOCKER_HOST.
Docker Registry
Хранилище образов. По умолчанию это Docker Hub, но крупные компании используют приватные реестры (например, Harbor или GitLab Container Registry). Процесс взаимодействия выглядит так:
Взаимодействие через Docker Engine API
Связь между клиентом и демоном осуществляется через:
* Unix Sockets: По умолчанию (/var/run/docker.sock), используется для локального управления.
* TCP Sockets: Для удаленного управления (требует настройки TLS для безопасности).
* File Descriptors: В системах с systemd.
Глубокое погружение в среду исполнения: containerd и runc
Современный Docker — это результат длительной декомпозиции. Раньше Docker Daemon делал всё сам, но для стандартизации (инициатива OCI — Open Container Initiative) архитектуру разделили.
Когда вы даете команду запустить контейнер, происходит следующая цепочка:
containerd запускает containerd-shim. Это небольшая прослойка, которая позволяет контейнеру работать, даже если сам демон Docker или containerd перезагружаются. Она также удерживает открытыми файловые дескрипторы stdin/stdout/stderr.containerd-shim вызывает runc — низкоуровневую среду выполнения, которая непосредственно взаимодействует с ядром Linux, создает Namespaces и Cgroups, а затем запускает процесс приложения. После запуска процесса runc завершается.Эта многослойность обеспечивает невероятную стабильность: вы можете обновить Docker, не останавливая работающие бизнес-приложения.
Слоистая файловая система (Union File System)
Одной из самых инновационных черт Docker является использование Union FS (обычно драйвер overlay2). Это позволяет объединять несколько каталогов в один виртуальный вид.
Как устроены слои образа
Каждый образ Docker состоит из набора неизменяемых (read-only) слоев. Рассмотрим пример Dockerfile:
Здесь будет три слоя:
Эти слои кэшируются. Если вы измените только код в /app, Docker при сборке не будет заново скачивать Ubuntu или устанавливать Python — он возьмет их из кэша. Это экономит гигабайты дискового пространства и сокращает время сборки до секунд.
Контейнерный слой (Writable Layer)
Когда контейнер запускается, Docker берет все слои образа и добавляет поверх них один тонкий записываемый слой (Container Layer). Все изменения, сделанные внутри запущенного контейнера (создание файлов, изменение конфигов), записываются именно в этот слой.
Если процессу нужно изменить файл, который находится в одном из read-only слоев образа, используется механизм Copy-on-Write (CoW):
Как только контейнер удаляется, записываемый слой уничтожается. Все данные, не вынесенные в Volumes (тома), теряются навсегда. Именно поэтому контейнеры называют эфемеровыми (преходящими).
Жизненный цикл контейнера: от рождения до удаления
Понимание состояний контейнера критично для отладки и автоматизации. Контейнер — это не статичный объект, а динамический процесс.
1. Создание (Created)
Командаdocker create. На этом этапе Docker подготавливает записываемый слой, настраивает сетевые интерфейсы и идентификаторы, но не запускает основной процесс. Контейнер уже существует в системе, занимает место, но не потребляет ресурсы CPU.2. Запуск (Running)
Командаdocker start или docker run (которая объединяет create и start). Процесс с PID 1 стартует внутри изолированной среды. Контейнер активен, пока активен его основной процесс. Если вы запустили скрипт, который отрабатывает за 2 секунды, контейнер перейдет в состояние Exited сразу после завершения скрипта.3. Пауза (Paused)
Командаdocker pause. Docker использует механизм cgroups (freezer), чтобы «заморозить» процессы. Процессы остаются в памяти, но планировщик ядра перестает выделять им кванты процессорного времени. Это полезно, если вам нужно временно освободить CPU для другой задачи, не теряя состояние приложения.4. Остановка (Stopped / Exited)
Командаdocker stop. Здесь важна механика завершения:
SIGTERM. Это вежливая просьба: «Пожалуйста, заверши работу, сохрани данные и закрой соединения».SIGKILL — принудительное немедленное завершение.Хорошей практикой считается написание приложений, которые корректно обрабатывают SIGTERM (Graceful Shutdown).
5. Удаление (Removed)
Командаdocker rm. Записываемый слой удаляется с диска. Образ, на базе которого был создан контейнер, остается нетронутым.Практический разбор: что происходит при docker run?
Давайте детально разберем команду:
docker run -d --name web-server -p 8080:80 nginx
nginx локально. Если нет — идет в Docker Hub, скачивает слои и сохраняет их в /var/lib/docker.containerd.runc.docker0) связывает порт 80 контейнера с портом 8080 хоста.overlay2.nginx.-d (detached) говорит клиенту не ждать вывода от приложения и сразу вернуть управление пользователю.Нюансы PID 1 и проблема «зомби-процессов»
В мире Linux процесс с PID 1 имеет особые обязанности. Он должен:
SIGTERM).Многие приложения (например, Java-машины или скрипты на Python) не умеют правильно работать как PID 1. Они могут игнорировать сигналы остановки, из-за чего docker stop всегда будет ждать 10 секунд и убивать их жестко.
Для решения этой проблемы часто используют легковесные инициализаторы, такие как tini. В Docker это можно активировать флагом --init. В этом случае tini становится PID 1, корректно пробрасывает сигналы вашему приложению и очищает систему от зомби-процессов.
Сравнение: Контейнеры vs Виртуальные машины
Для глубокого понимания архитектуры важно четко видеть границу между этими технологиями.
| Характеристика | Виртуальная машина (VM) | Контейнер (Docker) | | :--- | :--- | :--- | | Изоляция | Полная (аппаратная через гипервизор) | Программная (Namespaces/Cgroups) | | ОС | Полная гостевая ОС в каждой VM | Общее ядро хоста | | Скорость запуска | Минуты | Миллисекунды | | Потребление ресурсов | Высокое (резервирование ОЗУ/диска) | Низкое (потребляет столько, сколько нужно) | | Портативность | Зависит от формата образа (OVA, VHD) | Максимальная (любая система с Docker) |
Именно общее ядро делает Docker таким быстрым, но оно же является и «ахиллесовой пятой» в плане безопасности. Если злоумышленник сможет эксплуатировать уязвимость в ядре Linux изнутри контейнера, он потенциально может получить контроль над всем хостом. В ВМ для этого пришлось бы сначала «пробить» ядро гостевой ОС, а затем еще и гипервизор.
Эффективное управление слоями и кэшированием
Понимание архитектуры слоев позволяет писать оптимальные Dockerfile. Существует правило: часто меняющиеся слои должны быть в конце.
Рассмотрим плохой пример:
Здесь при любом изменении одной строчки в исходном коде (COPY . .) Docker сбросит кэш для всех последующих строк. Команда npm install будет запускаться заново, скачивая сотни мегабайт зависимостей.
Правильный подход:
Теперь, если вы не меняли список зависимостей в package.json, Docker возьмет результат npm install из кэша, и сборка займет секунды.
Взаимодействие с внешним миром: Сети и Данные
Хотя эти темы заслуживают отдельных глав, в контексте архитектуры важно понимать, как Docker «прошивает» изоляцию для связи.
* Сетевой мост (Bridge): По умолчанию Docker создает виртуальную сеть, где каждому контейнеру выдается внутренний IP (обычно из подсети 172.17.0.0/16). Демон настраивает правила iptables на хосте, чтобы обеспечить проброс портов (NAT).
* Тома (Volumes): Чтобы данные жили дольше контейнера, Docker позволяет «пробросить» директорию с хоста внутрь MNT Namespace контейнера. Это делается путем монтирования, которое происходит до того, как запустится основной процесс.
Тонкая настройка Docker Daemon
Для профессионального администрирования важно знать, что поведение dockerd можно менять через файл /etc/docker/daemon.json. Там можно настроить:
* Storage Driver: Хотя overlay2 — стандарт, в старых системах или специфических сценариях могут использоваться btrfs или zfs.
* Default Address Pools: Чтобы внутренние сети Docker не конфликтовали с корпоративной сетью офиса.
* Logging Drivers: Куда отправлять логи контейнеров (json-file, syslog, fluentd, journald). По умолчанию Docker хранит логи в JSON-файлах на диске, и если их не ограничивать (log-rotation), они могут забить всё свободное место.
Docker — это не магия, а искусная комбинация существующих инструментов Linux, обернутая в удобный интерфейс. Понимание того, как Namespaces создают видимость изоляции, как Cgroups сдерживают аппетиты процессов, и как слоистая файловая система экономит ресурсы, превращает пользователя из «оператора командной строки» в инженера, способного проектировать надежные и быстрые системы.