Полная наблюдаемость на одном VPS: метрики, логи, алерты, дашборды — 452 МБ requests

Published: 2026-06-13

За последнюю неделю этот k0s-кластер оброс полным стеком наблюдаемости: VictoriaMetrics для метрик, VictoriaLogs для логов, Promtail возит access-логи Traefik, vmalert стреляет в Telegram, Grafana всё это рисует. Каждой части достался свой пост; этот — карта: как куски соединяются, сколько всё вместе стоит в RAM и CPU на двухъядерном VPS, где параллельно живут девять сайтов, почтовый сервер и шесть прокси, и какие решения повторяются в каждом компоненте.


Общая картина

                    ┌──────────────────────────────────────────────┐
                    │                 Grafana :3000                │
                    │  datasources: VictoriaMetrics + VictoriaLogs │
                    └──────────┬─────────────────────┬─────────────┘
                               │ PromQL/MetricsQL    │ LogsQL
                               ▼                     ▼
        ┌─────────────────────────────┐   ┌──────────────────────┐
        │   vmsingle-server :8428     │   │  victoria-logs :9428 │
        │   scrape + store, 90d       │   │  store, 7d           │
        └──┬──────────────────────┬───┘   └──────────▲───────────┘
           │ scrapes              │ rules            │ loki push
           ▼                      ▼                  │
  node-exporter            ┌──────────┐       ┌──────┴─────┐
  kube-state-metrics       │ vmalert  │       │  promtail  │
  traefik :9101            └────┬─────┘       │ DaemonSet  │
  blackbox-exporter             │             └──────▲─────┘
  kubelet cadvisor              ▼                    │ tails
  proxy exporters        ┌──────────────┐    /var/log/pods/
                         │ alertmanager │──▶ Telegram   traefik_traefik-*
                         └──────────────┘

Два хранилища — по одному на тип сигнала. Оба — одиночные бинарники семейства VictoriaMetrics, оба хранят на hostPath PV, обоих опрашивает одна Grafana. Всё, что ниже — stateless-обвязка.

Счёт за ресурсы

Цифры, которые оправдывают всю архитектуру, прямо из манифестов:

Компонент CPU req RAM req CPU limit RAM limit
vmsingle 50m 128Mi 500m 512Mi
victoria-logs 10m 32Mi 200m 128Mi
promtail 10m 32Mi 100m 64Mi
vmalert 10m 32Mi 200m 128Mi
alertmanager 10m 32Mi 100m 64Mi
node-exporter 10m 20Mi 100m 64Mi
kube-state-metrics 10m 32Mi 200m 128Mi
blackbox-exporter 10m 16Mi 100m 64Mi
grafana 50m 128Mi 500m 512Mi
Итого 170m 452Mi 2000m 1664Mi

452 МБ requests на девять компонентов. Установка kube-prometheus-stack по умолчанию запрашивает больше под один только Prometheus. Сумма лимитов больше, чем есть у ноды — это нормально: пики не совпадают, а планировщик ориентируется на requests.

Путь метрик

vmsingle скрейпит всё сам — встроенный скрейпер принимает обычные Prometheus scrape_configs, так что ни Prometheus, ни оператора нет. Таргеты: node-exporter, kube-state-metrics, Traefik, kubelet cAdvisor (с маленьким ClusterRole на API kubelet'а), два экспортёра прокси, сам vmsingle, vmalert и три blackbox-джоба: HTTP-пробы девяти сайтов, TCP-проверки четырёх портов прокси и внешний DNS для связности. Срок хранения — 90 дней на hostPath-томе в 20 ГБ.

vmalert считает правила — сайт лежит, медленный ответ, SSL истекает раньше чем через 14 дней, порт прокси недоступен, деградация внешней связности, давление по RAM/диску/CPU, предсказание «диск заполнится через 4 часа», CrashLoop, расхождение реплик — и пушит сработавшие алерты в alertmanager, который доставляет в Telegram с шаблоном 🔴/✅ и inhibit-правилом: critical глушит свой же warning.

Путь логов

Traefik пишет JSON access-логи в stdout (logs.access.format: json в values чарта). Kubelet складывает их в /var/log/pods/traefik_traefik-*/. Promtail тейлит этот глоб — без Kubernetes service discovery, статическим путём — парсит JSON, поднимает RequestMethod и DownstreamStatusClientHost, что безопасно только благодаря терпимости VictoriaLogs к кардинальности) в лейблы, отбрасывает известную шумную ошибку и пушит в VictoriaLogs по протоколу Loki. Срок хранения — 7 дней на томе в 5 ГБ: логи отвечают на «что только что случилось», историю хранят метрики.

Это разделение важно: ничего долгоживущего из логов не выводится. Частота запросов, проценты ошибок и гистограммы латентности берутся из собственного Prometheus-эндпоинта Traefik — хранить и запрашивать их так дешевле, чем когда-либо вышло бы считать их из access-логов.

Дашборды

Grafana запровижинена, а не накликана:

  • Datasources приходят из Helm values — VictoriaMetrics (тип prometheus, default) и VictoriaLogs через плагин victoriametrics-logs-datasource.
  • Дашборды — ConfigMap'ы с лейблом grafana_dashboard: "1", их подбирает sidecar. Их пять: нода, Kubernetes, Traefik, прокси, VPN.
  • Анонимный доступ — только viewer; правки происходят в git.

Дашборд Traefik — место встречи обоих сигналов: панели per-service RPS, латентности, ошибок и трафика из метрик — лейбл service очищен от хеша relabel-правилом на этапе скрейпа — и две logs-панели внизу на LogsQL:

{job="traefik-access"}                                    # живой access-лог
{job="traefik-access"} DownstreamStatus:~"[45][0-9][0-9]" # только ошибки

Прыжок от «error rate вырос» к «вот конкретные упавшие запросы», не выходя из дашборда — ради этого вся неделя и была.

Решения, которые повторяются

Если перечитать четыре поста подряд, одни и те же выборы видны в каждом компоненте:

Одиночный бинарник вместо распределённого. vmsingle вместо Prometheus+оператор, single-node victoria-logs вместо кластера, promtail вместо конвейера агентов. На одном узле любой слой координации — чистый оверхед.

Статический конфиг вместо discovery. Scrape-таргеты — static_configs, путь к логам — глоб, дашборды и datasources — файлы в git. Механизмы discovery окупают сложность там, где вещи приходят и уходят; здесь не уходит ничего.

hostPath PV + Retain + явный биндинг через volumeName. Ни storage class, ни провижионера; данные переживают helm uninstall, а storageClassName: "" на PVC не даёт ему ждать провижионер, которого нет.

Ограниченная память везде. У каждого компонента лимиты; vmsingle дополнительно зажимает кэши через memory.allowedPercent: 20. На общей ноде безлимитный стек наблюдаемости первым делом OOM-ит те самые сервисы, за которыми должен следить.

Стабильные ClusterIP-имена. Grafana и vmalert ходят в vmsingle-stable — обычный ClusterIP перед headless-сервисом чарта: headless DNS раздаёт IP подов, которые протухают при рестартах CoreDNS.

Что может пойти не так

Стек мониторит всё, кроме собственной смерти. Если нода легла, vmalert лёг вместе с ней, и Telegram молчит. Внешняя страховка — алерт Beszel на отвал агента плюс blackbox-пробы публичных сайтов — но blackbox тоже живёт на этой же ноде. Честная детекция мёртвой ноды требует одного зонда снаружи; внешний uptime-чекер, дёргающий status.antonnovikov.com, закрывает вопрос.

Метрики в Grafana есть, logs-панели пустые. Диагноз в три шага: VMUI на victoria-logs:9428/select/vmui отдаёт данные (хранилище ок)? Страница /targets у Promtail показывает файл активным (доставка ок)? UID datasource в дашборде совпадает с запровижиненным (проводка ок)? У меня было третье — фиксируйте UID.

После ребута ноды всё рестартует разом. Девять компонентов, стартующих наперегонки на двух ядрах — liveness-пробы не успевают, и поды рестартуют волнами. Щедрый initialDelaySeconds на stateful-подах (vmsingle, victoria-logs, grafana) разрывает петлю; stateless могут потрепыхаться без вреда.

Итого

  • Две базы-бинарника — VictoriaMetrics для метрик (90d) и VictoriaLogs для логов (7d) — закрывают оба сигнала за 160 МБ RAM в requests на двоих
  • Полный стек из девяти компонентов запрашивает 452 МБ / 170m CPU и уживается с боевыми сервисами на двухъядерном VPS
  • Promtail соединяет два мира: JSON access-логи Traefik → протокол Loki → VictoriaLogs, рядом с метриками в одной Grafana
  • Алерты идут vmalert → alertmanager → Telegram; SSL expiry и предсказание заполнения диска — два, которые уже себя окупили
  • Везде одни паттерны: одиночный бинарник, статический конфиг, hostPath+Retain, ограниченная память, стабильные имена сервисов
  • Оставшаяся дыра — самонаблюдение: нужен зонд вне ноды