раздел 05 · шаг 7/7

Шаг 7. Деплой в Docker

Финальный шаг. Multi-stage Dockerfile для бэка и фронта, общий docker-compose.yml, smoke-тест который проверяет что прод реально работает. Локально поднимаем всё одной командой.

Что делаем

  • Пишем Dockerfile для backend (python:3.12-slim + uvicorn)
  • Пишем Dockerfile для frontend (multi-stage: node build, nginx-alpine serve)
  • Собираем docker-compose.yml с двумя сервисами
  • Выносим SQLite в named volume
  • Делаем test/smoke.py - проверяет прод-инварианты
  • deploy.sh запускает сборку, поднятие и smoke-тест

Промпт для Claude

Сделай Docker-инфраструктуру для todo-ai.

Структура infra/:
- infra/backend.Dockerfile
- infra/frontend.Dockerfile
- infra/nginx.conf
- docker-compose.yml (в корне проекта)
- .env.example (в корне) - с ANTHROPIC_API_KEY=
- deploy.sh (в корне) - executable bash-скрипт
- test/smoke.py - smoke-тест

infra/backend.Dockerfile:
- Base: python:3.12-slim
- Multi-stage не критичен, но --no-cache-dir обязателен
- Установить системные deps если нужны (build-essential для C-расширений)
- Скопировать requirements.txt, pip install --no-cache-dir
- Скопировать app/
- CMD: uvicorn app.main:app --host 0.0.0.0 --port 8000
- Healthcheck: curl -f http://localhost:8000/api/tasks || exit 1

infra/frontend.Dockerfile:
- Stage 1 (builder): node:20-alpine
  - WORKDIR /app
  - COPY package.json pnpm-lock.yaml
  - RUN corepack enable && pnpm install --frozen-lockfile
  - COPY . .
  - ARG VITE_API_URL (по умолчанию /api - чтобы nginx проксировал)
  - RUN pnpm build
- Stage 2 (runtime): nginx:alpine
  - COPY --from=builder /app/dist /usr/share/nginx/html
  - COPY infra/nginx.conf /etc/nginx/conf.d/default.conf

infra/nginx.conf:
- Слушать порт 80
- location / - try_files $uri $uri/ /index.html (SPA fallback)
- location /api/ - proxy_pass http://backend:8000
- gzip on для js/css/json
- кеш для статики 1 год, для index.html no-cache

docker-compose.yml:
- services: backend, frontend
- backend:
    build: { context: ./backend, dockerfile: ../infra/backend.Dockerfile }
    env_file: .env
    volumes:
      - todo-data:/data
    environment:
      DATABASE_URL: sqlite+aiosqlite:////data/tasks.db
    restart: unless-stopped
- frontend:
    build: { context: ./frontend, dockerfile: ../infra/frontend.Dockerfile }
    ports:
      - "8080:80"
    depends_on:
      - backend
    restart: unless-stopped
- volumes:
    todo-data:

test/smoke.py:
- Python-скрипт без зависимостей кроме stdlib + curl
- Принимает аргумент base_url (по умолчанию http://localhost:8080)
- Проверки (каждая - exit 1 при провале):
  1. GET /api/tasks возвращает 200 и JSON-массив
  2. POST /api/tasks с {"title":"smoke test"} возвращает 201,
     в ответе есть id, title, category (любая из валидных), done=false
  3. GET / возвращает 200 и Content-Type text/html
  4. GET /index.html содержит <div id="root"> или подобный маркер
  5. Созданная задача появляется в GET /api/tasks
  6. DELETE задачи возвращает 204
  7. Финал: SUCCESS или FAILED с диагностикой
- Вывод цветной (зелёный/красный), компактный

deploy.sh:
#!/bin/bash
set -e
docker compose up -d --build
echo "Waiting for services..."
sleep 5
python3 test/smoke.py http://localhost:8080 || {
  echo "Smoke test FAILED, rolling back"
  docker compose logs
  exit 1
}
echo "Deploy successful"

Сделай deploy.sh executable: chmod +x deploy.sh

Что Claude создаст

После генерации структура такая:

todo-ai/
├── backend/
├── frontend/
├── infra/
│   ├── backend.Dockerfile
│   ├── frontend.Dockerfile
│   └── nginx.conf
├── test/
│   └── smoke.py
├── docker-compose.yml
├── deploy.sh
├── .env
├── .env.example
└── CLAUDE.md

Локальный запуск

# убедитесь что .env содержит ANTHROPIC_API_KEY
cat .env

./deploy.sh

Что произойдёт:

  1. Docker собирает оба образа (первый раз - 2-3 минуты)
  2. Стартуют контейнеры backend и frontend
  3. Через 5 секунд запускается smoke.py
  4. Если всё ок - "Deploy successful"
  5. Если что-то отвалилось - exit 1 + логи

Откройте http://localhost:8080 - там должен быть рабочий ToDo.

Smoke-тест: что внутри

test/smoke.py минимальный, без зависимостей:

#!/usr/bin/env python3
import json
import subprocess
import sys

GREEN = "\033[92m"
RED = "\033[91m"
RESET = "\033[0m"

base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8080"
failures = []

def curl(method, path, body=None):
    cmd = ["curl", "-s", "-X", method, f"{base}{path}"]
    if body:
        cmd += ["-H", "Content-Type: application/json", "-d", json.dumps(body)]
    cmd += ["-w", "\n%{http_code}"]
    out = subprocess.check_output(cmd, text=True)
    parts = out.rsplit("\n", 1)
    status = int(parts[-1])
    payload = parts[0] if len(parts) > 1 else ""
    return status, payload

def check(name, ok, hint=""):
    if ok:
        print(f"{GREEN}PASS{RESET} {name}")
    else:
        print(f"{RED}FAIL{RESET} {name} - {hint}")
        failures.append(name)

# 1. GET /api/tasks
status, body = curl("GET", "/api/tasks")
check("GET /api/tasks 200", status == 200, f"status={status}")
tasks_data = json.loads(body) if body else None
check("GET /api/tasks returns list", isinstance(tasks_data, list))

# 2. POST /api/tasks
status, body = curl("POST", "/api/tasks", {"title": "smoke test"})
check("POST /api/tasks 201", status == 201, f"status={status}")
created = json.loads(body) if body else {}
check("POST returns id", isinstance(created.get("id"), int))
check("POST returns title", created.get("title") == "smoke test")
check("POST returns category", created.get("category") in
      {"work", "personal", "learning", "health", "other"})

# 3. GET /
status, _ = curl("GET", "/")
check("GET / 200", status == 200)

# 4. DELETE
if created.get("id"):
    status, _ = curl("DELETE", f"/api/tasks/{created['id']}")
    check("DELETE 204", status == 204, f"status={status}")

if failures:
    print(f"\n{RED}FAILED{RESET}: {len(failures)} checks")
    sys.exit(1)
print(f"\n{GREEN}SUCCESS{RESET}: all checks passed")

Прокси для Anthropic

Если деплой идёт на сервер в РФ - бэк должен ходить к Anthropic через прокси. Добавьте в docker-compose.yml:

backend:
  environment:
    HTTPS_PROXY: http://188.166.23.110:3128
    HTTP_PROXY: http://188.166.23.110:3128
    NO_PROXY: localhost,frontend

(Этот прокси - моя инфра. У вас будет свой адрес.)

Деплой на сервер

Базовое правило: только через Git, никаких scp.

# локально
git push origin main

# на сервере
ssh selectel
cd /opt/todo-ai
git pull
./deploy.sh

Если smoke.py упал - откат:

git log --oneline -5     # найти предыдущий рабочий коммит
git checkout <hash>
./deploy.sh

Точка сохранения

git add .
git commit -m "step 7: Docker deploy, smoke tests, deploy.sh"
git push

Что дальше

Базовый проект готов. Дальше можно:

  • Добавить аутентификацию (FastAPI-Users или собственное JWT)
  • Перевести SQLite на PostgreSQL
  • Добавить редактирование title и категории через UI
  • Сделать фильтрацию задач по категории
  • Расширить системный промпт примерами для качества категоризации
  • Добавить мониторинг (Sentry, простой /health endpoint с метриками)

Каждое из этих расширений - один промпт Claude + проверка smoke.py.

Полезные ссылки