1. Архитектура Docker и управление жизненным циклом контейнера
Архитектура Docker и управление жизненным циклом контейнера
Если на сервере, где запущен десяток высоконагруженных контейнеров, выполнить команду ps aux, вы увидите все запущенные внутри них приложения прямо в выводе хост-системы. Процесс базы данных PostgreSQL, который внутри контейнера уверенно считает себя процессом с идентификатором 1 (PID 1) и единоличным владельцем системы, для ядра Linux на хосте является обычным процессом с PID, например, 24518. Контейнеров в природе не существует. Это не легковесные виртуальные машины, не изолированные коробки и не отдельные операционные системы. Контейнер — это оптическая иллюзия, созданная ядром Linux для обычного процесса с помощью механизмов ограничения видимости и потребления ресурсов. Понимание того, как Docker конструирует эту иллюзию и как управляет ею, является водоразделом между разработчиком, умеющим запускать готовые образы, и инженером, способным эксплуатировать распределенные системы в production.
Деконструкция монолита: современный стек Docker
На заре своего развития Docker был монолитным приложением. Демон выполнял всё: скачивал образы, распаковывал их, настраивал сеть, запускал процессы и собирал логи. Это создавало критическую проблему для production-сред: обновление или падение демона Docker приводило к немедленной остановке всех запущенных на сервере контейнеров.
Современная архитектура Docker строго сегментирована и опирается на стандарты OCI (Open Container Initiative). Процесс запуска контейнера проходит через цепочку независимых компонентов.
!Архитектура Docker: от CLI до ядра Linux
Docker Client (CLI) — утилита командной строки, которая не выполняет никакой работы с контейнерами напрямую. Ее единственная задача — транслировать команды пользователя в REST API запросы. По умолчанию эти запросы отправляются через Unix-сокет /var/run/docker.sock.
Docker Daemon (dockerd) — фоновый процесс, предоставляющий API. Он управляет высокоуровневыми абстракциями: образами, сетями, томами (volumes) и маршрутизацией логов. Когда dockerd получает команду на запуск контейнера, он подготавливает файловую систему образа и передает эстафету следующему звену.
containerd — высокоуровневый runtime. Изначально он был частью Docker, но позже был выделен в отдельный проект. containerd берет на себя управление жизненным циклом: он знает, как скачать образ (если это не сделал dockerd), как распаковать слои файловой системы и как управлять выполнением. Однако сам он процессы не запускает.
containerd-shim — критически важный элемент для стабильности production. Для каждого контейнера containerd создает отдельный легковесный процесс-прослойку (shim). Этот процесс становится родительским для контейнеризированного приложения. Именно благодаря containerd-shim достигается режим daemonless: если dockerd или containerd упадут или будут перезапущены для обновления, запущенные контейнеры продолжат работать, так как их родительский процесс (shim) остается жив и продолжает перехватывать потоки ввода-вывода (stdout/stderr).
runc — низкоуровневый OCI-совместимый runtime. Это небольшая утилита, написанная на Go, которая делает всю «грязную» работу. Она берет конфигурацию, переданную от containerd, обращается к ядру Linux, создает необходимые изолированные пространства (namespaces), настраивает лимиты (cgroups), запускает процесс приложения внутри этой изолированной среды и… завершает свою работу. runc существует только в момент создания или удаления контейнера.
Цепочка вызова при выполнении docker run nginx выглядит так: CLI отправляет POST-запрос в dockerd dockerd вызывает containerd по gRPC containerd запускает containerd-shim shim вызывает runc runc просит ядро Linux создать изолированный процесс nginx runc завершается, а nginx остается работать под присмотром shim.
Фундамент иллюзии: Namespaces и Cgroups
То, что мы называем «контейнером», физически собирается из двух механизмов ядра Linux.
Namespaces (Пространства имен) отвечают за изоляцию видимости. Они ограничивают то, что процесс может «увидеть» в системе.
eth0), свою таблицу маршрутизации и правила iptables./ хоста.Когда вы выполняете команду docker exec -it my_container bash, Docker не «заходит внутрь виртуальной машины». API просит runc запустить новый процесс /bin/bash на хост-системе, но перед запуском присоединить этот процесс к уже существующим Namespaces целевого контейнера.
Control Groups (Cgroups) отвечают за ограничение потребления ресурсов. Если Namespaces говорят процессу, что он может видеть, то Cgroups диктуют, сколько ресурсов он может использовать.
В production-среде запуск контейнера без явного указания лимитов — это бомба замедленного действия. Процесс с утечкой памяти (memory leak) способен исчерпать всю оперативную память сервера, что приведет к нестабильности соседних сервисов или падению хоста.
Docker предоставляет флаги для управления cgroups:
--memory="512m" устанавливает жесткий лимит оперативной памяти. Если приложение попытается выделить больше, ядро Linux вызовет механизм OOM Killer (Out Of Memory Killer), который немедленно завершит процесс сигналом SIGKILL. Контейнер перейдет в состояние Exited (137).--cpus="1.5" ограничивает процессорное время. В основе лежит механизм Completely Fair Scheduler (CFS) Quota. Это означает, что контейнер может утилизировать максимум 150% времени одного ядра за определенный период (обычно 100 мс). В отличие от памяти, при превышении лимита CPU процесс не убивается. Ядро начинает применять троттлинг (throttling) — искусственно приостанавливать выполнение процесса до начала следующего квотного периода. Приложение начинает работать медленнее, растут задержки (latency) ответов.Жизненный цикл и проблема PID 1
Контейнер — это конечный автомат, который проходит через строго определенные состояния: Created (создан, но не запущен), Running (выполняется), Paused (заморожен средствами cgroups freezer), Exited (остановлен) и Dead (в процессе удаления).
!Жизненный цикл контейнера и обработка системных сигналов
Самый критичный момент жизненного цикла, приводящий к наибольшему числу проблем в production — это процесс остановки контейнера. Когда оркестратор (например, при деплое новой версии или масштабировании вниз) решает остановить контейнер, выполняется эквивалент команды docker stop.
Команда docker stop не убивает процесс мгновенно. Она отправляет основному процессу контейнера (PID 1) системный сигнал SIGTERM (Signal Terminate). Это вежливая просьба завершить работу. Ожидается, что приложение перехватит этот сигнал, прекратит принимать новые сетевые соединения, завершит текущие транзакции в базу данных, сбросит кэши на диск и корректно завершится (Graceful Shutdown).
Docker дает приложению 10 секунд на эту операцию (настраивается флагом --time). Если через 10 секунд процесс все еще жив, Docker отправляет сигнал SIGKILL (Signal Kill), который ядро выполняет безусловно, мгновенно уничтожая процесс. Это приводит к разорванным соединениям, поврежденным файлам и несохраненным данным.
Проблема заключается в том, что ядро Linux наделяет процесс с PID 1 особыми свойствами. По умолчанию PID 1 игнорирует сигналы SIGTERM, если в самом приложении явно не прописан обработчик этого сигнала.
Часто разработчики допускают ошибку на этапе написания Dockerfile, используя shell-форму инструкций:
CMD java -jar app.jar
При такой записи Docker запускает приложение через системную оболочку: /bin/sh -c "java -jar app.jar". В этом случае PID 1 становится процесс /bin/sh, а само Java-приложение запускается как дочерний процесс (например, с PID 7).
Когда приходит команда docker stop, сигнал SIGTERM отправляется процессу /bin/sh. Оболочка sh не перенаправляет сигналы своим дочерним процессам. Она просто игнорирует SIGTERM. Java-приложение продолжает работать, ничего не подозревая. Через 10 секунд ожидания Docker отправляет SIGKILL процессу PID 1, что приводит к жесткому убийству всей группы процессов.
Чтобы избежать этого, необходимо использовать exec-форму (в виде JSON-массива):
CMD ["java", "-jar", "app.jar"]
В этом случае оболочка не создается, приложение само становится процессом с PID 1 и получает сигнал SIGTERM напрямую. Если приложение само по себе не умеет корректно обрабатывать SIGTERM (что часто бывает со скриптами на Node.js или Python), в production-образах используют легковесные init-системы, такие как tini или dumb-init. Они запускаются как PID 1, корректно проксируют все сигналы дочернему процессу и решают проблему зомби-процессов, собирая осиротевшие дочерние вызовы.
Стратегии перезапуска и мониторинг состояния
Для обеспечения отказоустойчивости сервисов Docker предоставляет механизм политик перезапуска (Restart Policies), который оценивает код возврата (Exit Code) процесса PID 1 при его завершении.
no — политика по умолчанию. Контейнер завершился, и демон больше ничего с ним не делает.on-failure[:max-retries] — контейнер будет перезапущен только в том случае, если процесс завершился с ненулевым кодом возврата (возникла ошибка или падение). Полезно для задач, которые должны успешно выполниться один раз (например, миграции базы данных).always — демон будет перезапускать контейнер независимо от кода возврата. Если вы остановили его вручную, а затем демон Docker перезагрузился, контейнер снова будет запущен.unless-stopped — аналогичен always, но с важным отличием: если администратор явно выполнил docker stop, контейнер не будет автоматически поднят при следующем перезапуске демона Docker. Это наиболее предпочтительная политика для постоянных фоновых сервисов в production.Для анализа состояния контейнеров в реальном времени используется команда docker stats. Она читает данные напрямую из файловой системы sysfs, куда ядро Linux записывает метрики cgroups. Вывод показывает потребление CPU, памяти, сетевой трафик и операции блочного ввода-вывода. Важно понимать, что метрика расхода памяти в docker stats включает в себя файловый кэш (page cache). Если приложение активно читает файлы с диска, потребление памяти в статистике будет расти, приближаясь к лимиту, но это не приведет к OOM Kill, так как ядро при необходимости просто вытеснит страницы кэша из памяти.
Более глубокий срез состояния предоставляет команда docker inspect. Она возвращает полный JSON-манифест контейнера. В нем можно найти точные причины остановки (поле State.OOMKilled), параметры изоляции, смонтированные тома и внутренние IP-адреса. Работа с docker inspect и фильтрация его вывода через утилиту jq или встроенные Go-шаблоны (--format) — базовый навык для автоматизации отладки.
Управление жизненным циклом контейнера сводится к пониманию того, что мы управляем обычным процессом Linux. Изоляция через Namespaces и ограничение через Cgroups создают предсказуемую среду выполнения, а правильная обработка системных сигналов гарантирует сохранность данных при остановке. Архитектура с разделением на dockerd, containerd и runc обеспечивает независимость запущенных процессов от состояния управляющего демона, что делает возможным бесперебойное обновление инфраструктуры. Понимание этих механизмов позволяет не просто запускать приложения, а проектировать надежные системы, устойчивые к сбоям и нехватке ресурсов.