Наблюдаемость: логирование и метрики
Приложение, которое ты не видишь изнутри, — чёрный ящик. Оно может работать идеально неделями, а потом уронить запрос на десятую секунду, и ты узнаешь об этом из твита разозлённого пользователя. Наблюдаемость превращает чёрный ящик в стеклянный: по трём сигналам — логам, метрикам и трейсам (третий — в следующей главе) — ты отвечаешь на вопросы «что случилось», «насколько плохо» и «где именно». В этой главе разберём первые два столпа до уровня, когда диагностика инцидента занимает минуты, а не часы грепа по SSH.
Начнём с логов: почему текстовые строки «INFO User logged in» — мёртвый формат, и как структурированный JSON превращает лог в запрашиваемую базу данных. Поднимем Loki-стек для агрегации и научимся писать LogQL-запросы. Затем метрики: как Prometheus собирает time-series, чем Node Exporter отличается от cAdvisor, как экспонировать собственные метрики из Node.js через prom-client и как читать PromQL — rate, increase, histogram_quantile — без магии.
Три столпа наблюдаемости
Заголовок раздела «Три столпа наблюдаемости»| Столп | Отвечает на вопрос | Примеры | Инструменты |
|---|---|---|---|
| Логи | Что случилось? | «Оплата #4821 не прошла: timeout upstream» | pino → Loki/ELK |
| Метрики | Насколько плохо и как меняется? | «Доля 5xx выросла с 0.1% до 2% за 10 минут» | Prometheus + exporters |
| Трейсы | Где именно по цепочке? | «650 из 800 мс запроса — SELECT без индекса» | OpenTelemetry → Jaeger |
Столпы усиливают друг друга: метрика показывает скачок, лог даёт контекст события, трейс — точное место. Без склейки (request_id/trace_id в каждом сигнале) расследование превращается в археологию.
Логи: структура вместо текста
Заголовок раздела «Логи: структура вместо текста»Текстовый лог непарсим машиной: User 1234 logged in from 1.2.3.4 — чтобы найти все логины пользователя, нужен regex, который ломается на первом изменении формата. Структурированный JSON — самоописываемый:
{"ts":"2026-09-06T12:04:11Z","level":"error","msg":"payment failed","request_id":"a1b2c3d4","user_id":4821,"duration_ms":231,"error":"timeout upstream","service":"pet-api"}Правила хорошего лога: одно событие — одна строка JSON, request_id на каждый запрос для склейки цепочки, уровни по делу, избыточные поля лучше недостающих.
pino: самый быстрый JSON-логгер для Node.js
Заголовок раздела «pino: самый быстрый JSON-логгер для Node.js»// logger.ts — конфигурация через переменные окруженияimport { pino } from "pino";
export const log = pino({ level: process.env.LOG_LEVEL ?? "info", // debug в dev, info в prod base: { service: "pet-api", env: process.env.NODE_ENV }, // в каждую строку timestamp: pino.stdTimeFunctions.isoTime, // ISO 8601 formatters: { level: (label) => ({ level: label }), // level: "info" вместо level: 30 },});// app.ts — request_id на каждый запрос через дочерний логгерimport { randomUUID } from "node:crypto";import { log } from "./logger";
app.use((req, res, next) => { req.log = log.child({ request_id: randomUUID() }); req.log.info({ method: req.method, url: req.url }, "incoming request"); next();});
// В обработчиках — лог с контекстом запросаapp.post("/api/payments", async (req, res) => { const { userId, amount } = req.body; try { const result = await processPayment(userId, amount); req.log.info({ user_id: userId, amount, duration_ms: result.ms }, "payment ok"); res.json(result); } catch (err) { req.log.error({ user_id: userId, err: err.message }, "payment failed"); res.status(502).json({ error: "payment failed" }); }});Pino на порядок быстрее winston/console.log: запись в stdout через потоки без блокировки event loop. Логи идут в stdout → Docker перехватывает → файлы /var/lib/docker/containers/*/*.log → Promtail забирает. Это 12-факторный путь: приложение не знает, куда пишет файлы, лог-сборщик решает сам.
Уровни и сэмплирование
Заголовок раздела «Уровни и сэмплирование»Уровни — контракт: debug (только локально), info (значимые события: запрос, деплой, старт), warn (неожиданное, но система работает: retry, fallback), error (операция не выполнена: timeout, 500). В prod LOG_LEVEL=info — debug-логи съедают диск и CPU на парсинг.
Когда info слишком много (10k RPS с логом на каждый запрос) — сэмплирование: логируем долю успешных запросов, ошибки — всегда:
// Сэмплирование 10% успешных запросов, 100% ошибокapp.use((req, res, next) => { req.shouldLog = Math.random() < 0.1 || req.path.startsWith("/api/"); next();});
app.use((req, res, next) => { const start = Date.now(); res.on("finish", () => { if (!req.shouldLog && res.statusCode < 400) return; // пропускаем 90% успешных req.log.info( { method: req.method, url: req.url, status: res.statusCode, duration_ms: Date.now() - start }, "request completed", ); }); next();});Сэмплирование — компромисс: теряем точность (90% запросов не видим), но сохраняем диск и возможность агрегации. Для highload — стандарт, для pet-проекта — избыточно.
Loki-стек: агрегация и LogQL
Заголовок раздела «Loki-стек: агрегация и LogQL»Сырой stdout контейнера — не хранилище. Loki (Grafana Labs) забирает логи и индексирует только метки (labels), не содержимое: дешевле ELK на порядок, родной выбор для Grafana-стека.
# docker-compose.yml — стек наблюдаемостиservices: loki: image: grafana/loki:3 command: -config.file=/etc/loki/loki.yml volumes: - ./loki.yml:/etc/loki/loki.yml:ro - loki-data:/loki
promtail: image: grafana/promtail:3 volumes: - /var/lib/docker/containers:/var/lib/docker/containers:ro # логи контейнеров - /var/run/docker.sock:/var/run/docker.sock:ro # service discovery - ./promtail.yml:/etc/promtail/config.yml:ro command: -config.file=/etc/promtail/config.yml
grafana: image: grafana/grafana:11 ports: ["3000:3000"] volumes: [grafana-data:/var/lib/grafana] environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
volumes: loki-data: grafana-data:# promtail.yml — скрейпинг docker-логов с маппингом метокserver: http_listen_port: 9080
positions: filename: /tmp/positions.yaml # смещение в файлах — не читать заново
scrape_configs: - job_name: docker docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s relabel_configs: - source_labels: ["__meta_docker_container_name"] target_label: container - source_labels: ["__meta_docker_container_log_stream"] target_label: stream # stdout / stderr - source_labels: ["__meta_docker_container_label_com_docker_compose_service"] target_label: service # метка из compose servicePromtail читает JSON-логи Docker’а, парсит каждую строку (pipeline stages) и шлёт в Loki. Ключевая концепция — метки: {container="/pet-app", service="pet-api"}. Меток мало, значения дискретны (не уникальные ID!) — иначе Loki превратится в медленный grep.
LogQL: язык запросов
Заголовок раздела «LogQL: язык запросов»LogQL-запрос = селектор меток + фильтр/агрегация:
# Все логи контейнера приложения{container="/pet-app"}
# Только ошибки (фильтр по подстроке){container="/pet-app"} |= "level":"error"
# Regex: ошибки и warning'и{container="/pet-app"} |~ "level\":\"(error|warn)\""
# Извлечь поля из JSON и отфильтровать по ним{container="/pet-app"} | json | duration_ms > 500
# Агрегация: rate ошибок за 5 минутsum(rate({container="/pet-app"} |= "error" [5m])) by (service)
# Подсчёт логинов по user_id за часsum(count_over_time({container="/pet-app"} |= "login ok" | json [1h])) by (user_id)В Grafana: Explore → Loki → вводишь запрос → видишь строки, график по времени. Скорость: селектор меток отсекает 99% данных, фильтры работают по оставшемуся.
Grafana-дашборд логов
Заголовок раздела «Grafana-дашборд логов»Grafana подключается к Loki как datasource. Типичная панель для приложения:
- Time series: rate ошибок
sum(rate({service="pet-api"} |= "error" [5m]))— видишь всплески. - Logs panel:
{service="pet-api"} | json | level="error"— сами строки с подсветкой. - Stats: топ эндпоинтов по количеству 5xx —
sum by (url) (count_over_time({service="pet-api"} | json | status >= 500 [1h])).
Дашборд сохраняется JSON’ом, версионируется в git рядом с конфигами (grafana/dashboards/pet-logs.json), подключается provisioning’ом — не руками в UI.
ELK как альтернатива
Заголовок раздела «ELK как альтернатива»ELK (Elasticsearch + Logstash + Kibana) — классический стек: Logstash парсит, Elasticsearch индексирует полное содержимое (полнотекст), Kibana визуализирует. Мощнее Loki на сложном поиске («найди все запросы с телом, содержащим X»), но тяжелее: Elasticsearch жрёт RAM (heap от 4GB), требует шардирования при росте, эксплуатация — отдельная профессия. Выбор простой: Loki по умолчанию, ELK когда нужен полнотекст по сырым логам или когда экосистема уже на Elasticsearch (другие системы пишут туда).
Метрики: Prometheus-архитектура
Заголовок раздела «Метрики: Prometheus-архитектура»Prometheus — time-series БД: приложения и экспортеры отдают метрики на /metrics, Prometheus опрашивает (scrape) по расписанию и хранит. Модель pull (а не push, как у многих) — Prometheus сам решает, кого опрашивать: не нужны агенты на каждом хосте, детекция недоступности цели из коробки (up{job="x"} == 0).
Типы метрик
Заголовок раздела «Типы метрик»- Counter — только растёт (запросы, ошибки, байты).
http_requests_total 48213. - Gauge — растёт и падает (температура, соединения, память).
node_memory_MemAvailable_bytes 3.2e9. - Histogram — распределение значений по бакетам (латентность, размер ответа).
http_request_duration_seconds_bucket{le="0.1"} 39123. - Summary — как histogram, но с квантилями на стороне клиента. Редко, предпочитай histogram.
Scrape-конфиги
Заголовок раздела «Scrape-конфиги»global: scrape_interval: 15s # как часто опрашивать evaluation_interval: 15s # как часто проверять alert-правила
scrape_configs: - job_name: node # железо и ОС VPS static_configs: - targets: ["node-exporter:9100"]
- job_name: cadvisor # метрики контейнеров Docker static_configs: - targets: ["cadvisor:8080"]
- job_name: nginx # stub_status через exporter static_configs: - targets: ["nginx-exporter:9113"]
- job_name: app # свои метрики приложения static_configs: - targets: ["pet-app:3000"] scrape_interval: 10s metrics_path: /metricsЭкспортеры — sidecar-процессы, отдающие метрики:
- Node Exporter (
:9100): CPU, память, диск, сеть, load average хоста. Ставится на каждую VM. - cAdvisor (
:8080): per-container метрики — CPU, RSS, сеть, I/O по каждому контейнеру Docker. Единственный способ увидеть, кто из контейнеров жрёт память. - nginx-prometheus-exporter: парсит
stub_statusNginx → активные соединения, запросы/с.
docker run -d --name node-exporter --net=obs \ --pid="host" -v "/:/host:ro,rslave" \ prom/node-exporter --path.rootfs=/host
docker run -d --name cadvisor --net=obs \ -v /:/rootfs:ro -v /var/run:/var/run:ro -v /sys:/sys:ro \ -v /var/lib/docker/:/var/lib/docker:ro \ gcr.io/cadvisor/cadvisor:latestПриложение: prom-client
Заголовок раздела «Приложение: prom-client»Свои метрики из Node.js — библиотекой prom-client:
import client from "prom-client";
export const register = new client.Registry();client.collectDefaultMetrics({ register }); // event loop lag, heap, GC
// Счётчик запросов по эндпоинту и статусуexport const httpRequestsTotal = new client.Counter({ name: "http_requests_total", help: "Количество HTTP-запросов", labelNames: ["method", "route", "status"], registers: [register],});
// Гистограмма латентности — основа для RED-метрикexport const httpDuration = new client.Histogram({ name: "http_request_duration_seconds", help: "Длительность HTTP-запросов", labelNames: ["method", "route", "status"], buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], // бакеты в секундах registers: [register],});// app.ts — middleware, собирающее метрикиimport { register, httpRequestsTotal, httpDuration } from "./metrics";
app.use((req, res, next) => { const start = process.hrtime.bigint(); res.on("finish", () => { const route = req.route?.path ?? req.path; // /api/users/:id, а не /api/users/123 const duration = Number(process.hrtime.bigint() - start) / 1e9; httpRequestsTotal.inc({ method: req.method, route, status: res.statusCode }); httpDuration.observe({ method: req.method, route, status: res.statusCode }, duration); }); next();});
app.get("/metrics", async (req, res) => { // Prometheus ходит сюда res.set("Content-Type", register.contentType); res.send(await register.metrics());});Важно: метка route — шаблон пути (/api/users/:id), не реальный URL с ID. Иначе cardinality взорвётся: каждый уникальный URL = новая временная серия, Prometheus захлёбывается через час.
PromQL-практика
Заголовок раздела «PromQL-практика»PromQL — язык запросов к time-series. Три функции закрывают 90% задач:
rate() — скорость изменения counter’а. Всегда используй с [Xm]-окном:
# Запросов в секунду за 5 минутsum(rate(http_requests_total[5m]))
# Ошибок в секунду (только 5xx)sum(rate(http_requests_total{status=~"5.."}[5m]))
# Доля ошибок: ошибки / все запросыsum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))increase() — абсолютный прирост counter’а за окно:
# Сколько ошибок было за последний часincrease(http_requests_total{status=~"5.."}[1h])
# Сколько запросов обработал каждый инстанс за суткиsum by (instance) (increase(http_requests_total[24h]))histogram_quantile() — квантиль по бакетам гистограммы:
# P95 латентности за 5 минутhistogram_quantile( 0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# P50 (медиана) по конкретному эндпоинтуhistogram_quantile( 0.5, sum by (le) (rate(http_request_duration_seconds_bucket{route="/api/payments"}[5m])))Механика: гистограмма хранит счётчики попаданий в бакеты (le = less or equal). histogram_quantile интерполирует квантиль между бакетами. Важно: квантиль считается по агрегированным данным — sum by (le) объединяет бакеты всех инстансов. Ошибка новичка: считать квантиль по каждому инстансу отдельно и усреднять — результат будет занижен.
Метод RED: на что смотреть
Заголовок раздела «Метод RED: на что смотреть»Сто метрик парализуют. Для каждого HTTP-сервиса хватит трёх (RED — Rate, Errors, Duration):
| Метрика | PromQL | Вопрос |
|---|---|---|
| Rate | sum(rate(http_requests_total[5m])) |
Сколько запросов/с? База для сравнения при инцидентах |
| Errors | sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) |
Доля 5xx. Порог алерта: > 1% за 5 минут |
| Duration | histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) |
P95 латентности. Рост в 2× от базовой линии — сигнал |
Базовая линия — твой ориентир: записывай нормальные значения RED на неделю, получишь пороги из жизни системы, а не из головы. «P95 вырос с 120 до 400 мс» — действуем; «P95 > 500 мс» в вакууме — нет.
Дашборд в Grafana: три панели RED + панели node-exporter (CPU, RAM, диск). Этого достаточно для диагностики 90% инцидентов pet-проекта.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Текстовые логи без структуры.
grep "error"по 10 ГБ логов — часы вместо секунд, regex ломается при изменении формата. Хорошо: JSON с первого дня, pino/winston с фиксированной схемой полей. - Уникальные значения в метках Prometheus.
http_requests_total{url="/users/123"}— каждый пользователь новая серия, через час Prometheus OOM. Хорошо:routeс параметризованным путем, user_id/request_id — только в логи. rate()без окна или на gauge.rate(node_memory_MemAvailable_bytes[5m])— бессмыслица: rate только для counter’ов. Хорошо:rateдля counter’ов,deriv/deltaдля gauge, всегда с[Xm].LOG_LEVEL=debugв prod. Диск съеден за неделю, лог-агент захлёбывается, полезные логи утонули. Хорошо:infoв prod,debugвключается точечно и временно при диагностике.- Promtail без
positions. Перезапуск Promtail — чтение всех логов с начала: дубли в Loki, нагрузка на диск. Хорошо:positions.filename— Promtail помнит смещение и продолжает с места остановки. - Метрики без
route-маппинга. Middleware считает метрики поreq.pathвместоreq.route?.path— cardinality-взрыв (п.2 в другой одежде). Хорошо: параметризованный роут из роутера Express/Fastify. - ELK «на всякий случай». Elasticsearch на VPS с 4 ГБ RAM — тормоза и OOM вместо наблюдаемости. Хорошо: Loki для старта, ELK только при реальной потребности в полнотексте и ресурсах под него.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Почему структурированные логи лучше текстовых? JSON самоописываем: поля парсятся без regex, типизированы (duration_ms — число), фильтруются и агрегируются в Loki/ELK запросами. Текстовый лог требует regex, ломается при смене формата, не даёт агрегаций «по полю» без парсинга.
- Чем Loki отличается от ELK архитектурно? Loki индексирует только метки (labels), содержимое хранит сырым и фильтрует на чтение: дёшево, просто, но медленный сложный полнотекст. Elasticsearch индексирует всё содержимое инвертированным индексом: мощный поиск, но тяжёлая эксплуатация (RAM, шарды, кластер).
- Когда использовать counter, gauge, histogram? Counter — монотонно растущие события (запросы, ошибки). Gauge — значения, меняющиеся в обе стороны (память, соединения). Histogram — распределения (латентность, размер): считает бакеты и сумму, откуда
histogram_quantile. - Что делает
histogram_quantileи почему нельзя усреднять квантили? Интерполирует квантиль между бакетами агрегированной гистограммы. Усреднение квантилей по инстансам некорректно: P95 инстанса A и P95 инстанса B не дают общий P95 (скрывают выбросы на одном инстансе). Правильно:sum by (le)бакетов, затем одинhistogram_quantile. - Почему Prometheus — pull, а не push? Pull-модель: Prometheus сам опрашивает цели — упавшая цель видна из коробки (
up == 0), конфигурация целей централизована, не нужны агенты с буфером. Push требует агента с локальной очередью и отдельного механизма детекции недоступности. - Что такое cardinality и почему это проблема? Число уникальных комбинаций меток. Каждая комбинация — серия в памяти Prometheus (метки + чанки сэмплов). High cardinality (user_id, request_id в метках) взрывает память и замедляет запросы. Правило: метки с конечным множеством значений (route, status), неограниченные — в логи.
- Как работает сэмплирование логов и когда его применять? Логируется фракция событий (например, 10% успешных запросов), ошибки — всегда. Применять при высоком RPS, когда полные логи непомерно дороги. Инструменты: хвостовое сэмплирование в OTel Collector (решение после завершения запроса: логировать, если ошибка), вероятностное — в приложении.
- Метод RED: что это и почему именно эти три? Rate (запросы/с), Errors (доля ошибок), Duration (латентность P95). Это минимальный полный набор для HTTP-сервиса: нагрузка, корректность, скорость. Покрывает симптомы большинства инцидентов без паралича от сотни метрик.
Практика
Заголовок раздела «Практика»- Переведи логирование pet-проекта на pino: JSON,
request_idна каждый запрос,levelиз env. Подними Loki + Promtail + Grafana через compose. Найди через LogQL всеerrorза сутки и построй time series их rate за 5 минут. - Настрой Promtail с
positionsи меткамиcontainer/serviceиз Docker-метаданных. Перезапусти Promtail и убедись, что логи не продублировались (смещение сохранилось). - Подними Node Exporter и cAdvisor, подключи к Prometheus. Собери Grafana-дашборд: CPU, RAM, диск хоста + CPU/RSS по каждому контейнеру. Найди самый прожорливый контейнер.
- Добавь
/metricsв приложение через prom-client: counter запросов, гистограмма латентности с меткойroute(параметризованный путь!). Построй график P95 по эндпоинтам в Grafana и убедись, что cardinality разумная (count by (route) (http_requests_total)< 50). - Реализуй сэмплирование логов: 10% успешных запросов, 100% ошибок и все 5xx. Замерь размер логов за час до и после (нагрузка через
hey). - Напиши 5 PromQL-запросов для RED pet-проекта: rate запросов, доля 5xx, P95, P50, запросы в сутки по эндпоинтам. Сохрани их в Grafana как дашборд и экспортируй JSON в git.
Что почитать
Заголовок раздела «Что почитать»- Grafana Loki — архитектура, LogQL, retention
- Promtail configuration — pipeline stages, relabeling
- Prometheus — getting started и Querying basics
- prom-client — метрики для Node.js
- Google SRE Book — Monitoring Distributed Systems — RED, базовые линии, алертинг-философия
- ELK Stack vs Loki — честное сравнение