Контейнеризация с Docker: Глубокое погружение

Освойте стандарты контейнеризации с нуля до продвинутого уровня. Вы научитесь писать оптимальные Dockerfile, управлять сетями и томами, а также оркестрировать локальные среды с помощью Docker Compose.

1. Архитектура Docker: взаимодействие клиента, демона и реестра

В предыдущем модуле, изучая продвинутые практики работы с Git, мы выяснили, как управлять версиями исходного кода и обеспечивать совместную работу команды. Однако код — это лишь половина дела. Чтобы приложение работало у пользователей так же стабильно, как на ноутбуке разработчика, необходимо управлять не только кодом, но и средой его выполнения. Эту задачу решает контейнеризация, а индустриальным стандартом в этой области является Docker.

Многие начинающие инженеры воспринимают Docker как единую монолитную программу: ввел команду в терминал, и магия произошла — приложение запустилось. На самом деле под капотом скрывается распределенная клиент-серверная архитектура. Понимание того, как компоненты этой системы общаются между собой, отличает уверенного Junior+ DevOps инженера от новичка, который просто заучил набор команд.

В основе работы платформы лежат три главных компонента: Docker Client (клиент), Docker Daemon (демон) и Docker Registry (реестр).

!Схема архитектуры Docker: Клиент, Демон и Реестр

Разделение обязанностей: почему не монолит?

Архитектура Docker построена на строгом разделении обязанностей. Вместо того чтобы создавать одну тяжеловесную программу, разработчики разделили интерфейс управления и вычислительное ядро.

Такой подход дает колоссальную гибкость. Вы можете сидеть с легким ультрабуком в кафе, вводить команды в терминал (клиент), а вся тяжелая работа по сборке и запуску контейнеров будет происходить на мощном сервере в дата-центре (демон). Если бы Docker был монолитом, удаленное управление инфраструктурой было бы крайне затруднительным.

> Клиент-серверная архитектура — это вычислительная модель, в которой задачи распределяются между поставщиками ресурсов (серверами) и заказчиками этих ресурсов (клиентами). В контексте Docker сервер выполняет всю тяжелую работу, а клиент лишь передает ваши пожелания.

Рассмотрим каждый из компонентов детально.

Docker Daemon: Сердце системы

Docker Daemon (процесс dockerd) — это фоновый процесс, который работает на хост-машине (сервере или вашем компьютере). Это «мозг» и «руки» всей системы. Именно демон выполняет всю реальную работу.

Его главная задача — прослушивать запросы от клиента и управлять Docker-объектами. К таким объектам относятся:

  • Образы (Images) — шаблоны только для чтения, содержащие код приложения и его зависимости.
  • Контейнеры (Containers) — изолированные запущенные экземпляры образов.
  • Сети (Networks) — виртуальные интерфейсы, позволяющие контейнерам общаться друг с другом и внешним миром.
  • Тома (Volumes) — механизмы для постоянного хранения данных, генерируемых контейнерами.
  • Демон постоянно работает в фоне. Если вы остановите процесс dockerd, все ваши запущенные контейнеры по умолчанию также остановятся, а клиент потеряет возможность управлять системой.

    Практический нюанс: Live Restore

    В production-средах остановка контейнеров при перезапуске демона (например, для обновления самого Docker) недопустима. Для решения этой проблемы DevOps-инженеры используют функцию Live Restore.

    Она настраивается в конфигурационном файле демона daemon.json:

    При включении этой опции демон позволяет контейнерам продолжать работу даже во время своей перезагрузки. Это отличный пример того, как глубокое понимание архитектуры помогает обеспечивать высокую доступность (High Availability) сервисов.

    Docker Client: Ваш пульт управления

    Docker Client (утилита docker) — это основной способ взаимодействия пользователя с платформой. Когда вы вводите в терминале команду, например docker run nginx, вы взаимодействуете именно с клиентом.

    Важно понимать: клиент сам по себе ничего не запускает и не скачивает. Это просто транслятор. Он берет вашу человекочитаемую команду, превращает ее в HTTP-запрос и отправляет демону через REST API.

    Как клиент общается с демоном?

    Связь между клиентом и демоном может осуществляться тремя основными способами:

    | Способ связи | Описание | Когда используется | | :--- | :--- | :--- | | UNIX-сокет | Локальный канал связи внутри одной операционной системы (обычно /var/run/docker.sock). | По умолчанию на Linux-системах, когда клиент и демон находятся на одной машине. Самый быстрый и безопасный способ. | | TCP-сокет | Сетевое соединение по IP-адресу и порту (обычно 2375 без шифрования или 2376 с TLS). | Для удаленного управления демоном с другой машины. | | FD (File Descriptor) | Использование файловых дескрипторов. | Специфично для систем инициализации, таких как systemd. |

    Поскольку общение происходит через стандартизированный REST API, клиентом не обязательно должна быть официальная утилита командной строки. Вы можете написать скрипт на Python, использовать графический интерфейс (например, Portainer) или плагин в вашей IDE — все они будут отправлять точно такие же API-запросы к демону.

    Docker Registry: Библиотека образов

    Третий столп архитектуры — Docker Registry (реестр). Это хранилище, в котором лежат образы контейнеров. Если демон — это кухня ресторана, а клиент — официант, то реестр — это склад продуктов, откуда кухня берет ингредиенты.

    Реестры бывают двух типов:

  • Публичные. Самый известный — Docker Hub. Он настроен в демоне по умолчанию. Любой человек может скачать оттуда образы популярных программ (Nginx, Ubuntu, PostgreSQL).
  • Приватные. Компании разворачивают собственные реестры (например, AWS ECR, GitLab Container Registry или Harbor), чтобы хранить проприетарный код в безопасности. Доступ к ним требует авторизации.
  • Механика Pull и Push

    Взаимодействие с реестром происходит через две основные операции:

  • Pull (вытягивание) — скачивание образа из реестра на локальную машину демона.
  • Push (проталкивание) — загрузка локально собранного образа в реестр.
  • Образы в реестре хранятся не монолитными файлами, а в виде слоев (layers). Каждый слой представляет собой набор изменений файловой системы.

    Представьте, что у вас есть базовый образ операционной системы размером 150 МБ. Вы добавляете в него свое приложение размером 10 МБ. Итоговый образ весит 160 МБ. Если вы измените одну строчку кода в приложении и сделаете push, Docker не будет заново загружать все 160 МБ. Он загрузит только измененный слой размером в несколько килобайт. Это делает архитектуру Docker невероятно быстрой и экономной к сетевому трафику.

    Анатомия одной команды: что происходит под капотом

    Чтобы закрепить понимание архитектуры, давайте разберем по шагам, что происходит в системе, когда вы вводите базовую команду:

    docker run -d -p 8080:80 nginx

  • Клиент парсит команду. Он понимает, что вы хотите запустить контейнер в фоновом режиме (-d), пробросить порт 8080 на порт 80 внутри контейнера и использовать образ nginx.
  • Клиент формирует JSON-запрос и отправляет его через UNIX-сокет к Демону.
  • Демон получает запрос и проверяет свой локальный кэш: есть ли у него уже скачанный образ nginx?
  • Если образа нет, демон обращается к Реестру (Docker Hub), скачивает слои образа и сохраняет их на жесткий диск хоста.
  • Демон создает новый контейнер на основе этого образа, выделяет ему изолированную файловую систему, настраивает сетевой мост для проброса портов и запускает процесс внутри контейнера.
  • Демон возвращает клиенту идентификатор созданного контейнера (длинную строку из букв и цифр).
  • Клиент выводит этот идентификатор в ваш терминал.
  • Весь этот сложный процесс занимает доли секунды, но в нем участвуют все три компонента архитектуры.

    Удаленное управление и безопасность

    Как мы уже выяснили, клиент и демон не обязаны находиться на одном компьютере. Вы можете настроить свой локальный клиент так, чтобы он управлял демоном на удаленном сервере.

    Для этого используется переменная окружения DOCKER_HOST. Если вы выполните в терминале команду:

    export DOCKER_HOST=tcp://192.168.1.50:2375

    Все последующие команды docker будут отправляться не локальному демону, а на сервер с IP-адресом 192.168.1.50.

    Подводные камни безопасности

    Открытие порта демона наружу — одна из самых частых и фатальных ошибок начинающих DevOps-инженеров.

    Порт 2375 передает данные в открытом виде и не требует аутентификации. Если вы откроете этот порт в интернет, любой злоумышленник сможет подключиться к вашему демону, запустить контейнер с правами root и получить полный контроль над вашим сервером. Это равносильно тому, чтобы оставить ключи в замке зажигания открытого автомобиля.

    Для безопасного удаленного управления всегда используется порт 2376 и протокол TLS (Transport Layer Security). В этом случае клиент и демон обмениваются криптографическими сертификатами, подтверждая свою подлинность, а весь трафик между ними надежно шифруется.

    Архитектурные различия: Linux против Windows и macOS

    Важно понимать, как архитектура Docker адаптируется под разные операционные системы.

    Docker изначально создавался для Linux. Демон dockerd напрямую использует функции ядра Linux (такие как cgroups и namespaces) для создания изолированных контейнеров. Поэтому на серверах Ubuntu, CentOS или Debian Docker работает нативно, без прослоек, обеспечивая максимальную производительность.

    Однако ядра Windows и macOS не имеют встроенных механизмов Linux-контейнеризации. Как же тогда работает Docker Desktop на ноутбуках разработчиков?

    Здесь архитектура усложняется. Docker Desktop незаметно для пользователя запускает легковесную виртуальную машину (VM) с ядром Linux.

  • Ваш Docker Client работает нативно в macOS или Windows.
  • Docker Daemon работает внутри этой скрытой виртуальной машины Linux.
  • Клиент общается с демоном сквозь границу виртуальной машины.
  • Именно из-за этой архитектурной особенности (наличия прослойки в виде виртуальной машины) работа с файловой системой (монтирование томов) на Mac и Windows исторически работает медленнее, чем на нативном Linux. Понимание этого факта поможет вам правильно диагностировать проблемы с производительностью при локальной разработке.

    Понимание того, как клиент передает команды демону, а демон управляет образами из реестра, является фундаментом. В следующих модулях мы будем опираться на эти знания, чтобы автоматизировать процессы сборки и развертывания, а также управлять сотнями таких демонов с помощью оркестраторов.

    10. Сетевые драйверы Docker: режимы bridge, host и none

    В предыдущих статьях мы разобрали, как механизмы ядра Linux (Namespaces и Cgroups) создают иллюзию изолированного компьютера для обычного процесса, превращая его в контейнер. Мы научились управлять файловой системой и сохранять данные. Теперь пришло время разобраться с тем, как контейнеры общаются с внешним миром и друг с другом.

    Сетевая подсистема Docker — это абстракция над сетевыми пространствами имен (Network Namespaces), правилами маршрутизации и брандмауэром хост-машины. Docker предлагает подключаемую архитектуру сетевых драйверов (Container Network Model, CNM), где каждый драйвер реализует свой подход к изоляции и маршрутизации.

    В этой статье мы глубоко погрузимся в три базовых сетевых драйвера: none, host и bridge.

    Режим none: Абсолютная изоляция

    Драйвер none предоставляет контейнеру собственное сетевое пространство имен, но Docker не настраивает внутри него никаких сетевых интерфейсов, кроме стандартного интерфейса обратной петли (loopback, lo).

    Если вы запустите контейнер в этом режиме и проверите сетевые интерфейсы, вы увидите только локальный адрес .

    Контейнер с драйвером none полностью отрезан от внешнего мира. Он не может скачать обновления, не может обратиться к базе данных на хосте и не может принимать входящие HTTP-запросы.

    Зачем нужна полная изоляция?

    На первый взгляд, контейнер без сети кажется бесполезным. Однако в парадигме DevSecOps абсолютная изоляция — это мощный инструмент для работы с критически важными данными.

    Представьте микросервис, задача которого — генерация криптографических ключей или хеширование паролей. Вы передаете исходные данные в контейнер через Bind Mount или tmpfs (которые мы изучили ранее), процесс внутри выполняет математически сложные вычисления и записывает результат обратно в примонтированную директорию.

    Использование сети none гарантирует на уровне архитектуры, что даже если злоумышленник найдет уязвимость (RCE) в вашем приложении для хеширования, он физически не сможет отправить украденные ключи на свой сервер — у процесса просто нет маршрута наружу.

    Режим host: Стирание сетевых границ

    Драйвер host работает по принципу полного отказа от сетевой изоляции. Контейнер, запущенный в этом режиме, не получает собственного Network Namespace. Вместо этого он делит сетевое пространство с хост-машиной.

    Для процесса внутри контейнера это выглядит так, будто он запущен напрямую на вашем сервере. Если приложение в контейнере открывает порт 80, этот порт мгновенно становится занятым на самом сервере.

    Вам не нужно использовать флаг -p для проброса портов — приложение уже «слушает» интерфейсы хоста.

    Преимущества и сценарии использования

  • Максимальная производительность: При использовании других драйверов трафик проходит через механизмы трансляции сетевых адресов (NAT). Это требует процессорного времени на перезапись заголовков пакетов. Режим host исключает этот накладной расход, что критично для высоконагруженных систем (например, балансировщиков нагрузки или баз данных, обрабатывающих сотни тысяч транзакций в секунду).
  • Специфичные сетевые протоколы: Если вашему приложению нужно управлять сетевыми интерфейсами хоста, отправлять широковещательные (broadcast) пакеты или работать с протоколами, которые плохо переносят NAT (например, некоторые реализации SIP для IP-телефонии), режим host становится единственным выходом.
  • Ловушка Docker Desktop (macOS и Windows)

    Здесь кроется один из самых частых подводных камней, на котором спотыкаются инженеры уровня Junior.

    Если вы выполните команду docker run --network host nginx на Linux-сервере, вы сможете открыть браузер, ввести http://localhost и увидеть стартовую страницу Nginx.

    Если вы сделаете то же самое на своем MacBook или Windows-ноутбуке с Docker Desktop, страница не откроется.

    > Вспомните архитектуру Docker: на macOS и Windows демон Docker работает не на вашей физической ОС, а внутри скрытой легковесной виртуальной машины Linux.

    Когда вы указываете --network host, контейнер делит сеть с этой виртуальной машиной, а не с вашим Mac или Windows. Порт 80 открывается внутри скрытой ВМ, и ваша физическая ОС ничего об этом не знает. Поэтому для локальной разработки на Mac/Win режим host практически не используется.

    Режим bridge: Виртуальный коммутатор

    Драйвер bridge (мост) используется в Docker по умолчанию. Если вы запускаете контейнер, не указывая параметр --network, он подключается к стандартной сети типа bridge.

    Этот режим обеспечивает идеальный баланс: контейнеры изолированы друг от друга и от хоста, но при этом могут общаться между собой и выходить в интернет.

    Как это работает под капотом

    При установке Docker создает на хост-машине виртуальный сетевой интерфейс с именем docker0. Это программный коммутатор (switch). Ему назначается приватная подсеть, обычно .

    Маска подсети /16 означает, что подсеть может вместить уникальных IP-адресов для контейнеров.

    Когда вы запускаете контейнер, Docker выполняет следующую магию:

  • Создает для контейнера изолированный Network Namespace.
  • Создает veth pair (Virtual Ethernet) — виртуальный патч-корд. Это трубка с двумя концами: пакет, влетевший в один конец, мгновенно вылетает из другого.
  • Один конец этого кабеля помещается внутрь контейнера и переименовывается в eth0. Ему выдается IP-адрес из подсети docker0 (например, ).
  • Второй конец остается на хост-машине и подключается к виртуальному коммутатору docker0.
  • !Схема сетевой архитектуры Docker bridge: виртуальный коммутатор docker0, veth-пары и трансляция адресов NAT

    Теперь контейнер может отправить пакет на шлюз по умолчанию (docker0), а хост-машина, используя правила маршрутизации, перенаправит его в физический интернет.

    Проброс портов (Port Publishing) и iptables

    Поскольку контейнеры находятся в приватной подсети , они невидимы извне. Чтобы внешний трафик мог попасть в контейнер, используется проброс портов (флаг -p или --publish).

    Эта команда говорит: «Возьми порт 8080 на хост-машине и перенаправь весь трафик с него на порт 80 внутри контейнера».

    Под капотом Docker не запускает никаких прокси-серверов. Он напрямую модифицирует правила брандмауэра ядра Linux — iptables. Docker добавляет правило в цепочку PREROUTING таблицы NAT. Когда внешний запрос приходит на физический сетевой интерфейс сервера на порт 8080, ядро Linux на лету подменяет IP-адрес назначения на внутренний IP контейнера () и порт назначения на 80, после чего отправляет пакет в коммутатор docker0.

    Пользовательские сети (User-defined bridges)

    Стандартная сеть docker0 имеет один критический недостаток, из-за которого ее запрещено использовать в production-средах для связи контейнеров между собой.

    В стандартной сети контейнеры могут общаться друг с другом только по IP-адресам. Но IP-адреса контейнеров эфемерны: при пересоздании контейнера база данных может получить адрес вместо . Вы не можете жестко прописать IP-адрес в конфигурации вашего backend-приложения.

    Решение — создание пользовательской сети типа bridge:

    Когда вы подключаете контейнеры к пользовательской сети, Docker активирует встроенный DNS-сервер (он всегда доступен внутри контейнера по адресу ).

    Этот DNS-сервер автоматически отслеживает имена контейнеров. Если вы запустите базу данных с именем db и backend с именем api в одной пользовательской сети, backend сможет подключиться к базе данных, используя строку postgres://db:5432. Встроенный DNS-сервер Docker на лету разрешит имя db в актуальный IP-адрес контейнера.

    Внутри контейнера api команда ping db успешно найдет нужный IP-адрес. Это основа микросервисного взаимодействия в Docker.

    Сравнение сетевых режимов

    | Характеристика | none | host | bridge (default) | bridge (user-defined) | | :--- | :--- | :--- | :--- | :--- | | Изоляция | Абсолютная | Отсутствует | Полная | Полная | | Связь с интернетом | Нет | Да (через хост) | Да (через NAT) | Да (через NAT) | | Разрешение имен (DNS) | Нет | Нет (использует хост) | Нет (только по IP) | Да (по имени контейнера) | | Проброс портов (-p) | Неприменимо | Не требуется | Требуется | Требуется | | Главный Use Case | Секретные вычисления | Высокая производительность | Одиночные тесты | Связь микросервисов |

    Понимание того, как трафик течет через виртуальные интерфейсы и NAT, отличает уверенного DevOps-инженера от новичка. В следующем шаге мы перейдем к Docker Compose, где пользовательские сети создаются автоматически, связывая десятки контейнеров в единую инфраструктуру.

    11. Пользовательские сети: изоляция контейнеров и встроенный DNS

    В предыдущей статье мы разобрали базовые сетевые драйверы Docker: абсолютно изолированный none, стирающий границы host и стандартный виртуальный коммутатор bridge. Мы выяснили, что стандартная сеть docker0 отлично подходит для запуска одиночных контейнеров, но имеет критический архитектурный недостаток для многокомпонентных систем: отсутствие встроенного механизма обнаружения сервисов (Service Discovery).

    Когда вы разворачиваете микросервисную архитектуру, контейнеры постоянно создаются, масштабируются и удаляются. При каждом перезапуске контейнер может получить новый IP-адрес. Если ваш backend-сервис жестко привязан к IP-адресу базы данных (например, 172.17.0.3), любое обновление базы данных приведет к падению всего приложения.

    Решением этой проблемы являются пользовательские сети (User-defined networks). Они предоставляют не только надежную изоляцию, но и встроенный DNS-сервер, который позволяет контейнерам находить друг друга по именам, а не по эфемерным IP-адресам.

    Создание пользовательской сети

    Пользовательские сети создаются вручную или через инструменты оркестрации (например, Docker Compose). Под капотом Docker использует тот же драйвер bridge, но с совершенно другими правилами маршрутизации и разрешения имен.

    При выполнении этой команды ядро Linux создает новый виртуальный коммутатор (мост). В отличие от стандартного docker0, который создается один раз при установке Docker, пользовательских сетей может быть сколько угодно. Каждая новая сеть получает свою уникальную подсеть (например, 172.18.0.0/16, затем 172.19.0.0/16 и так далее).

    Чтобы подключить контейнер к этой сети при запуске, используется флаг --network:

    Встроенный DNS: Магия адреса 127.0.0.11

    Главное отличие пользовательской сети от стандартной — наличие встроенного DNS-сервера.

    В традиционной инфраструктуре для того, чтобы один сервер нашел другой по имени, администраторам приходится настраивать внешние DNS-серверы (например, BIND или CoreDNS) или править файл /etc/hosts. В Docker этот процесс полностью автоматизирован.

    Когда контейнер запускается в пользовательской сети, Docker модифицирует его файл /etc/resolv.conf (файл конфигурации DNS в Linux), добавляя туда запись:

    nameserver 127.0.0.11

    Адрес 127.0.0.11 — это локальный адрес обратной петли (loopback). Но как контейнер может обращаться к самому себе, чтобы узнать IP-адрес другого контейнера? Здесь вступает в игру глубокая интеграция Docker с сетевой подсистемой ядра Linux.

  • Процесс внутри контейнера (например, Node.js) пытается отправить запрос к базе данных по имени database.
  • Операционная система контейнера формирует DNS-запрос и отправляет его на адрес 127.0.0.11 (порт 53).
  • Внутри Network Namespace контейнера Docker заранее прописал специальные правила брандмауэра (iptables в таблице NAT).
  • Эти правила перехватывают пакет, летящий на 127.0.0.11, и перенаправляют его демону Docker на хост-машине.
  • Демон Docker проверяет свою внутреннюю базу данных: "Есть ли в сети my-app-net контейнер с именем database?".
  • Найдя совпадение, демон возвращает актуальный IP-адрес (например, 172.18.0.2) обратно в контейнер.
  • Благодаря этому механизму разработчику достаточно указать в конфигурации приложения строку подключения вида postgres://database:5432/mydb. Даже если контейнер с базой данных будет удален и пересоздан с новым IP-адресом, встроенный DNS мгновенно обновит запись, и приложение продолжит работать без сбоев.

    Сравнение стандартной и пользовательской сети

    | Характеристика | Стандартная сеть (docker0) | Пользовательская сеть (my-app-net) | | :--- | :--- | :--- | | Разрешение имен (DNS) | Нет (только по IP или через устаревший флаг --link) | Да (автоматически по имени контейнера) | | Изоляция | Все контейнеры без флага --network попадают сюда | Изолирована от других сетей | | Управление подсетью | Настраивается глобально в daemon.json | Настраивается индивидуально при создании | | Безопасность | Низкая (соседи видят друг друга) | Высокая (микросегментация) |

    Микросегментация: Изоляция на уровне сети

    В парадигме DevSecOps существует принцип наименьших привилегий (Principle of Least Privilege). Он гласит, что каждый компонент системы должен иметь доступ только к тем ресурсам, которые строго необходимы для его работы.

    Представьте классическую трехуровневую архитектуру:

  • Балансировщик нагрузки (Nginx)
  • Backend-приложение (Node.js)
  • База данных (PostgreSQL)
  • Если поместить все три контейнера в одну сеть, Nginx сможет напрямую обращаться к PostgreSQL. С точки зрения архитектуры это ошибка. Если злоумышленник найдет уязвимость в Nginx и получит доступ к его контейнеру, он сможет напрямую атаковать базу данных.

    Пользовательские сети позволяют реализовать микросегментацию — разделение инфраструктуры на изолированные зоны.

    Мы создаем две независимые сети:

    Затем мы распределяем контейнеры по сетям:

  • Nginx подключается только к frontend-net.
  • PostgreSQL подключается только к backend-net.
  • Node.js подключается к обеим сетям.
  • !Архитектура сетевой изоляции: контейнер Node.js выступает связующим звеном между двумя независимыми сетями, блокируя прямой доступ от веб-сервера к базе данных.

    Как Docker обеспечивает эту изоляцию физически? Когда создаются две сети, ядро Linux создает два разных виртуальных моста (например, br-front и br-back). По умолчанию ядро Linux маршрутизирует трафик между всеми интерфейсами. Чтобы предотвратить это, Docker автоматически добавляет правила в цепочку FORWARD брандмауэра iptables на хост-машине. Эти правила явно отбрасывают (DROP) любые пакеты, пытающиеся перейти из br-front в br-back.

    Таким образом, изоляция гарантируется на уровне ядра операционной системы. Node.js выступает единственным мостом между двумя мирами, так как имеет два виртуальных сетевых интерфейса (по одному в каждой сети).

    Динамическое подключение сетей

    Одной из самых мощных функций пользовательских сетей является возможность подключать и отключать контейнеры "на лету", без их остановки или перезапуска.

    Допустим, у вас есть работающий контейнер backend, который подключен только к frontend-net. Вы хотите дать ему доступ к базе данных. Выполните команду:

    В этот момент Docker создает новую пару виртуальных кабелей (veth pair), помещает один конец внутрь работающего контейнера backend, назначает ему IP-адрес из подсети backend-net и обновляет внутренний DNS. Приложение внутри контейнера мгновенно получает доступ к базе данных.

    Для отключения используется обратная команда:

    > Эта возможность критически важна для паттерна "Zero-Downtime Migration" (миграция без простоя). Вы можете запустить новую версию базы данных в новой сети, динамически подключить к ней backend-приложение, дождаться синхронизации, а затем отключить старую сеть — и всё это без единой секунды простоя для пользователей.

    Сетевые алиасы и Round-Robin DNS

    Встроенный DNS Docker умеет не только разрешать имена контейнеров, но и выполнять базовую балансировку нагрузки. Это реализуется через механизм сетевых алиасов (Network Aliases).

    Алиас — это альтернативное доменное имя, которое можно назначить одному или нескольким контейнерам в рамках конкретной сети.

    Представьте, что ваше приложение стало популярным, и один контейнер backend больше не справляется с нагрузкой. Вы решаете запустить три идентичных контейнера. Вы не можете дать им одинаковые имена (имена контейнеров должны быть уникальными на хосте), но вы можете дать им одинаковый алиас:

    Теперь в сети my-net есть три разных контейнера, но все они откликаются на DNS-имя api.

    Когда Nginx отправляет запрос к http://api, встроенный DNS-сервер Docker видит, что этому алиасу соответствуют три IP-адреса. Он возвращает все три адреса, но каждый раз меняет их порядок (механизм Round-Robin DNS).

    Клиентское приложение (в данном случае Nginx) обычно берет первый IP-адрес из списка. При следующем запросе порядок изменится, и запрос уйдет на второй контейнер. Таким образом трафик равномерно распределяется между всеми экземплярами.

    Подводный камень: DNS-кэширование

    Балансировка через Round-Robin DNS работает отлично, но имеет один существенный недостаток, о котором часто забывают начинающие инженеры. Многие языки программирования и фреймворки (например, Java или старые версии Node.js) кэшируют результаты DNS-запросов для повышения производительности.

    Если Node.js один раз разрешил имя api в адрес 172.18.0.2, он может запомнить этот адрес на 5 минут. Все последующие запросы в течение этого времени будут лететь только в первый контейнер, игнорируя остальные два. Более того, если первый контейнер упадет, приложение будет выдавать ошибки соединения, пока кэш DNS не истечет, даже если два других контейнера живы.

    Поэтому для серьезной балансировки нагрузки в production-средах Round-Robin DNS используют редко. Вместо этого применяют выделенные обратные прокси-серверы (Reverse Proxy), такие как HAProxy, Traefik или Nginx, которые динамически отслеживают состояние (Health Checks) каждого контейнера.

    Управление подсетями и конфликты IP-адресов

    По умолчанию Docker автоматически назначает подсети для новых пользовательских сетей. Он берет блоки адресов из приватных диапазонов (RFC 1918), начиная с , затем и так далее.

    Маска /16 означает, что в сети может быть хоста. Это огромное количество адресов, которое редко требуется в реальности.

    Проблема автоматического назначения возникает в корпоративных средах. Представьте, что вы работаете из офиса, и корпоративный VPN использует подсеть для внутренних серверов компании (например, Jira или GitLab).

    Если вы создадите пользовательскую сеть в Docker, и демон случайно выдаст ей ту же подсеть , произойдет конфликт маршрутизации. Когда вы попытаетесь открыть корпоративную Jira в браузере, ядро вашего Linux-ноутбука увидит, что пакет предназначен для сети . Ядро проверит таблицу маршрутизации, увидит, что эта сеть принадлежит виртуальному мосту Docker, и отправит пакет внутрь пустой сети контейнеров, а не в VPN-туннель. Сайт просто не откроется.

    Чтобы избежать таких конфликтов, профессиональные DevOps-инженеры всегда задают подсети явно, используя небольшие диапазоны:

    Маска /24 выделяет всего адреса, чего более чем достаточно для локальной разработки, и минимизирует риск пересечения с корпоративными сетями.

    Понимание того, как Docker управляет виртуальными мостами, DNS-запросами и правилами брандмауэра, позволяет строить безопасные и отказоустойчивые архитектуры. В следующем шаге мы перейдем к инструменту, который позволяет автоматизировать создание всех этих сетей, томов и контейнеров с помощью одного конфигурационного файла — Docker Compose.

    12. Управление ресурсами: лимитирование CPU и оперативной памяти

    В предыдущих материалах мы разобрали, что контейнер — это обычный процесс Linux, изолированный с помощью механизмов ядра. Мы выяснили, что namespaces (пространства имен) создают иллюзию одиночества, скрывая от контейнера другие процессы и сети. Однако изоляция видимости не решает проблему потребления физических ресурсов сервера.

    Представьте многоквартирный дом, где вода распределяется из одного общего резервуара. Если один жилец решит наполнить огромный бассейн, напор воды у остальных упадет до нуля. В мире серверов эта ситуация называется проблемой «шумного соседа» (Noisy Neighbor). Если одно приложение внутри контейнера начнет бесконтрольно потреблять оперативную память (из-за утечки памяти) или процессорное время (из-за бесконечного цикла), оно может привести к зависанию всей хост-машины и падению соседних, критически важных сервисов.

    Для предотвращения этого Docker использует второй важнейший механизм ядра Linux — cgroups (Control Groups). Этот механизм позволяет устанавливать жесткие и мягкие лимиты на потребление RAM, CPU и дискового ввода-вывода (Block I/O).

    Управление оперативной памятью (RAM)

    Оперативная память — это не сжимаемый ресурс (incompressible resource). Если процессу не хватает процессорного времени, он просто работает медленнее. Но если процессу не хватает оперативной памяти, операционная система вынуждена принимать радикальные меры, иначе ядро запаникует (Kernel Panic) и сервер перезагрузится.

    Docker предоставляет несколько флагов для тонкой настройки потребления памяти.

    Жесткий лимит (Hard Limit)

    Флаг -m или --memory устанавливает абсолютный максимум оперативной памяти, который может использовать контейнер.

    В этом примере Nginx никогда не получит больше 512 мегабайт физической памяти. Если процесс попытается выделить 513-й мегабайт, ядро Linux не позволит ему это сделать.

    Механизм OOM Killer

    Что происходит, когда контейнер достигает жесткого лимита памяти? В дело вступает системный процесс OOM Killer (Out-Of-Memory Killer).

    Его задача — спасти систему от полного исчерпания памяти. OOM Killer анализирует все процессы внутри контрольной группы (cgroup) контейнера, вычисляет их «оценку плохости» (oom_score) и принудительно завершает процесс, потребляющий больше всего памяти, отправляя ему сигнал SIGKILL.

    !Схема работы OOM Killer: процесс выделения памяти, достижение лимита cgroup и принудительное завершение процесса ядром Linux

    Чаще всего жертвой становится процесс с PID 1 (главный процесс контейнера). В результате контейнер переходит в статус Exited (137). Код 137 означает, что процесс был убит сигналом 9 (, где 9 — это номер сигнала SIGKILL).

    > В Docker есть флаг --oom-kill-disable, который запрещает ядру убивать процессы контейнера при нехватке памяти. Использовать его категорически не рекомендуется. Если вы отключите OOM Killer, а контейнер исчерпает лимит, процесс просто зависнет в ожидании свободной памяти, блокируя ресурсы и потенциально вызывая нестабильность демона Docker.

    Работа с файлом подкачки (Swap)

    По умолчанию, если вы задаете лимит --memory, Docker автоматически разрешает контейнеру использовать такой же объем файла подкачки (swap) на диске хост-машины. То есть при -m 512m контейнер фактически может потребить 512 МБ RAM и еще 512 МБ Swap (в сумме 1 ГБ).

    Использование Swap спасает от внезапного OOM-падения, но катастрофически снижает производительность приложения, так как скорость чтения/записи на диск (даже NVMe) в тысячи раз медленнее скорости работы RAM.

    Для точного контроля используется флаг --memory-swap. Важно понимать математику этого флага: он задает общую сумму RAM и Swap, а не размер самого Swap.

    Объем Swap = --memory-swap минус --memory.

    Примеры конфигураций:

  • docker run -m 512m --memory-swap 1g — контейнер получит 512 МБ RAM и 512 МБ Swap ().
  • docker run -m 512m --memory-swap 512m — контейнер получит 512 МБ RAM и 0 МБ Swap. Это самая частая и безопасная конфигурация для production-сред, гарантирующая предсказуемую производительность.
  • Мягкий лимит (Soft Limit)

    Флаг --memory-reservation устанавливает мягкий лимит. Это значение всегда должно быть меньше жесткого лимита.

    Мягкий лимит работает как предупреждение. Пока на хост-машине много свободной памяти, контейнер может превышать резервацию (вплоть до 1 ГБ). Но если общая память сервера начинает заканчиваться, ядро Linux заставит контейнер агрессивно освободить кэши и вернуть потребление к отметке 768 МБ.

    Управление процессором (CPU)

    В отличие от памяти, процессорное время — это сжимаемый ресурс. Если контейнеру не хватает CPU, он не падает с ошибкой, а просто начинает медленнее обрабатывать запросы (возрастает latency).

    Docker предлагает два принципиально разных подхода к ограничению CPU: относительные доли (Shares) и абсолютные квоты (Quotas).

    Относительные доли (CPU Shares)

    Флаг --cpu-shares (или -c) задает приоритет контейнера в борьбе за процессорное время. По умолчанию каждый контейнер получает 1024 доли.

    Этот механизм работает только в условиях конкуренции (когда загрузка процессора хоста достигает 100%). Если процессор свободен, контейнер с любым количеством долей может использовать все 100% мощности.

    Рассмотрим пример. У нас есть одноядерный сервер и два контейнера:

  • Контейнер A: --cpu-shares 1024 (стандарт)
  • Контейнер B: --cpu-shares 512 (пониженный приоритет)
  • Пока работает только Контейнер B, он забирает 100% ядра. Но как только Контейнер A тоже начинает требовать максимум ресурсов, планировщик ядра Linux (CFS) распределит время пропорционально их долям:

    Доля Контейнера A = Доля Контейнера B =

    Этот подход идеален для максимальной утилизации железа: ресурсы не простаивают, но при пиковых нагрузках важные сервисы (с большим числом shares) получают приоритет.

    !Интерактивная симуляция распределения CPU между контейнерами

    Абсолютные квоты (CPU Quotas)

    Если вам нужно жестко ограничить контейнер, чтобы он никогда не превышал определенный порог (даже если сервер абсолютно свободен), используются квоты.

    Самый современный и удобный флаг — --cpus. Он указывает, сколько виртуальных ядер может использовать контейнер.

    Под капотом флаг --cpus транслируется в две настройки планировщика CFS (Completely Fair Scheduler):

  • cfs_period_us (период, обычно 100 000 микросекунд или 100 мс)
  • cfs_quota_us (квота времени в рамках периода)
  • Формула расчета выглядит так:

    В нашем примере для --cpus="1.5" Docker установит период в 100 000 мкс, а квоту в 150 000 мкс. Это означает, что каждые 100 миллисекунд контейнеру разрешено выполняться суммарно 150 миллисекунд (что возможно только при распараллеливании потоков на два и более физических ядра).

    Привязка к ядрам (CPU Pinning)

    Флаг --cpuset-cpus позволяет жестко привязать контейнер к конкретным логическим ядрам процессора.

    В этом случае Nginx будет выполняться только на нулевом и третьем ядрах. Этот метод редко используется в обычных веб-приложениях, но критически важен для:

  • Баз данных (например, Redis, который является однопоточным) для предотвращения инвалидации кэша процессора L1/L2 при перебросе процесса между ядрами.
  • Систем реального времени (Real-time systems).
  • Серверов с архитектурой NUMA, где доступ к памяти привязан к конкретным физическим процессорам.
  • Подводный камень: Cgroup Awareness

    Ограничение ресурсов в Docker скрывает в себе одну из самых опасных ловушек для начинающих DevOps-инженеров, связанную с поведением виртуальных машин (JVM, V8, Python).

    Исторически языки программирования запрашивали информацию о доступной памяти у операционной системы через системные вызовы (например, читая /proc/meminfo). Проблема в том, что по умолчанию контейнер видит всю память хост-машины, а не свой лимит в cgroups.

    Рассмотрим классический сценарий:

  • У вас есть сервер с 32 ГБ RAM.
  • Вы запускаете Java-приложение в контейнере с лимитом -m 1g.
  • При старте JVM (Java Virtual Machine) смотрит на систему, видит 32 ГБ RAM и по своим внутренним правилам решает зарезервировать под Heap (кучу) 25% от доступной памяти — то есть 8 ГБ.
  • Как только приложение начинает работать и заполнять Heap, потребление памяти превышает 1 ГБ.
  • OOM Killer мгновенно убивает контейнер.
  • Разработчик видит статус Exited (137), увеличивает лимит до 2 ГБ, но JVM теперь резервирует те же 8 ГБ, и контейнер снова падает.

    Решение проблемы: Современные версии языков программирования стали cgroup aware (осведомленными о контрольных группах).

  • В Java начиная с версии 10 (и бэкпортах в 8u191) появилась поддержка флага -XX:+UseContainerSupport, который заставляет JVM читать лимиты из cgroups, а не из /proc/meminfo.
  • В Node.js (V8) необходимо использовать флаг --max-old-space-size, чтобы явно указать лимит сборщику мусора, который должен быть немного меньше жесткого лимита контейнера (например, 768 МБ при лимите контейнера в 1 ГБ).
  • Декларативное управление через Docker Compose

    В современных проектах контейнеры редко запускаются через docker run. Стандартом де-факто для локальной разработки и простых серверов является Docker Compose.

    Начиная с версии формата Compose 3.x (и в современной спецификации Compose), лимиты ресурсов описываются в блоке deploy:

    Такой декларативный подход не только удобнее для чтения, но и полностью совместим с оркестратором Docker Swarm, который использует эти значения для принятия решений о том, на какой узел кластера поместить контейнер (если на узле осталось меньше 256 МБ свободной памяти, контейнер туда не попадет).

    Мониторинг потребления

    Чтобы убедиться, что ваши лимиты работают корректно, Docker предоставляет встроенную утилиту мониторинга в реальном времени:

    Эта команда выводит интерактивную таблицу, похожую на утилиту top в Linux. В ней отображается текущее потребление CPU, RAM (с указанием установленного лимита), сетевой ввод-вывод и дисковые операции для каждого запущенного контейнера.

    Установка правильных лимитов ресурсов — это граница между любительским запуском контейнеров и профессиональным DevOps-подходом. Без лимитов ваша инфраструктура хрупка и подвержена каскадным сбоям. С грамотно настроенными квотами и долями вы получаете предсказуемую, стабильную и плотно упакованную систему, готовую к масштабированию.

    13. Инфраструктура как код: основы синтаксиса Docker Compose

    В предыдущих материалах мы детально разобрали внутреннее устройство Docker: как namespaces и cgroups изолируют процессы и ограничивают ресурсы, как работают слоистые файловые системы и как организовать постоянное хранение данных через тома. Мы научились запускать контейнеры, пробрасывать порты и объединять их в виртуальные сети.

    Однако, если вы попробуете развернуть полноценное веб-приложение, состоящее из фронтенда, бэкенда, базы данных и кэширующего сервера, вы быстро столкнетесь с серьезной проблемой. Запуск такой системы потребует выполнения четырех, пяти или даже десяти длинных команд docker run со множеством флагов.

    В этой точке ручное управление контейнерами перестает быть эффективным. На смену ему приходит концепция Инфраструктура как код (Infrastructure as Code, IaC) и главный инструмент локальной оркестрации — Docker Compose.

    Императивный подход против Декларативного

    Чтобы понять ценность Docker Compose, необходимо осознать разницу между двумя фундаментальными подходами в IT-инженерии: императивным и декларативным.

    Команда docker run — это классический императивный подход. Вы отдаете демону Docker пошаговые приказы: «создай сеть», «создай том», «запусти контейнер с базой данных, подключи к нему этот том, задай пароль через переменную окружения, ограничь память до 1 гигабайта».

    Представьте, что вам нужно запустить связку из приложения на Node.js и базы данных PostgreSQL. Ваш bash-скрипт будет выглядеть примерно так:

    Этот подход имеет три критических недостатка:

  • Человеческий фактор: пропуск одного флага или опечатка в названии сети приведет к неработоспособности всей системы.
  • Сложность обновления: чтобы изменить лимит памяти или добавить новую переменную окружения, вам придется остановить контейнер, удалить его и запустить заново с новой длинной командой.
  • Отсутствие версионирования: команды в терминале не сохраняются в Git. Новый разработчик, пришедший в команду, не будет знать, с какими флагами нужно запускать проект.
  • Декларативный подход решает эти проблемы. Вместо того чтобы отдавать пошаговые приказы, вы описываете желаемое конечное состояние системы в текстовом файле. Вы говорите: «Я хочу, чтобы существовала база данных и веб-сервер, они должны быть в одной сети, а база должна хранить данные в томе».

    Инструмент (в нашем случае Docker Compose) сам читает этот файл, анализирует текущее состояние системы и выполняет все необходимые императивные команды под капотом, чтобы привести реальность в соответствие с вашим описанием.

    > Инфраструктура как код (IaC) — это управление вычислительной инфраструктурой через машиночитаемые конфигурационные файлы, а не через физическое конфигурирование оборудования или интерактивные инструменты управления.

    Анатомия файла compose.yaml

    Docker Compose использует язык разметки YAML (YAML Ain't Markup Language). Исторически файл назывался docker-compose.yml, но в современных версиях стандартом де-факто является имя compose.yaml.

    YAML опирается на отступы (строго пробелы, использование табуляции запрещено) для определения структуры данных. Файл Compose состоит из трех основных блоков верхнего уровня: services (сервисы), networks (сети) и volumes (тома).

    !Схема работы Docker Compose: конфигурационный файл управляет созданием изолированной среды с контейнерами, сетями и томами

    Давайте перепишем наш императивный bash-скрипт в декларативный compose.yaml:

    Разберем магию, которая происходит при запуске этого файла:

  • Автоматическая сеть: Заметьте, что в файле нет блока networks. По умолчанию Docker Compose автоматически создает единую пользовательскую сеть (bridge) для всего проекта и подключает к ней все сервисы. Имя сети формируется из названия директории проекта (например, myproject_default). Благодаря встроенному DNS, контейнер node-app может обращаться к базе данных просто по имени сервиса — postgres-db.
  • Трансляция флагов: Ключи внутри сервиса напрямую соответствуют флагам docker run. Флаг -p стал массивом ports, флаг -v — массивом volumes, а -e — словарем environment.
  • Регистрация томов: Блок volumes в самом низу файла сообщает Docker, что том pg-data должен управляться им (это Named Volume). Если его не существует, Compose создадет его. Если он уже есть (остался от предыдущего запуска), Compose просто подключит его, сохраняя ваши данные.
  • Управление зависимостями и порядком запуска

    В микросервисной архитектуре компоненты зависят друг от друга. Бэкенд не может нормально функционировать без базы данных. В нашем примере мы использовали директиву depends_on, указав, что node-app зависит от postgres-db.

    Здесь кроется один из самых частых подводных камней для начинающих DevOps-инженеров.

    По умолчанию директива depends_on гарантирует только порядок старта контейнеров. Docker Compose сначала отправит команду на запуск контейнера postgres-db, дождется статуса Running, и в ту же миллисекунду запустит node-app.

    Проблема в том, что статус Running означает лишь то, что процесс внутри контейнера (PID 1) запустился. Но тяжелой базе данных, такой как PostgreSQL или Oracle, требуется время (иногда 10-20 секунд) на инициализацию: выделение памяти, проверку файлов, применение журналов транзакций.

    В результате веб-приложение стартует, мгновенно пытается открыть TCP-соединение с базой данных, получает отказ (Connection Refused), так как база еще не готова слушать порт, и падает с фатальной ошибкой.

    Решение: Healthchecks (Проверки работоспособности)

    Чтобы решить эту проблему, необходимо научить Docker понимать не просто статус процесса, а реальную готовность приложения внутри контейнера. Для этого используется блок healthcheck.

    Мы задаем команду, которую Docker будет периодически выполнять внутри контейнера. Если команда завершается успешно (код возврата 0), контейнер считается здоровым (Healthy).

    Модернизируем наш compose.yaml:

    Что изменилось:

  • Мы добавили healthcheck для базы данных. Утилита pg_isready — это стандартный инструмент PostgreSQL, который проверяет, готова ли база принимать подключения. Docker будет запускать эту команду каждые 5 секунд.
  • Мы изменили синтаксис depends_on. Теперь это не просто массив, а словарь с условием condition: service_healthy.
  • Теперь при запуске проекта Docker Compose запустит базу данных и поставит запуск node-app на паузу. Он будет ждать, пока pg_isready не вернет успешный ответ. Как только база данных перейдет в статус Healthy, Compose запустит веб-приложение. Это гарантирует абсолютно стабильный и предсказуемый старт всей инфраструктуры.

    Безопасность: работа с переменными окружения

    В предыдущих примерах мы совершили грубую ошибку с точки зрения информационной безопасности: мы захардкодили (жестко прописали) пароль от базы данных прямо в файле compose.yaml.

    Файлы конфигурации инфраструктуры (IaC) должны храниться в системе контроля версий (Git) вместе с исходным кодом. Если вы закоммитите файл с реальными паролями, токенами API или секретными ключами, они станут доступны всем разработчикам, а в случае утечки репозитория — и злоумышленникам.

    Правильный паттерн — вынесение секретов в переменные окружения хост-машины или в специальный файл .env (dot-env), который обязательно добавляется в .gitignore и никогда не попадает в репозиторий.

    Docker Compose нативно поддерживает интерполяцию (подстановку) переменных. Создадим файл .env в той же директории, где лежит compose.yaml:

    Теперь обновим наш compose.yaml, используя синтаксис подстановки {DB_USER} POSTGRES_PASSWORD: {APP_PORT}:3000" environment: DB_HOST: postgres-db DB_USER: {DB_PASSWORD} bash docker compose up -d bash docker compose stop bash docker compose down bash docker compose down -v bash docker compose logs -f `

    Флаг -f (follow) позволяет следить за логами в реальном времени. Вы также можете указать имя конкретного сервиса в конце команды, чтобы отфильтровать вывод (например, docker compose logs -f node-app`).

    Docker Compose — это фундаментальный инструмент для локальной разработки, тестирования (CI) и развертывания проектов на одиночных серверах. Он переводит вас от ручного набора команд к проектированию архитектуры в коде. Однако важно понимать его границы: Compose не умеет распределять нагрузку между несколькими физическими серверами и не восстанавливает упавшие узлы. Для этих задач в мире DevOps применяются оркестраторы кластерного уровня, такие как Kubernetes, к изучению которых мы перейдем на следующих этапах.

    14. Продвинутый Docker Compose: переменные окружения и зависимости сервисов

    В предыдущих материалах мы познакомились с базовым синтаксисом Docker Compose и перевели императивные команды docker run в декларативный формат файла compose.yaml. Мы научились запускать связку из веб-приложения и базы данных одной командой.

    Однако реальные production-системы и сложные среды разработки (CI/CD) требуют гораздо большей гибкости. Нельзя просто жестко прописать пароли в конфигурационном файле. Нельзя надеяться, что база данных запустится быстрее, чем бэкенд попытается к ней подключиться. Нельзя копировать один и тот же блок настроек в десять разных микросервисов.

    Для решения этих задач Docker Compose предоставляет продвинутые механизмы управления конфигурацией и оркестрации запуска.

    Управление конфигурацией: Искусство переменных окружения

    Согласно методологии 12-Factor App (Двенадцатифакторное приложение) — стандарту разработки современных веб-сервисов — любая конфигурация, которая меняется в зависимости от окружения (development, staging, production), должна храниться в переменных окружения.

    В контексте Docker Compose существует фундаментальная разница между двумя процессами, которую часто путают начинающие инженеры: интерполяция переменных в самом файле compose.yaml и передача переменных внутрь работающего контейнера.

    Интерполяция: подстановка значений до запуска

    Интерполяция — это процесс, при котором Docker Compose читает ваш compose.yaml, находит в нем специальные маркеры вида {IMAGE_TAG} ports: - "{VARIABLE:-default} — использовать значение default, если переменная не задана или пуста. * {PG_VERSION:-15-alpine} environment: POSTGRES_PASSWORD: {DB_PASSWORD} # Интерполяция значения из .env yaml services: backend: image: my-backend env_file: - ./config/backend.env yaml services: backend: image: my-backend depends_on: - database yaml services: database: image: postgres:15 environment: POSTGRES_USER: admin POSTGRES_PASSWORD: secret healthcheck: test: ["CMD-SHELL", "pg_isready -U admin"] interval: 10s timeout: 5s retries: 5 start_period: 30s yaml services: backend: image: my-backend depends_on: database: condition: service_healthy yaml services: database: image: postgres:15 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s retries: 5

    db-migration: image: my-migration-tool depends_on: database: condition: service_healthy command: ["run-migrations.sh"]

    backend: image: my-backend depends_on: db-migration: condition: service_completed_successfully yaml services: backend: image: my-backend # Запускается всегда, так как профиль не указан

    database: image: postgres:15 # Запускается всегда

    pgadmin: image: dpage/pgadmin4 profiles: - debug - tools

    e2e-tests: image: cypress/included profiles: - testing bash

    Запустит бэкенд, БД и pgAdmin

    docker compose --profile debug up -d

    Запустит бэкенд, БД и тесты

    docker compose --profile testing up yaml services: node-base: environment: - NODE_ENV=production - LOG_LEVEL=info logging: driver: "json-file" options: max-size: "10m" max-file: "3" yaml services: auth-service: extends: file: common-services.yaml service: node-base image: auth-app:latest ports: - "8081:8080"

    payment-service: extends: file: common-services.yaml service: node-base image: payment-app:latest ports: - "8082:8080" yaml services: web: image: my-web-app restart: always yaml services: web: build: . # Собирать локально, а не качать образ volumes: - .:/app # Пробросить код для hot-reload ports: - "3000:3000" # Открыть порт для локального тестирования `

    При запуске docker compose up на локальной машине применятся оба файла. При деплое на сервер файл override` не копируется, и запускается строгая базовая конфигурация.

    Освоив переменные окружения, проверки работоспособности, профили и наследование, вы превращаете Docker Compose из простой утилиты для запуска контейнеров в мощный инструмент оркестрации инфраструктуры, готовый к интеграции в современные CI/CD процессы.

    15. Логирование в Docker: настройка драйверов и ротация логов

    В предыдущих материалах мы разобрали жизненный цикл контейнеров, управление ресурсами и оркестрацию с помощью Docker Compose. Мы выяснили, что контейнеры эфемерны: они создаются, выполняют свою задачу и удаляются, унося с собой все изменения файловой системы, если те не были сохранены в тома (Volumes).

    Но что происходит с журналами работы приложения — логами? Если приложение внутри контейнера столкнется с критической ошибкой и завершит работу, как инженеру узнать причину сбоя? Управление логами — это фундаментальный навык, отличающий локальную разработку от эксплуатации систем в production-среде.

    Фундаментальный принцип: Стандартные потоки ввода-вывода

    В традиционной системной архитектуре приложения часто пишут логи в файлы на жестком диске, например, в /var/log/nginx/access.log или C:\App\logs\error.txt. В мире контейнеров этот подход считается антипаттерном.

    Согласно методологии 12-Factor App (Двенадцатифакторное приложение), контейнеризованное приложение вообще не должно заботиться о маршрутизации или хранении своих логов. Оно должно выводить все события в виде непрерывного потока в стандартные потоки операционной системы:

    * stdout (Standard Output) — стандартный поток вывода для обычных информационных сообщений. * stderr (Standard Error) — стандартный поток ошибок для предупреждений и критических сбоев.

    Когда процесс запускается в Docker (тот самый PID 1, который мы обсуждали в статье про ENTRYPOINT), демон Docker автоматически подключается к его потокам stdout и stderr. Демон перехватывает каждую строку, которую приложение выводит в консоль, добавляет к ней метаданные (например, временную метку) и передает ее драйверу логирования (Logging Driver).

    > Если ваше приложение внутри контейнера настроено писать логи в файл (например, через библиотеку Winston в Node.js или Logback в Java, настроенные на запись в .log файл), команда docker logs ничего не покажет. Docker «не видит» файлы внутри контейнера, он видит только то, что выводится в консоль.

    Драйвер по умолчанию: json-file и скрытая угроза

    По умолчанию Docker использует драйвер логирования json-file. Этот драйвер берет перехваченные строки из stdout/stderr, упаковывает их в формат JSON и сохраняет в файл на хост-машине (обычно в директории /var/lib/docker/containers/<container-id>/).

    Формат записи выглядит примерно так:

    Именно этот файл читает утилита Docker CLI, когда вы выполняете команду docker logs <container_name>.

    Проблема переполнения диска

    Драйвер json-file отлично работает «из коробки», но таит в себе серьезную угрозу для неопытных инженеров. По умолчанию Docker не ограничивает размер лог-файлов.

    Представьте высоконагруженный веб-сервер (например, Nginx), который генерирует 100 мегабайт логов в день. За месяц этот один контейнер создаст файл размером 3 гигабайта. Если на сервере работает десяток микросервисов, через несколько месяцев на хост-машине закончится свободное место (ошибка No space left on device). Базы данных перестанут записывать транзакции, а новые контейнеры не смогут запуститься.

    Ротация логов: Спасение дискового пространства

    Чтобы предотвратить переполнение диска, необходимо настроить ротацию логов. Ротация — это процесс автоматического разбиения логов на части (чанки) и удаления самых старых записей при достижении определенного лимита.

    В Docker ротация настраивается с помощью двух основных параметров (опций драйвера):

  • max-size — максимальный размер одного файла логов до его ротации (например, 10m для 10 мегабайт).
  • max-file — максимальное количество файлов, которые Docker будет хранить для одного контейнера.
  • Пример расчета: если установить max-size = 20m и max-file = 5, то Docker будет хранить максимум 5 файлов по 20 мегабайт. Общий объем логов для этого контейнера никогда не превысит 100 мегабайт. Как только текущий файл достигнет 20 МБ, Docker переименует его, а самый старый из 5 файлов будет безвозвратно удален.

    Настройка ротации через Docker Compose

    Самый простой способ применить ротацию для конкретного сервиса — использовать блок logging в файле compose.yaml.

    Этот подход хорош тем, что конфигурация хранится в коде (Инфраструктура как код) и применяется автоматически при развертывании проекта.

    Глобальная настройка демона (daemon.json)

    Если вы управляете сервером, на котором запускаются десятки контейнеров от разных команд разработчиков, вы не можете гарантировать, что каждый из них пропишет лимиты в своем compose.yaml. В этом случае безопаснее настроить ротацию глобально на уровне самого демона Docker.

    Для этого необходимо отредактировать конфигурационный файл демона, который в Linux обычно находится по пути /etc/docker/daemon.json (если файла нет, его нужно создать).

    После изменения этого файла необходимо перезапустить службу Docker (в Ubuntu/Debian это делается командой sudo systemctl restart docker).

    Важный нюанс: Глобальные настройки в daemon.json применяются только к вновь созданным контейнерам. Уже запущенные контейнеры продолжат использовать те настройки, с которыми они были созданы. Чтобы применить новые лимиты к старым контейнерам, их необходимо пересоздать (например, через docker compose up -d --force-recreate).

    Альтернативные драйверы логирования

    Хотя json-file используется по умолчанию, Docker поддерживает множество других драйверов, которые позволяют интегрировать контейнеры с корпоративными системами мониторинга.

    !Схема архитектуры логирования в Docker. Слева: прямоугольник 'Контейнер' с процессом внутри, от которого идут две стрелки 'stdout' и 'stderr'. В центре: большой блок 'Docker Daemon', который перехватывает эти потоки. Справа: три блока, к которым идут стрелки от демона — 'json-file (Локальный диск)', 'Syslog (ОС)', 'Fluentd (Внешний сервер)'

    Рассмотрим наиболее популярные альтернативы:

    | Драйвер | Описание | Сценарий использования | | :--- | :--- | :--- | | local | Сохраняет логи в бинарном формате, оптимизированном для быстрого чтения и минимального потребления диска. Имеет встроенную ротацию по умолчанию (max-size 20m, max-file 5). | Современная замена json-file для локальной разработки и одиночных серверов. | | syslog | Перенаправляет логи в стандартную службу логирования операционной системы Linux (Syslog daemon). | Интеграция со старыми системами мониторинга, которые привыкли читать /var/log/syslog. | | journald | Отправляет логи в systemd-journald. Позволяет использовать мощную утилиту journalctl для фильтрации логов контейнеров. | Серверы на базе современных дистрибутивов Linux (Ubuntu, CentOS), где systemd является стандартом. | | fluentd / gelf | Отправляет логи по сети на внешние коллекторы (Fluentd, Logstash, Graylog). | Микросервисная архитектура и кластеры (Kubernetes, Swarm), где логи сотен серверов нужно собирать в единой базе (например, Elasticsearch). | | awslogs / gcplogs | Нативная интеграция с облачными провайдерами (Amazon CloudWatch, Google Cloud Logging). | Инфраструктура, полностью развернутая в конкретном публичном облаке. |

    Переключение драйвера в compose.yaml выглядит так:

    В этом примере логи бэкенда вообще не будут сохраняться на жесткий диск сервера, где запущен Docker. Они будут мгновенно отправляться по TCP-соединению на выделенный сервер логирования (192.168.0.42). Тег backend-api поможет отфильтровать эти логи среди тысяч других сообщений.

    Режимы доставки: Блокирующий против Неблокирующего

    При использовании сетевых драйверов (таких как fluentd или syslog) возникает неочевидная архитектурная проблема. Что произойдет, если сервер логирования временно выйдет из строя или сеть оборвется?

    Docker предлагает два режима доставки логов (параметр mode):

    Блокирующий режим (mode: blocking)

    Это режим по умолчанию. Если Docker не может отправить лог-сообщение драйверу (например, Fluentd не отвечает), демон приостанавливает чтение stdout/stderr контейнера.

    Поскольку буфер вывода операционной системы ограничен, приложение внутри контейнера вскоре заполнит его и заблокируется. Процесс буквально зависнет в ожидании возможности вывести следующую строку лога.

    * Плюс: Гарантированная доставка логов. Ни одна строчка не потеряется. * Минус: Сбой в системе мониторинга приводит к полному отказу (Downtime) самого бизнес-приложения.

    Неблокирующий режим (mode: non-blocking)

    В этом режиме Docker создает промежуточный кольцевой буфер (Ring Buffer) в оперативной памяти. Если внешний сервер логирования недоступен, логи копятся в этом буфере.

    Если проблема с сетью затягивается и буфер (в примере выше — 4 мегабайта) переполняется, Docker начинает удалять самые старые логи из памяти, освобождая место для новых.

    * Плюс: Приложение продолжает работать и обслуживать пользователей даже при полном отказе системы логирования. * Минус: Возможна безвозвратная потеря части логов во время аварии.

    Для большинства веб-сервисов доступность приложения важнее сохранности 100% логов, поэтому в production-средах при использовании сетевых драйверов настоятельно рекомендуется включать mode: non-blocking.

    Чтение логов: продвинутое использование CLI

    Независимо от того, используете ли вы json-file или local, чтение логов осуществляется через команду docker logs. Однако вывод мегабайтов текста в терминал редко бывает полезным. Рассмотрим флаги для эффективного поиска аномалий.

    1. Потоковое чтение (Follow) Флаг -f (или --follow) привязывает терминал к потоку логов. Новые строки будут появляться на экране в реальном времени, как при использовании утилиты tail -f в Linux.

    docker logs -f my-container

    2. Ограничение вывода (Tail) Если контейнер работает давно, команда docker logs выведет всю историю с момента запуска, что может занять минуты. Чтобы посмотреть только последние события, используйте флаг -n (или --tail).

    docker logs --tail 50 my-container (покажет только последние 50 строк).

    3. Фильтрация по времени Для расследования инцидентов, которые произошли в известное время, используются флаги --since (начиная с) и --until (до).

    docker logs --since 30m my-container (логи за последние 30 минут). docker logs --since 2023-10-27T15:00:00 --until 2023-10-27T15:30:00 my-container

    4. Разделение stdout и stderr Иногда информационных логов так много, что среди них трудно заметить ошибки. В Linux можно использовать перенаправление потоков, чтобы отфильтровать только stderr (поток ошибок, имеющий дескриптор 2).

    docker logs my-container > /dev/null

    Эта команда отправляет весь стандартный вывод (stdout) в «черную дыру» (/dev/null), оставляя на экране терминала только сообщения об ошибках (stderr).

    Понимание механизмов логирования, умение настраивать ротацию и выбирать правильный драйвер доставки — это критически важные шаги для обеспечения стабильности инфраструктуры. Неконтролируемые логи — одна из самых частых причин падения серверов у начинающих специалистов. В следующих материалах мы рассмотрим, как автоматизировать процессы развертывания контейнеров с помощью CI/CD пайплайнов.

    16. Отладка контейнеров: использование команд exec, inspect и stats

    В предыдущих материалах мы настроили сбор логов приложения, чтобы понимать, что происходит внутри контейнера. Однако логи — это лишь то, что разработчик предусмотрел вывести в стандартные потоки stdout и stderr. Что делать, если приложение зависло без единой строчки ошибки? Или если база данных отказывает в подключении, хотя логи веб-сервера утверждают, что запрос отправлен?

    В традиционной инфраструктуре инженер подключился бы к серверу по протоколу SSH, открыл системные журналы и начал проверять конфигурационные файлы. В мире контейнеров этот подход не работает. Контейнеры эфемерны, в них часто нет SSH-сервера (и не должно быть из соображений безопасности), а многие базовые утилиты вроде ping или curl удалены для уменьшения размера образа.

    Для расследования инцидентов в Docker существует триада диагностических инструментов: команды exec, inspect и stats. Понимание того, как они взаимодействуют с механизмами ядра Linux (namespaces и cgroups), позволяет находить причину любого сбоя.

    Вторжение в матрицу: как работает docker exec

    Команда docker exec позволяет запустить новый, дополнительный процесс внутри уже работающего контейнера.

    Важно понимать механику: exec не создает новый контейнер. Демон Docker обращается к ядру Linux и просит запустить указанную команду, прикрепив ее к тем же самым namespaces (пространствам имен PID, сети, файловой системы), в которых работает основной процесс контейнера (PID 1).

    Интерактивный режим и TTY

    Самый частый сценарий использования exec — это запуск командной оболочки (shell) для ручного исследования файловой системы контейнера.

    Здесь используются два критически важных флага, которые часто объединяют в -it: -i (interactive*) — оставляет стандартный поток ввода (stdin) открытым, даже если вы не прикреплены к нему напрямую. Это позволяет вам печатать команды. -t (tty*) — выделяет псевдотерминал. Это сообщает процессу внутри контейнера, что он общается с человеком через терминал, а не просто получает поток байтов. Благодаря этому работают автодополнение по клавише Tab, история команд и корректное отображение переносов строк.

    > Использование docker exec похоже на работу с перчатками, встроенными в герметичный бокс лаборатории. Вы не нарушаете изоляцию среды, но получаете возможность манипулировать объектами внутри нее.

    Выполнение одиночных команд

    Запускать полноценный shell не всегда обязательно, а в автоматизированных скриптах — даже вредно. Вы можете передать конкретную команду, которая выполнится, вернет результат и сразу завершится.

    Например, чтобы проверить, готова ли база данных PostgreSQL принимать подключения, не заходя в сам контейнер:

    Или чтобы прочитать содержимое конфигурационного файла, сгенерированного приложением при старте:

    Подводные камни: Scratch и Alpine

    Главная проблема exec заключается в том, что запускаемый бинарный файл должен физически существовать внутри файловой системы контейнера.

    Если вы используете паттерн многоэтапной сборки (multi-stage builds) и ваш финальный образ базируется на scratch (абсолютно пустой образ), команда docker exec -it my-app sh выдаст ошибку executable file not found in \text{MEM USAGE}\text{LIMIT}\text{LIMIT}$ будет равен всему объему оперативной памяти хост-машины.

    Выявление утечек памяти (Memory Leaks)

    docker stats — главный инструмент для первичной диагностики утечек памяти. Если вы наблюдаете, что колонка MEM USAGE для определенного сервиса (например, бэкенда на Node.js или Python) постоянно растет с течением времени и никогда не снижается даже при отсутствии пользовательской нагрузки, это явный признак утечки.

    Когда значение MEM % достигает 100%, ядро Linux приостанавливает работу контейнера, вызывает OOM Killer, уничтожает процесс PID 1 и контейнер падает (с тем самым ExitCode: 137, который мы видели в inspect). Если в Docker Compose настроена политика restart: always, контейнер запустится заново, и цикл утечки начнется с нуля. Внешне для пользователя это выглядит как периодические «зависания» сервиса на несколько секунд.

    !Схема алгоритма отладки Docker-контейнера

    Алгоритм комплексной отладки

    Профессиональный DevOps-инженер не использует эти команды хаотично. Существует четкий алгоритм локализации проблемы, двигающийся от внешнего контура к внутреннему:

  • Проверка состояния (docker ps -a): Работает ли контейнер вообще? Как давно он перезапускался (колонка STATUS покажет Up 2 minutes или Exited (1) 5 seconds ago)?
  • Анализ вывода (docker logs --tail 50): Что приложение успело сказать перед смертью или что оно говорит прямо сейчас? Есть ли ошибки подключения или Stack Trace?
  • Проверка метаданных (docker inspect): Если логов нет, проверяем ExitCode и OOMKilled. Убеждаемся, что пути к томам (Mounts) указаны верно и переменные окружения переданы без опечаток.
  • Анализ ресурсов (docker stats): Если контейнер жив, но не отвечает, проверяем, не уперся ли он в лимиты CPU или RAM.
  • Внутреннее расследование (docker exec -it sh): Если все внешние метрики в норме, заходим внутрь. Проверяем доступность базы данных через curl или ping, читаем локальные файлы конфигурации, проверяем права доступа к примонтированным томам.
  • Освоив этот алгоритм и понимая, откуда каждая команда берет данные (логи из stdout, stats из cgroups, exec через namespaces`), вы сможете диагностировать 99% проблем, возникающих при эксплуатации контейнеризованных приложений. В следующем материале мы перейдем к автоматизации процессов и узнаем, как собирать и развертывать образы без ручного вмешательства с помощью CI/CD пайплайнов.

    17. Базовая безопасность: запуск контейнеров без root-прав

    Иллюзия изоляции — одна из самых опасных ловушек, в которую попадают начинающие инженеры при работе с Docker. В предыдущих материалах мы разобрали, как механизмы ядра Linux (namespaces и cgroups) создают для процесса видимость одиночества и ограничивают его ресурсы. Однако эта изоляция не является абсолютной, как в случае с полноценными виртуальными машинами.

    Контейнер — это просто процесс. И если этот процесс запущен от имени суперпользователя (root), он обладает потенциально разрушительными правами. Понимание того, как управлять привилегиями внутри контейнера, отличает базовое использование Docker от профессионального подхода к построению безопасной инфраструктуры.

    Анатомия привилегий: почему root в контейнере — это root на хосте

    В операционных системах семейства Linux права доступа базируются на числовых идентификаторах: UID (User ID) и GID (Group ID). Имя пользователя root или ubuntu — это лишь удобная текстовая обертка для человека. Ядро оперирует исключительно числами. Суперпользователь всегда имеет .

    Когда вы запускаете контейнер без дополнительных настроек, процесс внутри него (PID 1) стартует от имени пользователя root.

    Главная проблема безопасности заключается в архитектуре общего ядра (Shared Kernel). Демон Docker и все запущенные им контейнеры используют одно и то же ядро хост-машины. Следовательно, таблица идентификаторов пользователей у них общая.

    > UID 0 внутри контейнера — это физически тот же самый UID 0 на хост-машине.

    Если злоумышленник найдет уязвимость в вашем приложении (например, возможность удаленного выполнения кода — RCE) или уязвимость в самом ядре Linux, позволяющую совершить побег из контейнера (Container Breakout), он окажется в хост-системе с абсолютными правами суперпользователя. Он сможет удалить файловую систему, прочитать любые секреты или установить вредоносное ПО.

    Вектор атаки через Bind Mounts

    Опасность запуска от root многократно возрастает при использовании проброса директорий (Bind Mounts). Представьте, что разработчик для удобства отладки или сбора логов пробросил корневую директорию хоста в контейнер:

    Если приложение внутри my-app скомпрометировано, атакующий (имея права root внутри контейнера) может перейти в директорию /host-root/etc/ и изменить файл shadow, добавив туда свой пароль, или записать свой SSH-ключ в /host-root/root/.ssh/authorized_keys. Изоляция файловой системы будет полностью обойдена легитимным механизмом самого Docker.

    Инструкция USER: снижение привилегий на этапе сборки

    Самый надежный способ защитить инфраструктуру — изначально проектировать Docker-образы так, чтобы приложение работало от имени непривилегированного пользователя. Для этого в синтаксисе Dockerfile предусмотрена инструкция USER.

    Инструкция USER меняет пользователя (и опционально группу) для всех последующих инструкций RUN, CMD и ENTRYPOINT в Dockerfile.

    Правильное создание пользователя

    Просто написать USER myuser недостаточно, если этот пользователь не существует в базовом образе. Его необходимо предварительно создать с помощью стандартных утилит Linux.

    Рассмотрим пример безопасного Dockerfile для приложения на Node.js:

    Оптимизация прав доступа: COPY --chown

    Обратите внимание на строку COPY --chown=appuser:appgroup . .. Это критически важный паттерн оптимизации.

    Начинающие разработчики часто пишут так:

    Как мы помним из статьи об анатомии Docker-образа, каждая инструкция RUN и COPY создает новый физический слой в файловой системе UnionFS. Инструкция COPY скопирует файлы от имени root (создав слой №1). Затем инструкция RUN chown изменит метаданные каждого файла, что заставит механизм Copy-on-Write скопировать все эти файлы в новый слой (слой №2). В результате размер вашего образа увеличится ровно в два раза. Флаг --chown позволяет задать правильного владельца прямо в момент копирования, избегая дублирования данных.

    Проблема портов: ограничение CAP_NET_BIND_SERVICE

    При переходе на непривилегированного пользователя вы неизбежно столкнетесь с архитектурным ограничением Linux: обычные пользователи не могут открывать сетевые порты с номерами .

    Если ваш веб-сервер (например, Nginx) настроен на прослушивание стандартного порта 80, при запуске от имени appuser он упадет с ошибкой Permission denied.

    Существует два пути решения этой проблемы:

  • Изменение конфигурации приложения (Рекомендуемый). Настройте приложение на прослушивание порта выше 1024 (например, 8080 или 3000). При запуске контейнера вы просто пробросите стандартный порт хоста на этот высокий порт контейнера: docker run -p 80:8080 my-app.
  • Выдача конкретной привилегии (Capabilities). Ядро Linux позволяет разбивать монолитные права root на мелкие гранулярные разрешения — Capabilities. Вы можете разрешить бинарному файлу открывать привилегированные порты с помощью утилиты setcap на этапе сборки:
  • Ад прав доступа: работа с томами (Volumes)

    Переход на USER решает проблему безопасности, но создает новую проблему при эксплуатации — конфликты прав доступа при монтировании томов (Named Volumes или Bind Mounts).

    Представьте базу данных PostgreSQL. Она должна работать от непривилегированного пользователя postgres. Но когда Docker создает новый именованный том для хранения данных, он делает это от имени демона, то есть от root. Директория тома будет принадлежать . Когда процесс postgres попытается записать туда данные, он получит отказ.

    Паттерн Entrypoint Wrapper с использованием gosu

    Для решения этой проблемы в официальных образах баз данных (PostgreSQL, MySQL, Redis) применяется продвинутый паттерн Entrypoint Wrapper в связке с утилитой gosu (или su-exec в Alpine).

    Идея заключается в следующем:

  • Контейнер стартует от имени root.
  • Скрипт entrypoint.sh проверяет права на примонтированную директорию и при необходимости выполняет chown.
  • Скрипт использует gosu для понижения привилегий до целевого пользователя и запускает основной процесс приложения, передавая ему PID 1.
  • Почему нельзя использовать стандартные утилиты su или sudo? Они создают новые дочерние процессы и плохо работают с системными сигналами (SIGTERM), что нарушает механизм Graceful Shutdown (о котором мы говорили в статье про жизненный цикл контейнера). Утилита gosu работает аналогично команде exec — она подменяет текущий процесс новым, но с другим UID.

    Пример реализации entrypoint.sh:

    json { "userns-remap": "default" } bash docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE my-web-server yaml services: web: image: my-web-server cap_drop: - ALL cap_add: - NET_BIND_SERVICE bash docker run --read-only my-app yaml services: web: image: nginx:alpine read_only: true tmpfs: - /var/cache/nginx - /var/run ``

    В такой конфигурации корневая файловая система заблокирована от записи, а разрешенные директории хранятся в оперативной памяти и бесследно исчезают при остановке контейнера.

    Освоение этих концепций — от инструкции USER до userns-remap и read-only` файловых систем — переводит вас из категории пользователей, которые просто умеют запускать контейнеры, в категорию инженеров, способных проектировать отказоустойчивые и безопасные production-среды. В следующем материале мы перейдем к автоматизации этих процессов и узнаем, как интегрировать сборку и проверку безопасности образов в CI/CD пайплайны.

    18. Продвинутая безопасность: сканирование образов на уязвимости

    В предыдущем материале мы разобрали, как защитить хост-систему от скомпрометированного контейнера, лишив процесс прав суперпользователя (root) и ограничив его возможности через Capabilities. Однако изоляция среды выполнения — это лишь половина дела. Если внутри вашего контейнера работает приложение с известной уязвимостью (например, позволяющей выполнить произвольный код), злоумышленник сможет уничтожить данные, украсть базу пользователей или использовать ваш сервер для майнинга криптовалюты, даже не выходя за пределы контейнера.

    Безопасность инфраструктуры начинается задолго до команды docker run. Она начинается на этапе сборки образа. В современной DevOps-практике концепция проверки безопасности на ранних этапах называется Shift-Left (сдвиг влево по пайплайну разработки). Чем раньше обнаружена уязвимость, тем дешевле и быстрее ее исправить.

    Анатомия уязвимости: что мы ищем?

    Когда мы говорим о сканировании Docker-образов, мы ищем не абстрактные «вирусы», а конкретные, задокументированные ошибки в коде библиотек, утилит и операционных систем.

    Индустриальным стандартом для идентификации таких ошибок является система CVE (Common Vulnerabilities and Exposures — Общие уязвимости и подверженности). Каждой найденной уязвимости присваивается уникальный идентификатор, например, CVE-2021-44228 (печально известная уязвимость Log4Shell в библиотеке логирования Java).

    Сканеры безопасности анализируют образ на наличие трех основных категорий угроз:

  • Уязвимости ОС (OS Packages): Ошибки в системных библиотеках базового образа (например, glibc, openssl, curl в образах Debian, Alpine или Ubuntu).
  • Уязвимости зависимостей приложения (Language-specific packages): Ошибки в библиотеках, которые устанавливают разработчики через пакетные менеджеры (npm, pip, maven, composer).
  • Утечки секретов (Hardcoded Secrets): Случайно забытые в коде или конфигурациях API-ключи, пароли от баз данных, приватные SSH-ключи или токены AWS.
  • Как работает сканер: концепция SBOM

    Сканер не пытается запустить ваш код или взломать его. Он работает по принципу статического анализа слоев файловой системы (о которых мы говорили в статье про анатомию Docker-образа).

    Первый и самый важный шаг работы любого современного сканера — это генерация SBOM (Software Bill of Materials — Спецификация программного обеспечения).

    SBOM — это исчерпывающий список «ингредиентов» вашего контейнера. Сканер читает метаданные пакетных менеджеров (например, /var/lib/dpkg/status для Debian или package-lock.json для Node.js) и составляет точный манифест: какие библиотеки установлены и каких они версий.

    !Схема работы сканера уязвимостей: от анализа слоёв образа до сопоставления с базами данных CVE

    После того как SBOM сформирован, сканер обращается к базам данных уязвимостей (например, NVD — National Vulnerability Database или базам конкретных дистрибутивов) и ищет пересечения. Если в вашем SBOM числится openssl версии 1.1.1d, а в базе сказано, что все версии до 1.1.1k уязвимы, сканер бьет тревогу.

    Оценка критичности: система CVSS

    Не все уязвимости одинаково опасны. Ошибка, позволяющая удаленному анонимному пользователю выполнить код, критична. Ошибка, позволяющая локальному пользователю с правами администратора прочитать системный лог, имеет низкий приоритет (ведь у него и так есть права).

    Для стандартизации оценки используется CVSS (Common Vulnerability Scoring System). Это фреймворк, который рассчитывает оценку от 0.0 до 10.0 на основе ряда метрик.

    Ключевые базовые метрики CVSS: * Вектор атаки (Attack Vector): Откуда можно провести атаку? Через интернет (Network) — самое опасное. Только имея физический доступ к серверу (Physical) — наименее опасное. * Сложность атаки (Attack Complexity): Требуются ли специфические условия (например, состояние гонки) для успешного взлома? * Требуемые привилегии (Privileges Required): Нужна ли учетная запись для эксплуатации? * Взаимодействие с пользователем (User Interaction): Должен ли пользователь кликнуть по ссылке, чтобы атака сработала?

    !Интерактивный калькулятор CVSS v3.1 — позволяет понять, как вектор атаки, сложность и требуемые привилегии влияют на итоговую оценку критичности уязвимости

    В корпоративных средах обычно устанавливается жесткое правило: образы с уязвимостями, где оценка (High и Critical), блокируются и не допускаются до развертывания в production.

    Инструменты сканирования: обзор рынка

    На рынке существует множество инструментов для сканирования контейнеров. Рассмотрим три наиболее популярных решения в open-source сегменте:

    | Инструмент | Разработчик | Особенности | Идеально для | | :--- | :--- | :--- | :--- | | Trivy | Aqua Security | Самый популярный, быстрый, не требует базы данных (скачивает кэш на лету), сканирует секреты и IaC. | CI/CD пайплайнов, локальной разработки. | | Clair | Quay (Red Hat) | Исторически один из первых. Требует отдельной базы данных PostgreSQL для хранения CVE. | Крупных enterprise-реестров (встроен в Harbor, Quay). | | Docker Scout | Docker Inc. | Встроен в Docker CLI (docker scout). Отличная визуализация, дает советы по обновлению базового образа. | Быстрой проверки прямо в терминале разработчика. |

    В рамках данного курса мы сфокусируемся на Trivy, так как он стал де-факто стандартом для интеграции в DevOps-процессы благодаря своей скорости и простоте.

    Практика: глубокое погружение в Trivy

    Установка Trivy тривиальна (доступна через apt, brew или как отдельный бинарный файл). Но самый «Docker-way» способ — запустить сам сканер в контейнере, пробросив ему сокет демона Docker, чтобы он мог читать ваши локальные образы.

    > Проброс docker.sock — это паттерн Docker-out-of-Docker (DooD), который мы обсуждали в статье про Bind Mounts. Он позволяет контейнеру Trivy управлять демоном хост-машины.

    Базовая команда для сканирования образа (например, старой версии Python):

    Вывод Trivy представляет собой таблицу, сгруппированную по библиотекам. Для каждой уязвимости указаны: ID (CVE), уровень критичности (Severity), установленная версия, версия с исправлением (Fixed Version) и ссылка на описание.

    Фильтрация и управление выводом

    При сканировании старых или полновесных образов (например, ubuntu:latest) вы можете получить сотни уязвимостей уровня Low и Medium. Анализировать их вручную невозможно.

    Trivy позволяет жестко фильтровать вывод:

    Флаг --ignore-unfixed критически важен для нервной системы DevOps-инженера. Он скрывает те уязвимости, для которых разработчики ОС или библиотеки еще не выпустили патч. Нет смысла блокировать релиз из-за уязвимости, которую вы физически не можете исправить обновлением пакета.

    Поиск утекших секретов

    Как мы помним из статьи про слоистую файловую систему, если вы добавили файл с паролем в инструкции COPY, а затем удалили его в инструкции RUN rm password.txt, файл физически останется в предыдущем слое (Read-Only). Злоумышленник сможет извлечь его с помощью команды docker save.

    Сканеры безопасности анализируют каждый слой образа по отдельности. Trivy использует регулярные выражения и анализ энтропии (поиск строк, похожих на случайный набор символов, что характерно для токенов) для поиска секретов.

    Если Trivy найдет токен AWS или приватный ключ RSA, он укажет точный ID слоя и путь к файлу, где произошла утечка.

    Интеграция в CI/CD: автоматизация безопасности

    Сканирование образов вручную на локальной машине разработчика — хорошая практика, но она не гарантирует безопасности. Истинный DevSecOps подразумевает, что ни один образ не попадет в реестр (Registry) без автоматической проверки.

    Для этого сканер встраивается в пайплайн непрерывной интеграции (CI). Ключевой механизм здесь — управление кодами возврата (Exit Codes).

    По умолчанию Trivy просто выводит таблицу в консоль и завершается с кодом 0 (успех), даже если нашел критические уязвимости. Чтобы заставить CI-пайплайн упасть (заблокировать сборку), используется флаг --exit-code 1.

    Пример шага в GitHub Actions или GitLab CI:

    Этот простой скрипт реализует мощный барьер: уязвимый код физически не сможет покинуть среду сборки.

    Управление исключениями: ложные срабатывания и VEX

    В реальном мире автоматическое блокирование сборок часто приводит к конфликтам между отделом безопасности и разработчиками. Сканеры не идеальны и часто выдают ложные срабатывания (False Positives).

    Например, сканер нашел уязвимость в библиотеке обработки изображений libpng, которая установлена в базовом образе. Но ваше приложение — это API на Node.js, которое работает только с текстом и JSON. Уязвимость существует в файловой системе, но она не эксплуатируема в контексте вашего приложения.

    Для обработки таких ситуаций используются два механизма:

  • Файл .trivyignore: Простой текстовый файл в корне проекта, куда вписываются ID конкретных CVE, которые команда решила проигнорировать (принятие риска).
  • VEX (Vulnerability Exploitability eXchange): Более продвинутый индустриальный стандарт. Это машиночитаемый документ, который прикрепляется к SBOM и говорит: «Да, в этом образе есть CVE-2023-XXXX, но она не влияет на продукт, потому что уязвимая функция никогда не вызывается».
  • Пример содержимого .trivyignore:

    Непрерывное сканирование: почему одного раза недостаточно

    Представьте ситуацию: вы собрали образ, просканировали его — уязвимостей нет (0 CRITICAL). Образ успешно ушел в production и работает там месяцами.

    Спустя полгода хакеры находят уязвимость нулевого дня (Zero-Day) в библиотеке, которую вы используете. Ваш образ, который вчера считался безопасным, сегодня стал критически уязвимым. При этом CI/CD пайплайн не запускался, так как код приложения не менялся.

    Именно поэтому концепция Shift-Left должна дополняться Continuous Scanning (непрерывным сканированием).

    Современные реестры контейнеров (например, Harbor, AWS ECR, GitLab Container Registry) имеют встроенные сканеры, которые периодически (например, раз в сутки) перепроверяют уже загруженные образы по обновленным базам данных CVE. Если в старом образе обнаруживается новая уязвимость, система отправляет алерт в мессенджер или систему мониторинга команды эксплуатации.

    Безопасность контейнеров — это не разовая акция, а непрерывный процесс. Выбор минималистичного базового образа (как мы обсуждали в статье про многоэтапные сборки), запуск без root-прав и автоматизированное сканирование на уязвимости и секреты в совокупности создают эшелонированную защиту (Defense in Depth), которая является признаком зрелой DevOps-инфраструктуры.

    19. Управление артефактами: работа с публичными и приватными реестрами

    В современной разработке программного обеспечения исходный код — это лишь начало пути. Чтобы код превратился в работающий продукт, его необходимо скомпилировать, собрать вместе с зависимостями и упаковать в единый, неизменяемый формат. Такой готовый к развертыванию пакет называется артефактом. В контексте Docker артефактом является собранный образ (Image).

    После того как образ собран на локальной машине разработчика или сервере непрерывной интеграции (CI), его необходимо где-то сохранить, чтобы другие серверы могли его скачать и запустить. Для этой цели используются реестры контейнеров (Container Registries) — специализированные хранилища, оптимизированные для работы со слоистой файловой системой Docker.

    Анатомия имени образа

    Когда вы выполняете команду docker pull ubuntu, Docker скрывает от вас полную структуру имени образа. На самом деле, полное имя состоит из четырех компонентов, которые определяют точный маршрут к артефакту.

    Полный формат выглядит так: [registry_url]/[namespace]/[repository]:[tag]

    Разберем каждый элемент на примере полного имени ghcr.io/github/super-linter:v5.0.0:

  • Registry URL (ghcr.io): Адрес сервера реестра. Если он не указан, Docker по умолчанию использует публичный реестр Docker Hub (docker.io).
  • Namespace (github): Пространство имен, обычно соответствующее имени пользователя или организации. В Docker Hub официальные образы (например, ubuntu, nginx) используют скрытое пространство имен library.
  • Repository (super-linter): Имя самого репозитория, в котором хранятся образы конкретного приложения.
  • Tag (v5.0.0): Тег, указывающий на конкретную версию. Если тег не указан, Docker автоматически подставляет тег latest.
  • Таким образом, короткая команда docker pull nginx под капотом транслируется демоном в docker pull docker.io/library/nginx:latest.

    Как реестр хранит данные: Манифесты и Блобы

    Реестр не хранит Docker-образы как единые монолитные архивы (подобно .iso или .zip). Это было бы крайне неэффективно из-за дублирования данных. Вместо этого реестр использует архитектуру, основанную на дедупликации слоев, о которой мы говорили в статье про анатомию Docker-образа.

    Внутри реестра данные делятся на два основных типа:

    * Blobs (Блобы): Это физические файлы слоев (сжатые tar-архивы) и конфигурационные JSON-файлы образа. Каждый блоб идентифицируется своим криптографическим хешем SHA256. Реестр ничего не знает о содержимом блоба — для него это просто бинарный объект. * Manifests (Манифесты): Это JSON-документы, которые описывают конкретный образ. Манифест содержит список хешей всех слоев (блобов), из которых состоит образ, а также хеш конфигурационного файла.

    Когда вы выполняете docker push, клиент сначала отправляет манифест. Реестр проверяет, какие из хешей слоев уже существуют в его хранилище. Если базовый слой (например, Alpine Linux) уже был загружен кем-то другим или вами ранее, реестр сообщает клиенту: «Этот слой у меня есть, его отправлять не нужно». Клиент загружает только новые, уникальные слои. Это колоссально экономит сетевой трафик и дисковое пространство.

    !Интерактивная схема работы реестра: дедупликация слоев и связь тегов с манифестами

    Стратегии тегирования и ловушка «latest»

    Теги в Docker — это просто указатели (pointers) на конкретные манифесты. Они работают точно так же, как ветки или легковесные теги в Git. Один и тот же образ (с одним и тем же хешем) может иметь несколько тегов, например python:3.10.12, python:3.10 и python:3.

    В индустрии стандартом де-факто является использование семантического версионирования (Semantic Versioning или SemVer). Формат SemVer выглядит как MAJOR.MINOR.PATCH (например, 2.1.4):

    * MAJOR (Мажорная версия): Изменяется при несовместимых изменениях API или архитектуры. * MINOR (Минорная версия): Изменяется при добавлении нового функционала с сохранением обратной совместимости. * PATCH (Патч): Изменяется при исправлении багов без изменения функционала.

    Почему тег latest опасен в production

    Тег latest (последний) является источником наибольшего количества сбоев в мире контейнеризации. Вопреки распространенному заблуждению, latest не означает «самая свежая версия образа в реестре». Это просто строка текста, которая применяется по умолчанию, если вы не указали тег.

    Рассмотрим классический сценарий катастрофы:

  • Вы пишете в compose.yaml: image: my-database:latest.
  • Вы запускаете проект. Скачивается текущая версия базы данных (допустим, версия 1.0). Все работает отлично.
  • Проходит полгода. Разработчики базы данных выпускают версию 2.0 с совершенно другим форматом хранения данных и обновляют тег latest в реестре.
  • Ваш сервер перезагружается, или вы переносите проект на новый сервер. Docker скачивает my-database:latest (теперь это версия 2.0).
  • Новая версия базы данных не может прочитать старые файлы данных на томе (Volume). Приложение падает.
  • > Использование мутабельных (изменяемых) тегов, таких как latest, staging или dev, нарушает принцип идемпотентности инфраструктуры. Развертывание одного и того же манифеста в разное время должно приводить к идентичному результату. > > Официальная документация Docker по лучшим практикам

    Правильный подход: В production-средах всегда используйте иммутабельные (неизменяемые) теги, привязанные к конкретной версии кода. Идеальный вариант — тегировать образы хешем коммита из Git (например, my-app:a1b2c3d). Это обеспечивает 100% прослеживаемость: глядя на запущенный контейнер, вы точно знаете, из какой строчки кода он собран.

    Публичные реестры и ограничения Docker Hub

    Docker Hub — это крупнейший в мире публичный реестр, который используется по умолчанию. Он бесплатен для публичных репозиториев, что делает его идеальным для open-source проектов.

    Однако при использовании Docker Hub в корпоративной среде вы неизбежно столкнетесь с Rate Limits (ограничениями частоты запросов). Для анонимных пользователей (без выполнения docker login) Docker Hub разрешает скачивать (pull) не более 100 образов за 6 часов с одного IP-адреса. Для авторизованных бесплатных аккаунтов лимит составляет 200 скачиваний.

    Если ваша компания использует единый корпоративный NAT (все серверы выходят в интернет под одним IP-адресом), CI/CD пайплайны быстро исчерпают этот лимит. Сборки начнут падать с ошибкой toomanyrequests: You have reached your pull rate limit.

    Решением этой проблемы является использование зеркалирования или переход на приватные реестры.

    Аутентификация и безопасность учетных данных

    Для загрузки образов в реестр (или скачивания из приватных репозиториев) необходимо пройти аутентификацию с помощью команды:

    Docker запросит логин и пароль. Исторически Docker сохранял эти данные в виде обычного текста (Base64) в файле ~/.docker/config.json. Это огромная дыра в безопасности: любой скрипт, имеющий доступ к вашей домашней директории, мог украсть учетные данные.

    Современный подход требует использования Credential Helpers (помощников по учетным данным). Это внешние программы, которые интегрируют Docker с безопасными хранилищами ключей операционной системы (Keychain в macOS, Credential Manager в Windows, pass или secretservice в Linux).

    При использовании Credential Helper в файле config.json сохраняется не пароль, а указание на то, какую программу использовать для его получения:

    Персональные токены доступа (PAT)

    Даже при использовании безопасного хранилища, вводить свой основной пароль от аккаунта (особенно если у вас права администратора) — плохая практика. Вместо этого следует использовать Personal Access Tokens (PAT).

    PAT — это сгенерированная строка, которая действует как пароль, но имеет ограниченные права (например, только чтение образов, без права их удаления) и может быть легко отозвана в любой момент без смены основного пароля. В CI/CD системах (GitHub Actions, GitLab CI) всегда должны использоваться исключительно токены с минимально необходимыми привилегиями.

    Приватные реестры: зачем они нужны бизнесу

    Публичные реестры не подходят для проприетарного программного обеспечения. Исходный код и скомпилированные артефакты — это интеллектуальная собственность компании. Кроме того, скачивание тяжелых образов через интернет замедляет процессы развертывания и увеличивает расходы на трафик.

    Для решения этих задач компании разворачивают приватные реестры. Их можно разделить на две категории:

    1. Облачные управляемые реестры (Managed Cloud Registries)

    Если ваша инфраструктура находится в облаке, логично использовать встроенные решения от провайдера: * Amazon ECR (Elastic Container Registry) * Google Artifact Registry * Azure Container Registry

    Их главное преимущество — глубокая интеграция с системой управления доступом облака (IAM). Вам не нужно создавать отдельные логины и пароли; сервер в AWS автоматически получает временный токен для скачивания образа из ECR на основе своей IAM-роли.

    2. Self-hosted реестры (Устанавливаемые локально)

    Для on-premise инфраструктур (собственные физические серверы) или при строгих требованиях к безопасности (например, в банковском секторе, где серверы изолированы от интернета) используются self-hosted решения.

    Самым мощным open-source решением корпоративного уровня является Harbor (проект CNCF). Помимо хранения образов, Harbor предоставляет: * Управление доступом на основе ролей (RBAC). * Встроенное сканирование на уязвимости (интеграция с Trivy, о котором мы говорили в предыдущей статье). * Подписание образов (Notary), гарантирующее, что образ не был подменен. * Репликацию между несколькими дата-центрами.

    Практика: запуск собственного реестра

    Docker предоставляет базовый реестр в виде официального образа registry. Это отличный способ понять, как технология работает изнутри, и организовать локальный кэш для небольшой команды.

    Запустим реестр локально на порту 5000, примонтировав именованный том для постоянного хранения данных (чтобы образы не исчезли при перезапуске контейнера):

    Теперь у нас есть пустой реестр. Чтобы загрузить в него образ, нам нужно правильно его тегировать. Допустим, у нас есть локальный образ my-app:v1.

    Сначала мы создаем новый тег, который включает адрес нашего локального реестра (localhost:5000):

    Теперь мы можем отправить образ в наш реестр:

    Демон Docker видит, что имя начинается с localhost:5000, понимает, что это не Docker Hub, и отправляет манифест и блобы на наш локальный контейнер.

    Примечание: По умолчанию Docker требует, чтобы все реестры использовали защищенное соединение (HTTPS/TLS). Исключение сделано только для localhost и 127.0.0.0/8, которые считаются безопасными по умолчанию. Если вы развернете этот реестр на другом сервере в локальной сети (например, 192.168.1.100), Docker откажется с ним работать без настройки TLS-сертификатов или явного добавления адреса в список insecure-registries в файле daemon.json.

    Продвинутые механизмы: Pull-through Cache

    Возвращаясь к проблеме лимитов Docker Hub (Rate Limits) и медленного интернета: что делать, если в офисе работает 50 разработчиков, и каждый из них по несколько раз в день скачивает базовый образ node:18?

    Решением является настройка локального реестра в режиме Pull-through Cache (сквозное кэширование).

    !Архитектура Pull-through кэша: локальный реестр выступает посредником между клиентами и Docker Hub, сохраняя скачанные слои

    В этом режиме локальный реестр работает как прокси-сервер. Когда разработчик запрашивает образ node:18, запрос идет к локальному кэшу.

  • Если образа там нет (Cache Miss), кэш сам обращается к Docker Hub, скачивает образ, сохраняет его на свой диск и отдает разработчику.
  • Когда второй разработчик запрашивает тот же образ, локальный кэш отдает его мгновенно со своего диска (Cache Hit), не обращаясь к интернету.
  • Это не только экономит трафик и обходит лимиты Docker Hub (так как все запросы идут от одного авторизованного аккаунта кэша), но и ускоряет сборку проектов в десятки раз.

    Эволюция реестров: стандарт OCI

    Исторически реестры умели хранить только Docker-образы. Однако индустрия быстро поняла, что механизм хранения, основанный на манифестах и дедуплицированных блобах, идеально подходит для любых артефактов.

    Был создан OCI (Open Container Initiative) — открытый стандарт, описывающий форматы образов и API реестров. Сегодня большинство современных реестров (Harbor, GitHub Packages, AWS ECR) являются OCI-совместимыми.

    Это означает, что в тот же самый реестр, где лежат ваши Docker-образы, вы можете загружать: * Helm Charts (пакеты для Kubernetes, о которых мы поговорим в следующих модулях). * SBOM (спецификации программного обеспечения, необходимые для сканеров безопасности, таких как Trivy). * WASM (WebAssembly модули). * Обычные бинарные файлы и архивы.

    Реестр контейнеров превратился в универсальный центр управления всеми артефактами компании.

    Управление жизненным циклом: Garbage Collection

    Реестры имеют свойство быстро разрастаться. В CI/CD пайплайнах каждый коммит в Git может генерировать новый Docker-образ. За месяц активной разработки репозиторий может накопить терабайты данных.

    Простое удаление тега (например, через веб-интерфейс реестра) не освобождает место на диске. Как мы помним, тег — это лишь указатель на манифест. Удаление тега делает манифест «сиротой» (untagged), но сами тяжелые слои (блобы) остаются в хранилище, так как реестр предполагает, что они могут использоваться другими образами.

    Для физического удаления неиспользуемых данных применяется процесс Garbage Collection (сборка мусора).

    В локальном реестре registry:2 это делается специальной командой внутри контейнера:

    Сборщик мусора сканирует все манифесты, составляет список используемых блобов, а затем безвозвратно удаляет с диска те блобы, на которые больше нет ссылок. В enterprise-решениях вроде Harbor этот процесс автоматизирован и настраивается по расписанию (например, удалять все untagged образы старше 30 дней каждую субботу ночью).

    Управление артефактами — это связующее звено между написанием кода и его запуском. Понимание того, как работают реестры, как правильно тегировать образы и как безопасно их хранить, является критически важным навыком для любого DevOps-инженера, подготавливая почву для автоматизации этих процессов в CI/CD пайплайнах.

    2. Изоляция процессов: как работают namespaces и cgroups

    В прошлом модуле мы разобрали архитектуру Docker и выяснили, что клиент передает команды демону, а тот, в свою очередь, скачивает образы из реестра и запускает контейнеры. Но что именно происходит в тот момент, когда демон решает «запустить контейнер»?

    Многие начинающие инженеры представляют себе контейнер как крошечную виртуальную машину — эдакий маленький компьютер внутри большого компьютера, со своей операционной системой. Это одно из самых распространенных и опасных заблуждений в DevOps.

    На самом деле контейнер — это просто обычный процесс (или группа процессов) в операционной системе хоста. Если вы запустите Nginx в Docker, а затем откроете диспетчер задач на сервере, вы увидите процесс Nginx точно так же, как если бы запустили его без всякого Docker.

    Секрет «магии» контейнеризации кроется не в виртуализации железа, а в двух мощных механизмах ядра Linux: Namespaces (пространства имен) и Cgroups (контрольные группы). Именно они создают для процесса иллюзию того, что он работает на сервере в полном одиночестве.

    Namespaces: Иллюзия одиночества

    Namespaces (пространства имен) — это механизм ядра Linux, который изолирует глобальные системные ресурсы так, чтобы процесс видел только свою собственную, изолированную часть системы.

    Представьте себе огромный open-space офис (это наш сервер). Все сотрудники (процессы) сидят в одном помещении, видят друг друга, могут переговариваться и брать документы с общих столов. Если один сотрудник начнет кричать, он помешает всем остальным.

    Namespaces — это звуконепроницаемые перегородки, которые мы внезапно возводим вокруг одного из сотрудников. Теперь он не видит коллег, у него свой собственный стол и свой телефонный аппарат. Ему кажется, что он в офисе один, хотя физически он все еще находится в том же здании.

    Ядро Linux поддерживает несколько типов пространств имен, каждый из которых изолирует определенный тип ресурсов.

    PID Namespace (Изоляция процессов)

    В Linux каждый процесс имеет уникальный идентификатор — PID (Process ID). Процесс с PID 1 — это процесс инициализации системы (обычно systemd), от которого порождаются все остальные процессы.

    Когда Docker запускает контейнер, он создает для него новое пространство имен PID. Внутри этого пространства процесс получает PID 1. Он «думает», что он главный и единственный в системе.

    Однако, если мы посмотрим на этот же процесс со стороны хост-системы (снаружи «перегородки»), мы увидим, что у него совершенно обычный номер, например, PID 3452.

    > Изоляция PID гарантирует безопасность: процесс внутри контейнера не может увидеть, отправить сигнал (например, команду на завершение kill) или как-то повлиять на процессы хоста или других контейнеров, потому что для него их просто не существует.

    MNT Namespace (Изоляция файловой системы)

    Пространство имен монтирования (Mount Namespace) изолирует точки монтирования файловой системы.

    Когда вы заходите внутрь контейнера и вводите команду ls /, вы видите стандартную структуру папок Linux: /etc, /var, /usr. Процесс уверен, что это корневая файловая система сервера. На самом деле демон Docker подменил корень файловой системы для этого конкретного пространства имен, подсунув туда файлы из скачанного образа (того самого, который мы взяли из Docker Registry).

    NET Namespace (Сетевая изоляция)

    Сетевое пространство имен выдает процессу собственный сетевой стек: свои IP-адреса, свои таблицы маршрутизации и свои порты.

    Именно поэтому вы можете запустить на одном сервере десять контейнеров с веб-сервером Nginx, и каждый из них будет слушать порт 80 внутри своего контейнера. Конфликта портов не произойдет, потому что у каждого контейнера свой изолированный сетевой интерфейс.

    Чтобы пользователи из интернета могли достучаться до этого Nginx, мы используем проброс портов (команда -p 8080:80), которая создает мост между глобальным сетевым пространством хоста и изолированным пространством контейнера.

    Другие важные Namespaces

  • UTS Namespace: Позволяет контейнеру иметь собственное имя хоста (hostname).
  • USER Namespace: Позволяет процессу иметь права root (суперпользователя) внутри контейнера, оставаясь при этом обычным, непривилегированным пользователем на уровне хост-системы. Это важнейший механизм безопасности.
  • IPC Namespace: Изолирует механизмы межпроцессного взаимодействия (очереди сообщений, разделяемую память).
  • Cgroups: Строгий завхоз

    Если Namespaces ограничивают то, что процесс видит, то Cgroups (Control Groups, контрольные группы) ограничивают то, что процесс может использовать.

    Вернемся к аналогии с офисом. Мы построили для сотрудника звуконепроницаемую комнату (Namespace). Но что, если этот сотрудник принесет из дома обогреватель, включит его на полную мощность и выбьет пробки во всем здании? Изоляция видимости не спасает от исчерпания общих ресурсов.

    В мире серверов это называется проблемой «шумного соседа» (Noisy Neighbor). Если один контейнер из-за ошибки в коде начнет потреблять 100% оперативной памяти или процессорного времени, остальные контейнеры и сама операционная система хоста начнут зависать и падать.

    Cgroups решают эту проблему. Это механизм ядра, который позволяет устанавливать жесткие лимиты на потребление ресурсов (CPU, RAM, дисковый ввод/вывод) для группы процессов.

    !Схема изоляции контейнера: Namespaces создают невидимые стены для процесса, а Cgroups ограничивают потребление ресурсов хоста

    Ограничение памяти (RAM) и OOM Killer

    Когда вы запускаете контейнер, вы можете (и в production-средах обязаны) указать лимит оперативной памяти. Через Docker Client это делается флагом --memory:

    docker run -d --memory="512m" nginx

    Демон Docker передает эту информацию ядру Linux, и ядро создает новую контрольную группу с лимитом в 512 мегабайт. Что произойдет, если приложение внутри контейнера попытается выделить 513-й мегабайт?

    Ядро Linux не позволит этого сделать. В дело вступит механизм OOM Killer (Out Of Memory Killer). Это системный снайпер, который моментально «убивает» процесс, превысивший лимит памяти cgroup. Контейнер остановится с ошибкой OOMKilled.

    Звучит жестоко, но это спасает весь остальной сервер от падения. Лучше пусть упадет один неисправный контейнер, чем зависнет вся инфраструктура.

    Ограничение процессора (CPU)

    Управление процессором работает немного иначе. Вы не можете «убить» процесс за то, что он хочет слишком много считать. Вместо этого Cgroups используют механизм троттлинга (замедления).

    Если вы ограничите контейнер половиной процессорного ядра (--cpus="0.5"), а приложение попытается использовать больше, ядро Linux просто начнет ставить процесс на паузу на микросекунды, не давая ему превысить лимит.

    Математически это можно выразить через долю от общего времени процессора. Если сервер имеет 4 ядра, а контейнеру выделено ядра, максимальная вычислительная мощность, доступная контейнеру, рассчитывается так:

    Где — лимит контейнера, а — общее количество ядер хоста. Процесс внутри контейнера будет работать медленнее, но не помешает соседям.

    Анатомия контейнера: собираем пазл

    Теперь мы можем дать технически грамотное определение.

    > Контейнер — это обычный процесс операционной системы Linux, который обернут в Namespaces для изоляции видимости и помещен в Cgroups для ограничения потребляемых ресурсов.

    Docker сам по себе не создает контейнеры. Docker — это лишь удобный инструмент (клиент и демон), который автоматизирует сложный процесс общения с ядром Linux. Он говорит ядру: «Создай новое пространство имен PID, подмонтируй вот эту файловую систему, создай Cgroup с лимитом памяти в 1 ГБ и запусти там этот процесс».

    Сравнение с виртуальными машинами

    Понимание Namespaces и Cgroups позволяет четко ответить на популярный вопрос с собеседований: «Чем контейнер отличается от виртуальной машины (VM)?»

    | Характеристика | Виртуальная машина (VM) | Контейнер (Docker) | | :--- | :--- | :--- | | Что виртуализируется? | Аппаратное обеспечение (железо). | Операционная система. | | Кто управляет? | Гипервизор (VMware, VirtualBox). | Ядро хостовой ОС (через Namespaces/Cgroups). | | Гостевая ОС | В каждой VM установлена своя полноценная ОС (весит гигабайты). | Отсутствует. Контейнеры делят одно ядро хоста на всех. | | Скорость запуска | Минуты (нужно загрузить ядро гостевой ОС). | Миллисекунды (просто запуск процесса). | | Изоляция | Максимальная (аппаратный уровень). | Высокая, но на уровне процессов. |

    Контейнеры невероятно легковесны именно потому, что им не нужно загружать собственное ядро операционной системы. Они используют ядро хоста.

    Практические нюансы и подводные камни

    Глубокое понимание этих механизмов помогает решать реальные проблемы в работе DevOps-инженера.

    1. Привилегированные контейнеры

    Иногда вам нужно запустить в контейнере инструмент, которому нужен доступ к железу сервера (например, системе мониторинга или VPN-клиенту). Для этого используется флаг --privileged.

    Этот флаг фактически говорит демону Docker: «Не создавай строгие Namespaces и Cgroups, позволь этому процессу видеть хост-систему». Это намеренное пробивание дыры в изоляции. Использовать этот флаг нужно с крайней осторожностью, так как взлом такого контейнера означает полный взлом сервера.

    2. Почему Docker Desktop тормозит на Mac и Windows?

    В предыдущей статье мы упоминали, что Docker Desktop на macOS и Windows запускает скрытую виртуальную машину. Теперь вы знаете точную причину: Namespaces и Cgroups — это эксклюзивные функции ядра Linux.

    Ядра macOS (Darwin) и Windows (NT) не имеют таких механизмов. Поэтому Docker физически не может запустить контейнер нативно на макбуке. Ему приходится сначала запускать легковесную виртуальную машину с Linux, и уже внутри ее ядра использовать Namespaces и Cgroups. Именно эта прослойка виртуализации вызывает задержки при монтировании файлов с макбука внутрь контейнера.

    3. Утечки ресурсов в Java и Node.js

    Исторически старые версии некоторых языков программирования (например, Java 8) не умели «видеть» Cgroups.

    Процесс Java внутри контейнера смотрел на систему, видел 64 ГБ оперативной памяти хоста (игнорируя лимит Cgroup в 1 ГБ) и пытался забрать себе половину. В результате ядро Linux моментально убивало контейнер через OOM Killer. Современные версии языков программирования стали cgroup-aware (осведомленными о контрольных группах) и корректно считывают свои лимиты.

    В следующем модуле мы перейдем от запуска готовых образов к их созданию. Мы разберем, как работает файловая система UnionFS, из чего состоят слои образа и как правильно писать Dockerfile, чтобы ваши контейнеры собирались быстро и весили мало.

    20. Интеграция Docker: подготовка образов для CI/CD пайплайнов

    Переход от локальной разработки к автоматизированным конвейерам непрерывной интеграции и доставки (CI/CD) требует фундаментального изменения подхода к работе с Docker. На локальной машине разработчика среда обладает персистентностью (постоянством): слои кэшируются на диске, базовые образы скачиваются один раз, а контекст сборки всегда находится под рукой.

    В мире CI/CD пайплайнов (например, GitLab CI, GitHub Actions, Jenkins) бал правят эфемерные среды. Сборочный агент (Runner) создается с нуля для каждого коммита, выполняет задачу и уничтожается. У него нет локального кэша слоев, он ничего не знает о предыдущих сборках. Если перенести локальные команды docker build в пайплайн без изменений, сборка будет занимать неоправданно много времени, потреблять избыточный сетевой трафик и может привести к утечке секретных данных.

    Идемпотентность и детерминированность сборок

    Главное правило CI/CD: пайплайн должен быть детерминированным. Это означает, что сборка одного и того же исходного кода (конкретного коммита в Git) сегодня, завтра и через год должна приводить к созданию абсолютно идентичного Docker-образа (байт в байт).

    На практике детерминированность часто нарушается из-за плавающих зависимостей. Рассмотрим типичную инструкцию в Dockerfile:

    Если вы запустите эту сборку сегодня, пакетный менеджер установит Python версии 3.10. Если вы запустите её через полгода, в репозиториях Ubuntu может появиться Python 3.11, и он будет установлен по умолчанию. Ваш образ изменится без изменения вашего исходного кода, что может сломать приложение в production.

    Решение: Строгое фиксирование (pinning) версий всех зависимостей и базовых образов.

    Оптимизация контекста сборки в CI

    Когда вы выполняете команду docker build ., Docker-клиент берет всё содержимое текущей директории (Build Context) и отправляет его Docker-демону. На локальной машине это происходит быстро. Но в CI-системах демон часто работает на отдельном сервере (например, паттерн Docker-in-Docker или удаленный демон).

    Если в вашем репозитории есть папка .git (которая может весить гигабайты из-за истории коммитов) или локальная папка node_modules, они будут передаваться по сети при каждой сборке, замедляя пайплайн на минуты.

    Файл .dockerignore в CI/CD становится критически важным элементом инфраструктуры. Он должен работать по принципу «запрещено всё, что не разрешено явно»:

    Такой подход гарантирует, что в контекст сборки попадут только исходники и манифесты зависимостей, исключая случайное попадание логов, временных файлов тестов или локальных конфигураций разработчика.

    Управление кэшированием в эфемерных средах

    Как мы выяснили, эфемерный CI-раннер начинает работу с пустым локальным кэшем Docker. Чтобы не скачивать базовые образы и не пересобирать неизменные слои (например, зависимости) при каждом коммите, необходимо использовать внешний кэш.

    Современный движок сборки BuildKit (встроенный в Docker по умолчанию) умеет сохранять кэш слоев прямо в реестр контейнеров (Container Registry) и скачивать его оттуда при следующей сборке.

    Для этого используются флаги --cache-from (откуда брать кэш) и --cache-to (куда сохранять новый кэш).

    Разберем параметры кэширования: * type=registry: Указывает, что кэш хранится в удаленном реестре (как обычный образ, но со специальным манифестом). * ref: Путь в реестре, где будет лежать кэш. Обычно для него выделяют отдельный тег (например, buildcache). mode=max: Критически важный параметр для многоэтапных сборок (Multi-stage builds). По умолчанию (mode=min) Docker кэширует только слои финального образа. Режим max заставляет BuildKit кэшировать слои всех* промежуточных этапов (например, этап компиляции или скачивания зависимостей), что дает максимальное ускорение в CI.

    > Использование удаленного кэширования может ускорить сборку тяжелых проектов (Java, C++, Rust) в CI-пайплайне с 15-20 минут до нескольких секунд, если изменения коснулись только исходного кода, а зависимости остались прежними.

    Стратегии тегирования артефактов

    В автоматизированных пайплайнах ручное назначение тегов недопустимо. Теги должны генерироваться динамически на основе метаданных системы контроля версий (Git).

    Правильная стратегия тегирования включает создание нескольких указателей на один и тот же собранный образ:

  • Уникальный идентификатор (Primary Tag): Тег, который однозначно идентифицирует сборку. Стандартом индустрии является использование короткого хеша коммита Git (Git SHA). Например: my-app:a1b2c3d. Это обеспечивает 100% прослеживаемость — увидев этот тег в production, вы можете найти точную строчку кода, из которой он собран.
  • Контекстный тег (Environment/Branch Tag): Тег, указывающий на ветку или окружение. Например: my-app:main или my-app:feature-auth. Эти теги мутабельны (перезаписываются при каждом новом коммите в ветку) и используются для развертывания в тестовых средах (Staging).
  • Релизный тег (Semantic Versioning): При создании релиза (Git Tag) образ тегируется семантической версией, например my-app:v1.2.3.
  • В CI-скрипте это выглядит примерно так:

    Безопасная передача секретов на этапе сборки

    Часто для сборки приложения требуются секретные данные: токены доступа к приватным репозиториям (NPM, PyPI, Maven), SSH-ключи или пароли от баз данных для генерации статических клиентов.

    Фатальная ошибка: Передача секретов через аргументы сборки (--build-arg) или переменные окружения (ENV).

    Запуск сборки в CI-пайплайне:

    Такой подход инкапсулирует логику тестирования внутри Dockerfile. CI-раннеру больше не нужно иметь установленный Node.js, Python или Go для запуска тестов — всё происходит в изолированной, воспроизводимой среде контейнера.

    Интеграция Docker в CI/CD — это не просто перенос локальных команд на удаленный сервер. Это проектирование детерминированных, безопасных и оптимизированных процессов, где кэширование, правильное управление секретами и кроссплатформенность становятся фундаментом надежной доставки программного обеспечения.

    3. Жизненный цикл контейнера: создание, запуск, остановка и удаление

    В предыдущем модуле мы заглянули под капот Docker и выяснили, что контейнер — это не виртуальная машина, а обычный процесс Linux, изолированный с помощью Namespaces и ограниченный в ресурсах через Cgroups. Мы поняли, как ядро операционной системы создает иллюзию одиночества для приложения.

    Однако процесс не появляется из ниоткуда и не исчезает бесследно. Управление контейнерами в production-среде требует четкого понимания того, через какие стадии проходит контейнер с момента ввода команды до его полного удаления с диска сервера. Ошибка на этапе остановки может привести к потере данных в базе, а неправильный запуск — к недоступности сервиса.

    Жизненный цикл контейнера — это конечный автомат (state machine). Контейнер всегда находится в одном из строго определенных состояний, и переход между ними осуществляется с помощью конкретных команд Docker CLI, которые транслируются в системные вызовы ядра Linux.

    !Интерактивная схема жизненного цикла Docker-контейнера

    Состояние 1: Создание (Created)

    Первый шаг в жизни контейнера — это его создание. Многие инженеры привыкли использовать команду docker run, которая делает всё и сразу. Но чтобы понять механику, мы разделим этот процесс.

    Команда docker create берет образ из локального кэша (или скачивает его из реестра) и подготавливает контейнер к запуску, но не запускает сам процесс.

    docker create --name my_nginx -p 80:80 nginx

    Что именно делает демон Docker в этот момент на уровне хост-системы?

  • Проверка образа: Демон убеждается, что все слои файловой системы образа (например, Nginx) присутствуют на диске.
  • Создание слоя чтения-записи (Read-Write Layer): Образы Docker неизменяемы (read-only). Чтобы контейнер мог создавать временные файлы или писать логи, Docker добавляет поверх слоев образа новый, пустой слой, доступный для записи.
  • Резервирование параметров изоляции: Docker формирует конфигурацию для ядра Linux. Он записывает, какие Namespaces нужно будет создать, какие лимиты Cgroups применить, какие порты пробросить и какие тома (volumes) подмонтировать.
  • Выделение ID: Контейнеру присваивается уникальный 64-значный идентификатор и человекочитаемое имя (если оно не задано флагом --name, Docker сгенерирует его сам, например, jovial_turing).
  • В состоянии Created контейнер уже занимает немного места на диске (за счет метаданных и пустого RW-слоя), но он не потребляет ни процессорное время, ни оперативную память. Это как автомобиль, который полностью собран, заправлен и стоит на конвейере, но ключ в замке зажигания еще не повернут.

    Состояние 2: Запуск и Работа (Running)

    Чтобы перевести созданный контейнер в активное состояние, используется команда docker start.

    docker start my_nginx

    Именно в эту миллисекунду происходит магия, описанная в прошлой статье. Демон Docker обращается к ядру Linux и говорит: «Примени конфигурацию, которую я подготовил на этапе создания».

    Ядро создает новые пространства имен (PID, NET, MNT), применяет лимиты Cgroups и запускает процесс, указанный в инструкции CMD или ENTRYPOINT образа. Процесс получает PID 1 внутри своего изолированного пространства.

    Foreground против Detached

    При запуске контейнеров критически важно понимать разницу между режимами прикрепления стандартных потоков ввода-вывода (stdin, stdout, stderr).

    Если вы используете комбинированную команду docker run nginx (без флагов), контейнер запускается в режиме Foreground (на переднем плане). Ваш терминал привязывается к процессу внутри контейнера. Вы видите все логи Nginx прямо на экране. Но если вы нажмете Ctrl+C, вы отправите сигнал прерывания процессу, и контейнер остановится. Ваш терминал заблокирован работой контейнера.

    В DevOps-практике 99% сервисов запускаются в режиме Detached (в фоновом режиме) с помощью флага -d:

    docker run -d --name my_nginx nginx

    В этом случае Docker запускает контейнер, отвязывает его от вашего текущего терминала и просто возвращает ID созданного контейнера. Процесс работает в фоне. Чтобы посмотреть его логи, используется отдельная команда docker logs my_nginx.

    > Важное правило Docker: Контейнер живет ровно до тех пор, пока жив его основной процесс (PID 1). > > Если вы запустите контейнер на базе Ubuntu командой docker run -d ubuntu echo "Hello", контейнер запустится, выведет слово "Hello" и немедленно остановится. Процесс echo выполнил свою задачу и завершился. Контейнеру больше незачем работать. Контейнеризация не предназначена для поддержания пустой операционной системы в активном состоянии.

    Состояние 3: Пауза (Paused)

    Это наименее известное, но технически интересное состояние. Вы можете «заморозить» работающий контейнер:

    docker pause my_nginx

    Что происходит под капотом? Docker не убивает процесс и не выгружает его из оперативной памяти. Вместо этого он использует специальную подсистему Cgroups, которая называется Freezer.

    Freezer отправляет всем процессам внутри контейнера сигнал приостановки на уровне планировщика ядра Linux. Процессы остаются в оперативной памяти в точно таком же состоянии, в каком они были в момент паузы (сохранены все переменные, открытые сетевые соединения), но ядро полностью перестает выделять им процессорное время (CPU).

    Зачем это нужно на практике?

  • Освобождение CPU без потери состояния: Если на сервере временно возникла пиковая нагрузка от критически важного процесса, вы можете поставить на паузу фоновые воркеры (контейнеры), чтобы они не потребляли процессор, а затем мгновенно возобновить их работу командой docker unpause.
  • Снятие консистентных снапшотов: Заморозка гарантирует, что приложение не изменит файлы на диске в тот момент, когда вы делаете резервную копию тома (volume).
  • Состояние 4: Остановка (Stopped / Exited)

    Рано или поздно контейнер нужно остановить для обновления образа или изменения конфигурации. И здесь кроется одна из самых частых ошибок начинающих инженеров — непонимание разницы между мягкой и жесткой остановкой.

    В Linux процессы общаются с помощью сигналов (Signals).

    Мягкая остановка: docker stop

    Команда docker stop инициирует процедуру Graceful Shutdown (мягкое завершение работы).

  • Docker отправляет главному процессу контейнера (PID 1) сигнал SIGTERM (Signal Terminate).
  • Этот сигнал означает: «Пожалуйста, заверши свою работу».
  • Правильно написанное приложение перехватывает этот сигнал. Веб-сервер перестает принимать новые запросы, дожидается завершения обработки текущих запросов, база данных сбрасывает кэш из оперативной памяти на диск и корректно закрывает соединения.
  • Docker дает процессу 10 секунд на эти действия (время по умолчанию).
  • Если через 10 секунд процесс все еще работает (например, завис), Docker теряет терпение и отправляет сигнал SIGKILL.
  • SIGKILL перехватить невозможно. Ядро Linux мгновенно и безусловно уничтожает процесс.
  • Жесткая остановка: docker kill

    Команда docker kill пропускает фазу вежливости и сразу отправляет процессу SIGKILL.

    Использование docker kill для баз данных (PostgreSQL, MySQL) или брокеров сообщений (RabbitMQ, Kafka) — это прямой путь к повреждению данных (Data Corruption). Процесс убивается на полуслове, не успев записать транзакции на диск.

    | Характеристика | docker stop | docker kill | | :--- | :--- | :--- | | Отправляемый сигнал | SIGTERM, затем SIGKILL (через 10 сек) | SIGKILL (сразу) | | Возможность перехвата | Да (приложение может подготовиться) | Нет (убивается ядром ОС) | | Риск потери данных | Минимальный | Очень высокий | | Скорость выполнения | От миллисекунд до 10 секунд | Мгновенно |

    Остановленный контейнер переходит в состояние Exited. Он больше не потребляет CPU и RAM, но его файловая система (тот самый слой чтения-записи) всё ещё хранится на диске. Вы можете запустить его снова командой docker start, и все файлы, созданные внутри контейнера до остановки, останутся на месте.

    Политики перезапуска (Restart Policies)

    Что произойдет, если процесс внутри контейнера упадет из-за ошибки в коде (например, исключение NullPointerException в Java) или если сам сервер физически перезагрузится?

    По умолчанию Docker оставит контейнер в состоянии Exited. В production это недопустимо — сервисы должны восстанавливаться автоматически. Для этого при запуске используется флаг --restart.

    Существует четыре политики перезапуска:

  • no (по умолчанию): Не перезапускать контейнер ни при каких условиях.
  • on-failure: Перезапускать только в том случае, если процесс завершился с ошибкой (код возврата не равен нулю). Если вы остановили контейнер вручную (docker stop), он не перезапустится.
  • always: Перезапускать всегда. Если процесс упал — перезапустить. Если сервер перезагрузился и демон Docker стартовал заново — запустить контейнер. Если вы сделали docker stop, а затем перезапустили сам демон Docker — контейнер снова поднимется.
  • unless-stopped: Похоже на always, но уважает ручную остановку. Если вы сделали docker stop, контейнер останется выключенным даже после перезагрузки сервера.
  • Пример надежного запуска веб-сервера: docker run -d --restart unless-stopped --name my_web nginx

    Состояние 5: Удаление (Deleted)

    Остановленный контейнер — это мусор, который занимает место на диске. Слой чтения-записи хранит все логи и временные файлы, сгенерированные за время работы.

    Чтобы полностью уничтожить контейнер, используется команда docker rm:

    docker rm my_nginx

    Эта команда удаляет слой чтения-записи, стирает конфигурацию изоляции и освобождает имя контейнера. После этого восстановить данные из контейнера невозможно (если только они не были сохранены во внешние тома — Volumes, о которых мы поговорим в будущих модулях).

    По умолчанию docker rm откажется удалять работающий контейнер, чтобы защитить вас от случайных ошибок. Если вы действительно хотите удалить запущенный контейнер, нужно использовать флаг принудительного удаления -f (force), который сначала отправит SIGKILL, а затем удалит файлы:

    docker rm -f my_nginx

    Эфемерность контейнеров

    Понимание жизненного цикла приводит нас к главному архитектурному принципу Docker: Контейнеры эфемерны (ephemeral).

    Это означает, что контейнеры должны проектироваться так, чтобы их можно было остановить, удалить, пересоздать из образа и запустить заново в любой момент, при этом приложение не должно сломаться, а важные данные не должны потеряться.

    Если вы зашли в контейнер через docker exec, установили там новые пакеты через apt-get install и поправили конфигурационные файлы руками — вы нарушили принцип эфемерности. При следующем цикле обновления (удаление старого контейнера и запуск нового из образа) все ваши ручные изменения будут безвозвратно уничтожены вместе со слоем чтения-записи.

    Очистка системы (Prune)

    В процессе активной разработки на сервере скапливаются десятки остановленных контейнеров. Удалять их по одному неудобно. Для массовой очистки Docker предоставляет мощную команду:

    docker container prune

    Она найдет все контейнеры в состоянии Exited и удалит их разом, освободив дисковое пространство.

    В следующем модуле мы перейдем к тому, откуда берутся образы. Мы разберем концепцию слоистой файловой системы (UnionFS), научимся писать эффективные Dockerfile и поймем, почему порядок инструкций при сборке образа критически влияет на скорость развертывания инфраструктуры.

    4. Анатомия Docker-образа: слоистая файловая система и кэширование

    В предыдущем модуле мы выяснили, что контейнер — это обычный процесс Linux, которому ядро операционной системы создает иллюзию одиночества с помощью пространств имен (Namespaces) и ограничивает в ресурсах через контрольные группы (Cgroups). Мы также узнали, что контейнеры эфемерны: их жизненный цикл подразумевает возможность удаления и пересоздания в любой момент без потери критичных данных.

    Но любому процессу для работы нужны файлы: бинарные исполняемые файлы, системные библиотеки, конфигурационные документы и сам код приложения. Если контейнер изолирован от файловой системы хоста, откуда он берет эти данные?

    Ответ кроется в архитектуре Docker-образов. Образ — это не просто архив с файлами, как .zip или .tar. Это сложная, многослойная структура, оптимизированная для максимальной скорости скачивания, экономии дискового пространства и мгновенного запуска.

    Слоистая файловая система (UnionFS)

    В основе работы с образами лежит технология Union File System (UnionFS) — каскадно-объединенная файловая система.

    Представьте себе несколько прозрачных листов пластика (пленок), на каждом из которых нарисована часть картины. На нижнем листе — фон, на следующем — дерево, на третьем — птица. Если сложить эти листы стопкой и посмотреть сверху, вы увидите единую, цельную картину. Если на верхнем листе нарисовать облако поверх птицы, облако перекроет птицу, но сама птица на нижнем листе никуда не исчезнет.

    Именно так работает файловая система внутри контейнера. Docker-образ состоит из набора слоев (layers), каждый из которых представляет собой набор изменений (добавленных, измененных или удаленных файлов) по отношению к предыдущему слою.

    !Схема слоистой файловой системы Docker

    Свойства слоев образа

  • Неизменяемость (Read-Only): Все слои, из которых состоит скачанный образ, доступны только для чтения. После того как слой создан, его содержимое невозможно изменить. Это гарантирует, что образ nginx:latest будет абсолютно идентичным на ноутбуке разработчика и на production-сервере.
  • Дедупликация: Слои идентифицируются по их криптографическому хешу (SHA-256), который вычисляется на основе содержимого файлов. Если два разных образа используют один и тот же базовый слой (например, слой с операционной системой Ubuntu), Docker скачает и сохранит этот слой на диске хост-машины только один раз. Оба образа будут ссылаться на один и тот же физический файл.
  • > Дедупликация — главная причина легковесности Docker. Если у вас на сервере запущено 50 микросервисов, написанных на Python, и все они базируются на образе python:3.9-slim, базовая операционная система и интерпретатор Python будут храниться на диске в единственном экземпляре, экономя гигабайты пространства.

    Механизм Copy-on-Write (CoW)

    Если слои образа неизменяемы, как контейнер может писать логи, создавать временные файлы или обновлять конфигурацию во время работы?

    В момент запуска контейнера (переход из состояния Created в Running) Docker добавляет поверх всех Read-Only слоев образа один новый, пустой слой — Read-Write Layer (слой чтения-записи).

    Все изменения, которые процесс производит в файловой системе, происходят исключительно в этом верхнем слое. Для управления этим процессом используется стратегия Copy-on-Write (копирование при записи):

  • Чтение: Если приложение хочет прочитать файл, UnionFS ищет его, начиная с самого верхнего слоя и спускаясь вниз. Как только файл найден, он отдается приложению.
  • Создание: Если приложение создает новый файл, он записывается в верхний Read-Write слой.
  • Изменение: Если приложение хочет изменить существующий файл, который находится в нижнем (Read-Only) слое, UnionFS сначала копирует этот файл из нижнего слоя в верхний Read-Write слой, а затем приложение изменяет уже эту скопированную версию. Оригинал в нижнем слое остается нетронутым.
  • Удаление: Если приложение удаляет файл из нижнего слоя, UnionFS не удаляет его физически (ведь слой Read-Only). Вместо этого в верхнем Read-Write слое создается специальный маркер (whiteout file), который говорит системе: «Считай, что этого файла здесь нет». Приложение перестает его видеть, хотя на диске он все еще существует.
  • Понимание механизма CoW объясняет, почему активная запись данных внутрь файловой системы контейнера (например, файлов базы данных) снижает производительность — на каждое первое изменение файла тратится время на его копирование между слоями. Для интенсивного ввода-вывода всегда используются внешние тома (Volumes).

    Анатомия Dockerfile: как создаются слои

    Слои не появляются сами по себе. Они генерируются в процессе сборки образа на основе инструкций из текстового файла — Dockerfile.

    Каждая инструкция в Dockerfile анализируется демоном Docker. Однако не каждая инструкция создает новый слой.

  • Инструкции RUN, COPY и ADD изменяют файловую систему (скачивают пакеты, копируют код). Каждая из этих команд создает новый физический слой.
  • Инструкции ENV, EXPOSE, WORKDIR, CMD, ENTRYPOINT лишь меняют метаданные образа (указывают порты, переменные окружения, рабочую директорию). Они не создают новых слоев файловой системы, а лишь добавляют конфигурацию в JSON-файл манифеста образа.
  • Проблема раздувания слоев (Layer Bloat)

    Непонимание того, как RUN создает слои, — самая частая причина создания гигантских, неповоротливых образов.

    Рассмотрим плохой пример Dockerfile:

    Что произойдет при сборке этого образа?

  • Скачается базовый слой Ubuntu.
  • Создастся слой с обновленными списками пакетов.
  • Создастся слой с установленным curl.
  • Создастся слой, в котором лежит скачанный архив huge-archive.tar.gz (допустим, весом 500 МБ).
  • Создастся слой с распакованными файлами (еще 500 МБ).
  • Создастся слой, в котором архив удален.
  • В последнем слое архива действительно нет (там стоит маркер удаления). Если вы запустите контейнер и выполните ls, вы архив не увидите. Но физически архив навсегда остался в слое №4. И каждый раз, когда кто-то будет делать docker pull вашего образа, он будет скачивать эти лишние 500 МБ.

    Правильный подход — объединять команды, изменяющие одни и те же файлы, в одну инструкцию RUN с помощью логического оператора &&:

    В этом случае скачивание, распаковка и удаление архива (а также очистка кэша пакетного менеджера) происходят в рамках одного слоя. В итоговый слой попадут только распакованные файлы, а временный архив исчезнет бесследно, не увеличив размер образа.

    Кэширование при сборке: Эффект домино

    Сборка образа может занимать много времени: компиляция кода, скачивание зависимостей из интернета. Чтобы ускорить этот процесс, Docker использует агрессивное кэширование слоев.

    Когда вы запускаете команду docker build, демон Docker просматривает инструкции в Dockerfile сверху вниз и для каждой ищет уже существующий слой в локальном кэше.

    Как Docker понимает, можно ли использовать кэш?

  • Для инструкции RUN он проверяет саму строку команды. Если строка RUN apt-get install nginx не изменилась с прошлой сборки, Docker берет готовый слой из кэша.
  • Для инструкций COPY и ADD он вычисляет контрольные суммы (хеши) копируемых файлов с вашего жесткого диска. Если содержимое файлов не изменилось, используется кэш.
  • Правило инвалидации кэша

    Кэширование работает по принципу эффекта домино. Если кэш инвалидируется (сбрасывается) на каком-либо шаге, все последующие инструкции в Dockerfile будут выполняться заново, без использования кэша.

    Рассмотрим классическую ошибку при упаковке Node.js приложения:

    В этом сценарии при любом, даже самом крошечном изменении в коде (например, вы исправили опечатку в README.md или добавили комментарий в server.js), хеш файлов для команды COPY . . изменится.

    Docker поймет, что файлы другие, и создаст новый слой для COPY. Из-за эффекта домино следующая команда RUN npm install также лишится кэша и будет выполнена заново. В результате при каждом изменении кода вы будете заново скачивать сотни мегабайт библиотек из интернета, а сборка будет занимать минуты.

    Оптимизация порядка инструкций

    Золотое правило написания Dockerfile: Инструкции должны располагаться от наименее часто изменяемых к наиболее часто изменяемым.

    Зависимости проекта (package.json в Node.js, requirements.txt в Python, go.mod в Go) меняются редко. А вот исходный код приложения меняется постоянно.

    Правильный Dockerfile для Node.js выглядит так:

    | Действие разработчика | Статус кэша Шага 1 | Статус кэша Шага 2 | Статус кэша Шага 3 | Результат | | :--- | :--- | :--- | :--- | :--- | | Изменил server.js | Кэш работает (package.json не менялся) | Кэш работает (npm install не запускается) | Кэш сброшен (код изменился) | Сборка за 1 секунду | | Добавил новую библиотеку | Кэш сброшен (package.json изменился) | Кэш сброшен (запускается npm install) | Кэш сброшен (эффект домино) | Сборка за 30 секунд |

    Разделение копирования зависимостей и копирования кода — это базовый навык DevOps-инженера, который экономит сотни часов процессорного времени на CI/CD серверах.

    Многоэтапная сборка (Multi-stage Builds)

    Даже если вы правильно объединили команды RUN и оптимизировали кэш, остается еще одна проблема.

    Для компиляции приложений на языках со статической типизацией (Go, Java, C++) требуются тяжелые инструменты: компиляторы, SDK, заголовочные файлы. Например, образ golang:1.20 весит около 800 МБ. Но после того как исходный код скомпилирован, для его работы нужен только один бинарный файл весом 15 МБ.

    Если оставить бинарный файл в том же образе, где он собирался, мы отправим на production-сервер образ весом более 800 МБ, содержащий компилятор (что является огромной дырой в безопасности — злоумышленник сможет компилировать свой код прямо внутри вашего контейнера).

    Для решения этой проблемы в Docker существует механизм Multi-stage builds (многоэтапная сборка). Он позволяет использовать несколько инструкций FROM в одном Dockerfile.

    Каждая инструкция FROM начинает новый этап сборки. Вы можете выборочно копировать артефакты из предыдущих этапов в финальный образ, оставляя весь мусор (исходный код, компиляторы, временные файлы) позади.

    Пример сборки приложения на Go:

    Что здесь происходит?

  • На первом этапе (builder) скачивается тяжелый образ Go, копируется исходный код и запускается компиляция.
  • На втором этапе создается абсолютно пустой образ на базе scratch.
  • Директива COPY --from=builder берет скомпилированный файл myapp из первого этапа и кладет его в пустой образ.
  • Результат: финальный Docker-образ будет весить ровно столько, сколько весит сам бинарный файл (например, 15 МБ). В нем нет ни операционной системы, ни оболочки bash, ни компилятора. Это идеальный с точки зрения безопасности и производительности production-образ.

    Понимание слоистой архитектуры, грамотное управление кэшем и использование многоэтапных сборок — это то, что отличает новичка, пишущего docker build, от инженера, проектирующего надежные и быстрые пайплайны развертывания.

    5. Основы Dockerfile: инструкции FROM, RUN, COPY и ADD

    Любой Docker-образ начинается с текстового файла, который описывает шаги по его созданию. Dockerfile — это декларативная инструкция, чертеж, по которому демон Docker собирает слоистую файловую систему контейнера. Использование Dockerfile реализует базовый принцип DevOps — Infrastructure as Code (IaC), или инфраструктура как код. Вместо того чтобы вручную устанавливать пакеты на сервере, вы описываете этот процесс в текстовом файле, который хранится в системе контроля версий (Git) вместе с исходным кодом приложения.

    Каждая строка в Dockerfile начинается с инструкции (ключевого слова, написанного заглавными буквами), за которой следуют аргументы. Несмотря на то что Docker поддерживает около двух десятков различных инструкций, фундамент любого образа строится на четырех базовых командах: FROM, RUN, COPY и ADD.

    Инструкция FROM: Фундамент образа

    Инструкция FROM определяет базовый образ (base image), на основе которого будет строиться ваш контейнер. Это всегда первая исполняемая строка в любом Dockerfile (перед ней могут быть только комментарии или парсер-директивы).

    Базовый образ предоставляет начальную файловую систему: структуру директорий, системные утилиты, библиотеки и, как правило, менеджер пакетов (например, apt для Debian/Ubuntu или apk для Alpine).

    В этом примере демон Docker обратится к реестру (Docker Hub), скачает слои образа ubuntu с тегом 22.04 и будет применять все последующие инструкции поверх этой файловой системы.

    Выбор базового образа: размер имеет значение

    Размер итогового образа можно описать простой формулой:

    Где — итоговый размер, — размер базового образа, а — сумма размеров всех слоев, добавленных вашими инструкциями.

    Выбор правильного базового образа критически важен для безопасности и скорости развертывания. Рассмотрим три основных подхода на примере языка Python:

  • Полные образы (Full): python:3.11 (основан на Debian). Весит около 800 МБ. Содержит компиляторы, заголовочные файлы C++ и множество системных утилит. Удобен для разработки, но избыточен и небезопасен для production-среды.
  • Урезанные образы (Slim): python:3.11-slim. Весит около 120 МБ. Из него удалены компиляторы и документация, оставлен только необходимый минимум для запуска Python-скриптов. Это золотой стандарт для большинства проектов.
  • Минималистичные образы (Alpine): python:3.11-alpine. Весит около 50 МБ. Основан на дистрибутиве Alpine Linux, который использует альтернативную стандартную библиотеку C (musl вместо glibc). Максимально компактен, но может вызывать проблемы совместимости при установке сложных библиотек (например, numpy или pandas), требуя их компиляции из исходников.
  • > Идеальный базовый образ содержит ровно столько зависимостей, сколько необходимо для запуска вашего приложения, и ни байтом больше. > > Docker Best Practices

    Существует также специальный образ scratch. Это абсолютно пустая файловая система (весит 0 байт). Инструкция FROM scratch используется для создания сверхлегких образов для статически скомпилированных языков (Go, Rust), где бинарный файл приложения не требует никаких внешних библиотек операционной системы.

    Инструкция RUN: Выполнение команд

    Инструкция RUN выполняет команды внутри файловой системы образа на этапе его сборки. Каждая инструкция RUN фиксирует изменения (установленные пакеты, созданные файлы) и создает новый неизменяемый слой.

    Существует два формата записи инструкции RUN:

  • Shell-формат: RUN apt-get install -y curl
  • Команда выполняется внутри оболочки по умолчанию (обычно /bin/sh -c в Linux). Это позволяет использовать переменные окружения, конвейеры (|) и логические операторы (&&).
  • Exec-формат (JSON массив): RUN ["apt-get", "install", "-y", "curl"]
  • Команда выполняется напрямую, минуя оболочку (shell). Это полезно, если в базовом образе вообще нет оболочки /bin/sh, или для предотвращения проблем с экранированием спецсимволов.

    Борьба с раздуванием слоев (Layer Bloat)

    Как мы выяснили в предыдущей статье, каждое выполнение RUN создает физический слой. Если вы создаете временные файлы в одной инструкции RUN, а удаляете их в следующей, они навсегда останутся в истории образа, увеличивая его размер.

    Правильный подход — объединять логически связанные команды в одну инструкцию с помощью оператора && и очищать кэш в том же слое:

    Контекст сборки (Build Context) и .dockerignore

    Прежде чем переходить к инструкциям копирования файлов, необходимо понять, как Docker получает доступ к файлам на вашем компьютере.

    Архитектура Docker разделена на Клиента (ваша консоль) и Демона (серверная часть). Когда вы запускаете команду docker build ., точка в конце указывает путь к контексту сборки (Build Context) — директории на вашем жестком диске.

    Клиент берет все файлы и папки в этой директории, упаковывает их в .tar архив и отправляет Демону. Только после этого Демон начинает выполнять инструкции из Dockerfile.

    !Схема передачи файлов от Docker Client к Docker Daemon

    Если в директории проекта лежит папка node_modules весом 2 ГБ или виртуальное окружение .venv, клиент будет каждый раз архивировать и отправлять эти гигабайты демону, что сделает сборку невыносимо долгой.

    Для решения этой проблемы используется файл .dockerignore. Он работает точно так же, как .gitignore, указывая клиенту Docker, какие файлы и папки не нужно отправлять демону.

    Пример правильного .dockerignore для Node.js проекта:

    Инструкция COPY: Перенос файлов в образ

    Инструкция COPY берет файлы из контекста сборки (отправленного демону) и помещает их в файловую систему образа.

    Важные нюансы работы COPY:

  • Пути относительны контексту: Вы не можете написать COPY ../config.json /app/. Демон Docker не имеет доступа к файлам выше директории контекста сборки из соображений безопасности.
  • Права доступа: По умолчанию файлы копируются с правами пользователя root. Если ваше приложение должно работать от имени непривилегированного пользователя (что является стандартом безопасности), используйте флаг --chown:
  • Продвинутая оптимизация: COPY --link

    В современных версиях Docker (с использованием BuildKit) доступен флаг --link.

    Обычно, если изменяется базовый образ (инструкция FROM), все последующие слои, включая COPY, инвалидируются и пересобираются. Флаг COPY --link создает независимый слой с файлами, который прикрепляется к образу без жесткой привязки к предыдущим слоям. Это позволяет мгновенно обновлять базовый образ (например, при выходе патча безопасности ОС), не тратя время на повторное копирование файлов проекта.

    Инструкция ADD: Магия, которой стоит избегать

    Инструкция ADD — это старший брат COPY. Она имеет тот же базовый синтаксис, но обладает двумя дополнительными "магическими" функциями:

  • Распаковка архивов: Если источник — это локальный архив в формате tar, gzip, bzip2 или xz, инструкция ADD автоматически распакует его содержимое в директорию назначения.
  • Скачивание по URL: Если источник — это веб-ссылка (URL), ADD скачает файл из интернета и поместит его в образ.
  • Почему ADD считается антипаттерном?

    В DevOps предсказуемость ценится выше удобства. Неявное поведение ADD часто приводит к проблемам:

  • Проблема с URL: Если вы скачиваете архив по URL с помощью ADD, он не будет распакован автоматически (распаковываются только локальные архивы). Более того, вы не можете изменить права доступа скачанного файла или удалить его в той же инструкции. Это приведет к раздуванию слоев.
  • Инвалидация кэша: При использовании URL Docker не всегда может корректно определить, изменился ли файл на удаленном сервере, что ломает механизм кэширования.
  • Официальная документация Docker рекомендует использовать COPY в 99% случаев. Если вам нужно скачать файл из интернета, используйте RUN с утилитами curl или wget — это позволяет скачать файл, распаковать его и удалить исходный архив в рамках одного слоя.

    Единственный легитимный случай использования ADD в современных Dockerfile — это необходимость автоматической распаковки локального .tar архива в образ.

    Сравнение COPY и ADD

    | Характеристика | COPY | ADD | | :--- | :--- | :--- | | Копирование локальных файлов | Да | Да | | Поддержка флага --chown | Да | Да | | Авто-распаковка локальных архивов | Нет | Да | | Скачивание файлов по URL | Нет | Да | | Рекомендация к использованию | Всегда, по умолчанию | Только для локальных архивов |

    Синтез: собираем правильный Dockerfile

    Объединим изученные инструкции для создания оптимизированного образа. Допустим, у нас есть простое приложение на Python.

    В этом примере мы применили все лучшие практики: выбрали slim образ, объединили команды RUN для предотвращения раздувания слоев, использовали COPY с изменением владельца файлов и разделили копирование зависимостей и исходного кода для эффективного кэширования.

    Понимание того, как именно FROM, RUN, COPY и ADD взаимодействуют с файловой системой и кэшем Docker, позволяет создавать безопасные, компактные и быстро собираемые образы — ключевой навык любого DevOps-инженера.

    6. Управление запуском: глубокий разбор CMD и ENTRYPOINT

    В основе работы любого Docker-контейнера лежит один фундаментальный принцип: контейнер живет ровно до тех пор, пока работает его главный процесс. Этот процесс всегда имеет идентификатор PID 1 внутри изолированного пространства имен (namespace) контейнера. Как только процесс с PID 1 завершается, демон Docker немедленно останавливает контейнер.

    В предыдущих статьях мы разобрали, как собирается файловая система образа с помощью инструкций FROM, RUN, COPY и ADD. Теперь мы переходим к финальному этапу сборки — определению того, какой именно процесс станет тем самым PID 1 при запуске контейнера. Для управления этим поведением в Dockerfile используются две инструкции: CMD и ENTRYPOINT.

    Несмотря на кажущуюся схожесть, они решают разные задачи. Непонимание разницы между ними — одна из самых частых причин, по которым контейнеры не реагируют на команды остановки, оставляют после себя зомби-процессы или ведут себя непредсказуемо при передаче аргументов командной строки.

    Два формата записи: Shell и Exec

    Прежде чем разбирать сами инструкции, необходимо понять синтаксис их написания. И CMD, и ENTRYPOINT поддерживают два формата записи. Выбор формата критически влияет на то, как процесс будет обрабатывать системные сигналы (например, сигнал остановки SIGTERM).

    Shell-формат (строковый)

    В этом формате команда записывается как обычная строка текста, точно так же, как вы ввели бы ее в терминале.

    Когда Docker видит такую запись, он не запускает python напрямую. Вместо этого он оборачивает вашу команду в системную оболочку (shell). Фактически выполняется следующая команда:

    /bin/sh -c "python main.py"

    Проблема Shell-формата: В этом случае PID 1 получает оболочка /bin/sh, а ваш Python-скрипт становится дочерним процессом (например, с PID 7). Когда вы выполняете команду docker stop, демон Docker отправляет сигнал SIGTERM процессу с PID 1. Оболочка /bin/sh получает этот сигнал, но не передает его дочерним процессам.

    В результате ваш скрипт не знает, что его просят завершить работу. Он не закрывает соединения с базой данных и не сохраняет кэш. Docker ждет 10 секунд (Graceful Shutdown timeout), видит, что контейнер все еще работает, и отправляет жесткий сигнал SIGKILL, мгновенно убивая процесс. Это приводит к потере данных и повреждению файлов.

    Exec-формат (JSON-массив)

    В этом формате команда и все ее аргументы передаются в виде массива строк в формате JSON.

    При использовании Exec-формата Docker запускает исполняемый файл напрямую, минуя оболочку.

    В этом случае python main.py становится процессом с PID 1. Сигнал SIGTERM от docker stop приходит напрямую в ваше приложение, позволяя ему корректно завершить работу.

    > В 99% случаев в production-среде должен использоваться Exec-формат (JSON-массив). Shell-формат допустим только в тех редких случаях, когда вам критически необходимо использовать переменные окружения оболочки, конвейеры (|) или логические операторы (&&) прямо в команде запуска. > > Официальная документация Docker

    Инструкция CMD: Команда по умолчанию

    Инструкция CMD задает команду по умолчанию и/или аргументы по умолчанию для запуска контейнера. Главная особенность CMD заключается в том, что ее очень легко переопределить при запуске контейнера.

    Рассмотрим простой Dockerfile:

    Если мы соберем этот образ (назовем его my-ubuntu) и запустим без дополнительных параметров, мы увидим ожидаемый результат:

    В этом примере строка bash переопределила массив ["echo", "Hello from CMD!"]. Контейнер запустил интерактивную оболочку вместо вывода приветствия.

    Именно поэтому базовые образы операционных систем (Ubuntu, Debian, Alpine) обычно заканчиваются инструкцией CMD ["bash"] или CMD ["sh"]. Это позволяет разработчикам легко заходить в контейнер для отладки, но при этом дает возможность запустить любую другую утилиту.

    Инструкция ENTRYPOINT: Несгибаемый фундамент

    Инструкция ENTRYPOINT (точка входа) настраивает контейнер так, чтобы он работал как исполняемый файл. В отличие от CMD, команду в ENTRYPOINT нельзя переопределить просто добавив слова в конец docker run.

    Создадим новый Dockerfile:

    Запустим его:

    Вместо того чтобы запустить bash, контейнер вывел строку Hello from ENTRYPOINT! bash. Почему это произошло? Потому что все, что вы пишете после имени образа в docker run, передается как дополнительные аргументы к команде, указанной в ENTRYPOINT.

    Чтобы переопределить ENTRYPOINT, требуется явно использовать специальный флаг --entrypoint:

    Магия команды exec "@ — это специальная переменная в bash, которая содержит все аргументы, переданные скрипту. В контексте Docker она содержит то, что было передано из CMD (или переопределено пользователем при docker run). В нашем случае @ без exec, то bash-скрипт остался бы висеть с PID 1, породив Python как дочерний процесс. Мы бы снова столкнулись с проблемой потери сигналов SIGTERM.

    Благодаря exec "@" для сложной инициализации приложения перед запуском, сохраняя при этом правильную иерархию процессов (PID 1).

    Понимание того, как формируется стартовая команда контейнера, позволяет создавать надежные, предсказуемые и легко управляемые образы, которые корректно ведут себя в оркестраторах вроде Kubernetes.

    7. Оптимизация образов: многоэтапные сборки (multi-stage builds)

    Размер Docker-образа имеет критическое значение в production-среде. Когда образ весит 1.5 ГБ вместо 50 МБ, это приводит к каскаду проблем: пайплайны CI/CD работают медленнее, масштабирование в Kubernetes занимает больше времени (так как узлам нужно скачивать гигантские слои), а затраты на хранение в Docker Registry растут. Но самая главная проблема — это безопасность. Чем больше утилит, библиотек и компиляторов находится внутри контейнера, тем шире его поверхность атаки (attack surface).

    Исторически разработчики сталкивались с дилеммой: для компиляции приложения нужны тяжелые инструменты (исходный код, компиляторы вроде gcc или go, пакетные менеджеры npm или pip), но для работы готового приложения они абсолютно не нужны.

    До появления многоэтапных сборок (multi-stage builds) эту проблему решали с помощью паттерна Builder (сборщик). Инженерам приходилось создавать два отдельных файла: Dockerfile.build (для компиляции) и Dockerfile (для запуска). Специальный bash-скрипт запускал первый контейнер, извлекал из него скомпилированный бинарный файл с помощью команды docker cp, а затем собирал второй, чистый образ. Это было неудобно, сложно в поддержке и требовало внешних скриптов.

    Многоэтапные сборки элегантно решили эту проблему, позволив описать весь процесс в одном Dockerfile.

    Анатомия многоэтапной сборки

    Суть многоэтапной сборки заключается в использовании нескольких инструкций FROM в одном Dockerfile. Каждая инструкция FROM начинает новую стадию (этап) сборки с чистой файловой системой базового образа.

    Главная магия заключается в том, что вы можете выборочно копировать артефакты из предыдущих стадий в текущую, оставляя весь промежуточный «мусор» (исходники, кэши пакетных менеджеров, компиляторы) позади.

    Рассмотрим базовый синтаксис:

    В этом примере мы используем ключевое слово AS для присвоения имени первому этапу (builder). Во втором этапе мы используем флаг --from=builder в инструкции COPY.

    !Схема многоэтапной сборки: тяжелый этап компиляции передает только готовый артефакт в легкий production-образ

    Когда демон Docker выполняет этот файл, он создает слои для первого этапа, компилирует код, а затем начинает собирать второй этап. В финальный образ попадут только слои второго этапа. Слои этапа builder останутся на хост-машине в виде кэша, но не будут включены в итоговый образ, который отправится в Registry.

    > Использование многоэтапных сборок позволяет сократить размер образа приложения на Go с 800 МБ (размер базового образа golang) до 10-15 МБ (размер alpine + бинарный файл).

    Практические паттерны использования

    Многоэтапные сборки применяются по-разному в зависимости от стека технологий. Рассмотрим три самых популярных сценария в работе DevOps-инженера.

    1. Компилируемые языки (Go, C++, Rust) и образ scratch

    Для языков, которые умеют собирать код в статически слинкованный бинарный файл (где все зависимости включены в сам исполняемый файл), можно использовать специальный базовый образ scratch.

    scratch — это абсолютно пустой образ. В нем нет оболочки оболочки sh, нет утилит ls или cat, нет даже базовых библиотек C (glibc).

    Размер такого образа будет равен размеру самого бинарного файла (обычно несколько мегабайт). Поверхность атаки сведена к абсолютному минимуму: злоумышленник, даже найдя уязвимость в вашем приложении, не сможет запустить оболочку (shell), потому что ее просто не существует в файловой системе.

    2. Frontend-приложения (React, Vue, Angular)

    Современная фронтенд-разработка требует Node.js для установки зависимостей (через npm или yarn) и сборки проекта (например, через Webpack или Vite). Однако результатом этой сборки является набор статических файлов (HTML, CSS, JS), для раздачи которых Node.js не нужен — с этим гораздо эффективнее справляется веб-сервер Nginx.

    Здесь мы избавляемся от тяжеловесного окружения Node.js и папки node_modules, которая часто весит сотни мегабайт, оставляя только оптимизированную статику.

    3. Интерпретируемые языки (Python, Ruby, Node.js backend)

    Для интерпретируемых языков нельзя просто скопировать один бинарный файл — приложению нужен интерпретатор (например, python) для работы. Тем не менее, многоэтапные сборки здесь критически важны для отделения инструментов сборки (build tools) от среды выполнения (runtime).

    Часто Python-библиотекам (например, psycopg2 для PostgreSQL или cryptography) требуются C-компиляторы (gcc) и заголовочные файлы ОС для сборки из исходников. Оставлять gcc в production-образе — грубое нарушение безопасности.

    Стандартный паттерн для Python — сборка зависимостей в виртуальное окружение (Virtual Environment) на первом этапе и копирование всей папки окружения на втором:

    Продвинутые возможности

    Остановка на определенном этапе (Targeting)

    Иногда в одном Dockerfile описывают не только сборку и production, но и этапы тестирования или линтинга.

    При стандартном запуске docker build . Docker соберет образ до самой последней инструкции (этап production). Но если в CI/CD пайплайне вам нужно запустить только тесты, вы можете использовать флаг --target:

    docker build --target test -t myapp:test .

    В этом случае Docker остановит сборку сразу после завершения этапа test, проигнорировав инструкции из production.

    Копирование из внешних образов

    Инструкция COPY --from не ограничивается этапами внутри вашего Dockerfile. Вы можете копировать файлы напрямую из любых образов, доступных в Docker Registry. Это невероятно полезно, когда вам нужна конкретная утилита, но вы не хотите устанавливать ее через пакетный менеджер (что создало бы лишние слои и кэш).

    Например, вам нужна утилита curl для проверки здоровья контейнера (Healthcheck), но в вашем базовом образе ее нет:

    Использование предыдущих этапов как базовых образов

    Этапы могут наследоваться друг от друга. Это позволяет создавать сложные графы зависимостей внутри одного файла, избегая дублирования кода.

    Подводные камни и кэширование

    При использовании многоэтапных сборок важно помнить о том, как работает кэширование слоев (разобранное в предыдущих статьях).

    Если вы измените исходный код приложения, кэш инвалидируется на инструкции COPY . . в этапе builder. Это означает, что этап builder будет выполнен заново. Однако, если вы используете внешние системы CI/CD (например, GitHub Actions или GitLab CI), промежуточные слои этапа builder по умолчанию не сохраняются между запусками пайплайнов, так как в Registry отправляется только финальный образ.

    Чтобы ускорить сборку в CI/CD, необходимо явно указывать Docker сохранять кэш промежуточных этапов. В современных версиях Docker (с использованием BuildKit) это делается с помощью флагов --cache-from и --cache-to, либо с использованием нового синтаксиса монтирования кэша RUN --mount=type=cache.

    Многоэтапные сборки — это стандарт индустрии. Любой Dockerfile, предназначенный для развертывания в production, должен использовать этот подход для минимизации размера, ускорения доставки и снижения рисков безопасности.

    8. Постоянное хранение данных: работа с именованными томами (Volumes)

    Любое приложение генерирует данные: логи, пользовательские сессии, записи в базе данных или загруженные файлы. В предыдущих статьях мы выяснили, что контейнер — это эфемерная сущность. При запуске поверх неизменяемых слоев образа создается тонкий слой чтения-записи (Read-Write layer). Именно в него попадают все новые файлы.

    Главная проблема заключается в том, что этот слой жестко привязан к жизненному циклу конкретного контейнера. Как только вы выполняете команду docker rm, слой чтения-записи уничтожается навсегда. Для stateless-приложений (например, веб-серверов, которые только отдают статику или проксируют запросы) это идеальное поведение. Но для stateful-приложений (баз данных, очередей сообщений) потеря данных при перезапуске контейнера — это катастрофа.

    Чтобы разорвать связь между жизненным циклом контейнера и жизненным циклом данных, Docker предлагает механизмы внешнего хранения.

    Три стратегии хранения данных в Docker

    Docker предоставляет три основных способа монтирования данных внутрь контейнера. Выбор правильного механизма — один из первых архитектурных шагов при проектировании инфраструктуры.

    | Тип хранилища | Где хранится на хосте | Управление | Идеальный сценарий использования | | :--- | :--- | :--- | :--- | | Named Volumes (Именованные тома) | В специальной директории Docker (обычно /var/lib/docker/volumes/) | Управляется демоном Docker | Базы данных, постоянное хранение в production, совместное использование данных между контейнерами | | Bind Mounts (Связанные монтирования) | В любой точке файловой системы хоста (указывается абсолютный путь) | Управляется администратором хоста (ОС) | Локальная разработка (проброс исходного кода в контейнер для hot-reload) | | tmpfs Mounts | В оперативной памяти (RAM) хоста | Управляется ОС (исчезает при остановке) | Хранение секретов, ключей шифрования, временных кэшей, которые не должны попасть на диск |

    В production-среде стандартом де-факто являются именованные тома (Named Volumes).

    Глубокое погружение в Named Volumes

    Именованный том — это директория на хост-машине, которая полностью управляется Docker. Когда вы создаете том, Docker выделяет для него место в своей изолированной зоне (на Linux это /var/lib/docker/volumes/). Контейнеры не знают, где физически находятся данные, они просто видят стандартную директорию внутри своей файловой системы.

    !Схема архитектуры хранения данных в Docker: отличие Named Volumes от Bind Mounts

    Преимущества такого подхода перед Bind Mounts:

  • Изоляция от ОС: Вам не нужно беспокоиться о структуре директорий хост-машины. Том будет работать одинаково на Ubuntu, CentOS или macOS.
  • Безопасность: Сторонние процессы на хосте (кроме root) не имеют прямого доступа к директории томов Docker, что снижает риск случайного изменения или удаления данных базы.
  • Драйверы томов (Volume Drivers): Тома не обязаны храниться локально. С помощью плагинов можно настроить сохранение тома напрямую в облачное хранилище (AWS EBS, Azure Disk) или сетевую файловую систему (NFS, Ceph).
  • Синтаксис монтирования: -v против --mount

    Исторически для монтирования томов использовался флаг -v (или --volume). Он состоит из трех полей, разделенных двоеточием: имя_тома:путь_в_контейнере:опции.

    Однако этот синтаксис имеет существенный недостаток: он неявный. Если вы опечатаетесь и напишете абсолютный путь вместо имени тома (например, -v /pgdata:/var/lib/...), Docker молча создаст Bind Mount вместо Named Volume. В production это может привести к тому, что данные будут сохраняться не туда, куда планировалось.

    Поэтому в современном DevOps рекомендуется использовать флаг --mount. Он более многословен, но абсолютно прозрачен и строг:

    Параметры --mount передаются в формате ключ-значение. Если вы укажете type=volume, но попытаетесь передать абсолютный путь в source, Docker выдаст ошибку и не запустит контейнер. Принцип Fail Fast (падай быстро) — основа надежной инфраструктуры.

    Жизненный цикл и управление томами

    Тома имеют собственный жизненный цикл, независимый от контейнеров.

    Создание тома происходит либо неявно (при запуске контейнера с указанием несуществующего тома), либо явно через CLI:

    Чтобы посмотреть, где физически находятся данные и какие параметры применены, используется команда inspect:

    Вывод будет в формате JSON, где ключевым полем является Mountpoint — реальный путь на хост-машине.

    > Важное правило безопасности Docker: удаление контейнера (даже с флагом -f) никогда не приводит к удалению привязанного к нему именованного тома.

    Это защитный механизм от случайной потери данных. Если вы удалите контейнер с базой данных, том перейдет в статус dangling (висячий). Данные останутся на диске. Вы можете запустить новый контейнер, указать имя старого тома, и база данных продолжит работу с того же места.

    Однако со временем висячие тома могут занять все свободное место на сервере. Для очистки диска от томов, которые не привязаны ни к одному существующему контейнеру, используется команда:

    Эффект предварительного заполнения (Pre-population)

    Существует неочевидное, но критически важное поведение Docker при работе с пустыми томами.

    Представьте, что у вас есть образ веб-сервера Nginx, внутри которого по пути /usr/share/nginx/html уже лежат базовые файлы (например, index.html и 50x.html). Вы запускаете контейнер и монтируете абсолютно пустой именованный том в эту директорию.

    Что произойдет? Логично предположить, что пустой том перекроет содержимое директории, и Nginx не найдет свои файлы. Именно так работают Bind Mounts. Но Named Volumes ведут себя иначе.

    Если вы монтируете пустой именованный том в директорию контейнера, в которой уже есть файлы (созданные на этапе сборки образа инструкциями COPY или RUN), Docker скопирует содержимое этой директории внутрь тома перед запуском процесса.

    Это называется эффектом предварительного заполнения. Он позволяет легко извлекать дефолтные конфигурации или статические файлы из готовых образов на хост-машину для их последующего изменения или резервного копирования.

    Стратегии резервного копирования (Backup)

    Поскольку тома управляются Docker, прямое копирование файлов из /var/lib/docker/volumes/ с помощью утилит вроде cp или rsync считается плохой практикой (и часто требует прав root).

    Идиоматичный способ резервного копирования в Docker — использование паттерна Throwaway Container (одноразовый контейнер).

    Суть метода: мы запускаем легковесный контейнер (например, на базе Alpine Linux), монтируем в него том с данными, которые хотим спасти, и одновременно монтируем директорию хоста (через Bind Mount), куда хотим положить архив. Контейнер выполняет команду архивации и немедленно самоуничтожается.

    ``bash docker run --rm \ --mount type=volume,source=pgdata,target=/volume-data \ --mount type=bind,source=(pwd) в директорию /backup внутри контейнера.

  • alpine: используем минималистичный образ (весит около 5 МБ).
  • tar ...: команда, которая архивирует содержимое /volume-data и сохраняет архив в /backup.
  • В результате на вашей хост-машине появится файл pgdata_backup.tar.gz, а временный контейнер исчезнет, не оставив следов. Восстановление данных (Restore) происходит по аналогичной схеме, только команда tar используется для распаковки архива в пустой том.

    Проблема прав доступа (UID/GID)

    Одна из самых частых проблем, с которой сталкиваются Junior DevOps инженеры при работе с томами — это ошибка Permission denied (Отказано в доступе).

    Чтобы понять ее природу, нужно вспомнить концепцию User Namespaces. По умолчанию ядро Linux не делает различий между пользователями внутри контейнера и снаружи. Идентификация происходит не по именам (root, postgres, nginx), а по числовым идентификаторам — UID (User ID) и GID (Group ID).

    Если процесс внутри контейнера запущен от имени пользователя с , то все файлы, которые он создаст в примонтированном томе, будут принадлежать пользователю с на хост-машине.

    Проблема возникает в двух случаях:

  • Смена пользователя в Dockerfile: Если базовый образ создает пользователя (например, node с ) и запускает приложение от его имени, а вы монтируете том, созданный демоном Docker (от имени root, ), приложение внутри контейнера не сможет писать в эту директорию.
  • Миграция между серверами: Если вы переносите архив тома на другой сервер и распаковываете его от имени своего локального пользователя, права на файлы изменятся. При запуске контейнера база данных может отказаться читать собственные файлы.
  • Решение этой проблемы зависит от контекста. В официальных образах баз данных (PostgreSQL, MySQL, Redis) используется паттерн Entrypoint Script.

    Скрипт инициализации запускается от имени root. Он проверяет права на примонтированную директорию, при необходимости выполняет команду chown -R postgres:postgres /var/lib/postgresql/data, и только после этого понижает свои привилегии с помощью утилиты gosu (или su-exec) и запускает саму базу данных. Это гарантирует, что контейнер всегда будет иметь доступ к своему тому, независимо от того, кто его создал на хосте.

    Понимание механизмов постоянного хранения данных превращает Docker из инструмента для запуска простых скриптов в мощную платформу, способную надежно обслуживать критически важные stateful-приложения в production-среде.

    9. Проброс директорий: использование bind mounts и tmpfs

    В предыдущей статье мы разобрали именованные тома (Named Volumes) — стандарт де-факто для постоянного хранения данных в production-среде. Они изолированы, управляются самим Docker и идеально подходят для баз данных. Однако в реальной практике DevOps-инженера и разработчика возникают задачи, где изоляция данных от хост-машины скорее мешает, чем помогает.

    Например, при локальной разработке вы хотите писать код в своей любимой IDE на хосте и мгновенно видеть изменения в работающем контейнере без его пересборки. Или, наоборот, вам нужно передать в контейнер секретные ключи, которые вообще не должны касаться физического диска из соображений безопасности. Для этих сценариев Docker предлагает два других механизма: Bind Mounts (связанные монтирования) и tmpfs (файловые системы в оперативной памяти).

    Bind Mounts: прямой мост между хостом и контейнером

    Механизм Bind Mounts появился в Docker с самых первых версий. В отличие от именованных томов, которые хранятся в скрытой директории Docker, связанное монтирование позволяет пробросить любой файл или директорию с вашей хост-машины прямо внутрь контейнера.

    Контейнер не видит разницы: для процесса внутри это выглядит как обычная локальная папка. Но физически все операции чтения и записи происходят напрямую на диске вашего компьютера.

    !Схема архитектуры хранения данных в Docker: визуальное сравнение Named Volumes, Bind Mounts и tmpfs

    Синтаксис и абсолютные пути

    Для создания Bind Mount используется уже знакомый нам флаг --mount, но с типом bind. Ключевое отличие от томов заключается в том, что в поле source вы обязаны указать абсолютный путь на хост-машине.

    Если вы попытаетесь использовать относительный путь (например, ./src), Docker выдаст ошибку. Чтобы сделать команды переносимыми, в терминале часто используют подстановку текущей директории через {PWD} в Windows PowerShell.

    Главный сценарий: локальная разработка и Hot-Reload

    Представьте, что вы разрабатываете веб-приложение на Node.js. Без Bind Mounts ваш цикл разработки выглядел бы так: написали строчку кода пересобрали образ (docker build) удалили старый контейнер запустили новый. Это занимает минуты и полностью убивает продуктивность.

    С использованием Bind Mounts вы пробрасываете папку с исходным кодом внутрь контейнера. Внутри контейнера запускается утилита вроде nodemon (для Node.js) или uvicorn (для Python), которая следит за изменениями файлов. Как только вы сохраняете файл в своей IDE на хосте, процесс внутри контейнера мгновенно это замечает и перезапускает приложение. Это называется hot-reload (горячая перезагрузка).

    Эффект перекрытия (Overwriting)

    При работе с Bind Mounts кроется одна из самых частых ловушек для начинающих инженеров. В прошлой статье мы выяснили, что если смонтировать пустой Named Volume в директорию контейнера, где уже есть файлы, Docker заботливо скопирует эти файлы в том.

    С Bind Mounts это правило не работает.

    > Если вы монтируете директорию хоста (даже пустую) поверх директории контейнера, содержимое хоста полностью "перекрывает" содержимое контейнера. Файлы образа становятся невидимыми до тех пор, пока монтирование не будет отключено.

    Пример из жизни: вы собрали образ с приложением, где зависимости лежат в папке /app/node_modules. Затем вы запускаете контейнер и делаете Bind Mount вашей локальной папки (где нет node_modules) в /app. Контейнер упадет с ошибкой "модуль не найден", потому что ваша пустая локальная папка скрыла папку с зависимостями внутри контейнера.

    Проблема прав доступа (UID/GID)

    Как и с томами, при использовании Bind Mounts часто возникает ошибка Permission denied. Но здесь она проявляется в обратную сторону.

    Если процесс внутри контейнера работает от имени root (что является поведением по умолчанию для многих базовых образов) и создает новый файл в примонтированной директории, этот файл на вашей хост-машине также будет принадлежать пользователю root. Вы не сможете отредактировать или удалить его в своей IDE без прав администратора (через sudo).

    Решение: всегда явно указывать пользователя при запуске контейнера, чтобы его идентификатор (UID) совпадал с вашим локальным пользователем.

    Безопасность и паттерн Docker-out-of-Docker (DooD)

    Bind Mounts обладают огромной властью. Вы можете пробросить внутрь контейнера системные директории хоста, например /etc или даже корень /. Это открывает вектор для серьезных атак: скомпрометированный контейнер может изменить пароли на хост-машине.

    Однако эта же особенность используется для мощного архитектурного паттерна. Часто CI/CD системам (например, Jenkins или GitLab CI), работающим внутри контейнера, нужно самим собирать и запускать другие Docker-контейнеры.

    Вместо того чтобы устанавливать Docker внутри Docker (что сложно и нестабильно), инженеры пробрасывают внутрь контейнера UNIX-сокет демона Docker с хост-машины:

    Теперь, когда Jenkins внутри контейнера выполняет команду docker run, он на самом деле отправляет API-запрос демону на хост-машине. Контейнеры будут создаваться рядом с Jenkins, а не внутри него. Это называется Docker-out-of-Docker.

    tmpfs: эфемерное хранилище в оперативной памяти

    Третий тип монтирования кардинально отличается от первых двух. tmpfs (Temporary File System) создает директорию, которая физически находится в оперативной памяти (RAM) хост-машины, а не на жестком диске.

    Особенности tmpfs

  • Абсолютная эфемерность: Как только контейнер останавливается, данные исчезают навсегда. Их невозможно восстановить.
  • Высокая скорость: Чтение и запись в RAM происходят на порядки быстрее, чем на SSD или HDD.
  • Отсутствие совместного доступа: В отличие от томов и Bind Mounts, tmpfs нельзя разделить между несколькими контейнерами. Это строго индивидуальное пространство.
  • Синтаксис монтирования:

    Обратите внимание на дополнительные параметры: tmpfs-size ограничивает объем выделяемой памяти (чтобы контейнер не съел всю RAM хоста), а tmpfs-mode задает строгие права доступа на уровне файловой системы.

    Сценарии использования tmpfs

    1. Хранение секретов и ключей шифрования Если ваше приложение скачивает из внешнего хранилища (например, HashiCorp Vault) приватные сертификаты или ключи для расшифровки базы данных, сохранять их на диск небезопасно. Даже после удаления файла данные могут остаться на физическом носителе. Сохранение их в tmpfs гарантирует, что при выключении сервера или остановке контейнера секреты бесследно исчезнут.

    2. Высокопроизводительный кэш Если приложение генерирует большое количество временных файлов (например, обработка изображений на лету или компиляция шаблонов), запись их на диск создаст узкое место в производительности (I/O bottleneck). Перенос директории с временными файлами в tmpfs радикально ускоряет работу.

    3. Защита от записи (Read-Only Root Filesystem) В безопасных production-средах контейнеры часто запускают с флагом --read-only. Это делает всю файловую систему контейнера доступной только для чтения. Если хакер проникнет в приложение, он не сможет скачать вредоносные скрипты или изменить исполняемые файлы.

    Однако многим приложениям (например, Nginx) жизненно необходимо писать временные файлы (PID-файлы, логи) в специфические папки вроде /tmp или /var/run. В этом случае корень контейнера делают read-only, а нужные директории монтируют как tmpfs.

    Понимание разницы между Named Volumes, Bind Mounts и tmpfs позволяет DevOps-инженеру гибко управлять данными: надежно сохранять важное, мгновенно синхронизировать код при разработке и безопасно уничтожать временную информацию.