Docker Compose для production без Kubernetes

Когда Docker Compose всё ещё подходит для production и как выстроить вокруг него вменяемый процесс эксплуатации

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

Проблема не в самом Compose, а в том, что вокруг него обычно ничего не выстроено. Нет процесса обновления, нет мониторинга, нет отката. docker compose up -d по SSH — это не деплой, это ручная операция, которая ломается на третий раз.

В этой статье разберём, когда Compose остаётся нормальным выбором, что обязательно добавить вокруг него и в какой момент стоит смотреть дальше.

В статье

Когда Compose — это нормально

Compose подходит для production, если:

  • один хост или два-три с простой топологией;
  • небольшой набор сервисов — приложение, база, кеш, reverse proxy, мониторинг;
  • понятная операционная модель — один человек может держать в голове весь стек;
  • нет требований к автоматическому self-healing — перезапуск контейнера через restart: unless-stopped достаточен;
  • нет сложных rollout-стратегий — blue-green, canary не нужны.

Compose не подходит, если:

  • сервисы распределены по многим хостам и нужна координация;
  • нужен автоматический scaling по нагрузке;
  • требуется zero-downtime deployment с health-проверками при rollout;
  • команда уже переросла ручное управление и нуждается в платформе.

Честный вопрос: если ваш проект обслуживает один-два человека и работает на одном сервере — зачем вам etcd-кластер, control plane и CRD?

Структура production compose-файла

Минимальный production-стек обычно включает:

flowchart LR Internet["Интернет"] --> Proxy["Reverse proxy\n(Angie / Nginx)"] Proxy --> App["Приложение\n(Go / Node / etc)"] App --> DB["PostgreSQL"] App --> Cache["Redis"] Proxy --> Static["Статика\n(shared volume)"] style Internet fill:#f9f3e3,stroke:#8b7355 style Proxy fill:#c9e4c5,stroke:#5b8a5e style App fill:#e8dcc8,stroke:#8b7355 style DB fill:#d4c5a9,stroke:#8b7355 style Cache fill:#d4c5a9,stroke:#8b7355 style Static fill:#e8dcc8,stroke:#8b7355

flowchart LR
    Internet["Интернет"] --> Proxy["Reverse proxy\n(Angie / Nginx)"]
    Proxy --> App["Приложение\n(Go / Node / etc)"]
    App --> DB["PostgreSQL"]
    App --> Cache["Redis"]
    Proxy --> Static["Статика\n(shared volume)"]

    style Internet fill:#f9f3e3,stroke:#8b7355
    style Proxy fill:#c9e4c5,stroke:#5b8a5e
    style App fill:#e8dcc8,stroke:#8b7355
    style DB fill:#d4c5a9,stroke:#8b7355
    style Cache fill:#d4c5a9,stroke:#8b7355
    style Static fill:#e8dcc8,stroke:#8b7355
Типичная структура production Compose-стека

Пример compose-файла для такого стека:

services:
  angie:
    image: docker.angie.software/angie:1.7
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./angie/conf.d:/etc/angie/conf.d:ro
      - certs:/etc/angie/certs:ro
      - static:/var/www/html:ro
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  api:
    image: registry.example.com/myapp/api:${APP_VERSION:-latest}
    env_file: .env
    volumes:
      - static:/var/www/html
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

  postgres:
    image: postgres:16-alpine
    env_file: .env.db
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  pgdata:
  static:
  certs:

Ключевые моменты:

  • образ приложения берётся из registry с тегом через переменную APP_VERSION;
  • база и приложение имеют healthcheck;
  • reverse proxy стартует только после готовности приложения (depends_on + condition);
  • logging ограничен по размеру — без этого диск заканчивается неожиданно;
  • volumes с данными именованные — не привязаны к текущей директории.

Конфигурация: env-файлы и переопределения

Для production удобна схема с несколькими env-файлами:

project/
├── docker-compose.yml          # основной файл
├── docker-compose.override.yml # локальные переопределения (в .gitignore)
├── .env                        # переменные приложения (в .gitignore)
├── .env.db                     # переменные БД (в .gitignore)
├── .env.example                # шаблон без секретов (в репо)
└── .env.db.example             # шаблон для БД (в репо)

Правила:

  • .env и .env.db содержат секреты — только на сервере, в .gitignore;
  • .env.example — шаблон с описанием переменных, без реальных значений, в репозитории;
  • docker-compose.override.yml — для локальных отличий (проброс портов БД для дебага, дополнительные volumes). Compose автоматически подхватывает его;
  • секреты никогда не попадают в образ и не хардкодятся в compose-файле.

Общие переменные между сервисами

Когда несколько сервисов используют одни и те же переменные (адрес базы, уровень логирования, имя окружения), дублировать их в каждом env_file — путь к рассинхронизации. Compose поддерживает YAML-якоря и расширения:

x-common-env: &common-env
  LOG_LEVEL: info
  ENVIRONMENT: production
  TZ: Europe/Moscow

services:
  api:
    image: registry.example.com/myapp/api:${APP_VERSION}
    environment:
      <<: *common-env
      DATABASE_URL: postgres://...
      # специфичные для api

  worker:
    image: registry.example.com/myapp/worker:${APP_VERSION}
    environment:
      <<: *common-env
      QUEUE_URL: amqp://...
      # специфичные для worker

Блок x-common-env — это расширение Compose: любой ключ верхнего уровня, начинающийся с x-, игнорируется движком, но может использоваться как якорь. <<: *common-env вливает общие переменные в environment конкретного сервиса. Специфичные переменные добавляются рядом и при совпадении имени перезаписывают общие.

Альтернативный подход — общий env-файл:

services:
  api:
    env_file:
      - .env.common    # общие для всех
      - .env.api       # специфичные для api

  worker:
    env_file:
      - .env.common
      - .env.worker

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

Для более сложных случаев — несколько override-файлов с явным указанием:

docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Healthcheck, restart и logging

Три вещи, без которых production на Compose быстро становится непредсказуемым.

Healthcheck

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

Хороший healthcheck:

  • проверяет реальную работоспособность (HTTP-эндпоинт, pg_isready), а не просто true;
  • имеет разумный start_period — время на инициализацию до первой проверки;
  • не слишком частый (interval: 10-30s) — чтобы не создавать лишнюю нагрузку;
  • быстро таймаутится (timeout: 3-5s) — зависший healthcheck хуже, чем отсутствующий.

Restart policy

Для production почти всегда нужен restart: unless-stopped:

  • контейнер перезапускается при падении и после перезагрузки хоста;
  • не перезапускается, если явно остановлен (docker compose stop);
  • restart: always — перезапускает даже явно остановленные, обычно не нужно;
  • restart: on-failure — не переживёт reboot хоста.

Logging

По умолчанию Docker пишет логи в JSON-файлы без ограничения размера. На production это означает: через месяц диск заполнен.

logging:
  driver: json-file
  options:
    max-size: "10m"    # ротация при 10 МБ
    max-file: "5"      # хранить 5 файлов

Для централизованного сбора — драйвер fluentd, gelf или syslog. Но json-file с ротацией — минимально необходимый уровень.

Реплики, общие переменные и abort-on-exit

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

Реплики сервисов

Самый надёжный и явно документированный способ поднять несколько экземпляров сервиса в Compose — масштабирование через CLI:

docker compose up -d --scale worker=3

В compose-файле при этом остаётся обычное описание сервиса:

services:
  worker:
    image: registry.example.com/myapp/worker:${APP_VERSION}
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 256M

Compose создаст три контейнера: worker-1, worker-2, worker-3.

deploy.replicas часто всплывает в примерах и в некоторых версиях Compose V2 может работать ожидаемо, но как production-baseline лучше опираться на явно документированный сценарий через --scale. Так меньше риска привязаться к особенностям конкретной версии CLI.

Важные ограничения:

  • Порты — нельзя пробрасывать фиксированный host-порт, если реплик больше одной. Используйте expose вместо ports и обращайтесь к сервису через внутреннюю сеть Docker.
  • Балансировка — Docker встроенно балансирует по DNS round-robin при обращении по имени сервиса. Для HTTP-трафика лучше поставить reverse proxy.
  • Volumes — все реплики разделяют одни и те же volumes. Если сервис пишет в файлы, убедитесь, что конкурентный доступ безопасен.
  • Масштабирование на летуdocker compose up -d --scale worker=5 меняет количество реплик без перезапуска остальных сервисов.

Реплики в Compose подходят для stateless-воркеров (обработка очередей, фоновые задачи). Для stateful-сервисов (базы данных) — нет.

Abort-on-exit: завершение стека при падении сервиса

Флаг --abort-on-container-exit останавливает все сервисы, если хотя бы один контейнер завершился:

docker compose up --abort-on-container-exit

Есть также --exit-code-from, который дополнительно возвращает код выхода конкретного сервиса:

docker compose up --abort-on-container-exit --exit-code-from tests

Где это полезно:

  • Тестирование и CI — основной сценарий. Поднять стек (приложение + база + тестовый раннер), дождаться завершения тестов, остановить всё. --exit-code-from tests позволяет CI определить, прошли тесты или нет.
  • Одноразовые задачи — миграции, импорт данных, сид базы. Стек поднимается, задача выполняется, всё завершается.

Где это не подходит:

  • Production — в production контейнеры должны работать постоянно. Если один упал, restart: unless-stopped перезапустит его. --abort-on-container-exit остановит весь стек, что прямо противоположно желаемому.
  • Флаг несовместим с -d (detach) — он предполагает foreground-запуск.

Короткий ответ: --abort-on-container-exit — это для CI и тестирования, не для production.

Volumes и резервное копирование

Именованные volumes (pgdata:, static:) хранят данные отдельно от контейнера. При docker compose down данные сохраняются.

Осторожно с -v: команда docker compose down -v удаляет все именованные volumes проекта — включая данные базы. Это необратимая операция. На production использовать только осознанно и после бэкапа.

Бэкап PostgreSQL

Самый надёжный способ — pg_dump по cron:

#!/bin/bash
set -euo pipefail

BACKUP_DIR="/opt/backups/postgres"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

mkdir -p "$BACKUP_DIR"

docker compose exec -T postgres \
  pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
  | gzip > "$BACKUP_DIR/db_${TIMESTAMP}.gz"

# Удаляем бэкапы старше 14 дней
find "$BACKUP_DIR" -name "db_*.gz" -mtime +14 -delete
0 3 * * * /opt/project/backup.sh >> /var/log/backup.log 2>&1

Для критичных данных — дополнительно отправка в S3:

aws s3 cp "$BACKUP_DIR/db_${TIMESTAMP}.gz" \
  s3://backups/postgres/db_${TIMESTAMP}.gz

Бэкап volumes

Для volumes, которые нельзя забэкапить через приложение (файлы, медиа):

docker run --rm \
  -v myproject_static:/data:ro \
  -v /opt/backups:/backup \
  alpine tar czf /backup/static_$(date +%Y%m%d).tar.gz -C /data .

Reverse proxy: внешняя точка входа

В production Compose-стеке reverse proxy решает несколько задач:

  • TLS-терминация — сертификаты Let’s Encrypt;
  • маршрутизация — разные домены/пути на разные сервисы;
  • статика — раздача файлов без нагрузки на приложение;
  • кеширование — Cache-Control, ETag;
  • защита — rate limiting, заголовки безопасности.

Типичная связка: Angie (или Nginx) как первый контейнер в compose-стеке, проксирует запросы к приложению по внутренней сети Docker.

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/angie/certs/fullchain.pem;
    ssl_certificate_key /etc/angie/certs/privkey.pem;

    # Docker DNS — чтобы Nginx перерезолвил IP при перезапуске контейнера.
    # Без этого пересоздание api приведёт к 502 (подробнее — в разделе DNS и IP).
    resolver 127.0.0.11 valid=10s;

    # Статика
    location /static/ {
        root /var/www/html;
        expires 7d;
        add_header Cache-Control "public, immutable";
    }

    # API — переменная $backend заставляет Nginx резолвить DNS при каждом запросе
    location /api/ {
        set $backend "http://api:8080";
        proxy_pass $backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Всё остальное — приложение
    location / {
        set $backend_root "http://api:8080";
        proxy_pass $backend_root;
    }
}

Для автоматического обновления сертификатов — certbot в отдельном контейнере или cron на хосте.

DNS, IP и перезапуск отдельных сервисов

Перезапуск одного сервиса без остановки остальных

Compose позволяет оперировать отдельными сервисами — не обязательно пересоздавать весь стек:

# Перезапуск одного сервиса (stop + start)
docker compose restart api

# Пересоздание с новым образом (если образ обновился)
docker compose up -d api

# Остановить один сервис
docker compose stop worker

# Посмотреть логи одного сервиса
docker compose logs -f api

Разница между restart и up -d:

  • restart — останавливает и запускает тот же контейнер. Не подхватывает изменения в compose-файле или новый образ. Быстрый, но «тупой».
  • up -d — пересоздаёт контейнер, если что-то изменилось (образ, конфигурация, переменные окружения). Если ничего не изменилось — не трогает контейнер. Это безопасная операция: Compose сам решает, что пересоздать.

На практике для деплоя почти всегда используется docker compose up -d <сервис>, а не restart.

Что происходит с IP при перезапуске

Когда контейнер пересоздаётся, Docker назначает ему новый IP-адрес в bridge-сети. Это ожидаемое поведение — и потенциальная проблема для reverse proxy.

Как Nginx/Angie резолвит upstream:

По умолчанию Nginx резолвит DNS-имена в upstream блоке один раз — при старте или reload. Если после этого контейнер api пересоздался и получил новый IP, Nginx продолжит отправлять запросы на старый адрес. Результат — 502 Bad Gateway.

Решение 1: переменная в proxy_pass (рекомендуемый)

Если вынести имя хоста в переменную, Nginx начнёт резолвить его при каждом запросе:

server {
    # Указываем Docker DNS-резолвер
    resolver 127.0.0.11 valid=10s;

    location /api/ {
        set $backend "http://api:8080";
        proxy_pass $backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Ключевые моменты:

  • resolver 127.0.0.11 — встроенный DNS-сервер Docker. Он знает актуальные IP всех контейнеров в сети.
  • valid=10s — кешировать DNS-ответ не дольше 10 секунд. После этого Nginx сделает новый DNS-запрос и получит актуальный IP.
  • set $backend — именно использование переменной заставляет Nginx перерезолвить адрес. Без переменной proxy_pass http://api:8080 резолвится один раз при загрузке конфига.

Решение 2: reload после пересоздания

Добавить docker compose exec angie angie -s reload в скрипт деплоя после пересоздания сервиса:

docker compose up -d api
sleep 5
docker compose exec angie angie -s reload

Это проще, но ручное — и не спасает при автоматическом перезапуске через restart: unless-stopped.

Решение 3: явный reload или пересоздание обоих сервисов

Если по каким-то причинам не хочется использовать resolver + переменную, можно хотя бы запускать up сразу для приложения и reverse proxy:

docker compose up -d api angie

Это полезно как грубый operational workaround, но важно понимать ограничение: Compose пересоздаст api, а angie тронет только если у него самого что-то изменилось. То есть это не полноценная замена динамическому DNS-resolve. Если reverse proxy не был пересоздан или перезагружен, проблема со старым IP может остаться.

Рекомендация: используйте решение 1 (resolver + переменная) — это единственный подход, который работает автоматически и при ручном деплое, и при автоматическом рестарте после падения.

Процесс деплоя

ssh + docker compose pull + docker compose up -d — это минимум, который работает. Но его стоит оформить в воспроизводимый процесс.

Вариант 1: скрипт на хосте

#!/bin/bash
set -euo pipefail

cd /opt/project

# Запомнить текущую версию для отката
PREV_VERSION=$(grep APP_VERSION .env | cut -d= -f2)

# Обновить версию
sed -i "s/APP_VERSION=.*/APP_VERSION=$1/" .env

# Обновить образы и перезапустить
docker compose pull api
docker compose up -d api

# Проверить здоровье
sleep 10
if ! docker compose exec -T api wget -qO- http://localhost:8080/healthz; then
    echo "Health check failed, rolling back to $PREV_VERSION"
    sed -i "s/APP_VERSION=.*/APP_VERSION=$PREV_VERSION/" .env
    docker compose pull api
    docker compose up -d api
    exit 1
fi

echo "Deployed $1 successfully"

Вариант 2: Ansible

Для нескольких хостов или более строгого процесса — Ansible-плейбук:

- name: Deploy application
  hosts: production
  vars:
    app_version: "{{ version }}"
    project_dir: /opt/project

  tasks:
    - name: Update APP_VERSION in .env
      lineinfile:
        path: "{{ project_dir }}/.env"
        regexp: '^APP_VERSION='
        line: "APP_VERSION={{ app_version }}"

    - name: Pull new images
      community.docker.docker_compose_v2:
        project_src: "{{ project_dir }}"
        pull: always
        state: present
        services:
          - api

    - name: Wait for health check
      uri:
        url: "http://localhost:8080/healthz"
        status_code: 200
      retries: 6
      delay: 5

Вариант 3: CI/CD

GitHub Actions (или аналог) → self-hosted runner на сервере:

name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - run: docker compose build api
      - run: docker compose push api
      - run: sed -i "s/APP_VERSION=.*/APP_VERSION=${GITHUB_SHA}/" .env
      - run: docker compose pull api
      - run: docker compose up -d api
      - run: sleep 10 && curl -f http://localhost:8080/healthz

Ключевое: какой бы вариант вы ни выбрали, деплой должен быть воспроизводимым (запустил скрипт/команду — получил результат) и откатываемым (вернул предыдущую версию образа — вернулся к рабочему состоянию).

Мониторинг и наблюдаемость

Compose-стек без мониторинга — это закрытые глаза. Минимальный набор:

Healthcheck + alerts

Внешний мониторинг (UptimeRobot, Healthchecks.io или свой) проверяет HTTP-эндпоинт раз в минуту. Если не отвечает — алерт в Telegram/email.

Метрики (опционально, но полезно)

Добавить в compose-стек:

prometheus:
    image: prom/prometheus:v2.52.0
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    restart: unless-stopped

  grafana:
    image: grafana/grafana:11.0.0
    volumes:
      - grafana_data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
    restart: unless-stopped

Это добавляет два контейнера, но даёт видимость: CPU, память, запросы, latency — всё на одном дашборде.

Логи

Для начала достаточно docker compose logs -f --tail=100 api. Для более серьёзного подхода — Loki + Promtail или Vector, собирающий JSON-логи контейнеров.

Когда Compose уже не тянет

Признаки того, что пора смотреть дальше:

  • Много хостов — Compose работает в рамках одного хоста. Docker Swarm или Kubernetes нужны для распределённого деплоя.
  • Сложные rollout-стратегии — blue-green, canary, progressive delivery. В Compose это ручная работа.
  • Автоматический scaling — Compose не масштабирует контейнеры по нагрузке.
  • Self-healingrestart: unless-stopped перезапускает упавший контейнер, но не переносит его на другой хост и не пересоздаёт при зависании.
  • Слишком много ручного знания — если деплой требует помнить 15 шагов и работает только у одного человека, процесс сломан.
  • Секреты — Compose не управляет секретами (Docker Swarm имеет docker secret, Kubernetes — Secrets/Vault). На одном хосте env-файлы терпимы, но при масштабировании — нет.
  • Сетевые политики — если нужна изоляция между сервисами на уровне сети (network policies), Compose не даёт такого контроля.

Путь обычно выглядит так: Compose → Docker Swarm (если нужен минимальный оркестратор) → Kubernetes (если нужна полноценная платформа). Каждый шаг добавляет возможности и сложность.

Checklist: production-готовность compose-стека

Перед тем как считать compose-стек production-ready, проверьте:

  1. У всех сервисов есть healthcheck?
  2. Restart policy установлена (unless-stopped или on-failure)?
  3. Logging ограничен по размеру (max-size, max-file)?
  4. Секреты вынесены в env-файлы, которые не попадают в Git?
  5. Есть .env.example в репозитории?
  6. Бэкап базы настроен по cron и проверен восстановлением?
  7. Reverse proxy терминирует TLS и проксирует запросы?
  8. Reverse proxy использует resolver + переменную для динамического DNS (не сломается при перезапуске сервиса)?
  9. Деплой воспроизводим (скрипт, Ansible или CI/CD)?
  10. Есть процедура отката?
  11. Есть внешний мониторинг (хотя бы healthcheck + алерт)?
  12. Volumes с данными именованные и не удаляются при down?
  13. Образы берутся из registry с конкретными тегами, а не latest?

Если на половину вопросов ответ «нет» — стек ещё не production, даже если он уже работает.

Обсуждение в Telegram

Присоединиться →

Комментарии