food-market/docs/RUNBOOK.md
nns c4a59d7bf2
Some checks failed
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (test + latest) (push) Has been cancelled
Docker Web / Build + push Web (push) Has been cancelled
Docker Public / Deploy :test → test.food-market.kz (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
docs(prod): репа догоняет новую раскладку postgres на prod-vm
05.10.2026 общий postgres-инстанс с чужими боевыми базами (sklad, purchase,
calcman, calcman_staging, makesales + мусорная старая food_market) вынесен
из compose прода food-market в отдельный compose-проект shared-postgres
(контейнер shared-postgres, порт 5434, том food-market-postgres-data external).
Прод food-market поднимается в собственный контейнер food-market-prod-postgres
(порт 5437, том food-market-prod-postgres-data). Простой при разделении — 12с,
соседи живы. См. docs/shared-postgres-followup-progress.md.

Этот коммит синхронизирует репозиторий, чтобы следующий деплой из main не
воскресил старую раскладку:

- deploy/docker-compose.yml: container_name/port/volume приведены к прод-файлу
  (food-market-prod-postgres, 127.0.0.1:5437, food-market-prod-postgres-data).
- deploy/shared-postgres/ (новое): документационная копия соседнего compose'а,
  с явной пометкой «food-market им не управляет».
- deploy/food-market-backup.sh / anonymize-prod.sh / db-schema-diff.sh:
  defaults FM_PG_CONTAINER / FM_PROD_CONT = food-market-prod-postgres.
- docs/RUNBOOK.md: новый раздел «Postgres на prod-vm» с таблицей трёх
  инстансов и явным запретом трогать shared-postgres из наших скриптов.
- docs/backup-restore.md: контейнер в таблице и примерах — новый.

Guards (не в репе — живут в ~/deploy-stage.sh и ~/quality-watchdog.sh)
расширены симметрично: стенду запрещены shared-postgres, food-market-prod-
postgres[-data], 5434/5437; проду — food-market-postgres-data (чужие данные).
Проверка: docker compose config/ps на prod-vm shared-postgres не упоминают,
grep по репе — ни один скрипт не делает docker exec в shared-postgres.
2026-10-06 00:35:54 +05:00

644 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
## Публичный сайт: какой образ ↔ какой домен
Публичный Astro-сайт собирается в **двух** вариантах — build-arg
`PUBLIC_APP_URL` (куда уводит кнопка «Войти» и форма регистрации) инжектит
Astro в бандл при `pnpm build`, поэтому один образ работает для ровно
одного `admin`-хоста. Нельзя переиспользовать прод-образ для стенда и
наоборот.
| Образ (`food-market-public`) | `PUBLIC_SITE_URL` | `PUBLIC_APP_URL` | Хост-назначение | Деплой |
|---|---|---|---|---|
| `:test` + `:test-<sha>` | `https://test.food-market.kz` | `https://test.admin.food-market.kz` | **test.food-market.kz** (стенд публики) | **да** — в CI `.forgejo/workflows/docker-public.yml` job `deploy_test` обновляет контейнер `food-market-public` на prod-vm через `~/food-market/deploy/.env` c `PUBLIC_TAG=test` |
| `:latest` + `:<sha>` | `https://food-market.kz` | `https://admin.food-market.kz` | **food-market.kz** (прод публики) | **нет** — собирается, пушится, но на хост не деплоится; food-market.kz закрыт по решению владельца. Включать job `deploy_prod` когда разрешат. |
Workflow строит оба тега при каждом push в main, затрагивающем
`src/food-market.public/**`. Выбор образа на хосте через `PUBLIC_TAG` в
`~/food-market/deploy/.env` на prod-vm. Если `.env` отредактировали
руками (например, координатор сменил `PUBLIC_TAG=latest → test`), в
следующем CI-прогоне workflow проверит и не трогает, если уже `test`.
Smoke-проверка (jobs `deploy_test` → step «Smoke»):
`curl https://test.food-market.kz/signup/ | grep test.admin` должен
находить ссылки на `test.admin.food-market.kz`, и НЕ должен находить
`admin.food-market.kz`. Если ссылки не те — PUBLIC_APP_URL не прошёл в
билд, образ пересобрать.
Nginx внутри контейнера сделан с `absolute_redirect off; port_in_redirect
off;` — иначе 301 с `/signup` на `/signup/` строился через HTTP-schema и
браузер уходил с https → http. См. `src/food-market.public/deploy/nginx.conf`.
## Postgres на prod-vm
На prod-vm (`admin.food-market.kz`, `192.168.1.190`) **три** Postgres-контейнера.
Food-market владеет только одним из них — остальные **нельзя** трогать нашими
скриптами, бэкапами, миграциями или `docker exec`.
| Контейнер | Compose-проект | Том | Порт | Чьи данные |
|---|---|---|---|---|
| `food-market-prod-postgres` | `food-market` (`~/food-market/deploy/`) | `food-market-prod-postgres-data` | `127.0.0.1:5437` | **наш прод** |
| `food-market-stage-postgres` | `food-market-stage` (`~/food-market-stage/deploy/`) | `food-market-stage-postgres-data` | `127.0.0.1:5435` | наш стенд |
| `shared-postgres` | `shared-postgres` (`~/shared-postgres/`) | `food-market-postgres-data` *external* | `127.0.0.1:5434` | **НЕ наш** — чужие боевые базы `sklad`, `purchase`, `calcman`, `calcman_staging`, `makesales`, плюс мусорная старая `food_market` (повреждена, не восстанавливаем) |
Истоpически (до 2026-10-05) все эти базы жили в одном compose-проекте food-market
под именем контейнера `food-market-postgres` / томом `food-market-postgres-data`.
Любой `docker compose down` у food-market гасил чужие боевые базы. Координатор
физически разделил инстансы с одобрения владельца: food-market съехал в
собственный контейнер и том, а общий инстанс остался в compose-проекте
`shared-postgres` на старом томе (имя тома историческое — переименовывать
нельзя, в нём живые данные соседей). См. `docs/shared-postgres-followup-progress.md`.
**Правила:**
- Прод-бэкап, anonymize, schema-diff, миграции идут **только** через
`food-market-prod-postgres` (defaults скриптов именно такие).
- Контейнер `shared-postgres`, том `food-market-postgres-data`, порт `5434` —
**запрещённые** цели в наших compose/скриптах. Guards в `~/deploy-stage.sh`
и `~/quality-watchdog.sh` падают, если упомянутые паттерны всплывут в
шаблонах stage- или прод-compose в репе или на prod-vm.
- Нужен dump чужой базы — делает её владелец у себя, не из food-market.
- `deploy/shared-postgres/docker-compose.yml` в репе — это **документация**,
не исполняемый шаблон; наш `prod-deploy.sh` его не трогает.
## Бэкап и восстановление
### Расписание
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-prod-postgres` → `db-<TS>.dump`.
- `tar czf` каталога `/opt/food-market-data/uploads` → `uploads-<TS>.tgz`.
- Удаляет файлы старше 30 дней (`FM_BACKUP_RETENTION_DAYS`).
Папка: `/opt/food-market-data/backups/`.
> Контейнер прод-БД — `food-market-prod-postgres` (порт `127.0.0.1:5437`, том
> `food-market-prod-postgres-data`) с 2026-10-05. Соседний на той же машине
> `shared-postgres` (порт 5434, том `food-market-postgres-data` *external*) —
> **не наш**, в нём чужие боевые базы sklad/purchase/calcman/calcman\_staging/
> makesales. Любые `docker exec`/`pg_dump`/`pg_restore` в `shared-postgres` из
> наших скриптов запрещены, guards бьют. См. раздел «Postgres на prod-vm»
> ниже и `docs/shared-postgres-followup-progress.md`.
### Ручной бэкап
```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:5437 # прод-PG в Docker, exposed на 5437
```
### Recovery drill (RTO ≈ 25 секунд на сегодняшних данных)
Sprint 15 — verified восстановление stage'а в свежий PG-контейнер на dev-vm:
| Шаг | Время |
|---|---|
| `pg_dump -Fc` из stage-postgres | **~2 секунды** (на 1.5k чеков / 200 продуктов) |
| Создать чистый Docker-контейнер `postgres:16-alpine` | ~1 сек |
| `pg_restore --clean --if-exists --no-owner --no-privileges` | **~4 секунды** |
| Поднять API против восстановленной БД | ~19 сек (cold-start dotnet + migrations) |
| `/health/ready` → `{"status":"Healthy"}` | подтверждено |
| **Всего RTO (single-instance)** | **~25 секунд** |
Команды, выполненные в drill'е:
```bash
# 1. Снять бэкап со stage'а
ssh nns@192.168.1.190 'docker exec food-market-stage-postgres \
pg_dump -U food_market -d food_market -Fc -f /tmp/drill.dump'
ssh nns@192.168.1.190 'docker cp food-market-stage-postgres:/tmp/drill.dump /tmp/drill.dump'
scp nns@192.168.1.190:/tmp/drill.dump /tmp/drill.dump
# 2. Чистый PG
docker run -d --name drill-pg \
-e POSTGRES_DB=food_market \
-e POSTGRES_USER=food_market \
-e POSTGRES_PASSWORD=drill_pass \
-p 127.0.0.1:5499:5432 postgres:16-alpine
# 3. Восстановление
docker cp /tmp/drill.dump drill-pg:/tmp/drill.dump
docker exec drill-pg pg_restore -U food_market -d food_market \
--clean --if-exists --no-owner --no-privileges /tmp/drill.dump
# 4. Проверка: API на восстановленной БД
ASPNETCORE_ENVIRONMENT=Production \
ConnectionStrings__Default="Host=localhost;Port=5499;Database=food_market;Username=food_market;Password=drill_pass" \
Hangfire__Enabled=false \
ASPNETCORE_URLS="http://127.0.0.1:5099" \
RateLimiting__Enabled=false \
dotnet run --project src/food-market.api
curl http://127.0.0.1:5099/health/ready
# → {"status":"Healthy", checks:[{"name":"database","status":"Healthy",
# "description":"БД доступна, миграции применены."}]}
# Очистка
docker rm -f drill-pg
```
Для прод-данных большего объёма (50k+ чеков) RTO будет ~2-5 минут — но
порядок остаётся: pg_restore линейно по данным + API startup константный.
### Восстановление БД из дампа
> ⚠️ Перезаписывает данные. Сначала остановить 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`.
## Sprint 26 — Alert response (`deploy/prometheus/alerts.yml`)
Каждый alert в `alerts.yml` имеет `runbook` label — anchor сюда.
Junior-разработчик находит alert в Telegram, кликает runbook_url, попадает
на соответствующий раздел.
### api-down
**Alert:** `ApiDown` — `up{job="food-market-api"} == 0` 1 минуту.
**Что значит:** Prometheus не может scrap'нуть `/metrics`. API либо упал,
либо порт недоступен.
**Действия:**
1. `curl -fsS https://test.admin.food-market.kz/health/ready` —
подтверди что API недоступен (или вернулся).
2. `ssh nns@192.168.1.190 'docker ps | grep food-market-stage-api'` —
контейнер живой?
3. Если контейнер в `Restarting`: `docker logs --tail 200 food-market-stage-api-1`
— стек ошибки старта (часто миграция / OpenIddict cert mismatch).
4. Если контейнер OK, но не отвечает: `docker exec food-market-stage-api-1 curl
-s http://localhost:8080/health` (внутренний порт). Если внутри отвечает,
проблема в nginx/proxy цепочке.
5. Recovery: `cd ~/food-market-stage/deploy && docker compose -p
food-market-stage up -d --force-recreate api`.
6. Если не помогло: `~/deploy-stage.sh` с локального dev-vm (полный
build+push+restart).
### rps-drop
**Alert:** `RpsDropped50Percent` — RPS за 5 мин <50% от того же окна час
назад, при условии что было >0.5 rps.
**Действия:**
1. `curl https://test.admin.food-market.kz/health/ready` — API живой?
2. `ssh nns@192.168.1.190 'docker logs --tail 50 food-market-stage-api-1'`
— внезапные ошибки на старте/в логе.
3. Проверь DNS из дома/мобильной сети: `dig admin.food-market.kz` —
возможно сломалась запись.
4. Откати последний deploy: `cd ~/food-market-stage/deploy && git log -1
--format=%H && docker compose pull && docker compose up -d`. Если
откат на предыдущий image помог — баг в новом коде, см. логи.
### http-errors-spike
**Alert:** `HttpErrorsSpike` — доля 5xx >10% уже 5 минут.
**Действия:**
1. Logs: `ssh nns@192.168.1.190 'docker logs --tail 200 food-market-stage-api-1 | grep -iE "(error|exception)"'`.
2. Какая ручка валится: `curl https://test.admin.food-market.kz/metrics |
grep 'http_requests_received_total.*code="5'` — топ-3 контроллеров.
3. Hangfire-failed: `curl -u admin /hangfire/jobs/failed` (нужен
SuperAdmin login).
4. Часто — БД упала. См. `db-p95-high` раздел ниже.
5. Если баг локален (только одна ручка валится): найди фикс, deploy.
### http-errors-growing
**Alert:** `HttpErrorRateGrowing` — производная 5xx растёт >10%/min 10 мин.
**Действия:** Постепенная деградация, не emergency. Часто — память течёт
или коннекшен-пул исчерпывается.
1. Memory: `docker stats food-market-stage-api-1` (ratio %).
2. PG connections: `psql ... -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state"`
— если `idle in transaction` много, есть leak.
3. Restart api: `docker compose -p food-market-stage restart api` —
куплено время.
4. Найди корень в логах — кто часто получает Exception.
### doc-posting-errors
**Alert:** `DocumentPostingErrors` — типа документов > 0.05 ошибки/сек 5 мин.
**Действия:**
1. `docker logs food-market-stage-api-1 | grep "Posting failed"` —
ищи название документа в логе.
2. Hangfire failed: документы постятся через Hangfire-job — посмотри
`/hangfire/jobs/failed`.
3. Stock-инвариант: `Posting failed: stock would be negative` означает
попытку списать больше чем есть. Это бизнес-уровневая ошибка, не баг.
Сообщи владельцу org. Если ошибок много — возможно баг в pre-validate.
4. Concurrent posting: `Posting failed: serialization conflict` — это
Sprint 23 `SerializationConflictMiddleware` ловит и возвращает 409.
Если не возвращает 409 а 500 — middleware сломался, проверь deploy.
### db-p95-high
**Alert:** `DbQueryP95High` — p95 DB-запросов >500ms 10 минут подряд.
**Действия:**
1. Самые медленные запросы:
```sql
SELECT calls, mean_exec_time, total_exec_time, query
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
```
2. Если статистика устарела: `ANALYZE` всей БД (`vacuum-top-tables`
Hangfire-job делает это раз в неделю, см. `DatabaseMaintenanceJobs`).
3. Lock'и: `SELECT * FROM pg_locks WHERE NOT granted;` — заблокирована ли
какая-то таблица.
4. Disk: см. `disk-free-low` ниже — если IO упирается в диск.
5. Если новый медленный запрос в логе после deploy — откати relevant
контроллер.
### disk-free-low
**Alert:** `DiskFreeLow` — < 5 ГБ свободно на mount.
**Действия:**
1. `df -h` — какой mount упал.
2. Logs: `du -sh /var/lib/docker/containers/*/`. Логи Docker'a иногда
разрастаются. Truncate: `truncate -s 0 /var/lib/docker/containers/*/.log`.
3. БД growth: `psql -c "SELECT pg_size_pretty(pg_database_size('food_market'))"`.
Если >50 ГБ — запусти PruneStockMovements + VACUUM FULL под maintenance
(см. ниже).
4. Quality-watchdog test orgs: `PruneQualityTestOrgs` Hangfire-job (cron
02:30 UTC) удаляет старые `quality-{epoch}-*` org'и (см.
`[[sprint25_done]]`). Если не отработал: trigger вручную через
`/hangfire`.
5. Очисти `~/.fm-watchdog/quality.log.*` старее 14 дней (auto-ротация уже есть).
### watchdog-red
**Alert:** `WatchdogLastRunRed` — quality-watchdog последний прогон красный
>5 мин.
**Действия:**
1. Открой `docs/quality-status.md` в репо — сразу видно какой шаг упал.
2. Тот же шаг сам и воспроизведёшь:
```bash
/home/nns/quality-watchdog.sh # сразу прогоняет всё
tail -50 ~/.fm-watchdog/quality.log # детали последнего шага
```
3. Дальше зависит от шага:
- `health` — см. `api-down`.
- `auth_me` / `products` / `signalr` — см. `http-errors-spike`.
- `multi_tenant` — см. `multi-tenant-violation` (КРИТИЧНО).
- `perf` — см. `db-p95-high`.
- `ui_flow` — Playwright-тест. Прогон вручную: `cd tests/regression &&
pnpm exec playwright test flows/03-catalog.spec.ts --grep "3.1" --headed`.
### multi-tenant-violation 🚨
**Alert:** `MultiTenantViolation` — шаг multi_tenant упал в последнем часу.
**ЭТО P0.** Org B видит данные A — это утечка между арендаторами.
**Действия (немедленные):**
1. Останови stage'е приём новых signup'ов (косвенно — поставь
`RateLimiting__SignupPerIpPerHour=0`, redeploy api).
2. `tail -100 ~/.fm-watchdog/quality.log` — детали leak'а (`get_code`,
`list_total`).
3. В коде проверь `AppDbContext.ApplyTenantQueryFilter` (см. `[[sprint22_done]]`):
```bash
grep -n "ApplyTenantQueryFilter\|IgnoreQueryFilters" src/food-market.infrastructure/Persistence/
```
Кто-то добавил `IgnoreQueryFilters()` где не надо? Это самая частая
причина leak'ов.
4. Воспроизведи руками: создай 2 org'и (`curl POST /api/auth/signup` × 2),
токен для каждого, попробуй cross-access. Если воспроизводится —
фикс ASAP.
5. После фикса — `~/deploy-stage.sh`, дождись зелёного watchdog'a.
6. Если на prod уже катилось: notify владельцев (через Telegram-summary
job), audit-log за последние 48ч (`/api/admin/audit?since=...`) на
подозрительные cross-org операции.
### watchdog-incident
**Alert:** `WatchdogIncidentCreated` — 2+ подряд красных прогона ⇒ incident-файл.
**Действия:**
1. `ls -lt ~/.fm-watchdog/incident-*.txt | head -3` — последние инциденты.
2. `cat ~/.fm-watchdog/incident-{...}.txt` — описание + действия.
3. Server-Claude автоматически получит этот файл в очередь (через
`~/fm-watchdog.sh` → ротацию queue). Не дублируй — он начнёт фикс сам.
4. Если хочешь форсировать вмешательство: тот же файл sent'нул в
`~/.fm-watchdog/queue/0000-incident-XXX.txt` (uname-prefix `0000` →
первый в очереди).
## Что НЕ делать
- НЕ менять `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`).