Нас двое, у нас 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 + Kamal | Managed 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 с чётким пониманием, зачем он мне нужен.