# 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@` — здесь крутится локальный 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-.dump`. - `tar czf` каталога `/opt/food-market-data/uploads` → `uploads-.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`).