Назад к блогу

Docker Compose в продакшене Tirebase: почему мы не перешли на Kubernetes

Как я выстроил продакшен-инфраструктуру на Docker Compose и Kamal для команды из 2 человек — zero-downtime деплои, мониторинг, бэкапы и реальное сравнение с Kubernetes.

Docker Compose в продакшене Tirebase: почему мы не перешли на Kubernetes

Нас двое, у нас 8 проектов и около 30 контейнеров. Всё работает на Docker Compose, без Kubernetes, уже полтора года. Расскажу, почему это осознанный выбор и как мы это устроили.

Почему не Kubernetes

Kubernetes решает реальные проблемы — оркестрация сотен сервисов, автоскейлинг, self-healing. Но для команды из 2 человек и 8 проектов это избыточно:

  • Стоимость: managed K8s (EKS/GKE) — от $70/мес только за control plane. Плюс ноды, плюс балансировщики. Наш VPS стоит $40/мес.
  • Сложность: YAML-манифесты, Helm-чарты, Ingress-контроллеры, ServiceAccount, RBAC — это полноценная специализация. У нас нет выделенного DevOps.
  • Оверхед: etcd, kube-proxy, CoreDNS, контроллеры — всё это съедает ресурсы. На сервере с 4 GB RAM это ощутимо.
  • Время освоения: полноценное изучение K8s — месяцы. Docker Compose мы знаем наизусть.

Это не значит, что Kubernetes плохой. Это значит, что для нашего масштаба стоимость владения превышает пользу. Возможно, я недооцениваю K8s и с ним было бы проще в каких-то сценариях — но пока без него всё работает.

Наш стек: Docker Compose + Kamal

Kamal (бывший MRSK, от создателей Ruby on Rails) — инструмент деплоя, который решает главную проблему Docker Compose в продакшене: zero-downtime деплой. Он берёт Docker-образ, разворачивает его на сервере, проверяет healthcheck и переключает трафик. Всё через SSH, без агентов.

Конфигурация Kamal для типичного проекта:

# config/deploy.yml — конфигурация Kamal
service: catalog-api
image: registry.example.com/catalog-api

servers:
  web:
    hosts:
      - 185.XXX.XXX.XX
    labels:
      traefik.http.routers.catalog.rule: Host(`catalog.example.com`)
      traefik.http.routers.catalog.tls.certresolver: letsencrypt

registry:
  server: registry.example.com
  username: deploy
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  clear:
    ENVIRONMENT: production
    DATABASE_HOST: db
  secret:
    - DATABASE_URL
    - REDIS_URL
    - SECRET_KEY

healthcheck:
  path: /health
  port: 8000
  max_attempts: 10
  interval: 3

Деплой — одна команда: kamal deploy. Kamal собирает образ, пушит в реестр, запускает новый контейнер на сервере, ждёт healthcheck, переключает Traefik на новый контейнер и останавливает старый.

Docker Compose: правильная конфигурация для продакшена

Типичный docker-compose.yml из туториалов не готов к продакшену. Вот что я добавляю в каждый проект:

# docker-compose.production.yml
services:
  api:
    image: registry.example.com/catalog-api:${TAG:-latest}
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 256M
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 1G
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 3s
      retries: 5
    environment:
      POSTGRES_DB: catalog
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    shm_size: "256mb"

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 256M
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    volumes:
      - redis_data:/data
    command: redis-server --maxmemory 200mb --maxmemory-policy allkeys-lru

volumes:
  postgres_data:
  redis_data:

Ключевые моменты:

  • resource limits — без них один контейнер может съесть всю память и убить соседей.
  • healthcheck — Docker перезапускает контейнер, если healthcheck падает. Это базовый self-healing.
  • logging limits — без max-size логи заполнят диск за неделю. Я это выучил дорогой ценой: однажды проснулся от алерта на диск, 12 GB логов от одного контейнера.
  • depends_on с condition — сервис не стартует, пока база не готова. Без этого — race condition при каждом рестарте.
  • shm_size для PostgreSQL — без этого сложные запросы с сортировкой могут падать.

Бэкапы: потому что всё ломается

Нет бэкапов — нет бизнеса. Мой скрипт бэкапа, который работает в cron:

#!/bin/bash
# /opt/scripts/backup.sh — ежедневный бэкап
set -euo pipefail

BACKUP_DIR="/backups/$(date +%Y-%m-%d)"
mkdir -p "$BACKUP_DIR"

# PostgreSQL — pg_dump через docker exec
docker exec postgres pg_dump -U postgres -Fc catalog > "$BACKUP_DIR/catalog.dump"

# Redis — копируем RDB
docker exec redis redis-cli BGSAVE
sleep 2
cp /var/lib/docker/volumes/redis_data/_data/dump.rdb "$BACKUP_DIR/redis.rdb"

# Ротация — храним 30 дней
find /backups -maxdepth 1 -type d -mtime +30 -exec rm -rf {} \;

# Загрузка в S3 (через rclone)
rclone copy "$BACKUP_DIR" s3:backups/catalog/$(date +%Y-%m-%d) --progress

echo "$(date): Бэкап завершён" >> /var/log/backup.log

Раз в месяц я проверяю, что бэкап можно восстановить. Непроверенный бэкап — не бэкап. Честно говоря, проверял не каждый месяц — бывает, пропускаю. Но каждый раз, когда проверяю, нахожу что-нибудь: то rclone авторизацию протухшую, то формат дампа не тот. Процесс нужен именно для этого.

Мониторинг без оверинжиниринга

Для 8 проектов поднимать Prometheus + Grafana — можно, но мне показалось избыточным. Возможно зря — иногда не хватает нормальных графиков по метрикам. Пока использую простую комбинацию:

  • Uptime Kuma — self-hosted мониторинг доступности. Пингует все эндпоинты, шлёт алерты в Telegram.
  • docker stats через cron — собираю метрики потребления ресурсов.
  • Структурированные логи через structlog -> JSON -> просмотр через docker logs или Loki.
# Healthcheck-эндпоинт, который проверяет реальные зависимости
from fastapi import FastAPI
from sqlalchemy import text

app = FastAPI()

@app.get("/health")
async def health(db: AsyncSession = Depends(get_db)):
    checks = {}

    try:
        await db.execute(text("SELECT 1"))
        checks["database"] = "ok"
    except Exception as e:
        checks["database"] = f"error: {e}"

    try:
        await redis.ping()
        checks["redis"] = "ok"
    except Exception as e:
        checks["redis"] = f"error: {e}"

    status = "healthy" if all(v == "ok" for v in checks.values()) else "unhealthy"
    code = 200 if status == "healthy" else 503

    return JSONResponse({"status": status, "checks": checks}, status_code=code)

Healthcheck проверяет реальные зависимости, а не просто отвечает 200. Docker использует этот эндпоинт для автоматического рестарта.

Стоимость: реальные цифры

КомпонентDocker Compose + KamalManaged Kubernetes (GKE)
Сервер / ноды$40/мес (VPS)$150/мес (2 ноды)
Control plane$0$73/мес
Балансировщик$0 (Traefik)$18/мес
Registry$5/мес$5/мес
Мониторинг$0 (Uptime Kuma)$0 (Cloud Monitoring)
Итого$45/мес$246/мес
Время на DevOps~4 часа/мессложно оценить, но больше

Цены приблизительные, зависят от региона и конфигурации. Но порядок разницы примерно такой.

Когда переходить на Kubernetes

Я не противник K8s. Есть чёткие сигналы, что пора:

  • Больше 10 сервисов на одном хосте — оркестрация руками становится болью
  • Автоскейлинг — нагрузка скачет сильно в течение дня
  • Команда > 5 человек — нужна стандартизация деплоя
  • Multi-region — один VPS уже не справляется
  • Compliance требования — изоляция на уровне неймспейсов

Для наших 8 проектов с предсказуемой нагрузкой Docker Compose + Kamal — это осознанный, экономичный и надёжный выбор. Когда масштаб вырастет, перейду на K8s с чётким пониманием, зачем он мне нужен.