Документация для следующего разработчика (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>
17 KiB
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 упал
ssh nns@192.168.1.190 'docker logs --tail 100 food-market-stage-api'— стек ошибки на старте.- Типичные причины:
- Миграция упала: ищем
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.
- Миграция упала: ищем
- Если фикс требует кода —
~/deploy-stage.shпосле правки.
Деплой на stage
~/deploy-stage.sh
Скрипт делает:
docker buildapi и web с локальным registry в качестве кеша.docker pushобоих образов в192.168.1.193:5001.ssh nns@192.168.1.190→docker compose -p food-market-stage pull api web→up -d --force-recreate.- Ждёт
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-postgres→db-<TS>.dump.tar czfкаталога/opt/food-market-data/uploads→uploads-<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 (новый сервер)
- Поднять Docker, склонировать репо в
/opt/food-market. - Скопировать бэкапы в
/opt/food-market-data/backups/. - Запустить пустой стек:
cd /opt/food-market/deploy docker compose -p food-market-stage up -d postgres docker compose -p food-market-stage exec postgres pg_isready - Применить дамп (см. выше).
- Восстановить uploads.
- Запустить остальное:
docker compose -p food-market-stage up -d. - Поднять nginx + сертификат (см.
docs/stage-access.md). - Включить таймер бэкапов:
sudo cp deploy/food-market-backup.{service,timer} /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now food-market-backup.timer
Перенос на другой сервер
- На старом — снять свежий бэкап вручную.
- На новом — поднять Docker, склонировать репо, восстановить (см. выше).
- Обновить DNS A-запись
admin.food-market.kzна новый IP. - Дождаться распространения DNS (TTL).
- Старый сервер — выключить через 24 часа (для гарантии).
Смена SDK-версии
⚠️
global.jsonфиксирует8.0.417сrollForward: latestFeature. Менять только когда вышел новый patch и Microsoft анонсировал EOL текущего. memory: НЕ переключать systemwide postgres версию.
- На dev-машине:
dotnet --list-sdks— проверить что новая версия установлена. - Обновить
global.json→ новый patch. dotnet build+dotnet test.deploy/Dockerfile.api— обновитьFROM mirror/dotnet-sdk:X.Y(если тэг изменился).~/deploy-stage.sh— задеплоить, проверить/health/ready.- Verify-suite (Playwright или вручную smoke).
- Только после этого — менять на 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_Datavolume отдельно: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).