food-market/docs/RUNBOOK.md
nns 97e26a65d5 docs(s12): ARCHITECTURE/MULTI-TENANCY/RUNBOOK/DEVELOPER-GUIDE + k6 baseline + stage-verify CI
Документация для следующего разработчика (4 файла, ~1500 строк по
существу), реальный нагрузочный baseline на stage, и автоматический
smoke на каждый push.

Доки:
- docs/ARCHITECTURE.md — карта слоёв, модулей, Program.cs composition
  root, полный поток signup→post с трассировщиком ASP.NET pipeline.
- docs/MULTI-TENANCY.md — ITenantEntity + reflection query-filter,
  stamping в SaveChanges, SuperAdmin override (read-only + edit-mode
  с reason), 8 подводных камней, чеклист «как добавить tenant-сущность».
- docs/RUNBOOK.md — health-чеки, backup/restore с примером, смена SDK,
  disaster-recovery на новый сервер, 6 описанных инцидентов
  (включая docker-compose project name), БД-troubleshooting.
- docs/DEVELOPER-GUIDE.md — локальный setup, гочи integration-тестов,
  полные паттерны (controller с permission + tenant-сущность с
  RowVersion + 5 шагов миграции), валидация, structured-логирование,
  «НЕ делать» список.

k6 baseline:
- tests/load/ — 3 скрипта (signup-burst, retail-sales-parallel,
  sales-report-heavy) + README с инструкциями.
- docs/performance-baseline.md — реальные цифры на stage:
  * signup p95 446ms @ 50 RPM (IP-лимит 60/мин держит);
  * retail-sale sequential — 17/sec, p95 71ms;
  * retail-sale @ VU>1 — 53% failure из-за race в
    GenerateNumberAsync (unique-violation 23505 не ловится в
    SaveOrFkErrorAsync) — P0 для следующего рефакторинга;
  * reports на 1500 чеков — p95 50-114ms до VU=5.

CI:
- .forgejo/workflows/stage-verify.yml — on workflow_run после Docker
  API/Web, wait-for-ready → tests/stage-smoke.sh → Telegram пинг.
- tests/stage-smoke.sh — 7-секундный bash-смок (curl+jq+python3),
  5 этапов: health, signup, token, multi-tenant изоляция (B → 404
  на product A, B → пустой список), полный документ-цикл
  (supplier+supply.post → stock=100 → sale.post → stock=99).
  Локальный прогон против stage — все этапы зелёные.

Build чистый, локальный прогон smoke зелёный. Sprint 12 закрывает
автономно-безопасный цикл — дальше нужен вход от user'а.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:19:25 +05:00

17 KiB
Raw Blame History

Runbook — операционные процедуры food-market

Что делать, когда что-то идёт не так, или когда нужно сделать неавтоматическую операцию.

Контактные точки

Что Где
Stage URL https://test.admin.food-market.kz
Prod URL https://admin.food-market.kz (план, ещё не задеплоен)
Stage VM nns@192.168.1.190 (через ssh, prod-vm в локалке)
Dev VM (этот хост) nns@<this> — здесь крутится локальный API/Postgres + локальный Forgejo + локальный Docker registry
Forgejo (primary git) http://127.0.0.1:3000/nns/food-market.git
GitHub (mirror) https://github.com/nurdotnet/food-market (только зеркало)
Local Docker registry 192.168.1.193:5001 (memory: local_docker_registry)
Hangfire Dashboard (stage) https://test.admin.food-market.kz/hangfire — только SuperAdmin
Swagger (stage) https://test.admin.food-market.kz/swagger

Health-чеки

Endpoint Что значит Что делать при 503
GET /health Процесс отвечает Контейнер живёт, проблема в обвязке (nginx/cert/DNS).
GET /health/live Процесс жив (без проверок) То же.
GET /health/ready БД отвечает + миграции применены См. ниже «Health/ready упал».
GET /metrics Prometheus exposition Если 404 — приложение не стартануло.

/health/ready упал

  1. ssh nns@192.168.1.190 'docker logs --tail 100 food-market-stage-api' — стек ошибки на старте.
  2. Типичные причины:
    • Миграция упала: ищем Failed executing DbCommand / relation "..." already exists. Решение: миграция конфликтует со снапшотом БД. Возможно её надо переписать с IF NOT EXISTS (см. Phase6e_RetailSaleReturns.cs как пример «defensive migration»).
    • OpenIddict cert pass mismatch: переменная OpenIddict__CertPassword в docker-compose env'е не совпадает с паролем PFX-файла → CryptographicException: PKCS12 password incorrect.
    • Connection refused: Postgres контейнер не успел подняться. depends_on.condition: service_healthy должно это покрывать, но если healthcheck не успел — docker compose restart api.
  3. Если фикс требует кода — ~/deploy-stage.sh после правки.

Деплой на stage

~/deploy-stage.sh

Скрипт делает:

  1. docker build api и web с локальным registry в качестве кеша.
  2. docker push обоих образов в 192.168.1.193:5001.
  3. ssh nns@192.168.1.190docker compose -p food-market-stage pull api webup -d --force-recreate.
  4. Ждёт https://test.admin.food-market.kz/health/ready до 30с.

Важно: проект docker compose называется food-market-stage (флаг -p food-market-stage). См. инцидент ниже про project name.

Бэкап и восстановление

Расписание

systemd-таймер food-market-backup.timer (см. deploy/) запускается каждый день в 03:00 локального времени prod-vm. Запускается через OnCalendar=*-*-* 03:00:00 + Persistent=true (догоняет пропущенные если сервер был выключен).

Скрипт food-market-backup.sh:

  • pg_dump -Fc из контейнера food-market-postgresdb-<TS>.dump.
  • tar czf каталога /opt/food-market-data/uploadsuploads-<TS>.tgz.
  • Удаляет файлы старше 30 дней (FM_BACKUP_RETENTION_DAYS).

Папка: /opt/food-market-data/backups/.

Ручной бэкап

ssh nns@192.168.1.190 'sudo /opt/food-market/deploy/food-market-backup.sh'

Или из репо разработчика:

deploy/backup.sh --remote 192.168.1.190:5434   # PG в Docker exposed на 5434

Восстановление БД из дампа

⚠️ Перезаписывает данные. Сначала остановить API.

ssh nns@192.168.1.190
cd /opt/food-market

# 1. Остановить API/Web, оставить Postgres
docker compose -p food-market-stage stop api web

# 2. Применить дамп
DUMP=/opt/food-market-data/backups/db-YYYYMMDD-HHMMSS.dump
docker exec -i food-market-stage-postgres \
    pg_restore -U food_market -d food_market \
    --clean --if-exists --no-owner --no-privileges \
    < "$DUMP"

# 3. Поднять API обратно — миграции применятся автоматически (idempotent)
docker compose -p food-market-stage up -d api web

# 4. Проверить
curl https://test.admin.food-market.kz/health/ready

Восстановление uploads

ssh nns@192.168.1.190
cd /opt/food-market-data
sudo tar xzf backups/uploads-YYYYMMDD-HHMMSS.tgz
# Содержимое восстанавливается в текущий каталог (uploads/...)

Полный disaster-recovery (новый сервер)

  1. Поднять Docker, склонировать репо в /opt/food-market.
  2. Скопировать бэкапы в /opt/food-market-data/backups/.
  3. Запустить пустой стек:
    cd /opt/food-market/deploy
    docker compose -p food-market-stage up -d postgres
    docker compose -p food-market-stage exec postgres pg_isready
    
  4. Применить дамп (см. выше).
  5. Восстановить uploads.
  6. Запустить остальное: docker compose -p food-market-stage up -d.
  7. Поднять nginx + сертификат (см. docs/stage-access.md).
  8. Включить таймер бэкапов:
    sudo cp deploy/food-market-backup.{service,timer} /etc/systemd/system/
    sudo systemctl daemon-reload
    sudo systemctl enable --now food-market-backup.timer
    

Перенос на другой сервер

  1. На старом — снять свежий бэкап вручную.
  2. На новом — поднять Docker, склонировать репо, восстановить (см. выше).
  3. Обновить DNS A-запись admin.food-market.kz на новый IP.
  4. Дождаться распространения DNS (TTL).
  5. Старый сервер — выключить через 24 часа (для гарантии).

Смена SDK-версии

⚠️ global.json фиксирует 8.0.417 с rollForward: latestFeature. Менять только когда вышел новый patch и Microsoft анонсировал EOL текущего. memory: НЕ переключать systemwide postgres версию.

  1. На dev-машине: dotnet --list-sdks — проверить что новая версия установлена.
  2. Обновить global.json → новый patch.
  3. dotnet build + dotnet test.
  4. deploy/Dockerfile.api — обновить FROM mirror/dotnet-sdk:X.Y (если тэг изменился).
  5. ~/deploy-stage.sh — задеплоить, проверить /health/ready.
  6. Verify-suite (Playwright или вручную smoke).
  7. Только после этого — менять на prod-машине.

Логи

Где Что
docker logs food-market-stage-api (по контейнеру) Console JSON Serilog.
/opt/food-market-data/api-logs/ (Docker volume) Файлы Serilog rolling.
journalctl -u food-market-backup.service --no-pager Логи бэкапа.
Hangfire Dashboard /hangfire Состояние фоновых джобов, истории, ошибки.

Формат JSON-логов — структурированный, каждая запись содержит CorrelationId, OrgId, UserId (через LogEnrichmentMiddleware). Поиск по CorrelationId восстанавливает полный trace запроса.

Метрики

Prometheus scrape: GET /metrics (без auth). Локально в проекте нет prometheus-сервера — на stage его тоже пока нет; план — поднять prometheus + grafana отдельным compose'ом и proxy через nginx.

Ключевые метрики (food-market.api/Infrastructure/Observability/AppMetrics.cs):

  • food_market_posted_total{document_type="..."} — счётчик post'ов.
  • food_market_unposted_total{document_type="..."} — счётчик unpost'ов.
  • food_market_db_query_duration_seconds_* — гистограмма EF-запросов (interceptor).
  • Стандартные prometheus-net: http_requests_received_total, http_request_duration_seconds, dotnet_collection_count_total, etc.

Известные инциденты

Инцидент 1: docker-compose project name

Симптом (наблюдался при первой миграции на новый stage):

  • docker compose pull && up -d создавали контейнеры с именами deploy-api-1 вместо ожидаемых food-market-stage-api.
  • Healthcheck'и depends_on отрабатывали по новым именам, но nginx configurated на старые — 502 Bad Gateway.

Причина: docker compose берёт project name из имени каталога, если не указан -p. Каталог deploy/ → project=deploy → контейнеры с префиксом deploy-. Старые контейнеры с префиксом food-market-stage- оставались стопнутыми, новые поднялись параллельно (Docker не считает их дубликатами потому что имена разные).

Решение: всегда передавать -p food-market-stage. Сделано в ~/deploy-stage.sh. На prod ставить аналогичный wrapper-скрипт, не запускать docker compose голым из /opt/food-market/deploy.

Превенция: в будущем — COMPOSE_PROJECT_NAME=food-market-stage в /etc/environment на серверах, чтобы голый docker compose тоже не промахивался.

Инцидент 2: GHCR network flakiness

Симптом: docker push/pull в ghcr.io периодически зависает на 2-5 минут или падает по TCP-таймауту.

Причина: исходящая сеть с dev-vm к github.com нестабильна (memory: network_github_flaky).

Решение: используем локальный Docker registry на 192.168.1.193:5001 как primary, ghcr только как mirror (для external CI/CD когда понадобится). Stage compose тянет с локального (REGISTRY=192.168.1.193:5001). См. memory local_docker_registry.

Инцидент 3: OpenIddict cert rotation

Симптом: после docker compose down -v (с удалением volume api-data) OpenIddict не может расшифровать существующие refresh-токены → все пользователи разлогинены.

Причина: keys из App_Data/oidc-keys/ пропали вместе с volume.

Решение / превенция:

  • НИКОГДА не делать down -v на stage/prod без явного намерения.
  • Хранить App_Data volume отдельно: volumes: api-data: с external: true (план).
  • Бэкап App_Data вместе с БД (TODO: добавить в food-market-backup.sh).

Инцидент 4: rate-limiter eager-config

Симптом (в integration-тестах): тесты падают с 429 Too Many Requests после ~5 signup'ов.

Причина: RateLimiting:Enabled=true (default) читается ЭАГЕРНО при регистрации сервисов; ConfigureAppConfiguration в WebApplicationFactory применяется позже и не успевает override'нуть.

Решение: в integration-тестах ставим RateLimiting__Enabled=false через переменную окружения до создания factory. Сделано в ApiFactory static-конструкторе. Memory: test_suites_setup.

Инцидент 5: Telegram chat-id привязка

Симптом: владелец org вводит chat_id, сервер тестирует отправку → 403 Forbidden от Telegram API.

Причина: пользователь не отправил /start боту перед привязкой, бот не может писать первым.

Решение / превенция: UI показывает инструкцию «1. Откройте бота → /start. 2. Получите chat_id у @userinfobot. 3. Введите.» Это идёт сверху на странице привязки. Бэкенд возвращает ошибку с понятным текстом.

Инцидент 6: Identity password policy

Симптом: signup-форма принимает пароль 12345, потом /connect/token отшивает «Invalid credentials» — потому что Identity сам не разрешил создать пользователя с таким паролем, но контроллер проглотил ошибку.

Превенция: контроллер AuthController.Signup теперь возвращает IdentityResult.Errors массивом → фронт показывает причину.

Troubleshooting на стороне БД

Большой org_audit_log

prune-audit-log каждый день чистит >180 дней; если каталог-tenant делал массовый импорт (10к товаров за раз), таблица может вырасти на порядок. Проверка:

SELECT pg_size_pretty(pg_total_relation_size('org_audit_log'));
SELECT count(*) FROM org_audit_log WHERE created_at < now() - interval '30 days';

Ручная чистка:

DELETE FROM org_audit_log WHERE created_at < now() - interval '90 days';
VACUUM ANALYZE org_audit_log;

Stock-агрегат расходится с движениями

Инвариант: stocks.quantity = SUM(stock_movements.quantity) per (product_id, store_id). Если разошёлся (баг где-то не вызвали IStockService.ApplyMovementAsync):

-- найти расхождения
SELECT s.product_id, s.store_id, s.quantity AS cached,
       COALESCE(SUM(m.quantity), 0) AS actual
FROM stocks s
LEFT JOIN stock_movements m
    ON m.product_id = s.product_id AND m.store_id = s.store_id
GROUP BY s.product_id, s.store_id, s.quantity
HAVING s.quantity <> COALESCE(SUM(m.quantity), 0);

-- пересчитать всё (под maintenance window!)
UPDATE stocks s SET quantity = COALESCE((
    SELECT SUM(quantity) FROM stock_movements m
    WHERE m.product_id = s.product_id AND m.store_id = s.store_id
), 0);

__EFMigrationsHistory рассинхрон

Бывает после ручной правки миграции после её применения.

SELECT * FROM "__EFMigrationsHistory" ORDER BY 1 DESC LIMIT 5;

Если в коде есть миграция, которой нет в таблице — db.Database.Migrate() попытается её применить (что обычно и нужно). Если в таблице есть запись, а файла нет — обратное направление (миграция была удалена) — Migrate() не упадёт, но фокус с EF Tools перестанет работать, см. memory feedback_ef_migrations.

Что НЕ делать

  • НЕ менять global.json без явного решения (CLAUDE.md).
  • НЕ переключать systemwide postgres версию через brew (поломает смежные проекты в ~/Documents/devprojects/).
  • НЕ запускать docker compose down -v на stage/prod (потеря volume).
  • НЕ делать миграции через dotnet ef migrations add — снапшот в репо не синхронный с моделью, генератор выдаст ерунду. Пишем руками.
  • НЕ редактировать тот же файл одновременно с Mac-Claude (memory: feedback_serialize_edits).