All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Метрик в проекте не было ни одной: ни экспортеров, ни /metrics в бэкендах, единственный канал наблюдения — journald, единственный сигнал об аварии — исключение в GlitchTip. Из-за этого целый класс отказов невидим в принципе: задача рапортует done, строк ноль, исключения нет. Так протухли данные на семь месяцев (#2998), 34 дня был мёртв house_imv_backfill (#2698), 8 суток писал ноль newbuilding_enrich (#2767), 91 день копилось раздутие listings (#2992). Grafana не заменяет GlitchTip: ошибки остаются там. Grafana OSS не принимает Sentry DSN ни одним компонентом, а скрубберы в before_send — требование 152-ФЗ. Здесь появляется другой класс данных: ряды и алерты по трендам. Наблюдатель поставлен у ДРУГОГО провайдера, чем наблюдаемое: серверная сторона на Beget, рядом с GlitchTip. Если ляжет Poincare, мониторинг должен об этом сказать, а не лечь вместе с ним. Транспорт push, а не pull: агент на Poincare шлёт remote_write и логи исходящим HTTPS, поэтому там не открывается ни одного входящего порта сверх 22/80/443. При обрыве канала Alloy копит в WAL и досылает — pull-скрейп в той же ситуации терял бы точки именно в аварии, ради которой мониторинг и нужен. Два контура доступа с разными учётками. Пароль приёмника по построению лежит открытым на продуктовом хосте, значит его компрометация неизбежна вместе с хостом; будь это учётка витрины, утёк бы и доступ к дашбордам. GlitchTip читается прямым SQL, а не Sentry-плагином: у плагина на 6.1.6 stats_v2 отдаёт 500 (баг GlitchTip #381), Events/Discover — 404 (#416), а в grafana/sentry-datasource слово glitchtip не встречается ни разу. Схема сверена на живой базе: колонка времени называется timestamp, а не received, и отдельной таблицы IssueIndex не существует — агрегаты лежат на самой issue_events_issue. Алерты за профилем alerts: канал доставки — открытый вопрос #3078, и стек не должен на нём стоять. Деплой предупреждает, что уведомлять пока некому. Каждая настройка, способная отказать молча, закрыта явно: ретенция Prometheus задана и по времени и по размеру, retention_enabled у компактора Loki (без него retention_period не работает вовсе), путь к журналу и запуск Alloy от root (иначе агент читает ноль записей без ошибки), проверка Caddy до перезагрузки (на этом хосте тот же Caddy держит git, errors и obsidian). Refs #3078
208 lines
12 KiB
YAML
208 lines
12 KiB
YAML
# Правила алертов: инфраструктура и здоровье самого мониторинга.
|
||
#
|
||
# Принцип отбора — тот же, что у задачи #3078: сюда попадает только то, что уже
|
||
# ломалось молча. Правила «на всякий случай» не заводим: лишний алерт, который
|
||
# никто не разбирает, обесценивает остальные.
|
||
|
||
groups:
|
||
# ── Здоровье самого наблюдателя ─────────────────────────────────────────────
|
||
# Пустая панель неотличима от «всё хорошо». Эти правила закрывают именно это.
|
||
- name: monitoring-self
|
||
interval: 60s
|
||
rules:
|
||
# Всегда горит. Существует ради того, чтобы его ОТСУТСТВИЕ было заметно:
|
||
# раз в 12 часов приходит подтверждение, что цепочка правило → Alertmanager
|
||
# → Telegram → человек цела. Молчащий канал — самый частый способ узнать
|
||
# об аварии последним (в проекте МЕРЫ наружу не ушло ни одного сообщения
|
||
# с 30 мая, и это выяснилось случайно).
|
||
- alert: Watchdog
|
||
expr: vector(1)
|
||
labels:
|
||
severity: none
|
||
annotations:
|
||
summary: "Сторож мониторинга"
|
||
|
||
# Агент замолчал. На Poincare это единственный источник всех метрик:
|
||
# если он умер, графики просто перестанут обновляться, оставаясь зелёными.
|
||
- alert: HostAgentDown
|
||
expr: up{job="node"} == 0 or absent(up{job="node", host="apps"})
|
||
for: 5m
|
||
labels:
|
||
severity: critical
|
||
annotations:
|
||
summary: "Агент метрик не отвечает"
|
||
description: "Хост {{ $labels.host }}: node-exporter недоступен более 5 минут. Метрики этого хоста больше не поступают."
|
||
|
||
# Приёмник перестал принимать push. Симптом со стороны центра.
|
||
- alert: RemoteWriteStalled
|
||
expr: |
|
||
absent_over_time(up{job="node", host="apps"}[15m])
|
||
for: 5m
|
||
labels:
|
||
severity: critical
|
||
annotations:
|
||
summary: "С продуктового хоста 15 минут не приходят метрики"
|
||
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
|
||
|
||
# ── Хост ────────────────────────────────────────────────────────────────────
|
||
- name: host
|
||
interval: 60s
|
||
rules:
|
||
# Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
|
||
# порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
|
||
- alert: DiskSpaceLow
|
||
expr: |
|
||
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
|
||
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
|
||
for: 15m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Диск занят больше 85 %"
|
||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
|
||
|
||
- alert: DiskSpaceCritical
|
||
expr: |
|
||
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
|
||
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
|
||
for: 5m
|
||
labels:
|
||
severity: critical
|
||
annotations:
|
||
summary: "Диск почти кончился"
|
||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается."
|
||
|
||
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену.
|
||
- alert: DiskWillFillIn24h
|
||
expr: |
|
||
predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
|
||
for: 30m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "По текущему темпу диск кончится за сутки"
|
||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
|
||
|
||
- alert: MemoryPressure
|
||
expr: |
|
||
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
|
||
for: 15m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Памяти доступно меньше 10 %"
|
||
description: "{{ $labels.host }}: свободной памяти {{ $value | humanizePercentage }} от общей."
|
||
|
||
# Своп сам по себе не беда, но резкий рост означает, что что-то перестало
|
||
# помещаться. На Beget своп в 1,78 ГБ был симптомом сосуществования прода
|
||
# и инфраструктуры — и рассосался ровно в момент переезда.
|
||
- alert: SwapGrowing
|
||
expr: |
|
||
node_memory_SwapTotal_bytes > 0
|
||
and (1 - node_memory_SwapFree_bytes / node_memory_SwapTotal_bytes) > 0.50
|
||
for: 30m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Своп занят больше половины"
|
||
description: "{{ $labels.host }}: обычно означает, что рабочий набор перестал помещаться в память."
|
||
|
||
# ── Контейнеры ──────────────────────────────────────────────────────────────
|
||
- name: containers
|
||
interval: 60s
|
||
rules:
|
||
# Цикл перезапусков. Контейнер в restart-loop снаружи выглядит «поднятым»,
|
||
# а деплой при этом зелёный — именно так чуть не уехал в прод пустой пароль
|
||
# у infra-postgres (#3061).
|
||
- alert: ContainerRestartLoop
|
||
expr: |
|
||
changes(container_start_time_seconds{name!=""}[30m]) > 3
|
||
for: 5m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Контейнер перезапускается по кругу"
|
||
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
|
||
|
||
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
|
||
- alert: ContainerNearMemoryLimit
|
||
expr: |
|
||
container_spec_memory_limit_bytes{name!=""} > 0
|
||
and container_memory_working_set_bytes{name!=""}
|
||
/ container_spec_memory_limit_bytes{name!=""} > 0.90
|
||
for: 15m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Контейнер у своего потолка памяти"
|
||
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
|
||
|
||
# ── Postgres ────────────────────────────────────────────────────────────────
|
||
- name: postgres
|
||
interval: 60s
|
||
rules:
|
||
# Забытая транзакция держит горизонт vacuum и отравляет весь кластер.
|
||
# Разбор #2607: осиротевшие запросы висели 46 часов.
|
||
- alert: PostgresLongTransaction
|
||
expr: pg_activity_horizon_oldest_xact_age_s > 3600
|
||
for: 10m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Транзакция открыта больше часа"
|
||
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Пока она жива, vacuum не может убрать мёртвые строки во ВСЕЙ базе."
|
||
|
||
- alert: PostgresLongTransactionCritical
|
||
expr: pg_activity_horizon_oldest_xact_age_s > 21600
|
||
for: 10m
|
||
labels:
|
||
severity: critical
|
||
annotations:
|
||
summary: "Транзакция открыта больше шести часов"
|
||
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Это уже влияет на размер базы."
|
||
|
||
- alert: PostgresIdleInTransaction
|
||
expr: pg_activity_horizon_idle_in_transaction > 3
|
||
for: 15m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Соединения висят в открытой транзакции"
|
||
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value }} шт. Обычно это незакрытая сессия в коде."
|
||
|
||
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
|
||
# позволило 91 день не замечать 198 апдейтов на строку.
|
||
- alert: PostgresLowHotUpdateRatio
|
||
expr: |
|
||
rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
|
||
and
|
||
rate(pg_table_write_amplification_tup_hot_upd[6h])
|
||
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
|
||
for: 6h
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Обновления идут мимо HOT"
|
||
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
|
||
|
||
- alert: PostgresDeadTuplesHigh
|
||
expr: |
|
||
pg_table_write_amplification_dead_tup > 1000000
|
||
and pg_table_write_amplification_dead_tup
|
||
/ (pg_table_write_amplification_live_tup + 1) > 0.5
|
||
for: 1h
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Мёртвых строк больше половины от живых"
|
||
description: "{{ $labels.host }} / {{ $labels.table }}: {{ $value }} мёртвых. Autovacuum не справляется либо заблокирован долгой транзакцией."
|
||
|
||
# WAL. Замер 20.08: 7 ГБ/сутки при четырёх пользовательских расчётах.
|
||
- alert: PostgresWalRateHigh
|
||
expr: rate(pg_wal_bytes_wal_bytes_total[1h]) > 104857600 / 3600
|
||
for: 2h
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "WAL пишется быстрее 100 МБ/час"
|
||
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|