Docker Compose часто воспринимают как инструмент для локальной разработки — поднял стек, потестировал, выключил. Но для проектов с одним-двумя хостами и небольшим набором сервисов Compose вполне рабочий вариант и для production. Не потому что он лучше Kubernetes, а потому что Kubernetes для такой нагрузки — это пушка по воробьям.
Проблема не в самом Compose, а в том, что вокруг него обычно ничего не выстроено. Нет процесса обновления, нет мониторинга, нет отката. docker compose up -d по SSH — это не деплой, это ручная операция, которая ломается на третий раз.
В этой статье разберём, когда Compose остаётся нормальным выбором, что обязательно добавить вокруг него и в какой момент стоит смотреть дальше.
В статье
- Когда Compose — это нормально
- Структура production compose-файла
- Конфигурация: env-файлы и переопределения
- Healthcheck, restart и logging
- Реплики, общие переменные и abort-on-exit
- Volumes и резервное копирование
- Reverse proxy: внешняя точка входа
- DNS, IP и перезапуск отдельных сервисов
- Процесс деплоя
- Мониторинг и наблюдаемость
- Когда Compose уже не тянет
- Checklist: production-готовность 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
Пример 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 -dHealthcheck, 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: 256MCompose создаст три контейнера: 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 -delete0 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-healing —
restart: 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, проверьте:
- У всех сервисов есть
healthcheck? - Restart policy установлена (
unless-stoppedилиon-failure)? - Logging ограничен по размеру (
max-size,max-file)? - Секреты вынесены в env-файлы, которые не попадают в Git?
- Есть
.env.exampleв репозитории? - Бэкап базы настроен по cron и проверен восстановлением?
- Reverse proxy терминирует TLS и проксирует запросы?
- Reverse proxy использует
resolver+ переменную для динамического DNS (не сломается при перезапуске сервиса)? - Деплой воспроизводим (скрипт, Ansible или CI/CD)?
- Есть процедура отката?
- Есть внешний мониторинг (хотя бы healthcheck + алерт)?
- Volumes с данными именованные и не удаляются при
down? - Образы берутся из registry с конкретными тегами, а не
latest?
Если на половину вопросов ответ «нет» — стек ещё не production, даже если он уже работает.
Комментарии