Docker и CI/CD для небольшой команды: что мы реально запускаем в продакшене
Kubernetes мощный. Ещё это 40+ YAML-файлов, крутая кривая обучения и операционные накладные расходы, которые не окупаются для большинства команд до 20 инженеров. Вот наш реальный продакшен-стек: Docker Compose, GitHub Actions, Nginx-реверс-прокси и деплой-скрипт. Всё это держит реальный трафик.
Схема инфраструктуры
GitHub (код)
→ GitHub Actions (CI: тесты, сборка, push образа)
→ GitHub Container Registry (хранение образов)
→ VPS: деплой через SSH
→ docker-compose pull + up
→ Nginx маршрутизирует трафик
Один VPS (Hetzner CPX31, 4 vCPU, 8GB RAM, €12/месяц) запускает 6 продакшен-приложений через Docker Compose. До этого мы платили €200+/месяц за эквивалентные ресурсы на облачных провайдерах.
Dockerfile: продакшен Node.js
# Многоэтапная сборка: этап builder не попадает в продакшен
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Продакшен-этап: минимальный образ
FROM node:18-alpine AS production
WORKDIR /app
# Непривилегированный пользователь
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
USER nextjs
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node_modules/.bin/next", "start"]
Многоэтапная сборка: dev-зависимости и сборочные инструменты остаются в builder-этапе. Результат: образ 185MB вместо 1.2GB.
Docker Compose: продакшен-стек
# docker-compose.yml
version: '3.8'
services:
frontend:
image: ghcr.io/yourorg/frontend:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=https://api.yoursite.com
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
backend:
image: ghcr.io/yourorg/backend:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
depends_on:
postgres:
condition: service_healthy
networks:
- app-network
postgres:
image: postgres:15-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- app-network
volumes:
postgres_data:
redis_data:
networks:
app-network:
driver: bridge
GitHub Actions: сборка и деплой
# .github/workflows/deploy.yml
name: Деплой в продакшен
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,format=short
- name: Войти в GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Собрать и запушить образ
uses: docker/build-push-action@v5
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Деплой через SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/yourapp
export IMAGE_TAG=${{ needs.build-and-push.outputs.image-tag }}
docker-compose pull frontend backend
docker-compose up -d --no-deps frontend backend
docker image prune -f
Время деплоя: обычно 90 секунд от git push до продакшена. Zero downtime - Docker Compose поднимает новые контейнеры до остановки старых (с --no-deps).
Nginx: реверс-прокси
# /etc/nginx/sites-available/yoursite.com
server {
listen 443 ssl http2;
server_name yoursite.com;
ssl_certificate /etc/letsencrypt/live/yoursite.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yoursite.com/privkey.pem;
# Заголовки безопасности
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
server {
listen 80;
server_name yoursite.com;
return 301 https://$host$request_uri;
}
SSL-сертификаты через Certbot (Let's Encrypt). Обновление автоматическое через systemd-таймер.
Резервное копирование
#!/bin/bash
# /opt/scripts/backup.sh - запускается через cron ежедневно
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups"
# Дамп PostgreSQL
docker exec postgres pg_dump -U $DB_USER $DB_NAME | \
gzip > "$BACKUP_DIR/db_$DATE.sql.gz"
# Удаляем бэкапы старше 7 дней
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete
# Загружаем в S3-совместимое хранилище
aws s3 cp "$BACKUP_DIR/db_$DATE.sql.gz" \
"s3://backup-bucket/postgres/" \
--endpoint-url https://storage.example.com
Kubernetes? Не сейчас
Вопрос о Kubernetes возникает каждый квартал. Наш ответ: когда появятся выделенные DevOps-инженеры или когда одному приложению понадобится 10+ реплик - переедем. До тех пор этот стек справляется со всем, что мы на него кидаем.
Экономия реальная: €12/месяц за VPS вместо €200+/месяц за облачные сервисы с эквивалентными ресурсами. Для стартапа или небольшой студии разница принципиальная.