У нас 6 сервисов в Tirebase, и деплой был: SSH на сервер, git pull, docker compose up -d --build. Иногда забывали запустить миграции, иногда билд падал из-за другой версии Node на сервере. Тестов в пайплайне не было — гоняли локально, когда не забывали. Раз в пару недель что-то ломалось на проде, откат занимал 15-20 минут ручной работы.
Что было не так
6 микросервисов: API каталога (~250K SKU), сервис авторизации, обработка заказов, генератор отчётов, бот-нотификатор и фронтенд. Деплой — SSH и молитва. Откат — git log, найти предыдущий коммит, git checkout, пересобрать. Staging-окружения не было.
Шаг 1: Docker-образы с правильным кэшированием
Первое — воспроизводимые билды. Каждый сервис получил многоступенчатый Dockerfile:
# services/catalog-api/Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
EXPOSE 8000
CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
Ключевой момент — COPY requirements.txt отдельным слоем. Пока зависимости не меняются, Docker берёт слой из кэша. Билд ускорился примерно с 4 минут до 40-50 секунд.
Шаг 2: GitHub Actions — полный пайплайн
Вот рабочий workflow, который я обкатал на продакшене:
# .github/workflows/deploy.yml
name: Build & Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ghcr.io/${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
service: [catalog-api, auth-service, order-service]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: "services/${{ matrix.service }}/requirements.txt"
- name: Install & test
working-directory: services/${{ matrix.service }}
run: |
pip install -r requirements.txt -r requirements-dev.txt
pytest --tb=short -q
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
strategy:
matrix:
service: [catalog-api, auth-service, order-service, frontend]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: services/${{ matrix.service }}
push: true
tags: |
${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.sha }}
${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy via Kamal
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
gem install kamal
eval $(ssh-agent -s)
echo "$SSH_PRIVATE_KEY" | ssh-add -
kamal deploy
Тесты бегут параллельно для каждого сервиса — strategy.matrix. Три сервиса тестируются одновременно, а не последовательно.
Кэш Docker-слоёв через cache-from: type=gha — GitHub Actions хранит кэш между запусками. Повторный билд без изменений зависимостей отрабатывает за секунды.
Шаг 3: Kamal — деплой без Kubernetes
Kubernetes для 6 сервисов на одном сервере — overkill. Kamal (бывший MRSK, от Basecamp) делает то, что нужно: zero-downtime деплой Docker-контейнеров через SSH.
# config/deploy.yml
service: myapp
image: ghcr.io/myorg/myapp
servers:
web:
hosts:
- 192.168.1.10
labels:
traefik.http.routers.myapp.rule: Host(`app.example.com`)
workers:
hosts:
- 192.168.1.11
cmd: celery -A tasks worker -l info
registry:
server: ghcr.io
username: myorg
password:
- KAMAL_REGISTRY_PASSWORD
env:
clear:
RAILS_ENV: production
secret:
- DATABASE_URL
- REDIS_URL
- SECRET_KEY
traefik:
options:
publish:
- "443:443"
volume:
- "/letsencrypt:/letsencrypt"
Kamal сам поднимает Traefik как reverse proxy, обеспечивает TLS через Let’s Encrypt, делает health check нового контейнера и только потом переключает трафик. Если health check не проходит — старый контейнер остаётся.
Честно скажу, Kamal не идеален. Документация местами неполная, и при первой настройке я потратил вечер на то, чтобы разобраться с конфигурацией accessories. Но для нашего масштаба он подходит лучше, чем что-либо ещё.
Шаг 4: Стратегия отката
Каждый образ тегируется SHA коммита. Откат — это один вызов:
# Откат на предыдущую версию
kamal rollback abc1234
# Или через GitHub Actions — revert коммит и push
git revert HEAD --no-edit && git push
Я также добавил smoke-тест после деплоя:
# В deploy job после kamal deploy
- name: Smoke test
run: |
sleep 10
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health)
if [ "$STATUS" != "200" ]; then
echo "Smoke test failed! Rolling back..."
kamal rollback $(git rev-parse HEAD~1)
exit 1
fi
Если /health не возвращает 200 — автоматический откат. За несколько месяцев это сработало пару раз и спасло от даунтайма.
Шаг 5: Staging-окружение
Pull request автоматически деплоится в staging:
deploy-staging:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: |
kamal deploy -d staging
- name: Comment PR with URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Staging deployed: https://staging.example.com'
})
Ревьюер открывает staging, проверяет руками, аппрувит PR — деплой в прод.
Результаты
До:
- Деплой: 15-20 минут вручную
- Откат: 15 минут паники
- Тесты: когда не забывали
После:
git pushдо прода: около 3 минут- Откат: меньше минуты
- Тесты: на каждый коммит
Сломанный прод из-за деплоя пока не случался, но я не готов утверждать, что это полностью заслуга CI/CD — мы параллельно стали писать больше тестов и делать code review. Скорее, всё вместе.
Настройка заняла два выходных. Основной урок: не нужно ждать идеального момента, чтобы автоматизировать деплой. Даже базовый пайплайн с тестами и Docker-образами — уже огромный шаг вперёд по сравнению с ssh + git pull.