← К блогу
02.05.2026 · 15 мин чтения

CI/CD для AI-сервиса: автодеплой через GitHub Actions без даунтайма на примере Django + Docker

Разбираем production-схему непрерывной доставки для AI-сервиса на Django: GitHub Actions с paths-filter для раздельных пайплайнов, deploy-key аутентификация, rolling restart через volume-mounts, prerender фронтенда и health-check после каждого деплоя.

CI/CD для AI-сервиса: автодеплой через GitHub Actions без даунтайма на примере Django + Docker

Введение: почему классический CI/CD не подходит AI-сервисам

Когда мы запускали продакшен AI-сервиса генерации аватаров, столкнулись с проблемой: стандартные пайплайны CI/CD, рассчитанные на stateless микросервисы, не учитывают специфику машинного обучения. Модели весят гигабайты, инференс занимает секунды, а любой даунтайм означает потерю пользователей в очереди обработки. Классический подход «остановить контейнер — собрать новый образ — запустить» приводил к 30-40 секундам недоступности при каждом деплое.

Основные проблемы традиционного CI/CD для AI-приложений:

  • Пересборка Docker-образа с моделями занимает 5-15 минут из-за размера файлов
  • Остановка контейнера прерывает активные задачи генерации, которые могут выполняться 10-20 секунд
  • Холодный старт моделей (загрузка весов в GPU) добавляет ещё 15-30 секунд
  • Фронтенд и бэкенд часто деплоятся вместе, хотя меняется только один компонент

Мы разработали схему, которая решает эти проблемы через комбинацию GitHub Actions, volume-mounts и rolling restart. За шесть месяцев работы достигли 99.97% uptime при 2-3 деплоях в день. Даунтайм сократился с 35 секунд до нуля — пользователи не замечают обновлений.

Архитектура деплоя: разделение фронтенда и бэкенда

Первое архитектурное решение — разделить пайплайны для фронтенда и бэкенда. В монорепозитории это достигается через paths-filter в GitHub Actions: workflow запускается только если изменились файлы в определённых директориях.

Структура репозитория:

project/
├── frontend/          # React-приложение
│   ├── src/
│   ├── public/
│   └── package.json
├── backend/           # Django + Celery
│   ├── apps/
│   ├── models/        # ML-модели (не в git)
│   └── requirements.txt
├── .github/
│   └── workflows/
│       ├── deploy-frontend.yml
│       └── deploy-backend.yml
└── docker-compose.prod.yml

Ключевые компоненты production-окружения:

КомпонентТехнологияНазначениеМетод обновления
FrontendReact 18 + ViteUI, загрузка изображенийPrerender + scp в nginx
Backend APIDjango 4.2 + DRFREST API, бизнес-логикаVolume mount + reload
WorkerCelery + RedisОчередь задач генерацииGraceful restart
ML EnginePyTorch 2.1 + CUDAИнференс моделейБез перезапуска
NginxNginx 1.25Reverse proxy, статикаReload конфига

Критически важно: ML-модели загружаются один раз при старте контейнера и остаются в памяти GPU. При деплое кода мы не перезапускаем контейнер, а только перезагружаем Python-модули через механизм auto-reload Django.

Настройка GitHub Actions с paths-filter

GitHub Actions позволяет запускать workflow только при изменении определённых файлов. Используем action dorny/paths-filter для определения, какой компонент изменился.

Пример workflow для бэкенда:

name: Deploy Backend

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  detect-changes:
    runs-on: ubuntu-22.04
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            backend:
              - 'backend/**'
              - 'requirements.txt'
              - 'docker-compose.prod.yml'

  deploy:
    needs: detect-changes
    if: needs.detect-changes.outputs.backend == 'true'
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.DEPLOY_KEY }}
      
      - name: Deploy to production
        run: |
          ssh -o StrictHostKeyChecking=no deploy@prod.example.com \
            'cd /opt/app && ./scripts/deploy-backend.sh'
      
      - name: Health check
        run: |
          for i in {1..30}; do
            if curl -f https://api.example.com/health/; then
              echo "Health check passed"
              exit 0
            fi
            sleep 2
          done
          echo "Health check failed"
          exit 1

Разберём ключевые моменты:

  • detect-changes — джоба определяет, изменились ли файлы в backend/. Если нет — весь workflow пропускается, экономим минуты CI-времени
  • outputs.backend — передаём результат фильтра в следующую джобу через механизм outputs
  • if: needs.detect-changes.outputs.backend == 'true' — условие запуска деплоя
  • webfactory/ssh-agent — загружает SSH-ключ из secrets для подключения к серверу
  • StrictHostKeyChecking=no — отключаем проверку отпечатка хоста (в CI-окружении хост известен)

Аналогичный workflow создаём для фронтенда, но с фильтром frontend/** и другим скриптом деплоя. Таким образом, коммит, который меняет только стили CSS, не триггерит пересборку бэкенда.

Deploy-key аутентификация и безопасность

Для безопасного подключения GitHub Actions к production-серверу используем SSH-ключи с ограниченными правами. Классическая ошибка — давать CI полный root-доступ через пароль или ключ с sudo.

Правильная схема:

  1. На production-сервере создаём отдельного пользователя deploy без sudo
  2. Генерируем SSH-ключ: ssh-keygen -t ed25519 -C "github-actions-deploy"
  3. Публичный ключ добавляем в ~deploy/.ssh/authorized_keys
  4. Приватный ключ сохраняем в GitHub Secrets как DEPLOY_KEY
  5. Настраиваем sudoers для конкретных команд без пароля

Файл /etc/sudoers.d/deploy:

deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx
deploy ALL=(ALL) NOPASSWD: /usr/bin/docker compose -f /opt/app/docker-compose.prod.yml exec -T backend python manage.py reload
deploy ALL=(ALL) NOPASSWD: /usr/bin/docker compose -f /opt/app/docker-compose.prod.yml restart celery-worker

Теперь пользователь deploy может выполнить только три конкретные команды с sudo, но не может, например, читать /etc/shadow или устанавливать пакеты. Это минимизирует риск компрометации при утечке ключа.

Дополнительные меры безопасности:

  • Ротация ключей каждые 90 дней через календарное напоминание
  • IP-whitelist в authorized_keys: from="140.82.112.0/20" ssh-ed25519 AAAA... (диапазон GitHub Actions)
  • Логирование всех SSH-подключений в отдельный файл: Match User deploy в sshd_config
  • Алерты в Telegram при каждом деплое через webhook

Backend: rolling restart Django через volume-mounts

Главная идея zero-downtime деплоя бэкенда — не перезапускать Docker-контейнер, а обновить код внутри работающего контейнера и перезагрузить Python-процессы.

В docker-compose.prod.yml монтируем код как volume:

services:
  backend:
    image: avatarbox/backend:base-v2
    volumes:
      - /opt/app/backend:/app:ro
      - /opt/models:/models:ro
      - /opt/app/media:/app/media
    environment:
      - DJANGO_SETTINGS_MODULE=config.settings.production
    command: gunicorn config.wsgi:application \
      --bind 0.0.0.0:8000 \
      --workers 4 \
      --max-requests 1000 \
      --max-requests-jitter 100 \
      --reload \
      --reload-extra-file /app/reload.trigger

Обратите внимание на ключевые параметры:

  • volumes: /opt/app/backend:/app:ro — код монтируется read-only, контейнер не может изменить исходники
  • /opt/models:/models:ro — ML-модели хранятся отдельно, не в git, монтируются read-only
  • --reload — gunicorn автоматически перезапускает воркеры при изменении .py файлов
  • --reload-extra-file /app/reload.trigger — можем форсировать reload, изменив этот файл
  • --max-requests 1000 — воркер перезапускается после 1000 запросов, предотвращает утечки памяти

Скрипт деплоя scripts/deploy-backend.sh:

#!/bin/bash
set -e

APP_DIR="/opt/app/backend"
BACKUP_DIR="/opt/backups/backend-$(date +%Y%m%d-%H%M%S)"

echo "[1/5] Creating backup..."
cp -r "$APP_DIR" "$BACKUP_DIR"

echo "[2/5] Pulling latest code..."
cd /opt/app
git fetch origin main
git reset --hard origin/main

echo "[3/5] Installing dependencies..."
docker compose -f docker-compose.prod.yml exec -T backend \
  pip install -q --no-cache-dir -r requirements.txt

echo "[4/5] Running migrations..."
docker compose -f docker-compose.prod.yml exec -T backend \
  python manage.py migrate --noinput

echo "[5/5] Triggering reload..."
touch "$APP_DIR/reload.trigger"

echo "Waiting for workers to reload..."
sleep 5

echo "Deployment complete. Backup saved to $BACKUP_DIR"
echo "To rollback: cp -r $BACKUP_DIR/* $APP_DIR/ && touch $APP_DIR/reload.trigger"

Процесс деплоя занимает 8-12 секунд, из которых:

  • 2-3 секунды — git pull
  • 3-5 секунд — установка зависимостей (если изменились)
  • 1-2 секунды — миграции БД (обычно пустые)
  • 2 секунды — graceful reload воркеров

Во время reload gunicorn работает так: мастер-процесс получает сигнал, запускает новые воркеры с обновлённым кодом, дожидается завершения текущих запросов в старых воркерах (до 30 секунд таймаут), затем убивает старые. Пользователи не видят ошибок.

Для Celery-воркеров используем другой подход — graceful restart с таймаутом:

docker compose -f docker-compose.prod.yml exec -T celery-worker \
  celery -A config control shutdown --timeout=30

docker compose -f docker-compose.prod.yml restart celery-worker

Celery получает сигнал TERM, завершает текущие задачи (до 30 секунд), затем контейнер перезапускается с новым кодом. Задачи в очереди Redis не теряются.

Frontend: prerender и доставка в nginx-контейнер

Фронтенд на React требует сборки перед деплоем. Мы делаем это в GitHub Actions, а не на production-сервере, чтобы не нагружать его и ускорить процесс.

Workflow для фронтенда:

name: Deploy Frontend

on:
  push:
    branches: [main]

jobs:
  detect-changes:
    runs-on: ubuntu-22.04
    outputs:
      frontend: ${{ steps.filter.outputs.frontend }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            frontend:
              - 'frontend/**'

  build-and-deploy:
    needs: detect-changes
    if: needs.detect-changes.outputs.frontend == 'true'
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json
      
      - name: Install dependencies
        working-directory: frontend
        run: npm ci
      
      - name: Build production bundle
        working-directory: frontend
        run: |
          npm run build
          echo "Build size: $(du -sh dist/)"
      
      - name: Deploy to nginx
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_KEY }}
          source: "frontend/dist/*"
          target: "/tmp/frontend-deploy-${{ github.sha }}"
          strip_components: 2
      
      - name: Atomic swap
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_KEY }}
          script: |
            DEPLOY_DIR="/tmp/frontend-deploy-${{ github.sha }}"
            NGINX_ROOT="/opt/app/frontend"
            BACKUP_DIR="/opt/backups/frontend-$(date +%Y%m%d-%H%M%S)"
            
            mv "$NGINX_ROOT" "$BACKUP_DIR"
            mv "$DEPLOY_DIR" "$NGINX_ROOT"
            
            docker compose -f /opt/app/docker-compose.prod.yml exec -T nginx nginx -s reload
            
            echo "Deployed. Backup: $BACKUP_DIR"

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

  • npm ci вместо npm install — детерминированная установка зависимостей по lock-файлу, быстрее на 30-40%
  • cache: 'npm' — GitHub Actions кэширует node_modules между запусками, сборка ускоряется с 2 минут до 30 секунд
  • scp-action — копируем собранные файлы на сервер во временную директорию
  • Atomic swap — переименовываем директории одной командой, nginx видит либо старую версию, либо новую, но не промежуточное состояние
  • nginx -s reload — перечитывает конфиг и файлы без разрыва соединений

Vite собирает bundle с content-hash в именах файлов: main.a3f2b1c9.js. При обновлении кода хеш меняется, браузеры автоматически загружают новую версию. Старые файлы остаются на диске ещё 7 дней для пользователей с долгоживущими вкладками.

Размер production bundle после оптимизации: 340 КБ gzip (1.2 МБ uncompressed). Используем code-splitting по роутам, lazy-loading для тяжёлых компонентов (загрузчик изображений, галерея результатов). Первая загрузка страницы — 85 КБ, остальное подгружается по требованию.

Health-check и откат при ошибках

После каждого деплоя запускаем серию проверок, чтобы убедиться, что сервис работает корректно. Если проверка проваливается — автоматически откатываемся на предыдущую версию.

Эндпоинт health-check в Django:

# backend/apps/core/views.py
from django.http import JsonResponse
from django.db import connection
from redis import Redis
import torch

def health_check(request):
    checks = {
        'database': False,
        'redis': False,
        'gpu': False,
        'models': False
    }
    
    # Проверка БД
    try:
        with connection.cursor() as cursor:
            cursor.execute('SELECT 1')
        checks['database'] = True
    except Exception as e:
        checks['database'] = str(e)
    
    # Проверка Redis
    try:
        r = Redis.from_url(settings.REDIS_URL)
        r.ping()
        checks['redis'] = True
    except Exception as e:
        checks['redis'] = str(e)
    
    # Проверка GPU
    try:
        checks['gpu'] = torch.cuda.is_available()
        if checks['gpu']:
            checks['gpu_name'] = torch.cuda.get_device_name(0)
    except Exception as e:
        checks['gpu'] = str(e)
    
    # Проверка загрузки моделей
    try:
        from apps.ml.models import ModelRegistry
        checks['models'] = ModelRegistry.all_loaded()
    except Exception as e:
        checks['models'] = str(e)
    
    all_ok = all([
        checks['database'] is True,
        checks['redis'] is True,
        checks['gpu'] is True,
        checks['models'] is True
    ])
    
    status_code = 200 if all_ok else 503
    
    return JsonResponse({
        'status': 'healthy' if all_ok else 'degraded',
        'checks': checks,
        'version': settings.VERSION,
        'deployed_at': settings.DEPLOY_TIMESTAMP
    }, status=status_code)

В GitHub Actions после деплоя:

- name: Health check with retry
  run: |
    for i in {1..30}; do
      RESPONSE=$(curl -s -w "\n%{http_code}" https://api.example.com/health/)
      HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
      BODY=$(echo "$RESPONSE" | head -n-1)
      
      if [ "$HTTP_CODE" = "200" ]; then
        echo "Health check passed"
        echo "$BODY" | jq .
        exit 0
      fi
      
      echo "Attempt $i/30: HTTP $HTTP_CODE"
      echo "$BODY" | jq . || echo "$BODY"
      sleep 2
    done
    
    echo "Health check failed after 30 attempts"
    exit 1

- name: Rollback on failure
  if: failure()
  uses: appleboy/ssh-action@v1.0.3
  with:
    host: ${{ secrets.PROD_HOST }}
    username: deploy
    key: ${{ secrets.DEPLOY_KEY }}
    script: |
      cd /opt/app
      LAST_BACKUP=$(ls -t /opt/backups/backend-* | head -n1)
      echo "Rolling back to $LAST_BACKUP"
      cp -r "$LAST_BACKUP"/* /opt/app/backend/
      touch /opt/app/backend/reload.trigger
      sleep 5

Логика проверки:

  • 30 попыток с интервалом 2 секунды = 60 секунд максимум
  • Проверяем HTTP-код 200 и валидность JSON
  • Если хотя бы одна проверка внутри health-check вернула ошибку — код 503
  • При провале всех попыток — триггерим джобу rollback
  • Rollback копирует последний бэкап и перезагружает воркеры

Дополнительно мониторим метрики через Prometheus:

  • Latency p95 API-запросов — не должен вырасти более чем на 10% после деплоя
  • Error rate — не более 0.1% в течение 5 минут после деплоя
  • Celery queue size — не должен расти (признак зависших воркеров)
  • GPU memory usage — стабильный после reload

Если любая метрика выходит за пороги — алерт в Telegram, ручная проверка, при необходимости — ручной откат.

Метрики и мониторинг деплоя

За шесть месяцев работы схемы собрали статистику по 487 деплоям:

МетрикаЗначениеКомментарий
Среднее время деплоя backend11 секундОт push до health-check pass
Среднее время деплоя frontend47 секундВключая npm build
Успешных деплоев482 (99.0%)5 откатов из-за ошибок миграций
Даунтайм за период0 секундRolling restart без разрыва
Ложных алертов health-check3 (0.6%)Таймауты из-за нагрузки
Максимальное время rollback8 секундКопирование бэкапа + reload

Интересные наблюдения:

  • Деплои в пиковые часы (12:00-14:00 MSK) проходят на 15% медленнее из-за высокой нагрузки на сервер
  • После обновления зависимостей (requirements.txt) первый деплой занимает 45 секунд вместо 11 — pip install выполняется внутри контейнера
  • Frontend-деплои не влияют на backend-метрики, что подтверждает правильность разделения пайплайнов
  • Три ложных алерта произошли из-за того, что health-check запускался слишком рано — gunicorn ещё не успел перезагрузить все воркеры. Увеличили sleep с 3 до 5 секунд, проблема исчезла

Мониторинг деплоев в Grafana:

  • График частоты деплоев по дням недели — пик в среду-четверг
  • Heatmap времени деплоя — 95% укладываются в 15 секунд
  • Корреляция деплоев с error rate — нет значимой связи, что говорит о стабильности
  • Длительность выполнения миграций — обычно 0.1-0.3 секунды, максимум 4 секунды при добавлении индекса

Выводы и рекомендации

Реализованная схема CI/CD для AI-сервиса решает главные проблемы классических пайплайнов: устраняет даунтайм, ускоряет деплой, разделяет обновления фронтенда и бэкенда. Ключевые принципы, которые можно применить в других проектах:

Разделение ответственности. Paths-filter в GitHub Actions экономит время CI и снижает риск ошибок. Если изменился только CSS — не нужно трогать бэкенд с ML-моделями.

Volume-mounts вместо пересборки образов. Для приложений с тяжёлыми зависимостями (модели, библиотеки) монтирование кода как volume и использование auto-reload даёт выигрыш в скорости в 10-20 раз.

Graceful restart обязателен. Gunicorn с --reload, Celery с shutdown --timeout, nginx с reload — все компоненты должны уметь перезагружаться без разрыва соединений.

Health-check с retry и rollback. Автоматическая проверка после деплоя и откат при ошибках критичны для production. Три попытки недостаточно — используйте 20-30 с интервалом 2 секунды.

Безопасность через ограничение прав. Отдельный пользователь deploy, SSH-ключи вместо паролей, sudoers с конкретными командами, IP-whitelist — многоуровневая защита.

Метрики и алерты. Мониторинг времени деплоя, error rate, latency после обновления. Алерты в Telegram при аномалиях. Grafana-дашборд для анализа трендов.

Что можно улучшить в будущем:

  • Canary deployments — деплой сначала на 10% воркеров, проверка метрик, затем на остальные
  • Blue-green deployment для фронтенда — два nginx-контейнера, переключение через upstream
  • Автоматическое масштабирование воркеров при росте очереди Celery
  • Интеграция с Sentry для отслеживания новых ошибок после деплоя
  • Pre-deployment тесты: запуск integration tests в staging-окружении перед production

Исходный код скриптов и конфигов доступен в документации проекта. Схема протестирована под нагрузкой до 500 запросов в секунду, работает стабильно на серверах с 2-4 GPU.

Главный урок: zero-downtime deployment для AI-сервисов реален без сложных оркестраторов типа Kubernetes. Достаточно правильно использовать Docker Compose, volume-mounts и встроенные механизмы graceful restart. Простота архитектуры — залог надёжности.

Готовы попробовать AvatarBox?

Создать первое видео бесплатно

Читайте также