From 3a290c7fc2aba29a726a2bc9f0a64b20e682dcf2 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Sun, 9 Aug 2026 02:40:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(monitoring):=20=D0=B0=D0=BB=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BD=D0=B0=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=91=D0=94?= =?UTF-8?q?=20=D0=B8=20=D0=B8=D1=81=D1=87=D0=B5=D1=80=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D1=83=D0=BB=D0=BE=D0=B2=20+=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DatabaseUnavailable (vidconf_db_up == 0, for: 30s, critical) и DbConnectionPoolNearExhaustion/RedisConnectionPoolNearExhaustion (занято > 80% дольше минуты, warning) — сигнал оператору, не автолечение: healthcheck backend'а по решению оператора остаётся мягким, рестарт при недоступной БД оборвал бы WS у всех, кто в конференциях. Дашборд Grafana «БД и пулы соединений» — занятость пулов на графике, следующий нагрузочный тест будут смотреть глазами. --- deploy/monitoring/alerts.yml | 83 +++++++++ .../grafana/dashboards/db-pool.json | 173 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 deploy/monitoring/grafana/dashboards/db-pool.json diff --git a/deploy/monitoring/alerts.yml b/deploy/monitoring/alerts.yml index 8882c61..60b8a4a 100644 --- a/deploy/monitoring/alerts.yml +++ b/deploy/monitoring/alerts.yml @@ -61,6 +61,89 @@ groups: # см. также алерт QueueGrowing). Проверить # `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`. + # Состояние БД и пулов соединений (сессия 33, разбор инцидента 07.08 — + # `.forcc/session-results/32-loadtest-07-08-debug.md`). `/api/health` + # отдавал 200 с `db: false` во время отказа — Prometheus его не скрейпит и + # не умеет разобрать JSON-тело, поэтому оба сигнала строятся на метриках + # `backend/api/metrics.py`, которые читаются вне основного пула. + - name: vidconf-db + rules: + # `vidconf_db_up` — отдельное соединение вне основного пула + # (`core/db.py::check_db_up`), поэтому 0 означает именно «БД не + # отвечает», а не «пул занят» (для второго см. DbConnectionPoolNearExhaustion + # ниже — раздельные метрики нарочно, см. «Главное требование» промпта + # сессии 33). `for: 30s` — два цикла скрейпа (`scrape_interval: 15s`), + # чтобы не среагировать на одиночный неудачный `connect()` (сеть/GC-пауза), + # но не тянуть с сигналом дольше: это самый критичный алерт в проекте. + - alert: DatabaseUnavailable + expr: vidconf_db_up == 0 + for: 30s + labels: + severity: critical + annotations: + summary: "БД недоступна" + description: >- + vidconf_db_up == 0 дольше 30 секунд — backend не может открыть + отдельное (вне основного пула) соединение с Postgres. НЕ значит + автоматически «нужен рестарт контейнера» — по решению оператора + от 09.08 healthcheck backend'а остаётся мягким (не хардфейлится + на недоступной БД — рестарт-петля в разгар инцидента оборвала бы + WS у всех, кто в конференциях), это сигнал оператору, не + автолечение. Смотреть + `docker compose ps postgres`, `docker compose logs postgres`, + `pg_isready`. + + # Раннее предупреждение — тот самый сигнал, которого не хватило + # 07.08: пул заполнялся постепенно (idle in transaction 3→8→16→26→35→ + # 39→40 участников комнаты, см. session 32), а `up{job="backend"}` + # ничего не показывал, потому что backend отвечал исправно вплоть до + # самого потолка. Порог 80% — предложение из промпта сессии 33, + # `for: 1m` — фильтр от секундных всплесков (короткий пик параллельных + # запросов рассасывается за секунды, устойчивый рост участников + # комнаты — нет). На нагрузочном тесте 07.08 от пересечения 80% до + # исчерпания пула прошло по грубой оценке меньше двух минут — порог + # НЕ даёт большого запаса и это осознанный компромисс, а не идеал: + # цель — успеть до 500-х у пользователей, а не за много минут + # заранее. Перепроверить оба числа на следующем нагрузочном тесте + # (см. .forcc/session-results/33-db-health-alert.md) и подстроить, + # если реальный запас окажется у́же ожидаемого. + - alert: DbConnectionPoolNearExhaustion + expr: >- + (vidconf_db_pool_checked_out + / (vidconf_db_pool_size + vidconf_db_pool_max_overflow)) * 100 > 80 + for: 1m + labels: + severity: warning + annotations: + summary: "Основной пул соединений с БД близок к исчерпанию" + description: >- + Занято {{ $value | printf "%.0f" }}% основного пула БД дольше + минуты (порог 80%). Частая причина в этом проекте — долгоживущие + WS-подключения комнат (`api/chat.py`) держат соединение на + каждого сидящего в конференции; смотреть + `vidconf_db_pool_checked_out` и число открытых WS чата в логах, + не только текущую HTTP-нагрузку. + + # Тот же класс отказа, что у пула БД (см. выше), только пул Redis — + # закрыт в 0.0.31 (`451c18e`) заданием явного max_connections, но без + # метрики занятости прошлый потолок нашёлся только руками на + # нагрузочном тесте. Бонус к задаче сессии 33 («потолки в этом + # проекте стоят лесенкой»), не отдельно запрошен промптом — пороги + # взяты по аналогии с пулом БД, не проверялись отдельным нагрузочным + # тестом именно на Redis. + - alert: RedisConnectionPoolNearExhaustion + expr: (vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections) * 100 > 80 + for: 1m + labels: + severity: warning + annotations: + summary: "Пул соединений Redis близок к исчерпанию" + description: >- + Занято {{ $value | printf "%.0f" }}% пула Redis дольше минуты + (порог 80%). Каждое WS-подключение комнаты держит собственную + pub/sub-подписку из этого же пула — смотреть число открытых WS + чата, не только команды Celery/кэша. + # Железо хоста (job `node` — node-exporter). Пороги подобраны под # конкретный сервер 1gb: 8 ГБ RAM, 4 CPU, 50 ГБ диска — если сервер # сменится, пересчитать. diff --git a/deploy/monitoring/grafana/dashboards/db-pool.json b/deploy/monitoring/grafana/dashboards/db-pool.json new file mode 100644 index 0000000..9dfdb15 --- /dev/null +++ b/deploy/monitoring/grafana/dashboards/db-pool.json @@ -0,0 +1,173 @@ +{ + "title": "БД и пулы соединений", + "description": "Доступность БД (vidconf_db_up) и занятость основных пулов (SQLAlchemy/БД, Redis) — метрики читаются вне самих пулов, доступны и при их исчерпании (сессия 33, разбор инцидента 07.08 — .forcc/session-results/32-loadtest-07-08-debug.md). Пороги алертов см. deploy/monitoring/alerts.yml (группа vidconf-db).", + "uid": "vidconf-db-pool", + "editable": false, + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "time": { "from": "now-1h", "to": "now" }, + "refresh": "10s", + "tags": ["vidconf", "db", "pool"], + "panels": [ + { + "id": 1, + "title": "БД доступна", + "description": "vidconf_db_up — отдельное соединение вне основного пула (core/db.py::check_db_up). Алерт DatabaseUnavailable, for: 30s.", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } } + ], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + }, + "overrides": [] + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_up", "refId": "A" } + ] + }, + { + "id": 2, + "title": "Занятость пула БД сейчас, %", + "description": "vidconf_db_pool_checked_out / (vidconf_db_pool_size + vidconf_db_pool_max_overflow) * 100. Порог алерта DbConnectionPoolNearExhaustion — 80% дольше минуты.", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 60 }, { "color": "red", "value": 80 }] } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "(vidconf_db_pool_checked_out / (vidconf_db_pool_size + vidconf_db_pool_max_overflow)) * 100", + "refId": "A" + } + ] + }, + { + "id": 3, + "title": "Занятость пула Redis сейчас, %", + "description": "vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections * 100. Порог алерта RedisConnectionPoolNearExhaustion — 80% дольше минуты.", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 60 }, { "color": "red", "value": 80 }] } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "(vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections) * 100", + "refId": "A" + } + ] + }, + { + "id": 4, + "title": "Активных алертов группы vidconf-db", + "description": "ALERTS{alertname=~\"DatabaseUnavailable|.*PoolNearExhaustion\", alertstate=\"firing\"} — снимок того, что прямо сейчас видит Alertmanager/страница Alerts.", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "count(ALERTS{alertname=~\"DatabaseUnavailable|.*PoolNearExhaustion\", alertstate=\"firing\"}) OR on() vector(0)", + "refId": "A" + } + ] + }, + { + "id": 5, + "title": "Занятость пула БД (соединений)", + "description": "vidconf_db_pool_checked_out на фоне вместимости (size + max_overflow) — эта картина должна расти под нагрузочным тестом до срабатывания алерта. Ранний сигнал: в инциденте 07.08 занятость росла постепенно по мере входа участников в комнату, а не рывком от общей HTTP-нагрузки.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } }, + "overrides": [] + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_pool_checked_out", "legendFormat": "занято", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_pool_size + vidconf_db_pool_max_overflow", "legendFormat": "вместимость (size+overflow)", "refId": "B" } + ] + }, + { + "id": 6, + "title": "Занятость пула Redis (соединений)", + "description": "vidconf_redis_pool_in_use на фоне vidconf_redis_pool_max_connections. Второй потолок того же класса, что у БД (закрыт в 0.0.31, redis-py 8 сменил дефолт max_connections на 100).", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } }, + "overrides": [] + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_redis_pool_in_use", "legendFormat": "занято", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_redis_pool_max_connections", "legendFormat": "вместимость", "refId": "B" } + ] + }, + { + "id": 7, + "title": "БД доступна во времени", + "description": "vidconf_db_up как временной ряд — удобно видеть провал целиком (начало/длительность отказа), не только текущее состояние.", + "type": "timeseries", + "gridPos": { "h": 6, "w": 12, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "min": 0, + "max": 1, + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineInterpolation": "stepAfter" }, + "mappings": [ + { "type": "value", "options": { "0": { "text": "DOWN" }, "1": { "text": "UP" } } } + ] + }, + "overrides": [] + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_up", "legendFormat": "db_up", "refId": "A" } + ] + }, + { + "id": 8, + "title": "Сеансы failed / очереди Celery (для сверки с общей нагрузкой)", + "description": "Тот же контекст, что на дашборде «Пайплайны пост-обработки» — здесь рядом с пулами, чтобы не переключаться между дашбордами при разборе инцидента.", + "type": "timeseries", + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "fillOpacity": 5 } }, + "overrides": [] + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_pipeline_sessions{status=\"failed\"}", "legendFormat": "сеансов failed", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(vidconf_celery_queue_depth)", "legendFormat": "глубина очередей (сумма)", "refId": "B" } + ] + } + ] +}