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-сервисам
Когда мы запускали продакшен 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-окружения:
| Компонент | Технология | Назначение | Метод обновления |
|---|---|---|---|
| Frontend | React 18 + Vite | UI, загрузка изображений | Prerender + scp в nginx |
| Backend API | Django 4.2 + DRF | REST API, бизнес-логика | Volume mount + reload |
| Worker | Celery + Redis | Очередь задач генерации | Graceful restart |
| ML Engine | PyTorch 2.1 + CUDA | Инференс моделей | Без перезапуска |
| Nginx | Nginx 1.25 | Reverse 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.
Правильная схема:
- На production-сервере создаём отдельного пользователя
deployбез sudo - Генерируем SSH-ключ:
ssh-keygen -t ed25519 -C "github-actions-deploy" - Публичный ключ добавляем в
~deploy/.ssh/authorized_keys - Приватный ключ сохраняем в GitHub Secrets как
DEPLOY_KEY - Настраиваем 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-workerCelery получает сигнал 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 деплоям:
| Метрика | Значение | Комментарий |
|---|---|---|
| Среднее время деплоя backend | 11 секунд | От push до health-check pass |
| Среднее время деплоя frontend | 47 секунд | Включая npm build |
| Успешных деплоев | 482 (99.0%) | 5 откатов из-за ошибок миграций |
| Даунтайм за период | 0 секунд | Rolling restart без разрыва |
| Ложных алертов health-check | 3 (0.6%) | Таймауты из-за нагрузки |
| Максимальное время rollback | 8 секунд | Копирование бэкапа + 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?
Создать первое видео бесплатно