Перейти к содержимому

Observability

Прод развёрнут и работает, но мы про него ничего не знаем. Всё, чем сейчас можно диагностировать — docker ps и docker logs по SSH.

За одну сессию развёртывания это выстрелило четыре раза:

  1. Контейнер Up, приложение мертво. auth-api и user-api крутились в рестарт-петле PM2, при этом docker ps показывал Up N minutes. Traefik продолжал слать туда трафик, потому что проверять ему нечего — health-эндпоинта нет.
  2. Устаревшие логи вводят в заблуждение. Дважды читал через docker logs --since 2m ошибки уже убитого контейнера и делал вывод, что фикс не сработал. Централизованных логов с таймстемпами нет — сопоставить «когда упало» и «когда задеплоили» нечем.
  3. 404 от Traefik неотличим от лежащего приложения. Потеряли время, разбираясь, сломан ли сайт, хотя приложение отдавало 200 на своём порту, а 404 приходил от прокси.
  4. Тихие отказы. SMTP с неверным TLS не ошибается — он висит до таймаута. Битый YAML Traefik молча отверг, продолжив на старом конфиге. Без метрик и алертов такое живёт до жалобы пользователя.

Ни один из этих случаев не был бы загадкой при наличии метрик, централизованных логов и health-чеков.

Ставим Prometheus + Loki + Grafana + Alloy отдельным стеком в Dokploy.

Почему этот стек, а не другой. Команда уже работает с Grafana, Prometheus и Loki на другом проекте — нулевая кривая обучения, переиспользуемые паттерны метрик и готовая интуиция при разборе инцидентов. Это перевешивает любые аргументы про элегантность альтернатив.

Почему Alloy, а не promtail + node-exporter + cAdvisor. Alloy закрывает все три роли:

РольКомпонент Alloy
Сбор docker-логов → Lokiloki.source.docker + discovery.docker
Метрики хоста (CPU/RAM/диск/сеть)prometheus.exporter.unix
Метрики контейнеров (рестарты, OOM)prometheus.exporter.cadvisor

Один агент вместо трёх контейнеров: меньше конфигов, одно место для отладки. Promtail к тому же объявлен устаревшим в пользу Alloy.

S3 — целевое состояние, файловая система — стартовое. Loki работает на файловой системе, Prometheus пишет в локальный TSDB. При 51 GB свободного диска это полноценный вариант, а не компромисс, и он снимает блокировку от ожидания S3-кредов.

Чтобы переезд на S3 позже был сменой конфига, а не переархитектурой, стартуем сразу на TSDB + schema v13 (не на устаревшем boltdb-shipper). У Loki schema_config.configs — это список периодов, каждый со своим хранилищем. Миграция = дописать новый период с object_store: s3, начинающийся с будущей даты. Старые чанки остаются на диске и дочитываются оттуда, новые пишутся в S3. Без простоя и без переливки данных — язык конфига спроектирован ровно под этот сценарий.

Важная оговорка про метрики. У Prometheus нативного S3 нет — это не «включить позже галочкой». Долгосрочное хранение метрик в объектном хранилище означает Mimir или Thanos, то есть другую архитектуру. Поэтому «целевое S3» относится к логам; для метрик локального TSDB достаточно надолго, а переход на Mimir — отдельное решение, когда упрёмся в ретеншн или захотим федерацию.

Стек живёт в crewsforge-observability, не в crewsforge-back.

Не в бэкенде — потому что там включён автодеплой по пушу в main для всех пяти сервисов (проверено на практике). Правка дашборда пересобирала бы весь бэкенд.

Не по репе на компонент, как сделано для qdrant и rebbit-mq — потому что здесь конфиги взаимозависимы: Alloy знает адрес Loki, Prometheus знает что скрейпить, Grafana знает оба datasource, дашборды осмысленны только вместе с метриками. Добавление одной цели скрейпа затрагивает 2–3 файла; в разных репах это рассинхрон. Qdrant и RabbitMQ самодостаточны — там разделение оправдано.

Один compose-сервис в Dokploy, а не четыре: компоненты поднимаются и живут вместе, внутри одного compose видят друг друга по именам без внешних сетей. Данные на volume, рестарт стека ничего не теряет.

crewsforge-observability/
├── alloy/config.alloy
├── prometheus/prometheus.yml
├── prometheus/rules/*.yml
├── loki/loki-config.yml
├── grafana/provisioning/datasources/ # Prom + Loki подключаются сами
├── grafana/provisioning/dashboards/
├── grafana/dashboards/*.json # дашборды в git, не «нарисовал и потерял»
└── dokploy/docker-compose.observability.yml

Слой 1 — без правок кода. Даёт результат сразу:

СигналИсточникОтвечает на вопрос
CPU, RAM, диск, сеть хостаprometheus.exporter.unixНе упрёмся ли в ресурсы
Рестарты, OOM, потребление контейнеровprometheus.exporter.cadvisorКто перезапускается и почему
Логи всех контейнеровloki.source.dockerЧто происходило и в каком порядке
Метрики Traefikвстроенный экспортер TraefikКоды ответов, латентность по роутерам
Внешние пробы доменовprometheus.exporter.blackboxЖив ли сайт снаружи, не истекает ли сертификат

Метрики Traefik закрывают львиную долю RED-картины (rate/errors/duration по маршрутам) без единой строчки в приложениях — прокси видит все запросы. Это важно: слой 1 даёт наблюдаемость раньше, чем дойдут руки до кода.

Слой 2 — требует изменений в crewsforge-back (отдельный PR).

  • /health — которого сейчас нет вообще. Проверка Postgres, Redis, RabbitMQ. Позволит Traefik и Dokploy видеть реальное состояние, а не «процесс запущен».
  • /metrics через prom-client: бизнес-метрики, которых прокси не видит — успешные и неуспешные регистрации, отправки писем, публикации в очередь, длительность запросов к БД.

Метрики HTTP проектируем по паттерну, уже используемому командой: гистограмма с лейблами {method, route}, отдельный счётчик, status_code не в гистограмме. Причина — кардинальность: status_code в лейблах гистограммы умножает число временных рядов на количество кодов, а нужен он в основном для счётчика ошибок.

Сейчас логирование неоднородно: 9 мест с new Logger(), из них 13 вызовов структурных и 17 строковых. Строковые запекают значения в текст — `Client ${client.id} connected to project ${projectId}` — и фильтровать по projectId можно только регуляркой.

Плюс дефолтный логгер NestJS печатает объекты с переносами строк, а Docker отдаёт каждую строку отдельно: одно событие превращается в 10 записей в Loki. Это обнаружилось при первой же проверке сбора логов — контекст события рассыпается, счётчики ошибок завышаются в разы.

Решение: nestjs-pino, конвенция переносится из desktop-app, где она уже обкатана в бою. Не изобретаем — берём работающее:

this.logger.error({
action: 'ChatService/createChat', // Класс/метод
data: { companyId, visitId, clientPhone }, // контекст
err: e, // ошибка отдельным полем
});

Инжект PinoLogger через DI, autoLogging: false (автологирование HTTP выключено — как там и сделано), stage для разметки этапов, доменные идентификаторы верхним уровнем.

Три отличия от desktop-app:

  1. redact обязателен. Там его нет, но там и autoLogging выключен, так что заголовки в лог не попадали. Здесь уже логируется email, а логи живут 30 дней и видны каждому со входом в Grafana. Маскируем authorization, cookie, пароли, коды; email частично.
  2. requestId из CLS. ClsModule с генерацией id из X-Request-Id уже настроен во всех сервисах — и никуда не идёт. Подключаем к pino: один запрос становится прослеживаемым через все строки.
  3. Правило «только скаляры» в data. В desktop-app встречается data: { webhook, event } — целые объекты в логе; это раздувает Loki и тянет туда что попало.

Почему логирование идёт перед метриками: оно ценно само по себе (сегодняшние четыре инцидента — все про логи, не про метрики), и метрики потом ложатся на готовый каркас с тем же принципом «одна либа, подключение одной строкой».

Правило отбора: алерт обязан требовать действия. Всё остальное — дашборд.

Стартовый набор:

АлертУсловиеПочему важен
Сервис недоступенblackbox-проба падает 2 минПользователь этого не увидит раньше нас
Крэшлупрестарты контейнера > 3 за 10 минРовно случай auth-api/user-api
Сертификат истекает< 14 днейLet’s Encrypt обновляет сам, но молча ломается
Диск заканчивается> 85%Prometheus и Loki растут постоянно
Всплеск 5xxдоля 5xx > 5% за 5 мин по роутеруОтличает «сломали релизом» от «пользователи ушли»
OOM-killсобытие в cadvisorТихий убийца, в логах приложения не виден

Чего не алертим на старте: латентность до накопления базовой линии. Алерт на p95 без понимания нормы даёт ложные срабатывания и убивает доверие ко всем остальным — на другом проекте ровно это и произошло, алерт флапал с NaN.

Grafana на grafana.crewsforge.com (A-запись уже есть), TLS от Let’s Encrypt, вход через встроенную авторизацию Grafana. Prometheus, Loki и Alloy наружу не публикуются — только внутри dokploy-network; при необходимости через SSH-туннель.

Исходя из 51 GB свободного диска:

  • Prometheus — 15 дней (~2–4 GB при текущем объёме)
  • Loki — 30 дней (~5–10 GB, зависит от разговорчивости)

Обе цифры с большим запасом; пересмотрим по факту роста. На оба хранилища ставим лимит по размеру, чтобы диск не кончился внезапно — иначе упадёт вся нода, а не только мониторинг.

Инфраструктура существует только на сервере, вне git. Всплыло по ходу работы:

  • docker-compose.yml RabbitMQ живёт только в БД Dokploy. Я правил его вручную — добавил dokploy-network, заменил слабый пароль из шаблона, убрал публикацию management-UI наружу. Этих правок нет нигде в git.
  • Postgres и Redis — Dokploy-managed, compose-файла нет в принципе.
  • middlewares.yml Traefik с редиректом www → апекс лежит только на сервере (сам конфиг записан в docs/infrastructure.md, но не как исполняемый файл).
  • Значения env всех сервисов — только в БД Dokploy.

Учитывая, что VPS уже терялся один раз в июле и всё пришлось разворачивать заново, это реальный риск, а не теоретический. Правильное решение — перевести инфраструктурные сервисы на git-источник и положить конфиги в crewsforge-observability (или отдельную infra-репу). В организации уже есть форк Infisical — похоже, кто-то думал в сторону управления секретами; стоит решить, используем его или нет.

Осознанно откладываем, чтобы не смешивать с задачей обсервабилити.

  • Приложения на слое 1 не трогаем вообще.
  • Ни один существующий сервис не переконфигурируется и не перезапускается.
  • Схема БД, домены, деплой бэкенда — без изменений.