Поделиться
Поделиться

Docker Compose — стандарт де-факто для локальной разработки. Но большинство команд останавливаются на базовом docker-compose.yml, не используя и половины возможностей инструмента. В этой статье построим полноценный production-like стек и разберём тонкости, которые экономят часы отладки.

Полный многосервисный стек

Стартовая конфигурация для типичного веб-приложения: API + PostgreSQL + Redis + воркер задач.

# docker-compose.yml — базовая конфигурация (общая для dev и prod)
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: development          # multi-stage build
    ports:
      - "3000:3000"
    env_file:
      - .env
    environment:
      DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      REDIS_URL: redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy  # ждём готовности, не просто старта
      redis:
        condition: service_healthy
    networks:
      - backend

  worker:
    build:
      context: .
      dockerfile: Dockerfile
      target: development
    command: node dist/worker.js
    env_file:
      - .env
    environment:
      DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      REDIS_URL: redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-myapp}
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro   # init-скрипты
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d ${POSTGRES_DB:-myapp}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s
    networks:
      - backend

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-}
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD:-}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - backend

volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local

networks:
  backend:
    driver: bridge

Override-файлы: dev vs production

Разделение конфигурации через override-файлы — ключевой паттерн. Базовый docker-compose.yml содержит общее, а переопределения — среду-специфичное.

# docker-compose.override.yml — автоматически применяется в dev
# docker compose up = docker compose -f docker-compose.yml -f docker-compose.override.yml up

services:
  api:
    volumes:
      - .:/app                    # монтируем исходный код для hot reload
      - /app/node_modules         # исключаем node_modules из монтирования
    command: npm run dev          # переопределяем команду
    environment:
      NODE_ENV: development
      LOG_LEVEL: debug

  worker:
    volumes:
      - .:/app
      - /app/node_modules
    command: npm run worker:dev

  # В dev добавляем pgAdmin для удобства
  pgadmin:
    image: dpage/pgadmin4:latest
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@local.dev
      PGADMIN_DEFAULT_PASSWORD: admin
    ports:
      - "5050:80"
    depends_on:
      - postgres
    networks:
      - backend

  # Redis Insight для мониторинга Redis
  redis-insight:
    image: redis/redisinsight:latest
    ports:
      - "5540:5540"
    networks:
      - backend
# docker-compose.prod.yml — для production-like тестирования
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up

services:
  api:
    build:
      target: production           # финальный stage без dev-зависимостей
    restart: unless-stopped
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
    environment:
      NODE_ENV: production
      LOG_LEVEL: info

  worker:
    build:
      target: production
    restart: unless-stopped

  postgres:
    restart: unless-stopped

  redis:
    restart: unless-stopped

Именованные volumes: не теряем данные

Анонимные volumes (- /var/lib/postgresql/data) привязаны к контейнеру и удаляются вместе с ним. Именованные volumes существуют независимо.

# Полезные команды для работы с volumes
docker volume ls                          # список volumes
docker volume inspect myapp_postgres_data # детали volume
docker volume rm myapp_postgres_data      # удалить (осторожно!)

# Бэкап volume
docker run --rm \
  -v myapp_postgres_data:/data \
  -v $(pwd)/backup:/backup \
  alpine tar czf /backup/postgres-backup-$(date +%Y%m%d).tar.gz -C /data .

# Восстановление
docker run --rm \
  -v myapp_postgres_data:/data \
  -v $(pwd)/backup:/backup \
  alpine tar xzf /backup/postgres-backup-20260819.tar.gz -C /data

Healthchecks: правильное ожидание готовности

depends_on без condition: service_healthy только гарантирует старт контейнера, не его готовность. Postgres может запускаться 10–30 секунд — за это время API упадёт с ошибкой подключения.

# Продвинутый healthcheck для API (проверяем /health endpoint)
services:
  api:
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s    # даём время на запуск прежде чем считать неудачи

  nginx:
    depends_on:
      api:
        condition: service_healthy   # nginx стартует только после готовности API
// src/routes/health.ts — минимальный healthcheck endpoint
app.get("/health", async (req, reply) => {
  try {
    // Проверяем критичные зависимости
    await db.$queryRaw`SELECT 1`;
    await redis.ping();
    
    reply.send({
      status: "ok",
      timestamp: new Date().toISOString(),
      uptime: process.uptime()
    });
  } catch (err) {
    reply.status(503).send({
      status: "unhealthy",
      error: (err as Error).message
    });
  }
});

Управление секретами

Вариант 1: .env файлы (dev-окружение)

# .env (не коммитим в git!)
POSTGRES_PASSWORD=dev_password_only
POSTGRES_DB=myapp_dev
REDIS_PASSWORD=
JWT_SECRET=dev-jwt-secret-replace-in-prod

# .env.example (коммитим, без реальных значений)
POSTGRES_PASSWORD=
POSTGRES_DB=myapp
REDIS_PASSWORD=
JWT_SECRET=

Вариант 2: Docker Secrets (production)

# docker-compose.prod.yml с секретами
services:
  api:
    secrets:
      - db_password
      - jwt_secret
    environment:
      # Приложение читает из /run/secrets/<name>
      DB_PASSWORD_FILE: /run/secrets/db_password
      JWT_SECRET_FILE: /run/secrets/jwt_secret

secrets:
  db_password:
    file: ./secrets/db_password.txt    # файл с паролем, не в git
  jwt_secret:
    external: true                      # из Docker Swarm secrets store
// Чтение секретов из файлов (Docker Secrets паттерн)
import { readFileSync } from "fs";

function readSecret(name: string, envKey: string): string {
  const filePath = process.env[`${envKey}_FILE`];
  if (filePath) {
    return readFileSync(filePath, "utf-8").trim();
  }
  const value = process.env[envKey];
  if (!value) throw new Error(`Secret ${envKey} not configured`);
  return value;
}

const dbPassword = readSecret("db_password", "DB_PASSWORD");
const jwtSecret = readSecret("jwt_secret", "JWT_SECRET");

Типичные ловушки

Проблема с правами файлов

Контейнер пишет файлы от имени root, хост не может их удалить.

# Решение: указываем пользователя
services:
  api:
    user: "1000:1000"   # uid:gid, совпадающие с пользователем хоста
# В Dockerfile: создаём non-root пользователя
FROM node:22-alpine AS development
RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -S appuser
USER appuser
WORKDIR /app

DNS-проблемы между сервисами

Сервисы обращаются друг к другу по имени сервиса, а не по localhost.

// Неправильно (внутри контейнера)
const DATABASE_URL = "postgresql://postgres@localhost:5432/myapp";

// Правильно
const DATABASE_URL = "postgresql://postgres@postgres:5432/myapp";
//                                            ^^^^^^^^ имя сервиса в compose

Долгий rebuild при изменении зависимостей

# Плохо: COPY . . — любое изменение инвалидирует кеш node_modules
COPY . .
RUN npm install

# Хорошо: сначала только package.json, install, потом остальное
COPY package*.json ./
RUN npm ci --only=production
COPY . .

Потеря логов при рестарте

services:
  api:
    logging:
      driver: "json-file"
      options:
        max-size: "100m"    # ротация логов
        max-file: "5"       # 5 файлов максимум

Полезные команды

# Запуск только нужных сервисов
docker compose up postgres redis -d

# Rebuild конкретного сервиса
docker compose build --no-cache api

# Выполнить команду внутри запущенного контейнера
docker compose exec postgres psql -U postgres myapp

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

# Статус healthchecks
docker compose ps

# Полная очистка (volumes тоже!)
docker compose down -v --remove-orphans

# Посмотреть финальный конфиг (с override)
docker compose config

Итог

Docker Compose с override-файлами, healthchecks и named volumes — это не «просто локальная разработка», а полноценный production-like стек. Правильная структура экономит часы при отладке проблем «у меня работает, на сервере нет».

Следующий шаг — настройка CI/CD пайплайна поверх этой конфигурации: смотрите GitLab CI/CD pipeline. Про оптимизацию самих Dockerfile — Docker: оптимизация образов.