Docker Compose в продакшене: что не так и где аккуратно
Docker Compose часто используют как «продакшен-оркестратор для бедных»: один VPS, пара сервисов, простой docker compose up -d и пошёл. Я через это прошёл и сейчас на части проектов от Compose принципиально отказался, на части — оставил с оговорками. Расскажу, что именно у меня болело и как я с этим живу.
Сразу скажу: Compose — нормальный инструмент. Если ты понимаешь его ограничения, его можно использовать в проде. Но «по умолчанию» он не делает того, что нужно от прод-окружения, и эти пробелы ловятся в самые неудобные моменты.
Что не так с Compose в проде
1. Деплой = downtime
Базовый сценарий docker compose up -d для изменённого сервиса:
- Compose останавливает старый контейнер.
- Запускает новый с обновлённым образом.
- Между этими шагами сервис недоступен.
На простом блоге это терпимо. На API, который обслуживает фронт — пользователи получают 502 на пару секунд. Без отдельного балансировщика и health-check downtime не убрать.
Что я обычно делаю:
- На простых сервисах — оставляю downtime, признаю его, делаю деплой ночью.
- На критичных — поднимаю blue/green-схему: два набора контейнеров, перед ними nginx, который переключает upstream.
2. Health checks — формальность
Compose умеет healthcheck, но при up -d по умолчанию не ждёт, пока новый контейнер станет здоровым. Опция --wait помогает, но и она не делает rolling update — она просто ждёт.
services:
api:
image: myapp/api:1.2.3
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20sБез healthcheck Compose считает, что контейнер запущен в момент, когда внутри запустился процесс. Долгий старт Node-приложения с подключением к БД на 10–15 секунд означает, что nginx уже отправляет запросы туда, куда не следует.
3. Секреты лежат текстом в .env
Большинство Compose-сетапов хранят DATABASE_URL, JWT_SECRET и прочее в файле .env. Этот файл часто оказывается в git-репозитории, на резервном дампе диска, в чьём-то домашнем каталоге. Compose поддерживает secrets через файлы, но мало кто этим пользуется на одиночном VPS.
Минимум, что я делаю:
.envна сервере с правами 600 для пользователя, под которым работает Docker.- В git — только
.env.exampleс заглушками. - На больших проектах — Vault или хранилище секретов хостера.
4. Логи без ротации
По умолчанию Docker пишет логи в JSON-файлы и не ограничивает их размер. На многословном Node-приложении это означает, что через месяц у тебя 50 GB логов в /var/lib/docker/containers/. Лимиты ставятся в Compose:
services:
api:
logging:
driver: json-file
options:
max-size: '50m'
max-file: '5'Или глобально в /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "5"
}
}Без этого первый «странный сервер тормозит» через полгода — это диск, забитый логами.
5. Сеть и порты
Compose открывает порты через ports: - "3000:3000". Это значит, что Docker сам обходит iptables — твои настройки UFW его не остановят. Если ты не хочешь, чтобы порт был открыт во внешний мир, надо явно указывать localhost:
ports:
- "127.0.0.1:3000:3000"Меня эта особенность ловила дважды. Один раз обнаружил публичный 5432 на VPS, потому что забыл префикс 127.0.0.1:.
6. Переменные окружения и .env
Compose читает файл .env в текущем каталоге как переменные подстановки в YAML. Это удобно, но ровно один раз больно ловишь, когда переменная в YAML и переменная внутри контейнера — это разные вещи. environment: в сервисе — это переменные внутри. $$ и ${VAR} в YAML — это переменные на уровне Compose. Путаются часто.
7. restart: always vs systemd
Compose умеет рестартить контейнеры. systemd тоже умеет. На одном VPS это часто пересекается. Я выбираю restart: unless-stopped в Compose и не оборачиваю Compose в systemd — если очень нужно, делаю один compose@.service:
[Unit]
Description=Compose stack %i
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/stacks/%i
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.targetЭто даёт чистый старт стека после reboot и контролируемый compose down.
Когда Compose в проде нормальный
Я готов оставить Compose как продовое решение, если все пункты выполнены:
- Один VPS, не нужен failover.
- 2–5 сервисов, не больше.
- Допустим короткий downtime при деплое.
- Команда понимает, как смотреть логи и состояние контейнеров.
- Бэкапы данных делаются вне Compose (БД через managed или отдельный
pg_dump).
В таких условиях Compose — отличный лёгкий вариант. Не нужно ставить kubernetes ради 5 контейнеров.
Когда Compose уже не годится
Я перехожу на что-то другое, когда:
- Нужен zero-downtime deploy. Вариант — Docker Swarm, k3s или Nomad. Все три позволяют rolling update без танцев с blue/green.
- Нужно несколько серверов и автоматика «один упал — нагрузка перешла на другой». Compose так не умеет в принципе.
- Команда выросла, и хочется единого UI/CLI для деплоя десятков сервисов.
Дополнительно есть Coolify и Dokploy — open-source инструменты, которые поверх Compose дают удобный UI. Они закрывают часть болячек выше: автогенерация nginx, ACME, базовая ротация. Если хочется нагрузку «между голым Compose и кубом» — стоит посмотреть.
Шаблон Compose, который не стыдно показать
services:
api:
image: registry.example.ru/myapp/api:${API_VERSION}
restart: unless-stopped
env_file: ./api.env
networks: [internal]
expose: ["3000"]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
logging:
driver: json-file
options:
max-size: '50m'
max-file: '5'
deploy:
resources:
limits:
memory: 1g
cpus: '1'
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
api:
condition: service_healthy
networks: [internal]
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- /var/www/certbot:/var/www/certbot:ro
logging:
driver: json-file
options:
max-size: '50m'
max-file: '5'
networks:
internal:
driver: bridgeЧто важно:
exposeвместоportsдля api — порт открыт только внутри Compose-сети, наружу его не видно.depends_on: condition: service_healthy— nginx стартует только после готовности api.deploy.resources.limits— ограничения по памяти и CPU. Без этого один процесс с утечкой может прижать весь VPS.- Логирование настроено явно у каждого сервиса.
Деплой
Простейший деплой-скрипт, который у меня лежит в CI:
#!/bin/bash
set -e
VERSION=$(git rev-parse --short HEAD)
docker build -t registry.example.ru/myapp/api:${VERSION} .
docker push registry.example.ru/myapp/api:${VERSION}
ssh deploy@srv bash <<EOF
set -e
cd /opt/stacks/myapp
export API_VERSION=${VERSION}
docker compose pull api
docker compose up -d --no-deps api
docker image prune -f
EOF--no-deps важно: не трогаем nginx ради обновления только api. Image prune убирает старые образы, иначе диск кончается.
Если нужен zero-downtime, схема усложняется: запускаем два сервиса api-blue и api-green, переключаем upstream nginx через include + reload. На Compose это делается, но каждый шаг — твой шаг. На k3s/Nomad rolling update встроен.
Резюме
- Compose в проде работает, но требует осознанной настройки: лимиты, health, логи, secrets, restart-policy.
- Без отдельного балансировщика zero-downtime не получится.
- Логи и сеть — самые частые источники головной боли. Лимит логов и
127.0.0.1:в портах ставь сразу. - На сложных проектах смотри в сторону Swarm/k3s/Nomad. Лучше тот, который команда понимает.
Compose — это не замена оркестратору. Это удобный способ запускать связку контейнеров на одной машине. Когда понимаешь это с самого начала, его можно использовать долго и не страдать. Главное — настроить шероховатости, чтобы прод-окружение не сломалось от первого же чиха.