Docker Compose в production: 7 ошибок, которые мы совершили, и как их избежать
Docker Compose — это не "только для разработки". На небольших и средних проектах это вполне рабочий production-инструмент. Мы деплоили несколько проектов на VPS с Compose и набили шишки, которые теперь можно описать конкретно, с примерами.
Статья — не теория. Это список реальных ошибок, каждая из которых стоила либо потерянных данных, либо часов даунтайма, либо звонка клиента в 2 ночи.
Ошибка 1: Bind mount вместо named volume — и потеря данных при реструктуризации
Самая дорогостоящая ошибка. Bind mount выглядит так:
# ❌ Опасно для production
services:
postgres:
image: postgres:16
volumes:
- ./data/postgres:/var/lib/postgresql/data
Проблема: ./data/postgres привязан к конкретному пути на хосте. Когда мы переносили проект на новый VPS и клонировали репозиторий в другую директорию — путь изменился. Данные остались на старом сервере. Да, был бэкап. Нет, он был двухдневной давности.
Named volume управляется Docker'ом и не зависит от пути:
# ✅ Правильно
services:
postgres:
image: postgres:16
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
driver: local
Named volume живёт в /var/lib/docker/volumes/ — его не затронет переименование проектной директории. При docker compose down данные не удаляются. Только docker compose down -v удалит volumes (добавьте это в ваш чеклист вещей-которые-нельзя-запускать-в-production).
Ошибка 2: depends_on без healthcheck — и "база ещё не готова"
# ❌ depends_on не ждёт готовности сервиса
services:
app:
depends_on:
- postgres
depends_on гарантирует только что контейнер запустился, но не что PostgreSQL принял соединения. Приложение стартует, пытается подключиться, получает ECONNREFUSED и крашится. Особенно больно при первом запуске когда Postgres инициализирует кластер.
# ✅ Правильно: depends_on с condition
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
app:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
start_period — время после старта контейнера в течение которого неудачные healthcheck'и не считаются провалом. Для Postgres это важно: первый запуск инициализирует кластер (~8-15 секунд).
Ошибка 3: Секреты в docker-compose.yml
# ❌ Никогда не делайте так
environment:
DB_PASSWORD: my_super_secret_password_123
JWT_SECRET: anotherSecret
Даже если репозиторий приватный — это плохая практика. .env файл + .gitignore:
# .env (в .gitignore!)
DB_NAME=myapp_prod
DB_USER=myapp
DB_PASSWORD=<генерируйте через: openssl rand -base64 32>
JWT_SECRET=<openssl rand -base64 64>
REDIS_PASSWORD=<openssl rand -base64 32>
# docker-compose.yml
services:
postgres:
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
Запускайте docker compose --env-file .env.production up -d — разные .env файлы для staging и production. Не храните .env.production в репозитории никогда. На сервере он лежит в /opt/myapp/.env.production с правами 600.
Ошибка 4: Образ Node.js на 1.2 ГБ — multi-stage build снижает до 180 МБ
Стандартный Dockerfile без оптимизации:
# ❌ Результат: ~1.2 ГБ
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
Multi-stage build:
# ✅ Результат: ~180 МБ
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Копируем только package файлы для кэширования слоёв
COPY package*.json ./
RUN npm ci --only=production=false
COPY . .
RUN npm run build
# Удаляем dev-зависимости
RUN npm prune --production
# Stage 2: Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
# Только необходимые системные пакеты
RUN apk add --no-cache dumb-init
# Непривилегированный пользователь
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Копируем только то что нужно для запуска
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
USER nodejs
# dumb-init: правильная обработка сигналов и PID 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]
Что происходит: в builder образе остаются TypeScript, devDependencies, исходники. В runtime — только скомпилированный JS и production dependencies. node:20-alpine (~130 МБ) вместо node:20 (~1 ГБ).
dumb-init решает проблему PID 1 в контейнере: Node.js не обрабатывает SIGTERM корректно как init-процесс. С dumb-init graceful shutdown работает.
Ошибка 5: Все сервисы в одной сети — нет изоляции
По умолчанию Docker Compose создаёт одну сеть, и все контейнеры могут достучаться друг до друга. Это нарушает принцип наименьших привилегий:
# ✅ Изоляция через несколько сетей
networks:
frontend_net: # nginx ↔ app
driver: bridge
backend_net: # app ↔ postgres, app ↔ redis
driver: bridge
services:
nginx:
networks:
- frontend_net
# nginx НЕ имеет прямого доступа к postgres/redis
app:
networks:
- frontend_net
- backend_net
postgres:
networks:
- backend_net
# postgres доступен только для app, не для nginx
redis:
networks:
- backend_net
Теперь даже если атакующий взломал nginx-контейнер — он не видит postgres напрямую. Это не панацея, но это дополнительный слой защиты.
Ошибка 6: Rolling update без даунтайма
Стандартный docker compose up -d --build имеет короткий даунтайм: старый контейнер останавливается, новый стартует, nginx получает 502 пока приложение поднимается (~5-15 секунд).
Решение через nginx upstream с двумя инстансами:
# nginx/conf.d/app.conf
upstream app_backend {
server app_blue:3000;
server app_green:3000 backup;
keepalive 32;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 5s;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
}
}
services:
app_blue:
image: myapp:${VERSION:-latest}
networks: [frontend_net, backend_net]
app_green:
image: myapp:${VERSION:-latest}
networks: [frontend_net, backend_net]
Деплой-скрипт:
#!/bin/bash
NEW_VERSION=$1
# Собираем новый образ
VERSION=$NEW_VERSION docker compose build app_blue app_green
# Сначала поднимаем green (backup)
VERSION=$NEW_VERSION docker compose up -d --no-deps app_green
sleep 10
# Проверяем health
if curl -sf http://localhost:3001/health; then
# Переключаем blue на новую версию
VERSION=$NEW_VERSION docker compose up -d --no-deps app_blue
echo "Deploy successful"
else
echo "Health check failed, rolling back"
docker compose up -d --no-deps app_green # возвращаем старый
fi
Ошибка 7: Логи заполняют диск до 100%
По умолчанию Docker хранит логи без ограничений в /var/lib/docker/containers/*/. Мы несколько раз сталкивались с ситуацией когда диск забивается логами за 2-3 дня на активном проекте.
# Глобальные настройки логирования
x-logging: &default-logging
driver: json-file
options:
max-size: "50m"
max-file: "5"
# compress: "true" # для Docker >= 26
services:
app:
logging: *default-logging
postgres:
logging: *default-logging
nginx:
logging:
driver: json-file
options:
max-size: "100m" # nginx логи обычно больше
max-file: "10"
max-size: "50m" + max-file: "5" = максимум 250 МБ на сервис. Для VPS с 50 ГБ диском это разумно.
Полный production docker-compose.yml
version: "3.9"
x-logging: &default-logging
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
frontend_net:
driver: bridge
backend_net:
driver: bridge
volumes:
postgres_data:
redis_data:
nginx_certs:
services:
# ─── NGINX ──────────────────────────────────────────────
nginx:
image: nginx:1.25-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- nginx_certs:/etc/letsencrypt:ro
networks:
- frontend_net
depends_on:
app:
condition: service_healthy
logging: *default-logging
# ─── APP ────────────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
target: runtime
restart: unless-stopped
env_file: .env.production
environment:
NODE_ENV: production
PORT: 3000
networks:
- frontend_net
- backend_net
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
logging: *default-logging
# Ограничение ресурсов
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
# ─── POSTGRES ───────────────────────────────────────────
postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env.production
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
command: postgres -c config_file=/etc/postgresql/postgresql.conf
networks:
- backend_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
logging: *default-logging
# ─── REDIS ──────────────────────────────────────────────
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru --save 60 1000
volumes:
- redis_data:/data
networks:
- backend_net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
logging: *default-logging
Docker Compose — отличный инструмент для production на небольших проектах если знать эти подводные камни. Kubernetes избыточен пока у вас меньше 10 сервисов и один VPS.
Если вам нужна помощь с настройкой production-инфраструктуры для вашего проекта в Кыргызстане — команда Aunimeda занимается именно такими задачами. Посмотрите наши услуги или напишите напрямую в WhatsApp.