После 24 спринтов regress-suite разросся; нестабильность блокирует доверие. Этот спринт: ловит flaky тесты, добавляет observability (Grafana + Prometheus alerts + RUNBOOK), сертифицирует 10× cert-прогон. 1. tests/regression/find-flaky.sh — 10× прогон + JSON-агрегатор → docs/flaky-tests.md (per-test pass/fail sequence + reproduce). 2. OrgFactory.signupWithRetry теперь honors Retry-After header (api-client.ts:ApiError.retryAfterSec). Stage rate-limit поднят: RATE_SIGNUP_HOUR=5000, RATE_PER_IP_MIN=5000 (~/food-market-stage/deploy/.env). 3. fullyParallel=true + workers=4 = тесты идут в недетерминированном порядке; isolation работает (OrgFactory per-test). 4. workers=4 даёт **2.4× ускорение** (66.6s → 27.7s). Worker-scoped фикстура lib/worker-org.ts добавлена как opt-in. 5. deploy/grafana/dashboards/quality-watchdog.json (10 панелей: smoke success ratio 7d, incidents, multi-tenant violations, current emoji, p95 by endpoint, step failures, RPS, DB p95, docs posted, disk free) + dashboards/README.md. quality-watchdog.sh пишет Prometheus textfile экспорт в ~/.fm-watchdog/textfile/quality_watchdog.prom для node_exporter. 6. deploy/prometheus/alerts.yml — 10 правил, 4 группы (uptime, errors, database, quality-watchdog). MultiTenantViolation = P0. deploy/prometheus/prometheus.yml — reference config. 7. docs/RUNBOOK.md +178 строк: action per alert (api-down, rps-drop, http-errors-spike/growing, doc-posting-errors, db-p95-high, disk-free-low, watchdog-red, multi-tenant-violation, watchdog-incident). Junior-friendly с конкретными командами. **Cert-прогон (10× workers=4):** 420/420 passed, 0 flaky, avg 30.1s/run, total 300.6s (< 5min budget). Изменения вне репо: - ~/food-market-stage/deploy/.env — RATE_* limits bumped. - ~/quality-watchdog.sh — добавлен .prom textfile экспорт. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
576 lines
29 KiB
Markdown
576 lines
29 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
|
||
```
|
||
|
||
### 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-1 \
|
||
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-1:/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`).
|