Профессиональная разработка и администрирование в Docker: от основ до CI/CD

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

1. Архитектура Docker и жизненный цикл контейнеров

Архитектура Docker и жизненный цикл контейнеров

В 2013 году на конференции PyCon Соломон Хайкс представил проект, который навсегда изменил ландшафт ИТ-индустрии. До появления Docker запуск приложения в разных средах напоминал попытку перевезти россыпь разнокалиберных товаров на телегах: что-то терялось, что-то билось из-за разницы в дорожном покрытии (версиях библиотек и ОС). Docker предложил концепцию «стандартного контейнера», аналогичную морским грузоперевозкам. Но за внешней простотой команды docker run скрывается сложная инженерная экосистема, использующая глубокие механизмы ядра Linux для обеспечения изоляции и эффективности.

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

Многие ошибочно называют контейнеры «легковесными виртуальными машинами». Это технически неверно. Виртуальная машина (ВМ) эмулирует аппаратное обеспечение и запускает полноценную гостевую операционную систему со своим ядром. Контейнер же — это обычный процесс в основной ОС, который «думает», что он изолирован. Эта иллюзия поддерживается двумя ключевыми технологиями ядра Linux: Namespaces (Пространства имен) и Cgroups (Контрольные группы).

Namespaces: границы видимости

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

* PID Namespace: Изолирует идентификаторы процессов. Внутри контейнера ваше приложение имеет PID 1, хотя в основной системе это может быть PID 4502. Если PID 1 (процесс-родитель контейнера) завершается, контейнер останавливается. * NET Namespace: Предоставляет контейнеру собственный сетевой стек: IP-адреса, таблицы маршрутизации, порты. Это позволяет двум контейнерам одновременно слушать порт 80 без конфликтов. * MNT Namespace: Изолирует точки монтирования файловой системы. Контейнер видит свою корневую файловую систему (/), которая никак не пересекается с корнем хоста. * UTS Namespace: Позволяет контейнеру иметь собственное имя узла (hostname) и доменное имя. * IPC Namespace: Изолирует средства межпроцессного взаимодействия (очереди сообщений, разделяемую память). * USER Namespace: Позволяет сопоставлять идентификаторы пользователей внутри контейнера с другими идентификаторами на хосте. Например, процесс, запущенный от имени root (UID 0) в контейнере, может соответствовать непривилегированному пользователю на хосте, что критически важно для безопасности.

Cgroups: лимиты потребления

Если Namespaces отвечают за то, что процесс видит, то Control Groups (cgroups) отвечают за то, сколько ресурсов он может потребить. Без cgroups один «прожорливый» контейнер мог бы занять всю оперативную память или CPU, вызвав отказ всей системы (Kernel Panic или срабатывание OOM Killer).

Docker использует cgroups для ограничения:

  • CPU: Можно выделить доли процессорного времени или привязать контейнер к конкретным ядрам.
  • Memory: Установка жестких (hard limit) и мягких (soft limit) лимитов ОЗУ.
  • I/O: Ограничение скорости чтения/записи на диск.
  • PIDs: Ограничение максимального количества процессов внутри контейнера для защиты от «форк-бомб».
  • Клиент-серверная архитектура Docker

    Docker не является монолитным приложением. Это распределенная система, состоящая из нескольких компонентов, взаимодействующих по REST API.

    Docker Daemon (dockerd)

    Это «сердце» системы. Демон работает в фоновом режиме на хост-машине и управляет всеми объектами: образами, контейнерами, сетями и томами. Именно он слушает запросы от клиента и выполняет тяжелую работу по взаимодействию с ядром ОС.

    Docker Client (docker)

    Интерфейс командной строки (CLI), с которым взаимодействует пользователь. Когда вы вводите docker build, клиент отправляет команду демону dockerd. Важно понимать, что клиент и демон могут находиться на разных машинах. Вы можете управлять удаленным сервером Docker со своего ноутбука, просто настроив переменную окружения DOCKER_HOST.

    Docker Registry

    Хранилище образов. По умолчанию это Docker Hub, но крупные компании используют приватные реестры (например, Harbor или GitLab Container Registry). Процесс взаимодействия выглядит так:

  • Build: Создание образа на основе Dockerfile.
  • Pull: Скачивание образа из реестра на локальный хост.
  • Push: Загрузка созданного образа в реестр для совместного использования.
  • Взаимодействие через Docker Engine API

    Связь между клиентом и демоном осуществляется через: * Unix Sockets: По умолчанию (/var/run/docker.sock), используется для локального управления. * TCP Sockets: Для удаленного управления (требует настройки TLS для безопасности). * File Descriptors: В системах с systemd.

    Глубокое погружение в среду исполнения: containerd и runc

    Современный Docker — это результат длительной декомпозиции. Раньше Docker Daemon делал всё сам, но для стандартизации (инициатива OCI — Open Container Initiative) архитектуру разделили.

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

  • Docker Daemon получает команду.
  • Он передает её в containerd — высокоуровневую среду выполнения, которая управляет жизненным циклом (скачивание образов, управление хранилищем).
  • containerd запускает containerd-shim. Это небольшая прослойка, которая позволяет контейнеру работать, даже если сам демон Docker или containerd перезагружаются. Она также удерживает открытыми файловые дескрипторы stdin/stdout/stderr.
  • containerd-shim вызывает runc — низкоуровневую среду выполнения, которая непосредственно взаимодействует с ядром Linux, создает Namespaces и Cgroups, а затем запускает процесс приложения. После запуска процесса runc завершается.
  • Эта многослойность обеспечивает невероятную стабильность: вы можете обновить Docker, не останавливая работающие бизнес-приложения.

    Слоистая файловая система (Union File System)

    Одной из самых инновационных черт Docker является использование Union FS (обычно драйвер overlay2). Это позволяет объединять несколько каталогов в один виртуальный вид.

    Как устроены слои образа

    Каждый образ Docker состоит из набора неизменяемых (read-only) слоев. Рассмотрим пример Dockerfile:

    Здесь будет три слоя:

  • Базовый слой Ubuntu.
  • Слой с установленным Python.
  • Слой с вашим исходным кодом.
  • Эти слои кэшируются. Если вы измените только код в /app, Docker при сборке не будет заново скачивать Ubuntu или устанавливать Python — он возьмет их из кэша. Это экономит гигабайты дискового пространства и сокращает время сборки до секунд.

    Контейнерный слой (Writable Layer)

    Когда контейнер запускается, Docker берет все слои образа и добавляет поверх них один тонкий записываемый слой (Container Layer). Все изменения, сделанные внутри запущенного контейнера (создание файлов, изменение конфигов), записываются именно в этот слой.

    Если процессу нужно изменить файл, который находится в одном из read-only слоев образа, используется механизм Copy-on-Write (CoW):

  • Файл копируется из нижнего (только для чтения) слоя в верхний (записываемый).
  • Изменения вносятся в копию.
  • Для процесса в контейнере оригинальный файл становится «скрытым» измененной копией.
  • Как только контейнер удаляется, записываемый слой уничтожается. Все данные, не вынесенные в Volumes (тома), теряются навсегда. Именно поэтому контейнеры называют эфемеровыми (преходящими).

    Жизненный цикл контейнера: от рождения до удаления

    Понимание состояний контейнера критично для отладки и автоматизации. Контейнер — это не статичный объект, а динамический процесс.

    1. Создание (Created)

    Команда docker create. На этом этапе Docker подготавливает записываемый слой, настраивает сетевые интерфейсы и идентификаторы, но не запускает основной процесс. Контейнер уже существует в системе, занимает место, но не потребляет ресурсы CPU.

    2. Запуск (Running)

    Команда docker start или docker run (которая объединяет create и start). Процесс с PID 1 стартует внутри изолированной среды. Контейнер активен, пока активен его основной процесс. Если вы запустили скрипт, который отрабатывает за 2 секунды, контейнер перейдет в состояние Exited сразу после завершения скрипта.

    3. Пауза (Paused)

    Команда docker pause. Docker использует механизм cgroups (freezer), чтобы «заморозить» процессы. Процессы остаются в памяти, но планировщик ядра перестает выделять им кванты процессорного времени. Это полезно, если вам нужно временно освободить CPU для другой задачи, не теряя состояние приложения.

    4. Остановка (Stopped / Exited)

    Команда docker stop. Здесь важна механика завершения:
  • Docker отправляет процессу PID 1 сигнал SIGTERM. Это вежливая просьба: «Пожалуйста, заверши работу, сохрани данные и закрой соединения».
  • Запускается таймер (по умолчанию 10 секунд).
  • Если процесс не завершился сам, Docker отправляет SIGKILL — принудительное немедленное завершение.
  • Хорошей практикой считается написание приложений, которые корректно обрабатывают SIGTERM (Graceful Shutdown).

    5. Удаление (Removed)

    Команда docker rm. Записываемый слой удаляется с диска. Образ, на базе которого был создан контейнер, остается нетронутым.

    Практический разбор: что происходит при docker run?

    Давайте детально разберем команду: docker run -d --name web-server -p 8080:80 nginx

  • Клиент парсит команду и отправляет POST-запрос к Docker Daemon API.
  • Daemon проверяет, есть ли образ nginx локально. Если нет — идет в Docker Hub, скачивает слои и сохраняет их в /var/lib/docker.
  • Daemon выделяет ресурсы: создает конфигурацию для containerd.
  • containerd создает задачу, вызывает runc.
  • runc создает Namespaces. В частности, создается сетевой интерфейс, который через виртуальный мост (docker0) связывает порт 80 контейнера с портом 8080 хоста.
  • runc монтирует слои образа и записываемый слой через overlay2.
  • Запускается процесс nginx.
  • Клиент получает ID контейнера и выводит его в терминал. Флаг -d (detached) говорит клиенту не ждать вывода от приложения и сразу вернуть управление пользователю.
  • Нюансы PID 1 и проблема «зомби-процессов»

    В мире Linux процесс с PID 1 имеет особые обязанности. Он должен:

  • Обрабатывать сигналы (например, SIGTERM).
  • «Усыновлять» и корректно завершать процессы-сироты (reaping zombies).
  • Многие приложения (например, Java-машины или скрипты на Python) не умеют правильно работать как PID 1. Они могут игнорировать сигналы остановки, из-за чего docker stop всегда будет ждать 10 секунд и убивать их жестко.

    Для решения этой проблемы часто используют легковесные инициализаторы, такие как tini. В Docker это можно активировать флагом --init. В этом случае tini становится PID 1, корректно пробрасывает сигналы вашему приложению и очищает систему от зомби-процессов.

    Сравнение: Контейнеры vs Виртуальные машины

    Для глубокого понимания архитектуры важно четко видеть границу между этими технологиями.

    | Характеристика | Виртуальная машина (VM) | Контейнер (Docker) | | :--- | :--- | :--- | | Изоляция | Полная (аппаратная через гипервизор) | Программная (Namespaces/Cgroups) | | ОС | Полная гостевая ОС в каждой VM | Общее ядро хоста | | Скорость запуска | Минуты | Миллисекунды | | Потребление ресурсов | Высокое (резервирование ОЗУ/диска) | Низкое (потребляет столько, сколько нужно) | | Портативность | Зависит от формата образа (OVA, VHD) | Максимальная (любая система с Docker) |

    Именно общее ядро делает Docker таким быстрым, но оно же является и «ахиллесовой пятой» в плане безопасности. Если злоумышленник сможет эксплуатировать уязвимость в ядре Linux изнутри контейнера, он потенциально может получить контроль над всем хостом. В ВМ для этого пришлось бы сначала «пробить» ядро гостевой ОС, а затем еще и гипервизор.

    Эффективное управление слоями и кэшированием

    Понимание архитектуры слоев позволяет писать оптимальные Dockerfile. Существует правило: часто меняющиеся слои должны быть в конце.

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

    Здесь при любом изменении одной строчки в исходном коде (COPY . .) Docker сбросит кэш для всех последующих строк. Команда npm install будет запускаться заново, скачивая сотни мегабайт зависимостей.

    Правильный подход:

    Теперь, если вы не меняли список зависимостей в package.json, Docker возьмет результат npm install из кэша, и сборка займет секунды.

    Взаимодействие с внешним миром: Сети и Данные

    Хотя эти темы заслуживают отдельных глав, в контексте архитектуры важно понимать, как Docker «прошивает» изоляцию для связи.

    * Сетевой мост (Bridge): По умолчанию Docker создает виртуальную сеть, где каждому контейнеру выдается внутренний IP (обычно из подсети 172.17.0.0/16). Демон настраивает правила iptables на хосте, чтобы обеспечить проброс портов (NAT). * Тома (Volumes): Чтобы данные жили дольше контейнера, Docker позволяет «пробросить» директорию с хоста внутрь MNT Namespace контейнера. Это делается путем монтирования, которое происходит до того, как запустится основной процесс.

    Тонкая настройка Docker Daemon

    Для профессионального администрирования важно знать, что поведение dockerd можно менять через файл /etc/docker/daemon.json. Там можно настроить: * Storage Driver: Хотя overlay2 — стандарт, в старых системах или специфических сценариях могут использоваться btrfs или zfs. * Default Address Pools: Чтобы внутренние сети Docker не конфликтовали с корпоративной сетью офиса. * Logging Drivers: Куда отправлять логи контейнеров (json-file, syslog, fluentd, journald). По умолчанию Docker хранит логи в JSON-файлах на диске, и если их не ограничивать (log-rotation), они могут забить всё свободное место.

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

    2. Создание кастомных образов и глубокое понимание инструкций Dockerfile

    Создание кастомных образов и глубокое понимание инструкций Dockerfile

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

    Анатомия процесса сборки: от контекста до манифеста

    Когда вы запускаете команду docker build, происходит гораздо больше, чем просто последовательное выполнение команд. Первое, что делает Docker-клиент — это отправка «контекста сборки» (build context) на Docker Daemon.

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

    Процесс сборки итеративен. Каждая инструкция в Dockerfile создает новый промежуточный слой. Технически это выглядит так:

  • Docker берет ID предыдущего слоя.
  • Запускает временный контейнер из этого слоя.
  • Выполняет инструкцию (например, apt-get install).
  • Фиксирует изменения (commit) и сохраняет новый слой.
  • Удаляет временный контейнер.
  • Этот механизм объясняет, почему Dockerfile должен быть детерминированным. Если вы используете команду apt-get update в одном слое, а apt-get install в другом, вы рискуете получить неработоспособный образ через месяц, так как кэш первого слоя сохранится, а репозитории в нем устареют.

    Базовые инструкции: фундамент образа

    FROM: Выбор точки отсчета

    Любой Dockerfile начинается с FROM. Эта инструкция определяет родительский образ. Выбор базового образа — это компромисс между удобством и безопасностью.

    * Толстые образы (Ubuntu, Debian): Содержат массу утилит (curl, git, python), что удобно для отладки, но увеличивает поверхность атаки и размер образа (от 100 МБ). * Тонкие образы (Alpine): Построены на базе musl libc и busybox. Весят около 5 МБ. Однако использование Alpine может вызвать проблемы с совместимостью библиотек C, особенно в Python (бинарные колеса/wheels часто требуют glibc). * Distroless: Образы от Google, которые не содержат даже шелла. Это золотой стандарт безопасности для production, так как злоумышленник, попав внутрь контейнера, не сможет выполнить даже ls или cd.

    RUN: Формирование файловой системы

    RUN — самая часто используемая инструкция. Она выполняет команды в новом слое. Существует две формы записи:

  • Shell form: RUN apt-get update — команда запускается через /bin/sh -c.
  • Exec form: RUN ["apt-get", "update"] — прямой запуск бинарного файла без вызова оболочки. Рекомендуется для предотвращения проблем с парсингом переменных и корректной передачи сигналов.
  • Главное правило RUN: объединяйте команды. Вместо:

    Пишите:

    В первом случае вы создадите три слоя. Причем во втором слое останутся кэши apt, которые вы попытаетесь удалить в третьем слое. Но в Docker удаление файла в новом слое не освобождает место в предыдущем — файл просто помечается как «удаленный» в Union FS (whiteout file), но физически остается в составе образа.

    Копирование данных: COPY против ADD

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

    COPY — максимально прозрачная инструкция. Она просто копирует файлы или директории. Профессионалы предпочитают её за предсказуемость.

    ADD обладает «магическими» свойствами: * Она умеет скачивать файлы по URL (что не рекомендуется, так как нельзя проверить контрольную сумму и очистить кэш в рамках одного слоя). * Она автоматически распаковывает локальные архивы (tar, gzip, bzip2).

    > Правило большого пальца: Используйте COPY для всего. Используйте ADD только тогда, когда вам действительно нужно автоматически распаковать локальный .tar.gz в файловую систему образа.

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

    Переменные и окружение: ARG и ENV

    Понимание разницы между ARG и ENV критично для построения гибких пайплайнов.

    ARG (Build-time variables)

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

    ENV удобен для задания дефолтных настроек, которые пользователь сможет переопределить через docker run -e.

    Опасность: Никогда не используйте ENV для хранения секретов (паролей, API-ключей). Любой, кто имеет доступ к образу, может увидеть их командой docker inspect. Для секретов на этапе сборки используйте ARG (с осторожностью, они видны в истории слоев) или современные механизмы docker build --secret.

    Точки входа: ENTRYPOINT и CMD

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

    CMD

    CMD задает команду по умолчанию. Если пользователь при запуске контейнера укажет свою команду (docker run my-image bash), то CMD будет полностью игнорироваться.

    ENTRYPOINT

    ENTRYPOINT превращает контейнер в исполняемый файл. Команда, указанная здесь, не будет заменена аргументами из docker run. Вместо этого аргументы из командной строки будут добавлены в конец команды ENTRYPOINT.

    Совместное использование (Паттерн "Исполняемый инструмент")

    Лучшая практика для сервисных образов:

    В этой конфигурации python app.py выполнится всегда, а --help будет аргументом по умолчанию. Если пользователь запустит docker run my-app --server, итоговая команда станет python app.py --server.

    Всегда используйте exec form (массивы строк) для обеих инструкций. Если использовать shell form (CMD python app.py), Docker запустит процесс через /bin/sh -c. В этом случае ваш Python-процесс не будет иметь PID 1 и не получит сигнал SIGTERM от Docker Daemon, что приведет к «грязному» завершению работы по таймауту через 10 секунд.

    Оптимизация слоев и кэширования

    Кэширование в Docker работает по принципу цепочки. Если слой изменился, все последующие слои пересобираются заново.

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

    Здесь при любом изменении в коде (даже если вы просто поправили опечатку в README.md), Docker сбросит кэш на шаге COPY . .. В результате npm install будет запускаться заново, скачивая сотни мегабайт из интернета.

    Правильный подход — разделение зависимостей и кода:

    Этот принцип применим ко всем языкам: requirements.txt в Python, go.mod в Go, pom.xml в Java. Сначала — описание окружения, затем — установка, и в самом конце — изменчивый исходный код.

    Работа с пользователями и правами доступа

    По умолчанию процессы в контейнере запускаются от имени root. Это серьезная брешь в безопасности. Если злоумышленник взломает ваше приложение и сможет выйти за пределы контейнера (container breakout), он получит права суперпользователя на хост-машине.

    Профессиональный Dockerfile всегда создает непривилегированного пользователя:

    Инструкция USER гарантирует, что все последующие команды (включая CMD) будут выполнены от имени appuser. Флаг --chown в инструкции COPY критически важен: без него файлы будут принадлежать root, и ваше приложение не сможет в них писать (например, логи или временные файлы).

    Метаданные и организация: WORKDIR, EXPOSE, LABEL

    WORKDIR

    Никогда не используйте RUN cd /path. Команда cd затронет только текущий слой. Для смены директории используйте WORKDIR. Она создает директорию, если её нет, и делает её текущей для всех последующих инструкций. Рекомендуется всегда задавать абсолютный путь для WORKDIR, чтобы избежать путаницы при наследовании образов.

    EXPOSE

    Эта инструкция носит информационный характер. Она сообщает Docker и пользователям, на каких портах приложение слушает трафик. EXPOSE 80 не «открывает» порт наружу сам по себе — для этого все равно нужен флаг -p при запуске. Однако это служит отличной документацией и используется некоторыми системами оркестрации для автоматического обнаружения сервисов.

    LABEL

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

    Это стандарт индустрии (OCI Annotations), который позволяет инструментам мониторинга корректно идентифицировать ваши образы.

    Глубокие нюансы: работа с сигналами и PID 1

    В первой главе мы упоминали проблему PID 1. В Dockerfile это решается правильным выбором ENTRYPOINT. Если ваше приложение не умеет корректно обрабатывать сигналы (например, написано на Shell), используйте tini.

    tini перехватит SIGTERM, корректно перешлет его вашему приложению и, что не менее важно, «удочерит» процессы-зомби, которые могут возникнуть в ходе работы.

    Проверка состояния: HEALTHCHECK

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

    Если команда вернет ненулевой код выхода, Docker пометит контейнер как unhealthy. Это критически важно для систем автоматического деплоя: они не начнут переключать трафик на новую версию приложения, пока не увидят статус healthy.

    Финальные штрихи: минимизация образа

    Каждая инструкция — это слой. Но есть способы сделать образ еще чище.

  • Удаление временных файлов в том же слое: Если вы скачали архив, распаковали его и скомпилировали программу, удаление архива и исходников должно быть в той же команде RUN, где была загрузка.
  • Использование --no-install-recommends: При установке пакетов в Debian/Ubuntu это экономит десятки мегабайт, не устанавливая лишние зависимости вроде документации.
  • Сжатие слоев (Squashing): Экспериментальная функция docker build --squash, которая объединяет все слои в один в конце сборки. Однако это лишает вас преимуществ кэширования при передаче образов по сети. Лучшим решением является использование Multi-stage builds, которые мы подробно разберем в следующей главе.
  • Создание Dockerfile — это баланс между читаемостью, скоростью сборки и безопасностью. Понимая, как работают слои и как Docker интерпретирует каждую инструкцию, вы переходите от простого копирования шаблонов к осознанному проектированию инфраструктуры.