Документация для следующего разработчика (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>
345 lines
17 KiB
Markdown
345 lines
17 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
~/deploy-stage.sh
|
||
```
|
||
|
||
Скрипт делает:
|
||
1. `docker build` api и web с локальным registry в качестве кеша.
|
||
2. `docker push` обоих образов в `192.168.1.193:5001`.
|
||
3. `ssh nns@192.168.1.190` → `docker compose -p food-market-stage pull api web` → `up -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-postgres` → `db-<TS>.dump`.
|
||
- `tar czf` каталога `/opt/food-market-data/uploads` → `uploads-<TS>.tgz`.
|
||
- Удаляет файлы старше 30 дней (`FM_BACKUP_RETENTION_DAYS`).
|
||
|
||
Папка: `/opt/food-market-data/backups/`.
|
||
|
||
### Ручной бэкап
|
||
|
||
```bash
|
||
ssh nns@192.168.1.190 'sudo /opt/food-market/deploy/food-market-backup.sh'
|
||
```
|
||
|
||
Или из репо разработчика:
|
||
```bash
|
||
deploy/backup.sh --remote 192.168.1.190:5434 # PG в Docker exposed на 5434
|
||
```
|
||
|
||
### Восстановление БД из дампа
|
||
|
||
> ⚠️ Перезаписывает данные. Сначала остановить API.
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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. Запустить пустой стек:
|
||
```bash
|
||
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. Включить таймер бэкапов:
|
||
```bash
|
||
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к товаров за раз), таблица может вырасти на
|
||
порядок. Проверка:
|
||
```sql
|
||
SELECT pg_size_pretty(pg_total_relation_size('org_audit_log'));
|
||
SELECT count(*) FROM org_audit_log WHERE created_at < now() - interval '30 days';
|
||
```
|
||
|
||
Ручная чистка:
|
||
```sql
|
||
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`):
|
||
|
||
```sql
|
||
-- найти расхождения
|
||
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` рассинхрон
|
||
|
||
Бывает после ручной правки миграции после её применения.
|
||
```sql
|
||
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`).
|