Контейнеризация инфраструктуры: Docker и Compose

Курс по упаковке мульти-агентных ИИ-систем в изолированные контейнеры. Вы научитесь оркестровать FastAPI, PostgreSQL, Qdrant и Ollama для стабильного деплоя и подготовки к переходу в Kubernetes.

1. Философия контейнеризации: изоляция ресурсов и решение проблемы 'работает на моей машине'

Философия контейнеризации: изоляция ресурсов и решение проблемы 'работает на моей машине'

Пятница, вечер. Вы локально протестировали пайплайн на базе LangGraph: FastAPI-сервер успешно принимает запросы, Celery-воркеры маршрутизируют задачи, Qdrant мгновенно отдает векторы, а локальная Llama 3 через llama.cpp генерирует идеальные ответы. Вы пушите код, DevOps-инженер разворачивает его на staging-сервере, и система рассыпается. Qdrant падает из-за конфликта версий glibc, Celery-воркер не может найти нужную версию sentence-transformers, а процесс инференса LLM съедает всю оперативную память сервера, убивая процесс базы данных PostgreSQL. Локально всё работало идеально, потому что ваша операционная система, установленные системные библиотеки, переменные окружения и распределение памяти случайно сложились в уникальную, неповторимую конфигурацию.

Эта ситуация — классический симптом «Матрицы зависимостей» (Dependency Hell). В архитектуре современных ИИ-решений, где в одном проекте сплетаются Python-фреймворки, бинарные C++ движки, векторные хранилища на Rust и СУБД на C, управление зависимостями выходит далеко за рамки файла requirements.txt. Требуется механизм, который упакует не только сам код, но и всю его среду обитания в стандартизированный, переносимый формат.

!Погрузка стандартизированного морского контейнера

До 1950-х годов морские грузоперевозки напоминали развертывание софта в начале нулевых: бочки, мешки, ящики и тюки грузились вручную. Каждый тип груза требовал своего подхода, специфичного крепления и условий. Изобретение стандартизированного интермодального контейнера произвело революцию: портовым кранам, поездам и грузовикам стало абсолютно неважно, что находится внутри — электроника или зерно. Они оперируют стандартными габаритами и креплениями. В индустрии программного обеспечения эту же революцию совершила технология контейнеризации.

От виртуализации к контейнеризации: эволюция изоляции

Исторически первой попыткой решить проблему изоляции приложений и зависимостей стали виртуальные машины (Virtual Machines, VM).

Виртуальная машина эмулирует физическое оборудование. На физическом сервере (Host) устанавливается специальное программное обеспечение — Гипервизор (Hypervisor). Гипервизор нарезает физические ресурсы (CPU, RAM, диск) на виртуальные куски и отдает их виртуальным машинам. Внутри каждой такой машины устанавливается полноценная гостевая операционная система (Guest OS), со своим собственным ядром, драйверами и системными процессами. И уже поверх этой гостевой ОС запускается наше приложение (например, FastAPI).

Такой подход обеспечивает высочайший уровень изоляции (аппаратный), но имеет критические архитектурные недостатки для микросервисных ИИ-систем:

  • Избыточность накладных расходов (Overhead): Если наша архитектура состоит из API, БД, брокера сообщений, воркера и векторного хранилища, нам потребовалось бы поднять 5 виртуальных машин. Каждая из них будет тратить ресурсы на работу своего ядра ОС, планировщика задач и системных демонов.
  • Скорость запуска: Старт виртуальной машины — это процесс загрузки ОС, который занимает минуты. В условиях динамических нагрузок (когда нам нужно резко поднять еще 10 Celery-воркеров для обработки всплеска запросов к LLM) такие задержки неприемлемы.
  • Размер: Образ виртуальной машины весит гигабайты, так как содержит полную копию операционной системы.
  • Контейнеризация предлагает принципиально иной подход — изоляцию на уровне операционной системы, а не оборудования.

    !Архитектурное сравнение: Виртуальные машины против Контейнеров

    В контейнерной модели гипервизор и гостевые ОС отсутствуют. Вместо них работает Контейнерный движок (Container Engine, например, Docker). Все контейнеры на хост-машине разделяют одно общее ядро (Kernel) хостовой операционной системы.

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

    | Характеристика | Виртуальная машина (VM) | Контейнер | | :--- | :--- | :--- | | Уровень абстракции | Оборудование (Hardware) | Операционная система (OS) | | Ядро ОС | У каждой ВМ своё (Guest OS) | Одно общее ядро хоста | | Время запуска | Минуты (загрузка ОС) | Миллисекунды (старт процесса) | | Размер образа | Гигабайты | Мегабайты / Десятки мегабайт | | Утилизация ресурсов | Статическое выделение (зарезервировано) | Динамическое выделение (по потребности) |

    Математически потребление памяти на хосте можно выразить так. Для виртуальных машин общий объем требуемой памяти:

    Для контейнеров формула избавляется от тяжеловесных слагаемых гостевых ОС:

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

    Магия ядра Linux: Namespaces и Cgroups

    Контейнер не является монолитной технологией. Это абстрактное понятие, маркетинговый термин, под которым скрывается комбинация трех фундаментальных механизмов ядра Linux: Namespaces, Control Groups (cgroups) и UnionFS. Именно они создают ту самую иллюзию изоляции.

    Namespaces (Пространства имен): Изоляция видимости

    Механизм Namespaces ограничивает то, что процесс может видеть. Когда процесс запускается в новом пространстве имен, ядро скрывает от него ресурсы, находящиеся вне этого пространства.

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

  • PID Namespace (Process ID): В Linux первый процесс, запускаемый при старте системы, получает PID 1 (обычно это systemd или init). Если мы запустим наш FastAPI-сервер в новом PID namespace, ядро присвоит ему PID 1 внутри этого пространства. Сервер будет считать себя главным процессом системы. Он не сможет увидеть процессы PostgreSQL или Celery, запущенные в других контейнерах (или на хосте), и, следовательно, не сможет отправить им сигналы (например, SIGKILL).
  • NET Namespace (Network): Изолирует сетевой стек. Контейнер получает собственный виртуальный сетевой интерфейс (обычно eth0), свою таблицу маршрутизации и свои правила iptables. Именно поэтому два разных контейнера могут слушать порт 8000 внутри себя, и это не вызовет конфликта Address already in use на уровне хоста.
  • MNT Namespace (Mount): Изолирует точки монтирования файловой системы. Процесс видит только ту файловую систему, которая была смонтирована для него. Он не имеет доступа к корневому каталогу / хост-машины, что предотвращает чтение системных конфигураций или перезапись чужих данных.
  • USER Namespace: Позволяет процессу иметь права root (UID 0) внутри контейнера, но при этом на уровне хост-машины этот процесс будет выполняться от имени обычного, непривилегированного пользователя. Это критически важный механизм безопасности: даже если злоумышленник совершит побег из контейнера (Container Breakout), на хост-машине у него не будет административных прав.
  • Control Groups (cgroups): Изоляция потребления

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

    В мульти-агентных ИИ-системах потребление ресурсов крайне неравномерно. Векторный поиск в Qdrant может вызвать кратковременный всплеск нагрузки на CPU. Генерация ответа в локальной Llama 3 требует огромного объема памяти для KV-кэша. Если один из агентов попадет в бесконечный цикл саморефлексии (например, из-за ошибки в графе LangGraph), он начнет бесконтрольно потреблять оперативную память.

    Без cgroups ядро Linux в попытке спасти систему от полного зависания вызовет системный процесс OOM Killer (Out-Of-Memory Killer), который начнет принудительно завершать процессы. OOM Killer может случайно убить PostgreSQL, уничтожив несохраненные транзакции.

    !Интерактивная симуляция: Работа Cgroups при исчерпании RAM

    Cgroups решают эту проблему путем создания жестких лимитов. Мы можем указать ядру: «Процесс LLM-шлюза и все его дочерние процессы не могут использовать более 4 ГБ RAM и более 200% процессорного времени (2 ядра)». Если процесс попытается выделить память сверх лимита cgroup, OOM Killer убьет только этот конкретный процесс внутри его группы. База данных, брокер сообщений и другие контейнеры даже не заметят этого инцидента.

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

    Третий столп контейнеризации — каскадно-объединенные файловые системы (Union File System, например, OverlayFS). Вместо того чтобы копировать всю файловую систему (ОС, библиотеки, код) для каждого нового контейнера, UnionFS позволяет собирать файловую систему из слоев, накладываемых друг на друга.

    Нижние слои доступны только для чтения (Read-Only). Если у нас есть 10 воркеров Celery, они не копируют файлы Python и библиотек 10 раз. Они все ссылаются на один и тот же физический набор файлов на диске (базовый образ). Когда контейнер запускается, поверх слоев «только для чтения» создается тонкий эфемерный слой «для чтения и записи» (Read-Write Layer). Все изменения, которые контейнер вносит в файлы (создание логов, запись временных файлов), происходят только в этом верхнем слое.

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

    Эфемерность и неизменяемость: смена парадигмы

    Переход к контейнеризации требует изменения инженерного мышления. Главный концептуальный сдвиг — это принятие парадигмы эфемерности (Ephemerality) и неизменяемости (Immutability).

    В мире виртуальных машин и классических серверов инфраструктура рассматривалась как «Домашние питомцы» (Pets). Серверам давали имена, их бережно обновляли, настраивали по SSH, лечили при сбоях. Если сервер с базой данных начинал тормозить, администратор подключался к нему, чистил кэш, перезапускал демоны.

    В контейнерном мире инфраструктура — это «Стадо» (Cattle). Контейнеры безымянны, одноразовы и заменяемы.

    Образ (Image) против Контейнера (Container)

    Разделение на неизменяемое и эфемерное реализуется через понятия Образа и Контейнера:

  • Образ (Image): Это статический, неизменяемый шаблон. В нем зафиксирована операционная система (например, минималистичный Alpine Linux), точная версия Python (3.11.4), все зависимости с хэшами версий и исходный код вашего приложения. Образ собирается один раз. После сборки его невозможно изменить.
  • Контейнер (Container): Это запущенный экземпляр Образа. Это живой процесс, обладающий PID, потребляющий RAM и CPU.
  • Из одного Образа можно запустить тысячи идентичных Контейнеров. Поскольку Образ неизменяем, мы получаем математическую гарантию идентичности среды. Тот Образ, который вы протестировали на своем ноутбуке в пятницу вечером, будет побитово совпадать с тем Образом, который запустится на production-сервере. Проблема «работает на моей машине» исчезает, потому что машина теперь упакована вместе с кодом.

    Управление состоянием (State)

    Следствие эфемерности контейнеров заключается в том, что любой контейнер может быть остановлен, удален или перезапущен в любую миллисекунду. Если Celery-воркер завис, оркестратор не будет пытаться его "отвисать". Он просто пошлет сигнал SIGKILL и поднимет новый контейнер из того же Образа.

    Это означает, что контейнер не должен хранить внутри себя состояние (State), которое должно пережить перезапуск. Если ваш FastAPI-сервер сохраняет загруженные PDF-документы в локальную папку /app/uploads внутри контейнера, то при перезапуске контейнера (уничтожении Read-Write слоя) все файлы исчезнут навсегда.

    Именно поэтому в предыдущих модулях мы проектировали архитектуру с выносом состояния во внешние системы:

  • Эпизодическая память и транзакции биллинга хранятся в PostgreSQL.
  • Семантические эмбеддинги лежат в Qdrant.
  • Очереди задач и статусы их выполнения делегированы в брокер (Kafka/Redis) и Result Backend.
  • Сами агенты, API-серверы и воркеры стали Stateless (без состояния). Их контейнеры выполняют исключительно вычислительную функцию, что позволяет безболезненно уничтожать их и масштабировать горизонтально.

    Проекция философии на ИИ-архитектуру

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

  • Контейнер AI Gateway (FastAPI): Упакован с минимальным базовым образом Python. Ограничен по CPU, так как его задача — только маршрутизация HTTP-запросов и валидация Pydantic-моделей. Он не содержит тяжелых ML-библиотек.
  • Контейнеры Celery Workers: Содержат логику LangGraph. Их можно масштабировать (запустить 5 или 50 контейнеров) в зависимости от длины очереди задач. Если воркер падает из-за ошибки в кастомном инструменте (Tool), это никак не влияет на соседние воркеры.
  • Контейнер Sentence Transformers: Изолированный микросервис, упакованный с PyTorch. Мы можем жестко ограничить его память через cgroups, чтобы предотвратить OOM при обработке аномально длинных текстов, или пробросить внутрь контейнера доступ к физическому GPU (что мы разберем в будущих главах).
  • Контейнеры PostgreSQL и Qdrant: Запускаются из официальных, оптимизированных вендорами образов. Их конфигурация (настройки пулов, лимиты памяти) передается через переменные окружения, а данные пишутся на специальные внешние тома (Volumes), которые существуют независимо от жизненного цикла самих контейнеров.
  • Такая архитектура делает систему предсказуемой. Изоляция процессов через Namespaces гарантирует безопасность (воркер не прочитает память шлюза). Изоляция ресурсов через Cgroups гарантирует стабильность (тяжелый векторный поиск не отберет CPU у базы данных). А неизменяемость Образов гарантирует, что код, успешно прошедший тесты, будет работать идентично на любой инфраструктуре, от локального ноутбука до кластера из сотен серверов.

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

    10. Подготовка к Kubernetes: трансформация Docker Compose в K8s-манифесты и Helm-чарты

    Подготовка к Kubernetes: трансформация Docker Compose в K8s-манифесты и Helm-чарты

    Архитектура на базе Docker Compose достигает своего предела в момент, когда физические ресурсы одного сервера исчерпываются, или когда отказ единственной виртуальной машины приводит к полной остановке системы. Compose отлично справляется с декларативным описанием контейнеров на одном хосте, но он ничего не знает о распределенных системах, самовосстановлении (Self-healing) и балансировке нагрузки между узлами кластера. Для перехода к отказоустойчивой корпоративной среде требуется смена парадигмы: от управления отдельными процессами к управлению желаемым состоянием кластера через Kubernetes (K8s).

    Перенос инфраструктуры из Compose в Kubernetes — это не просто конвертация YAML-файлов из одного формата в другой. Это переосмысление топологии: как компоненты обнаруживают друг друга, как они запрашивают дисковое пространство и как система реагирует на зависания приложений.

    Архитектурный сдвиг: от контейнеров к подам и циклу согласования

    В Docker Compose базовой единицей развертывания является контейнер. В Kubernetes минимальной неделимой единицей выступает Pod (Под). Pod может содержать один или несколько контейнеров, которые гарантированно запускаются на одном физическом узле, разделяют общее сетевое пространство имен (Network Namespace) и могут обращаться друг к другу по localhost.

    Если в Compose мы использовали паттерн Init-контейнеров через сложную цепочку depends_on и скрипты инициализации, то в Kubernetes Pod нативно поддерживает список initContainers, которые выполняются строго до старта основных контейнеров. Это идеально подходит для применения миграций базы данных Alembic или скачивания весов моделей GGUF до того, как запустится FastAPI-сервер или процесс Ollama.

    Главное отличие Kubernetes от Compose заключается в цикле согласования (Reconciliation Loop). Compose читает манифест, запускает контейнеры и завершает свою активную работу. Kubernetes работает непрерывно: контроллеры кластера постоянно сравнивают текущее физическое состояние системы с желаемым состоянием, описанным в манифестах. Если процесс Celery-воркера падает из-за ошибки сегментации (OOM), контроллер Kubernetes автоматически замечает расхождение и создает новый Pod для восстановления заданного количества реплик.

    Трансляция Stateless-нагрузок: Deployment и управление ресурсами

    Для компонентов, не сохраняющих состояние на диск (FastAPI-сервер, Celery-воркеры, AI Gateway), в Kubernetes используется ресурс Deployment. Он управляет созданием подов и обеспечивает стратегии бесшовного обновления (Rolling Update), при которых старые версии приложения плавно заменяются новыми без прерывания обслуживания пользовательских запросов.

    Рассмотрим трансформацию конфигурации ресурсов. В Docker Compose защита от "шумных соседей" (Noisy Neighbor) и настройка Control Groups (cgroups) описывалась в блоке deploy.resources:

    В Kubernetes эта же логика транслируется в секцию resources внутри спецификации контейнера, но с более строгой семантикой планировщика (Scheduler):

    Параметр requests (аналог reservations) критически важен для Kubernetes. На его основе планировщик решает, на какой физический узел (Node) поместить Pod. Если сумма requests всех подов на узле превышает его физическую емкость, планировщик откажется размещать там новый Pod. Параметр limits напрямую транслируется в жесткие ограничения cgroups. При превышении limits.memory процесс будет немедленно убит системным OOM Killer, а при превышении limits.cpu — подвергнут троттлингу (искусственному замедлению).

    Эволюция Healthchecks: Liveness и Readiness пробы

    В Compose директива healthcheck выполняла одну функцию: определяла, готов ли контейнер. Kubernetes разделяет эту логику на три независимых механизма, что критически важно для сложных ИИ-систем:

  • Startup Probe: Выполняется только один раз при старте. Если загрузка весов LLM в память занимает 40 секунд, эта проба защищает процесс от преждевременного убийства контроллером.
  • Liveness Probe: Проверяет, жив ли процесс. Если FastAPI завис из-за исчерпания пула потоков anyio и не отвечает на HTTP-запросы, Liveness-проба провалится, и Kubernetes жестко перезапустит Pod.
  • Readiness Probe: Проверяет, готов ли Pod принимать пользовательский трафик. Если база данных временно недоступна, Readiness-проба переведет Pod в статус Unready. Kubernetes не станет убивать процесс, но исключит его IP-адрес из балансировщика нагрузки, прекратив отправку новых запросов к этому экземпляру API.
  • Трансляция Graceful Shutdown

    В Compose мы использовали директиву stop_grace_period, чтобы дать длительным задачам (например, LLM-генерации) время на завершение перед отправкой сигнала SIGKILL. В Kubernetes это транслируется в параметр terminationGracePeriodSeconds на уровне пода. Если Celery-воркер обрабатывает тяжелый RAG-запрос, необходимо установить этот параметр с запасом (например, 120), чтобы при масштабировании или обновлении кластера воркер успел корректно закрыть соединения с брокером и завершить транзакцию в БД.

    Управление состоянием: StatefulSet и Persistent Volumes

    Трансляция баз данных (PostgreSQL, Qdrant) и брокеров сообщений (Redis, Kafka) требует иного подхода. Ресурс Deployment не подходит для них по архитектурным причинам: поды в Deployment эфемерны, их имена генерируются случайно (например, postgres-deployment-7b8f9a-xyz), и они не гарантируют порядок запуска.

    Для stateful-нагрузок используется ресурс StatefulSet. Он предоставляет подам стабильные сетевые идентификаторы (например, postgres-0, postgres-1) и гарантирует строгий порядок их создания и удаления.

    Управление дисками также абстрагируется. В Compose мы использовали Named Volumes, которые Docker-демон создавал в /var/lib/docker/volumes. В Kubernetes хранилище отделено от вычислительных ресурсов через систему PersistentVolume (PV) и PersistentVolumeClaim (PVC).

    Разработчик создает запрос на хранилище (PVC), указывая нужный объем и режим доступа (ReadWriteOnce — диск может быть смонтирован только к одному узлу). Контроллер хранилища Kubernetes динамически обращается к облачному провайдеру (или локальному provisioner'у), создает физический диск (PV) и связывает его с PVC. Если Pod базы данных падает и пересоздается на другом физическом сервере кластера, Kubernetes автоматически отмонтирует сетевой диск от старого сервера и примонтирует к новому, сохраняя целостность данных Qdrant или PostgreSQL.

    Сетевая топология и Service Discovery

    В Docker Compose контейнеры общались друг с другом напрямую по именам сервисов благодаря встроенному DNS-серверу на 127.0.0.11. В Kubernetes поды эфемерны, их IP-адреса меняются при каждом перезапуске. Обращаться к поду напрямую — антипаттерн.

    Для маршрутизации трафика Kubernetes вводит абстракцию Service. Сервис работает как внутренний балансировщик нагрузки со стабильным виртуальным IP-адресом (ClusterIP) и постоянным DNS-именем.

    Когда мы создаем сервис для FastAPI:

    Внутренний DNS-сервер кластера (CoreDNS) создает запись api-service.default.svc.cluster.local. Любой другой компонент в кластере может отправить HTTP-запрос на этот адрес. Сервис через правила iptables или IPVS на уровне ядра Linux распределит трафик по алгоритму Round-Robin между всеми подами, имеющими метку app: fastapi и успешно прошедшими Readiness-пробу.

    Для баз данных в StatefulSet часто используется специальный тип сервиса — Headless Service (clusterIP: None). Он не выделяет единого виртуального IP-адреса, а возвращает через DNS список реальных IP-адресов всех подов базы данных. Это необходимо для настройки репликации (Primary-Replica), когда узлам базы данных нужно устанавливать прямые TCP-соединения друг с другом.

    Конфигурация и безопасность: ConfigMap и Secret

    В Compose мы передавали конфигурацию через файлы .env и директиву environment, а чувствительные данные — через Docker Secrets, монтируемые в tmpfs.

    В Kubernetes эти сущности строго разделены:

  • ConfigMap: хранит неконфиденциальные данные (URL баз данных, уровни логирования, настройки пула соединений).
  • Secret: хранит пароли, API-ключи и сертификаты.
  • Обе сущности могут быть переданы в Pod либо как переменные окружения, либо смонтированы как файлы. Паттерн _FILE, разбиравшийся ранее для безопасной инициализации PostgreSQL, идеально ложится на Kubernetes. Мы создаем Secret, содержащий пароль, и монтируем его как файл в директорию /run/secrets/db_password внутри пода базы данных.

    Важно учитывать архитектурный нюанс: по умолчанию секреты в Kubernetes хранятся в базе данных кластера (etcd) в виде обычного текста, закодированного в Base64. Это не шифрование. Для защиты от компрометации физических дисков мастер-узлов кластера необходимо настраивать Encryption at Rest на уровне etcd, что гарантирует криптографическую защиту данных в состоянии покоя.

    Helm: шаблонизация инфраструктуры и управление релизами

    Прямой перевод docker-compose.yml в манифесты Kubernetes порождает проблему дублирования. Для развертывания одного ИИ-сервиса (например, агента суммаризации) требуется написать Deployment, Service, ConfigMap, Secret и HorizontalPodAutoscaler — суммарно сотни строк YAML-кода. Если в системе 10 различных микросервисов-агентов, поддержка тысяч строк статического YAML становится невозможной. Изменение порта или добавление новой переменной окружения потребует ручной правки десятков файлов.

    Эту проблему решает Helm — пакетный менеджер для Kubernetes. Он позволяет упаковать набор связанных манифестов в единый логический модуль — Чарт (Chart).

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

    Структура deployment.yaml внутри Helm-чарта выглядит так:

    Все конкретные значения выносятся в отдельный файл values.yaml:

    Такая архитектура позволяет использовать один и тот же Helm-чарт для развертывания сред development, staging и production, просто подменяя файл values.yaml. В среде разработки можно задать replicaCount: 1 и минимальные ресурсы, а для production-кластера передать файл с replicaCount: 5 и жесткими лимитами памяти.

    Кроме того, Helm управляет жизненным циклом релиза. Команда helm upgrade вычисляет разницу (diff) между текущим состоянием кластера и новым шаблоном, применяя только необходимые изменения. Если после деплоя новой версии агента метрики качества (Evals) падают, команда helm rollback мгновенно возвращает всю группу манифестов к предыдущему стабильному состоянию, что невозможно реализовать средствами чистого Docker Compose.

    Переход от Compose к Kubernetes и Helm требует высокого порога входа, но именно этот стек обеспечивает фундамент для корпоративных ИИ-систем. Разделение вычислительных мощностей и хранилищ, гранулярный контроль ресурсов через cgroups, автоматическое самовосстановление подов и декларативное управление релизами через шаблоны позволяют системе масштабироваться пропорционально росту пользовательской нагрузки, сохраняя при этом строгую изоляцию и безопасность компонентов.

    2. Создание эффективных Docker-образов для Python: многоэтапная сборка и минимизация веса

    Создание эффективных Docker-образов для Python: многоэтапная сборка и минимизация веса

    Стандартная сборка образа для простейшего FastAPI-приложения через команду docker build часто приводит к созданию файла размером более одного гигабайта. В масштабах микросервисной архитектуры, где кластер может динамически запускать десятки подов для обработки пиковых нагрузок, развертывание таких образов означает передачу десятков гигабайт по сети при каждом обновлении. Это увеличивает время холодного старта (Cold Start), повышает расходы на хранение в реестре контейнеров (Container Registry) и экспоненциально расширяет поверхность атаки.

    Оптимизация Docker-образов для Python-приложений требует понимания того, как работает кэширование слоев файловой системы (UnionFS), как операционная система взаимодействует с C-расширениями интерпретатора и как изолировать сборочные зависимости от среды выполнения.

    Проблема базового образа: ловушка Alpine и стандартных сборок

    Директива FROM в Dockerfile определяет фундамент будущего контейнера. Официальный репозиторий Python на Docker Hub предлагает три основных варианта базовых образов, выбор между которыми критически влияет на производительность и размер ИИ-системы.

    Полный образ python:3.11 основан на полноценном дистрибутиве Debian. Он включает в себя компиляторы, заголовочные файлы, утилиты для работы с сетью и исходные коды множества системных библиотек. Его размер превышает 1 ГБ еще до установки первой зависимости через pip. Использование такого образа оправдано только на этапе локальных экспериментов, но в production-среде это антипаттерн, приводящий к избыточному потреблению ресурсов.

    Образ python:3.11-alpine исторически популярен благодаря своему минимализму — его базовый размер составляет около 50 МБ. Alpine Linux достигает этого за счет использования легковесной стандартной библиотеки языка C под названием musl libc вместо классической glibc, применяемой в Debian и Ubuntu. Для языков вроде Go, которые компилируются в статические бинарные файлы, Alpine является идеальным выбором. Однако для Python этот выбор часто становится фатальным.

    Экосистема Python (особенно библиотеки для работы с данными и ИИ, такие как numpy, pydantic-core, asyncpg, sentence-transformers) сильно зависит от C-расширений. Пакетный менеджер pip скачивает предварительно скомпилированные бинарные пакеты (Wheels) стандарта manylinux, которые жестко привязаны к glibc. При попытке установить такой пакет в Alpine, pip обнаруживает несовместимость архитектуры, скачивает исходный код библиотеки (source tarball) и пытается скомпилировать его на лету.

    Это приводит к трем критическим проблемам:

  • Время сборки образа увеличивается с нескольких секунд до десятков минут.
  • Для успешной компиляции требуется установка тяжеловесных пакетов gcc, g++, musl-dev, что сводит на нет изначальную экономию места.
  • Скомпилированные под musl математические библиотеки иногда демонстрируют непредсказуемое поведение или снижение производительности при высокоинтенсивных вычислениях.
  • Золотой серединой для Python-приложений является образ python:3.11-slim. Это усеченная версия Debian, из которой удалены графические интерфейсы, документация и инструменты компиляции, но сохранена совместимость с glibc. Базовый размер такого образа составляет около 150 МБ. Он позволяет pip мгновенно распаковывать готовые manylinux Wheels, обеспечивая высокую скорость сборки и гарантированную стабильность работы C-расширений.

    Разделение сред: сборочные зависимости против среды выполнения

    Даже при использовании slim-образа возникает архитектурная проблема при добавлении специфичных драйверов. Например, для сборки драйвера PostgreSQL (psycopg2 или компиляции специфичных расширений asyncpg), а также для установки некоторых ML-библиотек, системе требуются заголовочные файлы и компиляторы (пакеты build-essential, libpq-dev).

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

    В этом сценарии компилятор gcc и заголовочные файлы навсегда остаются в слое UnionFS. Помимо раздувания размера (дополнительные 300-400 МБ), наличие компилятора в production-контейнере создает серьезную уязвимость. Если злоумышленник найдет способ выполнить произвольный код (Remote Code Execution) через уязвимость в веб-фреймворке, наличие gcc позволит ему скомпилировать вредоносное ПО, эксплойты для повышения привилегий или сетевые сканеры прямо внутри контейнера.

    Идеальный контейнер должен содержать только интерпретатор Python, исходный код приложения и скомпилированные бинарные файлы библиотек. Любые инструменты сборки должны быть отброшены.

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

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

    Для языков с компиляцией в один файл (Go, Rust) перенос артефакта сводится к копированию одного бинарного файла. Для Python задача сложнее: зависимости разбросаны по директориям site-packages, а исполняемые скрипты (например, uvicorn или celery) находятся в директории bin.

    Наиболее элегантный и надежный способ изоляции Python-зависимостей в многоэтапной сборке — использование виртуального окружения (venv). В сборочной среде создается venv, в него устанавливаются все пакеты, а затем вся директория виртуального окружения целиком копируется в финальную среду.

    Проектирование сборочного этапа (Builder Stage)

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

    В финальном этапе устанавливается пакет libpq5. В отличие от libpq-dev, который содержит заголовочные файлы для компиляции, libpq5 содержит только скомпилированные динамические библиотеки (shared objects .so), необходимые для работы драйвера базы данных во время выполнения. Это минимально возможный набор системных зависимостей.

    Директива COPY --from=builder /opt/venv /opt/venv является ядром паттерна. Она переносит полностью готовую, скомпилированную и изолированную среду Python из первого этапа. Обновление переменной PATH в финальном образе гарантирует, что вызов uvicorn в директиве CMD будет разрешен в исполняемый файл внутри скопированного виртуального окружения.

    Оптимизация переменных окружения интерпретатора

    В финальном Dockerfile используются две специфичные для Python переменные окружения, которые критически важны при работе в изолированных средах:

  • PYTHONDONTWRITEBYTECODE=1. По умолчанию интерпретатор Python компилирует исходный код .py в байт-код .pyc и сохраняет его в директории __pycache__. Это ускоряет последующие запуски скриптов. Однако контейнеры эфемерны: код внутри них не изменяется после сборки образа. Генерация файлов байт-кода во время работы контейнера создает ненужные операции дискового ввода-вывода, увеличивает размер слоя чтения-записи (Read-Write layer) в UnionFS и может привести к ошибкам прав доступа, если контейнер запущен от имени непривилегированного пользователя. Отключение этой функции защищает файловую систему контейнера от мутаций.
  • PYTHONUNBUFFERED=1. Python буферизирует стандартный вывод (stdout) и вывод ошибок (stderr). В обычной операционной системе это оптимизирует работу с терминалом. В архитектуре контейнеров буферизация приводит к тому, что логи приложения (например, сообщения от FastAPI или ошибки Celery-воркеров) задерживаются в памяти интерпретатора и не передаются немедленно в поток вывода контейнера. Это ломает системы агрегации логов и усложняет отладку падающих подов. Значение 1 принудительно отключает буферизацию, делая логирование потоковым и синхронным.
  • Контекст безопасности: отказ от привилегий root

    По умолчанию процесс внутри Docker-контейнера запускается от имени пользователя root (UID 0). Несмотря на то, что этот root изолирован с помощью механизма Namespaces ядра Linux, он все еще обладает избыточными правами внутри своей изолированной среды. Если злоумышленник эксплуатирует уязвимость в приложении (например, Path Traversal при загрузке файлов агентом), права root позволят ему модифицировать любые файлы внутри контейнера, устанавливать системные пакеты или пытаться осуществить побег на хост-систему (Container Breakout).

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

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

    Команда useradd -r создает системного пользователя, который не может авторизоваться через терминал (отсутствует shell). Директива USER appuser дает указание контейнерному движку сменить контекст (UID/GID) перед выполнением директивы CMD.

    Важно отметить, что копирование виртуального окружения /opt/venv не требует изменения прав владения (chown) для пользователя appuser. Библиотеки в виртуальном окружении должны быть доступны только для чтения (Read-Only). Если процесс попытается выполнить pip install во время работы контейнера (что является антипаттерном), ОС отклонит запрос из-за отсутствия прав записи в /opt/venv, обеспечивая дополнительный слой защиты от инъекций зависимостей во время выполнения.

    Математика оптимизации очевидна: переход от прямолинейного python:3.11 к многоэтапной сборке на базе slim с изоляцией компиляторов и кэшей снижает размер итогового образа с ГБ до МБ. Это не просто экономия дискового пространства. В распределенной системе, где оркестратор переносит образы между узлами кластера, уменьшение веса в 6 раз пропорционально сокращает сетевую задержку развертывания, снижает потребление оперативной памяти кэшами узлов и кардинально уменьшает количество потенциально уязвимых системных компонентов, доступных злоумышленнику.

    3. Слои и кэширование: оптимизация Dockerfile для ускорения CI/CD пайплайнов

    Слои и кэширование: оптимизация Dockerfile для ускорения CI/CD пайплайнов

    Разработчик исправляет опечатку в логгировании FastAPI-маршрута, делает коммит и отправляет код в репозиторий. CI/CD пайплайн запускает сборку образа, которая длится 14 минут. Из них 13 минут уходит на скачивание и установку тяжеловесных библиотек: PyTorch, LangChain и Sentence Transformers. Изменение одного байта в исходном коде привело к полному пересчету гигабайтных зависимостей. Эта ситуация — классический симптом непонимания механики инвалидации кэша контейнерного движка.

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

    Механика вычисления хэшей и каскадная инвалидация

    Каждая инструкция в Dockerfile (например, RUN, COPY, ENV) формирует независимый слой. Контейнерный движок не выполняет инструкцию вслепую; перед запуском он вычисляет криптографический хэш (обычно SHA256) ожидаемого результата. Если слой с таким хэшем уже существует в локальном кэше, движок пропускает выполнение и переиспользует готовый бинарный блоб.

    Алгоритм вычисления хэша слоя зависит от трех компонентов:

  • Хэш родительского слоя .
  • Строковая сигнатура самой инструкции (например, RUN apt-get update).
  • Метаданные и контрольные суммы файлов (только для инструкций COPY и ADD).
  • Из первого пункта вытекает фундаментальное правило каскадной инвалидации: если слой изменил свой хэш (инвалидировался), то все последующие слои гарантированно теряют кэш и будут выполнены заново. Дерево кэша не умеет «срастаться» обратно.

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

    Классический антипаттерн сборки Python-приложений выглядит так:

    При такой структуре инструкция COPY . . переносит весь исходный код в образ. Любое изменение в бизнес-логике (например, в файле main.py) меняет контрольную сумму копируемых данных. Слой COPY инвалидируется. Следом за ним, по правилу каскада, инвалидируется слой RUN pip install. В результате каждая правка кода заставляет сервер заново скачивать зависимости по сети.

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

    В этом случае слой установки зависимостей жестко привязан только к хэшу файла requirements.txt. Правки в main.py инвалидируют только последний слой COPY . ., выполнение которого занимает доли секунды.

    При использовании современных менеджеров пакетов (Poetry, PDM, uv) логика остается прежней, но копировать необходимо два файла: манифест и файл блокировок:

    Управление контекстом сборки через .dockerignore

    Команда docker build . не обращается к файлам на диске напрямую. Символ . указывает путь к контексту сборки (Build Context). Перед выполнением первой инструкции Docker-клиент архивирует все содержимое этой директории и передает его Docker-демону (который может находиться на удаленном сервере).

    Передача тяжелого контекста — скрытое узкое место CI/CD пайплайнов. Если в директории проекта лежат локальные виртуальные окружения (venv/), веса моделей (.gguf), логи или скрытая директория .git/, они будут упакованы и отправлены демону, даже если ни разу не используются в инструкциях COPY. Это приводит к трем проблемам:

  • Затраты времени на I/O операции и архивацию перед стартом сборки.
  • Неоправданное потребление оперативной памяти демоном.
  • Случайная инвалидация кэша. Директория .git/ меняется при каждом коммите (обновляются логи HEAD). Если в Dockerfile используется COPY . ., изменение служебных файлов Git сломает кэш.
  • Файл .dockerignore работает аналогично .gitignore, но на уровне контекста сборки. Он отсекает файлы до того, как они попадут к демону.

    Оптимальный .dockerignore для ИИ-проекта:

    Исключение директории tests/ из контекста production-образа — важный архитектурный шаг. Код тестов меняется часто, но он не нужен для запуска API. Исключив его, мы гарантируем, что добавление нового юнит-теста не вызовет пересборку финального слоя с бизнес-логикой.

    BuildKit и персистентное монтирование кэша

    Стандартный послойный кэш Docker имеет бинарную природу: он либо полностью валиден, либо полностью сброшен. Если в requirements.txt из 50 пакетов добавляется один новый (например, redis), хэш файла меняется. Слой RUN pip install инвалидируется целиком. Пакетный менеджер вынужден заново скачивать все 51 пакет, включая тяжелые тензорные библиотеки.

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

    Директива --mount=type=cache,target=/root/.cache/pip указывает BuildKit примонтировать специальный том в директорию, где pip хранит скачанные архивы (wheels).

    Сценарий при добавлении нового пакета меняется кардинально:

  • Хэш requirements.txt меняется.
  • Слой RUN pip install инвалидируется, Docker запускает контейнер для его выполнения.
  • Внутрь сборочного контейнера монтируется кэш от предыдущих сборок.
  • pip читает requirements.txt, видит 50 старых пакетов и находит их готовые wheel-архивы в /root/.cache/pip. Сетевой запрос не выполняется.
  • pip скачивает только один новый пакет (redis), сохраняет его в кэш и устанавливает.
  • Время сборки при добавлении зависимости сокращается с минут до секунд. Аналогичный подход применяется для системных пакетов на этапе установки компиляторов (в паттерне многоэтапной сборки):

    Параметр sharing=locked гарантирует безопасный конкурентный доступ к кэшу, если BuildKit параллельно собирает несколько образов на одном сервере.

    Распределенное кэширование в CI/CD пайплайнах

    Локальная сборка на машине разработчика опирается на внутреннее хранилище Docker (/var/lib/docker). Однако в корпоративных CI/CD системах (GitLab CI, GitHub Actions) рабочие узлы (Runners) часто эфемерны. Каждый новый запуск пайплайна происходит на чистой виртуальной машине с пустой файловой системой. В таких условиях локальный кэш слоев отсутствует, и сборка всегда выполняется с нуля.

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

    Существует два основных формата экспорта кэша: Inline и Registry.

    Inline Cache

    В режиме Inline метаданные кэша встраиваются непосредственно в манифест финального образа.

    Команда сборки в CI выглядит так:

    При запуске пайплайна BuildKit скачивает манифест my-ai-app:latest из реестра. Он извлекает из него дерево хэшей предыдущей сборки. Если хэши слоев (например, установки зависимостей) совпадают с текущими, BuildKit скачивает готовые слои напрямую из реестра, пропуская этап локального выполнения RUN.

    Недостаток Inline-кэша в том, что он сохраняет только те слои, которые вошли в финальный образ. Если используется многоэтапная сборка (Multi-stage Build), слои этапа builder (с компиляторами и исходниками C-библиотек) будут потеряны, так как они не переносятся в финальный образ.

    Registry Cache

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

    Параметр mode=max указывает движку кэшировать все этапы сборки, а не только целевой. Когда CI-раннер начинает компиляцию Python-пакетов, он обращается к my-ai-app:cache. Если другой разработчик в соседней ветке уже компилировал этот набор зависимостей, раннер просто скачает готовый слой этапа builder.

    Математика распределенного кэширования сводится к сравнению пропускной способности сети и вычислительной мощности CPU. Скачивание слоя скомпилированного PyTorch объемом 1.5 ГБ из внутреннего корпоративного Registry со скоростью 5 Гбит/с занимает около 3 секунд. Локальная установка и распаковка того же пакета через pip с вычислением хэшей займет более минуты.

    Управление слоями, использование BuildKit Mounts и настройка внешних кэшей превращают Dockerfile из простого скрипта установки в детерминированный граф вычислений. Грамотно выстроенная архитектура сборки позволяет системе масштабироваться алгоритмически: время пайплайна начинает зависеть только от объема реально измененного кода, а не от суммарного веса всех зависимостей проекта.

    4. Docker Compose: декларативное описание мульти-контейнерной архитектуры ИИ-решения

    Docker Compose: декларативное описание мульти-контейнерной архитектуры ИИ-решения

    Запуск изолированного микросервиса FastAPI через команду docker run не вызывает сложностей. Однако спроектированная нами архитектура мульти-агентной системы требует одновременной работы множества компонентов: PostgreSQL для хранения эпизодической памяти и логов LangGraph, Qdrant для семантического поиска, Redis в качестве брокера сообщений, пула воркеров Celery для тяжелых задач и, опционально, локального инстанса Ollama. Попытка управлять шестью взаимосвязанными контейнерами через императивные bash-скрипты с десятками флагов --network, -e и -v неизбежно приводит к ошибкам конфигурации, рассинхронизации сред и невозможности надежно воспроизвести систему на новом сервере.

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

    Парадигма Infrastructure as Code и декларативная конфигурация

    В основе Docker Compose лежит концепция Infrastructure as Code (IaC). Вместо ручного выполнения команд разработчик описывает желаемое состояние всей системы в едином файле docker-compose.yml. Движок Compose берет на себя задачу вычисления разницы между текущим состоянием демона Docker и описанным в файле, после чего выполняет минимально необходимый набор действий для их синхронизации.

    Декларативный подход обеспечивает свойство идемпотентности: многократный запуск команды docker compose up -d не приведет к созданию дубликатов контейнеров. Если конфигурация сервиса не изменилась, Compose просто сообщит, что сервис Running. Если вы изменили лимит памяти для Qdrant в YAML-файле, Compose пересоздаст только контейнер Qdrant, не прерывая работу PostgreSQL.

    Файл конфигурации логически разделен на три корневых блока:

  • services — описание самих вычислительных узлов (контейнеров).
  • networks — топология виртуальных сетей для изоляции трафика (подробно рассмотрим в следующей главе).
  • volumes — именованные тома для персистентного хранения данных (рассмотрим в главе про управление состоянием).
  • Внутри блока services каждый микросервис получает уникальное логическое имя, которое одновременно становится его DNS-именем во внутренней сети.

    Проблема жизненного цикла: depends_on и Healthchecks

    При запуске распределенной системы критически важен порядок инициализации. FastAPI-приложение, использующее SQLAlchemy и asyncpg, при старте немедленно пытается установить соединение с пулом базы данных. Если PostgreSQL еще не готов принимать подключения, ASGI-сервер Uvicorn завершится с фатальной ошибкой ConnectionRefusedError, и контейнер api упадет.

    Базовая директива depends_on указывает Compose порядок запуска контейнеров:

    Однако стандартное поведение depends_on содержит архитектурную ловушку: Compose считает зависимость удовлетворенной в ту миллисекунду, когда процесс внутри контейнера db (PID 1) просто запустился. Но СУБД PostgreSQL требуется время на выделение разделяемой памяти, чтение конфигурации и восстановление из WAL-журналов. Контейнер api стартует за 0.5 секунды, тогда как db может инициализироваться 5-7 секунд.

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

    Правильная конфигурация связки FastAPI и PostgreSQL выглядит так:

    Разберем параметры healthcheck:

  • test — команда, выполняемая внутри контейнера. Утилита pg_isready возвращает код выхода 0 только если СУБД готова принимать TCP-соединения.
  • interval — пауза между проверками (5 секунд).
  • timeout — время ожидания завершения команды проверки.
  • retries — количество неудачных попыток подряд, после которых контейнер помечается как unhealthy.
  • start_period — льготный период инициализации. Провалы проверок в первые 10 секунд игнорируются и не увеличивают счетчик retries. Это критически важно для баз данных с большими объемами данных, требующих длительного восстановления при старте.
  • Директива condition: service_healthy в сервисе api заставляет движок Compose блокировать запуск FastAPI до тех пор, пока healthcheck базы данных не вернет статус healthy.

    Аналогичный подход применяется к векторной БД Qdrant. Поскольку Qdrant предоставляет HTTP API, для проверки используется утилита curl:

    Управление ресурсами и защита от Noisy Neighbor

    В мульти-контейнерной среде все сервисы по умолчанию делят общие ресурсы хост-машины (CPU и RAM). В архитектуре ИИ-систем это приводит к эффекту Noisy Neighbor (Шумный сосед).

    Например, при пакетной вставке тысяч документов Qdrant начинает интенсивное построение графов HNSW в фоновом оптимизаторе. Этот процесс является строго CPU-bound. Если Qdrant захватит 100% процессорного времени хоста, асинхронный Event Loop в контейнере FastAPI перестанет получать кванты времени, что приведет к таймаутам HTTP-запросов и обрывам потоков Server-Sent Events (SSE). Аналогично, утечка памяти в воркере Celery может спровоцировать системный OOM Killer, который уничтожит процесс PostgreSQL.

    Docker Compose позволяет декларативно настроить Control Groups (cgroups) ядра Linux через блок deploy.resources.

    Архитектурно ресурсы делятся на две категории:

  • limits (жесткие ограничения) — потолок потребления. При превышении лимита по CPU процесс будет принудительно троттлиться (замедляться). При превышении лимита по RAM процесс будет немедленно убит OOM Killer'ом.
  • reservations (гарантии) — минимальный объем ресурсов, который планировщик Docker обязан обеспечить сервису при запуске. Если на хосте недостаточно свободных ресурсов для удовлетворения reservations, контейнер не запустится.
  • Пример распределения ресурсов для ИИ-стека:

    В этом сценарии Qdrant ограничен 4 ядрами процессора (даже если на сервере их 16) и 8 ГБ оперативной памяти. FastAPI гарантированно получит половину ядра и 512 МБ памяти, что обеспечит стабильную работу сетевого слоя независимо от нагрузки на векторную базу.

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

    Передача учетных данных, URL-адресов и ключей API (например, OPENAI_API_KEY) внутрь контейнеров осуществляется через переменные окружения. Хардкодить такие значения в docker-compose.yml категорически запрещено из соображений безопасности.

    Compose поддерживает два механизма работы с окружением: директиву environment и директиву env_file.

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

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

    Иерархия приоритетов: Если одна и та же переменная определена и в .env файле, и в блоке environment, значение из environment имеет более высокий приоритет и перезапишет значение из файла. Это свойство используется для точечного переопределения базовых настроек для конкретных контейнеров.

    Паттерн Compose Overrides: разделение Development и Production

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

    В production-среде код приложения «запечен» внутрь образа (через инструкцию COPY . /app в Dockerfile). Контейнер должен автоматически перезапускаться при сбоях и иметь жесткие лимиты ресурсов. В development-среде разработчику нужен Hot Reloading (автоматическая перезагрузка Uvicorn при изменении Python-файлов), для чего необходимо пробросить директорию с исходным кодом с хост-машины внутрь контейнера. Лимиты ресурсов при локальной разработке часто мешают отладке.

    Решение этой архитектурной дилеммы — использование механизма Compose Overrides (переопределений).

    Базовый файл docker-compose.yml описывает конфигурацию, общую для всех сред, или конфигурацию для production:

    Для локальной разработки создается файл docker-compose.override.yml. При выполнении команды docker compose up без указания конкретных файлов, движок автоматически ищет файл с суффиксом override и выполняет глубокое слияние (Merge) YAML-структур.

    При слиянии Compose применяет следующие правила:

  • Списки (например, ports, volumes) объединяются.
  • Скалярные значения (например, command, restart) из override-файла заменяют значения из базового файла.
  • На production-сервере override-файл отсутствует (или игнорируется через явное указание файлов: docker compose -f docker-compose.yml up -d), и система запускается в строгом изолированном режиме. На локальной машине разработчик получает удобную среду с Hot Reloading, при этом используя ту же топологию баз данных и брокеров сообщений, что и в production.

    Декларативное описание инфраструктуры через Docker Compose формирует надежный фундамент для сложной мульти-агентной системы. Мы перешли от ручного управления разрозненными процессами к единому конфигурационному файлу, который гарантирует правильный порядок запуска через Healthchecks, предотвращает ресурсное голодание через Control Groups и позволяет гибко разделять среды разработки и эксплуатации. Этот шаг не только стабилизирует текущий MVP, но и формирует ментальную модель, необходимую для будущего переноса системы в Kubernetes, где абстракции Pod, Deployment и Liveness Probe работают по схожим декларативным принципам.

    5. Сетевое взаимодействие и Service Discovery внутри Docker: связь FastAPI с базами данных

    Сетевое взаимодействие и Service Discovery внутри Docker: связь FastAPI с базами данных

    При переносе локального кода в Docker разработчики массово сталкиваются с ошибкой asyncpg.exceptions.CannotConnectNowError: Connection refused. Приложение FastAPI, идеально работавшее на хост-машине с .env параметром DATABASE_URL=postgresql://user:pass@localhost:5432/db, мгновенно падает при запуске в контейнере. Причина кроется в фундаментальном свойстве сетевых пространств имен: localhost (или 127.0.0.1) внутри контейнера указывает исключительно на сам этот контейнер. База данных PostgreSQL, запущенная в соседнем контейнере, находится в совершенно другом изолированном сетевом пространстве.

    Сетевая модель Docker полностью меняет парадигму адресации. Вместо статических IP-адресов или локальных портов инфраструктура полагается на динамическое разрешение имен (Service Discovery) и программно-определяемые сети (Software-Defined Networking).

    Архитектура стандартной сети Bridge

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

    Когда запускается контейнер, Docker использует механизм Network Namespaces ядра Linux для создания полностью изолированного сетевого стека. У контейнера появляется собственный интерфейс loopback (lo), собственная таблица маршрутизации и собственные правила межсетевого экрана. Чтобы контейнер мог общаться с внешним миром, Docker создает пару виртуальных Ethernet-интерфейсов (veth pair). Один конец этого виртуального кабеля помещается внутрь контейнера и получает имя eth0, а второй конец остается в пространстве хоста (с именем вроде veth1234abc) и жестко подключается к мосту docker0.

    Если запустить несколько контейнеров без явного указания сети, все они будут подключены к мосту docker0 по умолчанию. Они получат IP-адреса из дефолтной подсети (часто ) и смогут пинговать друг друга по этим IP-адресам.

    Однако использование стандартного моста docker0 в мульти-контейнерных приложениях считается антипаттерном. Во-первых, IP-адреса выдаются динамически при старте контейнера: если контейнер PostgreSQL перезапустится, он может получить адрес вместо , что сломает конфигурацию FastAPI. Во-вторых, в стандартной сети bridge по умолчанию отключен механизм автоматического разрешения имен (DNS), что делает невозможным обращение к контейнерам по их именам.

    Встроенный DNS и механизм Service Discovery

    В современных архитектурах микросервисы должны находить друг друга без жесткой привязки к IP-адресам. Эту задачу решает Service Discovery. При использовании Docker Compose для каждого проекта создается выделенная пользовательская сеть (custom bridge network). Внутри таких сетей Docker активирует встроенный DNS-сервер, который всегда доступен внутри любого контейнера по зарезервированному IP-адресу .

    Когда FastAPI-приложение пытается установить соединение с postgresql://user:pass@db_postgres:5432/db, происходит следующий процесс:

  • Сетевой стек контейнера FastAPI отправляет UDP-запрос на порт 53 по адресу с просьбой разрешить имя db_postgres.
  • Встроенный DNS-сервер Docker перехватывает этот запрос. Он проверяет свою внутреннюю таблицу маршрутизации, которая динамически обновляется демоном Docker при каждом старте или остановке контейнеров.
  • DNS-сервер находит контейнер, имя которого (или имя сервиса в docker-compose.yml) совпадает с db_postgres, и возвращает его текущий внутренний IP-адрес (например, ).
  • FastAPI устанавливает TCP-соединение напрямую с этим IP-адресом.
  • Этот механизм работает абсолютно прозрачно для кода приложения. В docker-compose.yml ключи верхнего уровня в блоке services автоматически становятся DNS-именами:

    В этом примере коду внутри контейнера api достаточно сделать HTTP-запрос на http://vector_db:6333, и встроенный DNS направит трафик к контейнеру Qdrant. Если контейнер vector_db будет пересоздан и получит новый IP-адрес, DNS-запись обновится мгновенно, и новые запросы от api автоматически пойдут по правильному маршруту.

    Балансировка нагрузки через DNS Round-Robin

    Service Discovery в Compose поддерживает базовую балансировку нагрузки. Если запустить несколько реплик одного сервиса (например, docker-compose up --scale api=3), Docker создаст три контейнера. Внутренний DNS-сервер свяжет имя сервиса api со списком из трех IP-адресов. При каждом новом DNS-запросе к имени api, сервер будет возвращать список адресов в разном порядке (Round-Robin). Это позволяет распределять внутренний трафик между воркерами без необходимости поднимать отдельный балансировщик вроде Nginx внутри сети Docker.

    Изоляция трафика: проектирование пользовательских сетей

    По умолчанию Docker Compose помещает все сервисы из одного YAML-файла в единую сеть (обычно с именем <project_name>_default). Это означает, что любой контейнер может инициировать соединение с любым другим контейнером по любому порту. Для ИИ-систем, оперирующих конфиденциальными корпоративными данными и векторными базами, такой подход нарушает принцип наименьших привилегий (Zero Trust).

    Если злоумышленник найдет уязвимость в публично доступном веб-интерфейсе (например, Streamlit) и выполнит произвольный код внутри его контейнера, в плоской сети он получит прямой сетевой доступ к порту 5432 контейнера PostgreSQL и порту 6333 контейнера Qdrant.

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

    В этой топологии:

  • web_ui может общаться только с api_gateway. Он физически не может разрешить DNS-имя postgres_db или отправить пакеты в подсеть db_net.
  • postgres_db изолирован в db_net и принимает запросы только от api_gateway.
  • api_gateway выступает в роли маршрутизатора, имея сетевые интерфейсы во всех трех сетях.
  • Такая архитектура сдерживает латеральное движение (Lateral Movement) при компрометации внешнего периметра, так как маршрутизация между виртуальными сетями Docker запрещена на уровне ядра хоста.

    Маршрутизация портов: ловушка ports и обход межсетевых экранов

    Одна из самых опасных концептуальных ошибок при работе с Docker — непонимание разницы между директивами expose и ports.

    Директива expose (или инструкция EXPOSE в Dockerfile) не публикует порт на хост-машине. Она лишь документирует намерение приложения слушать определенный порт и позволяет другим контейнерам внутри той же Docker-сети обращаться к этому порту. В современных версиях Compose контейнеры в одной сети и так имеют доступ ко всем портам друг друга, поэтому expose носит преимущественно информационный характер.

    Директива ports выполняет проброс (Publishing) порта из изолированного сетевого пространства контейнера на сетевой интерфейс физической хост-машины. Синтаксис "5432:5432" означает: «принимать трафик на порту 5432 хоста и перенаправлять его на порт 5432 внутри контейнера».

    Главная ловушка заключается в том, как Docker реализует этот проброс на Linux-системах. Docker напрямую модифицирует правила iptables, добавляя правила трансляции сетевых адресов (DNAT) в цепочку PREROUTING таблицы nat.

    Если системный администратор настроил межсетевой экран UFW (Uncomplicated Firewall) на сервере, разрешив входящий трафик только на порты 80 и 443, и явно запретив доступ к 5432, директива ports: ["5432:5432"] в Compose проигнорирует правила UFW.

    По умолчанию Docker биндит проброшенные порты к интерфейсу 0.0.0.0 (все доступные IPv4-адреса хоста). Пакеты из интернета, идущие на порт 5432, будут перехвачены правилом Docker в PREROUTING до того, как они достигнут цепочки INPUT, которую контролирует UFW. В результате база данных окажется открытой всему интернету, несмотря на включенный системный файрвол.

    Для безопасной отладки баз данных с локальной машины разработчика необходимо жестко привязывать пробрасываемый порт к интерфейсу loopback хоста:

    При такой конфигурации подключиться к PostgreSQL можно будет только изнутри самой хост-машины (или через SSH-туннель), а извне порт 5432 будет недоступен. В production-среде базы данных, брокеры сообщений (Redis, RabbitMQ) и внутренние микросервисы вообще не должны содержать блока ports. Публиковаться на хост должны только порты Reverse Proxy (например, Nginx или Traefik), который возьмет на себя терминацию SSL и маршрутизацию HTTP-трафика во внутреннюю сеть Docker.

    Управление IPAM и разрешение конфликтов подсетей

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

    Если корпоративный VPN (например, WireGuard или OpenVPN) использует подсеть для маршрутизации трафика к внутренним серверам компании, а Docker Compose случайно выберет ту же подсеть для сети backend_net, возникнет коллизия. Когда FastAPI-контейнер попытается отправить запрос к корпоративному API по адресу , ядро Linux направит пакет не в VPN-туннель, а в виртуальный мост Docker, поскольку маршрут к локально подключенной сети имеет более высокий приоритет. Запрос завершится таймаутом.

    Чтобы предотвратить коллизии, необходимо использовать модуль IPAM (IP Address Management) в docker-compose.yml и явно задать диапазоны адресов, которые гарантированно не пересекаются с инфраструктурой компании:

    Использование маски /24 (соответствующей CIDR-нотации) ограничивает сеть диапазоном от до . Вычитая адреса сети и широковещания, мы получаем доступных IP-адреса, чего с огромным запасом хватает для любого микросервисного проекта в рамках одного хоста, при этом минимизируется риск пересечения с широкими корпоративными подсетями.

    Интеграция с хост-системой: host.docker.internal

    В процессе поэтапной миграции инфраструктуры часто возникает ситуация, когда часть системы уже контейнеризирована (например, FastAPI и агенты), а тяжелые компоненты (например, кластер PostgreSQL или локально запущенная Ollama с прямым доступом к GPU) все еще работают непосредственно на хост-операционной системе.

    Контейнер не может обратиться к хосту по адресу 127.0.0.1, так как этот адрес замкнут внутри самого контейнера. Использование публичного IP-адреса хоста не всегда возможно (он может быть динамическим или закрытым файрволом). Для решения этой задачи Docker предоставляет специальное DNS-имя host.docker.internal.

    При обращении к host.docker.internal встроенный DNS-сервер Docker разрешает это имя во внутренний IP-адрес моста docker0 на стороне хоста (обычно ). Это позволяет FastAPI-контейнеру прозрачно подключаться к базе данных, работающей на хосте, используя строку подключения postgresql://user:pass@host.docker.internal:5432/db.

    Важно учитывать, что сервис на хост-машине (например, PostgreSQL) должен быть настроен на прослушивание интерфейса моста Docker, а не только локального 127.0.0.1. В конфигурации postgresql.conf параметр listen_addresses должен включать IP-адрес Docker-моста или быть установленным в '*', иначе ядро хоста отклонит пакеты, пришедшие из сети контейнеров.

    Сетевая модель Docker Compose с ее встроенным Service Discovery, строгой изоляцией через пользовательские сети и декларативным управлением — это прямой концептуальный предшественник оркестрации в Kubernetes. Понимание того, как имена сервисов разрешаются в динамические IP-адреса внутри изолированных мостов, формирует фундамент для работы с объектами Service и CoreDNS в распределенных кластерах, к которым архитектура ИИ-решения перейдет на финальных этапах развертывания.

    6. Управление состоянием: Docker Volumes для персистентного хранения PostgreSQL и векторов Qdrant

    Управление состоянием: Docker Volumes для персистентного хранения PostgreSQL и векторов Qdrant

    Инженер выполняет команду docker compose down, чтобы обновить переменную окружения, а затем docker compose up -d. API-сервер успешно стартует, маршруты отвечают, но при попытке извлечь историю диалогов или выполнить семантический поиск система возвращает пустые массивы. База данных PostgreSQL и векторное хранилище Qdrant девственно чисты. Эфемерность контейнеров, обеспечивающая идеальную изоляцию и воспроизводимость вычислительной логики, сыграла злую шутку с данными: вместе с остановкой процесса была уничтожена и его локальная файловая система.

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

    Анатомия файловой системы контейнера и проблема Copy-on-Write

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

    Образ Docker состоит из набора неизменяемых (Read-Only) слоев. При запуске контейнера поверх этих слоев создается тонкий эфемерный слой для чтения и записи (Read-Write layer). Любые изменения файлов, создание логов или запись новых строк в базу данных физически происходят именно в этом верхнем слое.

    Если процесс внутри контейнера пытается изменить файл, который изначально находился в нижнем (неизменяемом) слое образа, файловая система применяет механизм Copy-on-Write (CoW). Сначала файл целиком копируется из Read-Only слоя в верхний Read-Write слой, и только после этого в него вносятся изменения.

    Для баз данных этот механизм фатален по двум причинам:

  • Потеря данных. При удалении контейнера Read-Write слой удаляется безвозвратно.
  • Деградация производительности. Если PostgreSQL обновляет одну строку размером 100 байт внутри файла данных размером 1 ГБ, механизм CoW вынужден скопировать весь гигабайтный файл в верхний слой перед внесением изменений. Время выполнения операции записи можно выразить как , где для больших файлов на порядки превышает время самой транзакции.
  • Для высоконагруженных систем, таких как Qdrant, постоянно обновляющий HNSW-индексы, или PostgreSQL, пишущий Write-Ahead Log (WAL), накладные расходы UnionFS приводят к катастрофическому падению IOPS (количества операций ввода-вывода в секунду). Решение заключается в полном обходе каскадной файловой системы контейнера.

    Стратегии монтирования: Bind Mounts против Named Volumes

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

    Bind Mounts (Привязка директорий)

    При использовании Bind Mount вы явно указываете абсолютный или относительный путь на хост-машине и проецируете его в директорию контейнера.

    Этот подход популярен при локальной разработке (например, для проброса исходного кода FastAPI с включенным Hot Reloading), но для баз данных он создает серьезную проблему прав доступа (Permissions Mismatch).

    Процесс PostgreSQL внутри официального контейнера работает от имени непривилегированного пользователя postgres, которому жестко назначен UID 999. Если директория ./my_local_data на хост-машине была создана вашим пользователем (например, с UID 1000), процесс внутри контейнера получит ошибку Permission denied при попытке инициализировать базу данных. Решение этой проблемы требует ручного выполнения chown -R 999:999 ./my_local_data на хосте, что нарушает принцип переносимости (Infrastructure as Code) и делает запуск проекта зависимым от операционной системы.

    Named Volumes (Именованные тома)

    Named Volumes — это абстракция, при которой Docker полностью берет на себя управление физическим размещением данных на диске хоста (обычно в /var/lib/docker/volumes/).

    В этом сценарии вы не указываете физический путь. Вы объявляете логический ресурс pg_data в корневом блоке volumes конфигурации Compose.

    Преимущества Named Volumes для баз данных:

  • Автоматическое управление правами. При первом запуске Docker создает директорию тома от имени root. Когда стартует контейнер PostgreSQL, его стартовый скрипт (Entrypoint) автоматически меняет владельца этой директории на UID 999 перед тем, как запустить сам процесс СУБД. Никакого ручного вмешательства не требуется.
  • Изоляция от хостовой ОС. Том скрыт от случайного удаления или модификации пользователями хост-машины (в отличие от папки проекта).
  • Оптимизация I/O. Данные пишутся напрямую на файловую систему хоста, минуя драйвер хранилища Docker и механизм CoW.
  • Персистентность эпизодической памяти: настройка PostgreSQL

    Официальный образ PostgreSQL имеет строгий алгоритм инициализации, который напрямую завязан на состояние примонтированного тома. Директория, в которую СУБД сохраняет свои файлы, по умолчанию находится по пути /var/lib/postgresql/data.

    Когда контейнер запускается, его Entrypoint-скрипт проверяет эту директорию:

  • Если директория пуста, скрипт выполняет команду initdb, создает системные таблицы, применяет переменные окружения (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB) и запускает базу.
  • Если в директории уже есть файлы (например, файл PG_VERSION), скрипт полностью игнорирует переменные окружения для создания пользователя/пароля и просто запускает СУБД, используя существующие данные.
  • Интеграция PostgreSQL в docker-compose.yml корпоративной ИИ-системы выглядит следующим образом:

    bash docker volume create myproject_qdrant_snapshots

    docker run --rm \ -v myproject_qdrant_snapshots:/data \ -v $(pwd)/backups:/backup \ alpine \ sh -c "cd /data && tar -xzvf /backup/qdrant_backup_2023-10-25.tar.gz" ```

    Этот паттерн гарантирует независимость от операционной системы хоста и позволяет автоматизировать процессы резервного копирования в CI/CD пайплайнах или через cron-скрипты, не нарушая изоляцию контейнерной среды.

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

    7. Контейнеризация GPU-нагрузок: проброс NVIDIA Runtime для Ollama и Sentence Transformers

    Контейнеризация GPU-нагрузок: проброс NVIDIA Runtime для Ollama и Sentence Transformers

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

    Анатомия изоляции и проблема драйверов

    В операционных системах семейства Linux взаимодействие с оборудованием происходит через специальные файлы устройств (device nodes), расположенные в директории /dev. Для видеокарт NVIDIA это файлы вида /dev/nvidia0, /dev/nvidiactl и /dev/nvidia-uvm.

    Казалось бы, проблему можно решить простым монтированием этих файлов внутрь контейнера через механизм Bind Mounts. Если передать устройству путь /dev/nvidia0, процесс получит доступ к железу. На практике этот подход не работает из-за архитектуры графических драйверов.

    Для выполнения вычислений на GPU приложению (например, PyTorch или llama.cpp) недостаточно просто видеть файл устройства. Ему требуются библиотеки пользовательского пространства (user-space libraries), такие как libcuda.so, которые транслируют высокоуровневые команды в специфичные инструкции ядра. Версия этих библиотек должна строго совпадать с версией драйвера ядра, установленного на хост-машине. Если жестко зашить эти библиотеки в Docker-образ на этапе сборки, образ потеряет портативность: он запустится только на серверах с точно такой же версией драйвера NVIDIA. Это полностью разрушает парадигму контейнеризации «собрал однажды — запускай везде».

    NVIDIA Container Toolkit: динамическая инъекция зависимостей

    Для разрешения конфликта между портативностью образа и жесткой привязкой к драйверу был разработан NVIDIA Container Toolkit. Это набор утилит, который модифицирует стандартный процесс запуска контейнеров.

    Вместо того чтобы помещать драйверы внутрь образа, NVIDIA Container Toolkit использует механизм динамической инъекции. Когда Docker-демон получает команду на запуск контейнера с запросом GPU, в дело вступает модифицированная среда выполнения (nvidia-container-runtime или механизм Container Device Interface — CDI). Перед тем как передать управление пользовательскому процессу, среда выполнения выполняет следующие шаги:

  • Анализирует хост-систему и находит установленные драйверы NVIDIA.
  • Пробрасывает внутрь контейнера файлы устройств (/dev/nvidia*).
  • Динамически монтирует необходимые библиотеки пользовательского пространства (например, libcuda.so, libnvcuvid.so) из хост-системы в файловую систему контейнера.
  • Благодаря этому механизму, внутри Docker-образа достаточно иметь только высокоуровневый фреймворк (PyTorch или скомпилированный бинарник Ollama). Всю работу по связыванию этого фреймворка с конкретным железом хоста берет на себя Container Toolkit в момент старта (Runtime).

    Декларативный проброс GPU в Docker Compose

    Исторически проброс видеокарт осуществлялся через CLI-флаг --gpus all. В современной парадигме Infrastructure as Code (IaC) мы используем спецификацию Docker Compose V2, которая предоставляет гранулярный контроль над аппаратными ресурсами через блок deploy.

    Рассмотрим конфигурацию для запуска официального образа Ollama с доступом к графическому ускорителю:

    Блок deploy.resources.reservations.devices является стандартизированным способом запроса аппаратных ресурсов.

  • driver: nvidia указывает Docker-демону использовать NVIDIA Container Toolkit.
  • count: 1 сообщает, что контейнеру требуется ровно один графический ускоритель. Если на сервере установлено несколько видеокарт, Docker выберет первую свободную. Для многопроцессорных систем можно указать count: all или использовать параметр device_ids: ['0', '1'] для жесткой привязки к конкретным PCIe-устройствам.
  • capabilities: [gpu] определяет требуемый функционал. Для базового инференса достаточно gpu, но для специфичных задач могут потребоваться дополнительные возможности, например [gpu, compute, utility].
  • Обратите внимание на использование именованного тома ollama_models. Как обсуждалось в контексте баз данных, контейнеры эфемерны. Без этого тома каждый перезапуск контейнера приводил бы к повторному скачиванию многогигабайтных файлов GGUF-моделей (например, llama3:8b). Том /root/.ollama гарантирует, что веса моделей переживут пересоздание контейнера.

    Управление состоянием: автоматическая загрузка весов

    Официальный образ Ollama запускает серверный процесс, но не содержит самих моделей. В распределенной системе мы не можем вручную заходить в контейнер и выполнять команду ollama run llama3. Требуется автоматизировать процесс «холодного старта».

    Реализовать это можно через паттерн Init-контейнеров или одноразовых задач в Compose. Создадим вспомогательный сервис, который дождется старта основного API Ollama, скачает нужную модель и завершит работу:

    Этот подход требует добавления healthcheck в сервис ollama, чтобы ollama-init не пытался отправить POST-запрос до того, как HTTP-сервер будет готов принимать соединения.

    Контейнеризация AI Gateway: PyTorch и Sentence Transformers

    Если Ollama поставляется в виде готового бинарного файла, не требующего сложных зависимостей, то микросервис AI Gateway, отвечающий за генерацию эмбеддингов через Sentence Transformers, требует сборки кастомного Python-образа с поддержкой CUDA.

    Здесь возникает классическая архитектурная дилемма: выбор базового образа. Использование тяжеловесных официальных образов nvidia/cuda (размером более 3 ГБ) избыточно, так как нам нужна только среда выполнения (Runtime), а не инструменты разработчика (Development Kit) вроде компилятора nvcc.

    Оптимальный путь — использовать легковесный python:3.11-slim и устанавливать сборки PyTorch, которые уже содержат внутри себя скомпилированные библиотеки CUDA (так называемые CUDA-enabled wheels). Поскольку NVIDIA Container Toolkit пробросит драйвер хоста, этого будет достаточно для работы тензорных вычислений.

    Однако размер wheel-архива PyTorch с поддержкой CUDA превышает 2 ГБ. Если использовать стандартную команду RUN pip install, сборка образа будет занимать недопустимо много времени при каждом изменении requirements.txt. Применим механизм BuildKit Cache Mounts, изученный ранее, адаптировав его под тяжелые ML-зависимости:

    В файле requirements.txt мы явно указываем версии библиотек:

    Флаг --extra-index-url https://download.pytorch.org/whl/cu121 критически важен. Без него pip скачает версию PyTorch для CPU, и контейнер будет выполнять генерацию эмбеддингов на процессоре, несмотря на наличие блока deploy.resources.reservations.devices в Compose-файле.

    Разделение видеопамяти (VRAM) в едином Compose-стеке

    Когда мы объединяем Ollama и AI Gateway в одном файле docker-compose.yml и для обоих сервисов указываем count: 1 с драйвером nvidia, возникает архитектурный конфликт. Оба изолированных контейнера получают доступ к одному и тому же физическому графическому ускорителю.

    В отличие от оперативной памяти (RAM), где ядро Linux может использовать файл подкачки (Swap), видеопамять (VRAM) конечна и не прощает перерасхода. Если суммарный запрос памяти превысит физический объем, процесс будет немедленно убит с ошибкой CUDA Out of Memory.

    Математическое условие стабильной работы стека:

    Где — память, занятая весами Llama 3 и ее KV-кэшем, — память под модель Sentence Transformers (например, all-MiniLM-L6-v2), а — накладные расходы CUDA-контекста (обычно 300-500 МБ на каждый процесс).

    Чтобы предотвратить состояние гонки за видеопамятью между контейнерами, необходимо ввести жесткие программные лимиты (Soft Limits) на уровне самих приложений, так как Docker Compose (в отличие от аппаратного механизма MIG) не умеет квотировать VRAM.

    Ограничение VRAM для Ollama

    По умолчанию Ollama пытается загрузить в VRAM максимально возможное количество слоев модели (Layer Offloading). Если запустить Ollama первой, она может монополизировать всю доступную видеопамять, не оставив места для AI Gateway.

    Мы можем ограничить аппетиты Ollama через переменные окружения, передаваемые в Compose:

    Установив OLLAMA_MAX_VRAM, мы гарантируем, что процесс не попытается выделить больше памяти, даже если она физически свободна. Оставшиеся слои модели будут принудительно выгружены в системную RAM, что снизит общую пропускную способность (TPS), но спасет систему от падения.

    Изоляция видимости через CUDA_VISIBLE_DEVICES

    Если сервер оснащен несколькими видеокартами (например, двумя RTX 4090), лучшей практикой является физическое разнесение нагрузок. Вместо того чтобы полагаться на планировщик NVIDIA, мы можем жестко закрепить контейнеры за конкретными GPU с помощью переменной окружения CUDA_VISIBLE_DEVICES.

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

    Конфигурация для многопроцессорной системы:

    Обратите внимание на важный нюанс абстракции: переменная CUDA_VISIBLE_DEVICES=0 в сервисе ai_gateway указывает на первую доступную контейнеру видеокарту. Поскольку через блок devices мы пробросили физическое устройство с индексом 1, внутри изолированного пространства имен контейнера оно становится устройством с индексом 0. Это классический пример того, как контейнеризация переопределяет видимость ресурсов.

    Замыкание архитектурного контура

    Интеграция NVIDIA Container Toolkit и декларативного синтаксиса Docker Compose завершает трансформацию локальных разрозненных скриптов в монолитную, воспроизводимую инфраструктуру. Мы устранили проблему несовместимости драйверов, обеспечили кэширование многогигабайтных ML-зависимостей на этапе сборки и настроили безопасное разделение дефицитной видеопамяти между независимыми агентами. Теперь ИИ-модели, векторные хранилища и реляционные базы данных работают в единой изолированной сети, управляемой одним конфигурационным файлом, что открывает прямой путь к переносу этой системы в распределенные кластеры.

    8. Переменные окружения и секреты: безопасная конфигурация агентов в Docker-стеке

    Переменные окружения и секреты: безопасная конфигурация агентов в Docker-стеке

    В 2023 году аналитики безопасности зафиксировали массовую утечку ключей OpenAI API и токенов доступа к базам данных. Причина крылась не во взломе серверов или уязвимостях кода, а в банальной команде docker inspect. Разработчики передавали критичные учетные данные в контейнеры через стандартные переменные окружения. Любой процесс, имеющий доступ к демону Docker, или любой инструмент мониторинга, собирающий метаданные контейнеров, получал эти ключи в открытом виде. Контейнеризация требует строгого разделения между конфигурацией приложения и секретами, так как механизмы их доставки и хранения кардинально отличаются.

    Парадигма 12-Factor App и изоляция конфигурации

    Методология разработки современных приложений (12-Factor App) диктует жесткое правило: код должен быть отделен от конфигурации. Образ Docker (Image) — это неизменяемый артефакт. Один и тот же образ агента, собранный на CI-сервере, должен без перекомпиляции запускаться на локальной машине разработчика, в тестовой среде и в production-кластере.

    Разница между этими средами заключается исключительно в конфигурации: URL-адресах баз данных, уровнях логирования, лимитах токенов и API-ключах. Внедрение этих данных происходит на этапе запуска контейнера.

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

    Переменные сборки (ARG)

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

    Фатальная ошибка безопасности — передача приватных токенов доступа к репозиториям через ARG. Значения ARG навсегда сохраняются в метаданных слоев образа. Любой человек, скачавший такой образ, может выполнить команду docker history <image_name> и увидеть переданный токен в открытом виде.

    Переменные времени выполнения (ENV)

    Инструкция ENV задает переменные окружения, которые будут доступны основному процессу внутри запущенного контейнера. Значения, указанные через ENV в Dockerfile, становятся значениями по умолчанию. Их можно переопределить в момент старта контейнера через флаг -e в CLI или через блок environment в Docker Compose.

    Переменные окружения идеально подходят для несекретной конфигурации:

  • LOG_LEVEL=INFO
  • MAX_RETRIES=3
  • LLM_PROVIDER=ollama
  • Однако они категорически не подходят для паролей, сертификатов и API-ключей.

    Иллюзия безопасности файлов .env

    При локальной разработке стандартом де-факто стало использование файлов .env. Docker Compose нативно поддерживает этот формат. Существует два способа работы с ними, которые кардинально отличаются по своему поведению.

    Первый способ — интерполяция переменных в самом файле docker-compose.yml. Docker Compose автоматически читает файл .env, лежащий в той же директории, и использует его значения для подстановки в манифест.

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

    Второй способ — директива env_file. Она приказывает демону Docker загрузить все переменные из указанного файла и внедрить их в контейнер массово.

    Оба подхода обладают одним и тем же архитектурным изъяном: данные попадают в глобальное пространство переменных окружения процесса внутри контейнера (в Linux это можно увидеть, прочитав файл /proc/1/environ).

    Почему это небезопасно для ИИ-систем:

  • Утечка через метаданные: Команда docker inspect <container_id> выводит полный список переменных окружения в формате JSON. Если злоумышленник или скомпрометированный соседний контейнер получит доступ к сокету Docker (/var/run/docker.sock), он мгновенно извлечет все ключи.
  • Утечка через логи и отчеты об ошибках: Многие фреймворки (включая некоторые компоненты LangChain) при возникновении необработанного исключения могут сбросить в лог дамп текущего окружения для облегчения отладки.
  • Наследование дочерними процессами: Переменные окружения автоматически копируются во все дочерние процессы. Если ваш агент использует инструмент Python REPL или запускает внешние bash-скрипты, эти процессы получают полный доступ к родительским переменным, включая ключи к БД.
  • Архитектура Docker Secrets

    Для решения проблемы доставки критичных данных был разработан механизм Docker Secrets. Изначально он создавался для распределенных кластеров Docker Swarm, но позже был адаптирован для использования в рамках Docker Compose на одном узле.

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

    Ключевая особенность безопасности Docker Secrets заключается в использовании файловой системы tmpfs. Когда вы пробрасываете секрет в контейнер, Docker создает виртуальную файловую систему в оперативной памяти (RAM) хост-машины и монтирует её внутрь контейнера, по умолчанию в директорию /run/secrets/.

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

    Декларативное описание секретов в Compose

    Для использования секретов в docker-compose.yml необходимо определить их на двух уровнях: глобальном (откуда Docker должен взять данные) и локальном (в какие сервисы их нужно пробросить).

    В этом сценарии на хост-машине существуют реальные текстовые файлы в папке ./secrets/. При запуске контейнера ai-agent демон Docker прочитает содержимое этих файлов и создаст внутри контейнера два файла: /run/secrets/openai_api_key и /run/secrets/db_password.

    Основной процесс приложения (FastAPI или Celery-воркер) должен быть спроектирован так, чтобы читать учетные данные из этих файлов, а не искать их в os.environ.

    Интеграция секретов в Pydantic V2

    Переход от переменных окружения к файловым секретам требует адаптации кода приложения. Если ранее приложение ожидало найти ключ в os.getenv("OPENAI_API_KEY"), теперь оно должно открыть файл /run/secrets/openai_api_key и прочитать его содержимое.

    Ручная реализация такой логики приводит к появлению множества проверок (существует ли файл, есть ли переменная окружения). К счастью, библиотека Pydantic, используемая в FastAPI для настройки контрактов, имеет встроенную поддержку Docker Secrets через класс BaseSettings.

    Для активации этого механизма необходимо указать директорию с секретами в конфигурации модели настроек.

    Архитектурная элегантность этого подхода заключается в каскадном разрешении зависимостей. При инстанцировании AppSettings() Pydantic выполняет поиск значений в строгом порядке:

  • Явно переданные аргументы при инициализации класса.
  • Переменные окружения системы (os.environ).
  • Переменные из файла .env (если он существует).
  • Файлы в директории, указанной в secrets_dir.
  • Если Pydantic ищет значение для поля db_password, он проверяет переменную окружения DB_PASSWORD. Если её нет, он проверяет наличие файла /run/secrets/db_password. Если файл существует, Pydantic читает его, удаляет лишние пробелы и символы переноса строки (которые часто появляются при создании файлов через echo или vim), и присваивает значение атрибуту.

    Это позволяет использовать один и тот же код в разных средах. Локально разработчик может создать файл .env с тестовыми паролями, а в production-среде файл .env отсутствует, и Pydantic бесшовно переключается на чтение из tmpfs Docker Secrets.

    Адаптация инфраструктурных контейнеров (PostgreSQL)

    Если мы можем легко изменить код нашего Python-агента для чтения файлов из /run/secrets/, то как быть с готовыми инфраструктурными образами, такими как PostgreSQL? База данных должна знать свой пароль суперпользователя при первом запуске, чтобы инициализировать системные таблицы.

    Разработчики официальных образов баз данных осведомлены о проблеме безопасности переменных окружения. Поэтому в скриптах инициализации (docker-entrypoint.sh), которые выполняются при старте контейнера PostgreSQL, реализован специальный паттерн суффикса _FILE.

    Вместо передачи открытого пароля через переменную POSTGRES_PASSWORD, мы передаем путь к файлу с секретом через переменную POSTGRES_PASSWORD_FILE.

    Механика работы этого паттерна такова:

  • Демон Docker монтирует tmpfs с файлом пароля в /run/secrets/db_password.
  • Контейнер стартует и запускает скрипт docker-entrypoint.sh.
  • Скрипт видит переменную POSTGRES_PASSWORD_FILE.
  • Он читает содержимое указанного файла и использует его для выполнения внутренних SQL-команд (CREATE ROLE ... WITH PASSWORD '...').
  • Пароль никогда не экспортируется в глобальное окружение контейнера, оставаясь локальной переменной внутри bash-скрипта инициализации.
  • Аналогичный паттерн _FILE поддерживается большинством качественных open-source образов (Redis, MongoDB, RabbitMQ).

    Управление жизненным циклом и ротация секретов

    Использование файлов на хост-машине (как в примере file: ./secrets/postgres.pass) — это шаг вперед по сравнению с .env, но в производственной среде файлы с секретами не должны лежать в открытом виде на диске сервера.

    В полноценных кластерах (Docker Swarm или Kubernetes) секреты хранятся в зашифрованной распределенной базе данных (например, etcd) и доставляются на рабочие узлы только в момент запуска контейнера. В рамках одного сервера с Docker Compose мы ограничены файловой системой хоста.

    Для частичного решения этой проблемы применяется интеграция с внешними менеджерами секретов (HashiCorp Vault, AWS Secrets Manager) на этапе развертывания. CI/CD система (например, GitHub Actions) извлекает секрет из защищенного хранилища, временно создает файл на сервере, запускает docker compose up -d, а затем немедленно удаляет исходный файл. Docker Compose успевает прочитать файл и передать его демону Docker, который будет поддерживать tmpfs до остановки контейнера.

    Вторая архитектурная сложность — ротация (обновление) скомпрометированных секретов. Docker Secrets спроектированы как неизменяемые объекты. Вы не можете просто изменить содержимое файла /run/secrets/db_password внутри работающего контейнера.

    Для ротации применяется паттерн версионирования. В docker-compose.yml создается новый секрет с новым именем:

    Затем в конфигурации сервиса старый секрет заменяется на новый. При выполнении команды docker compose up -d Docker обнаружит изменение в конфигурации сервиса, корректно остановит старый контейнер и запустит новый, смонтировав актуальную версию секрета. Старый секрет db_password_v1 можно будет безопасно удалить из файла манифеста при следующем релизе.

    Безопасная конфигурация — это компромисс между удобством разработки и защитой данных. Переход от .env к Docker Secrets и интеграция директории /run/secrets/ в Pydantic устраняет целый класс уязвимостей, связанных с утечкой метаданных, подготавливая архитектуру агентов к будущей миграции в оркестраторы промышленного уровня, где концепция монтирования секретов в память является единственным стандартом.

    9. Оркестрация фоновых задач: запуск Celery-воркеров и Redis в едином Compose-проекте

    Оркестрация фоновых задач: запуск Celery-воркеров и Redis в едином Compose-проекте

    Сложная ИИ-система не может существовать в рамках одного синхронного процесса. Когда пользователь загружает 50-страничный PDF для векторизации или запускает графовый пайплайн LangGraph, ожидание ответа может занимать десятки секунд. Если выполнять эти вычисления внутри контейнера FastAPI, пул потоков веб-сервера быстро исчерпается, и система перестанет отвечать новым клиентам. Распределенная архитектура с использованием брокера сообщений и фоновых воркеров решает эту проблему, но при переходе от локальной разработки к контейнеризации возникает новый вызов: как управлять жизненным циклом множества связанных, но независимых процессов (API, Broker, Worker, Scheduler), не превращая инфраструктуру в хаос дублирующегося кода и конфликтующих ресурсов.

    Паттерн «Один образ — множество ролей»

    Типичная ошибка при контейнеризации стека FastAPI + Celery — создание отдельных Dockerfile для веб-сервера и для воркера. Это приводит к двукратному увеличению времени сборки, расходу дискового пространства и риску рассинхронизации версий библиотек (например, когда API отправляет задачу, сериализованную новой версией Pydantic, а воркер пытается десериализовать её старой).

    Поскольку Celery-воркеру требуется тот же исходный код (модели БД, схемы валидации, логика ИИ-агентов) и те же зависимости, что и FastAPI, архитектурно правильным решением является использование единого монолитного или многоэтапного Docker-образа. Разделение ролей происходит исключительно на уровне docker-compose.yml через переопределение директивы command.

    Чтобы не дублировать переменные окружения, тома и настройки сети для каждого сервиса, в Docker Compose используются Extension Fields (поля расширения) и YAML Anchors (якоря).

    В этом манифесте блок x-app-base определяет базовую конфигурацию. Символ &app-base создает якорь, а конструкция <<: *app-base внутри сервисов распаковывает (наследует) все ключи базового блока. Если потребуется добавить новую переменную окружения или примонтировать новый том с весами локальной модели, это делается в одном месте, гарантируя абсолютную консистентность среды для API и воркера.

    Контейнеризация брокера: эфемерность Redis

    Celery требует брокера сообщений для маршрутизации задач. В экосистеме Python стандартом де-факто для этой роли выступает Redis. При его контейнеризации в рамках Compose-проекта необходимо сделать фундаментальный выбор: должен ли брокер сохранять состояние на диск (Persistence) или работать исключительно в оперативной памяти (Ephemerality).

    Для очередей задач ИИ-агентов персистентность брокера часто является антипаттерном. Если контейнер Redis падает, а затем перезапускается, чтение устаревшего дампа с диска (dump.rdb) может привести к повторному выполнению задач (например, повторному списанию токенов или дублированию записей в векторную БД), если идемпотентность воркеров не реализована безупречно. Кроме того, сохранение на диск создает ненужный I/O-оверхед.

    Чтобы сделать Redis строго эфемерным брокером, необходимо отключить встроенные механизмы сохранения (RDB и AOF) через передачу аргументов командной строки.

    Директива command: redis-server --save "" --appendonly no гарантирует, что при выполнении docker compose down или аварийном завершении контейнера все необработанные сообщения исчезнут. Надежность системы в этом случае обеспечивается не брокером, а паттерном Transactional Outbox на стороне PostgreSQL и механизмом acks_late в самом Celery.

    Управление ресурсами и математика Concurrency

    При запуске Celery внутри Docker критически важно синхронизировать настройки конкурентности самого фреймворка с жесткими лимитами Control Groups (cgroups), заданными в Compose.

    По умолчанию Celery использует prefork-пул (создание дочерних процессов) и запускает количество рабочих процессов, равное количеству логических ядер процессора, доступных операционной системе. Однако внутри контейнера Celery видит все ядра хост-машины, даже если Docker ограничил контейнер директивой cpus: '2'.

    Если на 32-ядерном сервере запустить контейнер воркера с лимитом в 2 ядра, Celery по умолчанию породит 32 процесса. Эти процессы начнут агрессивно конкурировать за выделенные 2 ядра, вызывая жесточайший контекст-свитчинг (трэшинг), что приведет к катастрофическому падению производительности.

    Количество процессов необходимо задавать явно через флаг --concurrency (или -c), основываясь на типе нагрузки и лимитах контейнера.

    Для CPU-bound задач (например, локальная векторизация текста через Sentence Transformers на CPU): Количество процессов должно строго совпадать с лимитом cpus в Docker. command: celery -A app worker -c 2 (при cpus: '2').

    Для I/O-bound задач (например, ожидание ответа от OpenAI API или запросы к Qdrant): Здесь процессы большую часть времени спят, ожидая сетевого ответа. Использование prefork-пула неэффективно из-за высокого потребления памяти каждым процессом ОС. Формула потребления памяти выглядит так:

    где — значение concurrency.

    Для I/O-нагрузок целесообразно переключить пул Celery на корутины (gevent или eventlet), что позволит обрабатывать сотни задач параллельно в рамках одного процесса ОС, потребляя минимум памяти.

    В мульти-агентных системах лучшей практикой является запуск нескольких специализированных контейнеров-воркеров: один для тяжелой математики (prefork, привязан к очереди math_tasks), другой для сетевых запросов (gevent, привязан к очереди api_tasks). Docker Compose позволяет легко масштабировать их независимо.

    Планировщик Celery Beat и ловушка PID-файла

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

    В среде Docker запуск Celery Beat скрывает опасную ловушку, связанную с файловой системой. При старте Beat создает файл celerybeat.pid для защиты от двойного запуска. Если контейнер останавливается штатно, процесс удаляет этот файл. Но если контейнер убит принудительно (OOM Killer, сбой питания хоста, docker kill), PID-файл остается на примонтированном томе или в слое контейнера (если он перезапускается).

    При следующей попытке старта контейнера Celery Beat обнаружит старый celerybeat.pid, решит, что другой экземпляр уже работает, и немедленно завершится с ошибкой. Контейнер уйдет в бесконечный цикл перезапусков (CrashLoop).

    Решение в рамках Docker Compose — принудительное удаление PID-файла перед запуском процесса через составную команду:

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

    Изящная остановка (Graceful Shutdown) в реалиях ИИ

    Остановка контейнеров через docker compose down или docker stop запускает строго определенный механизм операционной системы. Docker отправляет главному процессу контейнера (PID 1) сигнал SIGTERM (просьба завершить работу). Если процесс не завершается в течение заданного тайм-аута (по умолчанию 10 секунд), Docker отправляет сигнал SIGKILL, который ядро ОС исполняет немедленно, уничтожая процесс без возможности очистки ресурсов.

    Для веб-сервера 10 секунд обычно достаточно, чтобы доотправить HTTP-ответы. Для Celery-воркера, выполняющего ИИ-задачу, это время фатально.

    Представьте узел графа LangGraph, который делает сложный запрос к LLM. Генерация ответа занимает 35 секунд. На 15-й секунде администратор запускает обновление системы (docker compose up -d). Docker отправляет SIGTERM воркеру. Celery переходит в режим Warm Shutdown (Теплая остановка): он перестает брать новые задачи из Redis и ждет завершения текущих. Но через 10 секунд Docker теряет терпение и отправляет SIGKILL. Процесс убит. Задача прервана на середине генерации. В базе данных навсегда "зависает" статус generating, а токен-лимиты у провайдера уже потрачены.

    Чтобы защитить длительные фоновые вычисления, необходимо переопределить поведение Docker Compose с помощью директивы stop_grace_period.

    Увеличение stop_grace_period до 2 минут (или более, в зависимости от профиля нагрузки) дает Celery достаточно времени для штатного завершения текущих генераций.

    Однако этого недостаточно для абсолютной надежности. Если хост-машина физически теряет питание, stop_grace_period не поможет. Поэтому на уровне конфигурации самого Celery необходимо включить позднее подтверждение задач (acks_late = True) и отказ от задачи при потере воркера (task_reject_on_worker_lost = True). При такой конфигурации сообщение удаляется из Redis только после успешного возврата результата (return) из функции воркера. Если контейнер уничтожен SIGKILL, задача останется в брокере и будет подхвачена другим контейнером после перезапуска.

    Мониторинг фоновых процессов: интеграция Flower

    Распределенная природа фоновых задач делает их отладку сложной: логи размазаны по разным контейнерам, а статус очередей скрыт в бинарном протоколе Redis. Для визуализации работы кластера в Compose-проект добавляется Flower — веб-интерфейс для мониторинга Celery.

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

    Запустив этот стек, мы получаем полностью изолированную, детерминированную среду. FastAPI мгновенно принимает запросы и ставит их в очередь. Эфемерный Redis маршрутизирует сообщения. Группа Celery-воркеров, с жестко заданными лимитами CPU и памяти, параллельно выполняет обращения к ИИ-моделям, не мешая друг другу. Celery Beat по расписанию триггерит системные события, а Flower предоставляет визуальный контроль над всем графом выполнения.

    Такая архитектура docker-compose.yml перестает быть просто скриптом локального запуска — она превращается в полноценный черновик для будущего масштабирования системы в кластере Kubernetes, где сервисы станут Deployment'ами, а лимиты ресурсов транслируются в requests и limits подов.