Назад к блогу

Наш CI/CD на GitHub Actions + Kamal для 6 сервисов

Как я настроил пайплайн на GitHub Actions + Docker + Kamal для Tirebase — от ручного деплоя через SSH до автоматического. Конфиги, кэширование, откаты.

Наш CI/CD на GitHub Actions + Kamal для 6 сервисов

У нас 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=ghaGitHub 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.