Docker Compose: оркестрация для одного сервера
Docker Compose часто воспринимают как инструмент для локальной разработки — поднял docker compose up, поработал, снёс. Но на практике Compose отлично справляется с продакшен-деплоем на одном сервере. Не каждому проекту нужен Kubernetes. Если у вас один VPS и десяток сервисов — Compose закрывает 90% задач оркестрации. Надо только знать, какие рычаги у него есть.
Зависимости и healthcheck-и
Самая распространённая проблема — порядок запуска. Приложение стартует раньше базы данных, ловит connection refused, падает. depends_on без условий гарантирует только порядок запуска контейнеров, но не готовность сервиса внутри.
Правильный подход — depends_on с condition:
services:
api:
image: myapp:latest
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
migrations:
image: myapp:latest
command: ["alembic", "upgrade", "head"]
depends_on:
db:
condition: service_healthy Здесь api не запустится, пока PostgreSQL не пройдёт pg_isready, Redis не ответит на ping, а миграции не завершатся успешно. start_period даёт базе время на инициализацию — healthcheck-и в этот период не считаются за fail.
Паттерн с service_completed_successfully — мощная штука. Миграции, seed-данные, генерация конфигов — всё это можно вынести в отдельные сервисы, которые запускаются один раз и завершаются.
Тома: named vs bind vs tmpfs
Docker Compose поддерживает три типа монтирования, и каждый нужен для своего:
services:
app:
volumes:
# Bind mount — код для hot reload (dev)
- ./src:/app/src
# Named volume — персистентные данные (prod)
- app_uploads:/app/uploads
# tmpfs — временные файлы в RAM (кэш, сессии)
- type: tmpfs
target: /tmp
tmpfs:
size: 100M
db:
volumes:
# Named volume с указанием драйвера
- pg_data:/var/lib/postgresql/data
# Init-скрипт (read-only bind mount)
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
volumes:
app_uploads:
pg_data:
driver: local
driver_opts:
type: none
device: /srv/postgres-data
o: bind Последний пример — named volume, привязанный к конкретной директории на хосте. Это даёт контроль над расположением данных (удобно для бэкапов), но с семантикой named volume (Docker управляет жизненным циклом).
Переменные окружения и .env
Жёстко вбивать пароли в compose-файл — очевидный антипаттерн. Docker Compose автоматически загружает .env файл из текущей директории:
# .env
POSTGRES_PASSWORD=supersecret
REDIS_PASSWORD=anothersecret
APP_VERSION=1.4.2 services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
api:
image: myapp:${APP_VERSION}
env_file:
- .env
- .env.local # override для локальных настроек env_file загружает переменные внутрь контейнера, а ${VAR} подставляет значения в сам compose-файл. Это разные механизмы — не путайте. Для продакшена храните .env вне репозитория и деплойте отдельно (через Ansible, например).
Профили: dev и prod в одном файле
Вместо поддержки двух отдельных compose-файлов удобнее использовать профили:
services:
api:
image: myapp:${APP_VERSION}
ports:
- "8000:8000"
db:
image: postgres:16-alpine
volumes:
- pg_data:/var/lib/postgresql/data
# Только для разработки
adminer:
image: adminer
ports:
- "8080:8080"
profiles:
- dev
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025"
- "8025:8025"
profiles:
- dev
# Только для продакшена
backup:
image: prodrigestivill/postgres-backup-local
volumes:
- ./backups:/backups
environment:
POSTGRES_HOST: db
SCHEDULE: "@daily"
profiles:
- prod
volumes:
pg_data: # Для разработки — поднимаем dev-профиль
docker compose --profile dev up
# Для продакшена
docker compose --profile prod up -d
# Без профиля — только api и db
docker compose up -d Сервисы без profiles запускаются всегда. С указанным профилем — только при явной активации. Чисто и без дублирования.
Ресурсные лимиты и restart policies
В продакшене обязательно ограничивайте ресурсы. Один утекающий по памяти сервис не должен ронять весь сервер:
services:
api:
image: myapp:latest
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
reservations:
memory: 256M
cpus: "0.25"
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
window: 120s
# Или короткая форма для restart
restart: unless-stopped reservations — гарантированный минимум ресурсов. limits — потолок. Если контейнер превысит лимит по памяти, Docker его убьёт (OOMKill). По CPU — просто тротлинг.
restart: unless-stopped — контейнер перезапускается автоматически при падении и после ребута сервера (если не остановлен вручную). Для продакшена — must have.
Сети между compose-проектами
Типичная ситуация: у вас отдельные compose-файлы для разных проектов, и одному нужно обратиться к другому. Решение — общая внешняя сеть:
docker network create infra-net # Проект 1: traefik/docker-compose.yml
services:
traefik:
image: traefik:v3.0
networks:
- infra-net
ports:
- "80:80"
- "443:443"
networks:
infra-net:
external: true # Проект 2: myapp/docker-compose.yml
services:
api:
image: myapp:latest
networks:
- default
- infra-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
networks:
infra-net:
external: true Traefik видит контейнеры из других compose-проектов через общую infra-net и маршрутизирует трафик на основе лейблов. Каждый проект при этом сохраняет свою изолированную default-сеть для внутренних коммуникаций.
Реальный пример: full-stack приложение
Собираем всё вместе. FastAPI-бэкенд + PostgreSQL + Redis + Nginx в качестве reverse proxy:
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
- static_files:/var/www/static:ro
depends_on:
api:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
memory: 128M
api:
build:
context: ./backend
dockerfile: Dockerfile
environment:
DATABASE_URL: postgresql+asyncpg://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY}
volumes:
- static_files:/app/static
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
migrations:
build:
context: ./backend
dockerfile: Dockerfile
command: ["alembic", "upgrade", "head"]
environment:
DATABASE_URL: postgresql+asyncpg://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
volumes:
- pg_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
deploy:
resources:
limits:
memory: 1G
redis:
image: redis:7-alpine
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
deploy:
resources:
limits:
memory: 192M
volumes:
pg_data:
redis_data:
static_files: Этот стек покрывает типичный веб-проект: Nginx терминирует TLS и раздаёт статику, API обрабатывает бизнес-логику, PostgreSQL хранит данные, Redis кэширует горячие запросы. Миграции выполняются автоматически при деплое. Все сервисы с healthcheck-ами и лимитами ресурсов.
Полезные паттерны
Sidecar-контейнер для логов или метрик:
services:
api:
image: myapp:latest
volumes:
- app_logs:/var/log/app
log-shipper:
image: grafana/promtail:latest
volumes:
- app_logs:/var/log/app:ro
- ./promtail.yml:/etc/promtail/config.yml:ro Общий init-контейнер для подготовки данных:
services:
init:
image: myapp:latest
command: ["./prepare-data.sh"]
volumes:
- shared_data:/data
worker-1:
image: myapp:latest
command: ["./process.sh", "--shard=1"]
volumes:
- shared_data:/data:ro
depends_on:
init:
condition: service_completed_successfully Итог
Docker Compose — это полноценный инструмент оркестрации для single-node деплоя. Healthcheck-и, профили, ресурсные лимиты, внешние сети — всё это позволяет строить надёжные production-ready стеки без Kubernetes. Для большинства проектов на одном сервере этого более чем достаточно. А когда перерастёте — у вас уже будет хорошее понимание концепций, которые напрямую транслируются в Kubernetes.