Как делать11 октября 2026 г.12 мин чтения
DevOps, тестирование и безопасностьШаг 8 из 12

Мониторинг и логирование: что собирать и как реагировать

Коротко

Сервису нужны три вида телеметрии: структурированные логи, чтобы разобрать конкретный запрос; метрики, чтобы видеть тренды и ставить алерты; трассировки, чтобы проследить запрос через несколько сервисов. Минимальный набор метрик — четыре золотых сигнала из книги Google SRE: задержка, трафик, ошибки и насыщение. Алерт должен срабатывать на симптом, который видят пользователи, с порогом и выдержкой времени, а не на любую странность.

ОсновыУровень: СреднийМониторинг и логи

После материала вы сможете

  • настроить структурированные JSON-логи на pino с уровнями, маскированием секретов и reqId в каждой строке
  • передать correlation id между двумя сервисами в заголовке X-Request-Id и собрать по нему все строки одного запроса
  • отдать метрики RED на /metrics и посчитать долю ошибок и p95 запросами PromQL
  • описать правило алерта с порогом и for и проверить, что оно переходит в firing

Проверено на

Node.js 24 LTS (24.18)Express 5.3.0pino 10.4.0pino-http 11.0.0@prometheus-io/client 0.16.1Prometheus 3.15.0Docker Compose 5.3

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Логи, метрики, трассировки: что есть что
  2. 02Что измерять: четыре золотых сигнала, RED и USE
  3. 03Структурированные логи на pino: уровни и маскирование
  4. 04Correlation id: одна нить через все сервисы
  5. 05Health и readiness: живой ли процесс и готов ли он к трафику
  6. 06Метрики Prometheus: /metrics и запросы PromQL
  7. 07Алерты: пороги, выдержка и шум
  8. 08Grafana, OpenTelemetry и упражнения

Логи, метрики, трассировки: что есть что

Пользователь пишет в поддержку «заявка не отправилась». Без телеметрии команда гадает: упал сервер, медленная база или ошибка во фронтенде. Мониторинг отвечает на два вопроса, которые глава 6 книги Google SRE «Monitoring Distributed Systems» формулирует так: что сломалось (симптом) и почему (причина). Для ответа используют три сигнала — так их называет и OpenTelemetry.

Лог

Что это
Запись о событии: время, уровень, сообщение и поля
На какой вопрос отвечает
Что именно случилось с этим запросом?
Типичный инструмент
pino, Loki, Elasticsearch

Метрика

Что это
Число во времени: счётчик, текущее значение, гистограмма
На какой вопрос отвечает
Сколько, как быстро, растёт ли проблема?
Типичный инструмент
Prometheus, Grafana

Трассировка (trace)

Что это
Путь одного запроса через сервисы, разбитый на участки (span)
На какой вопрос отвечает
Где именно запрос провёл время?
Типичный инструмент
OpenTelemetry, Jaeger, Tempo

Сигналы дополняют друг друга. Метрика дешёвая: одно число на интервал, сколько бы ни было запросов, поэтому на метриках строят графики и алерты. Лог дорогой — строка на каждое событие, — зато в нём контекст: какой заказ, какая ошибка, какой стек вызовов. Типичный путь разбора: алерт по метрике «ошибок стало 25%» → фильтр логов по времени и уровню error → request id конкретного запроса → все строки этого запроса во всех сервисах.

Книга SRE различает мониторинг «белого ящика» — метрики и логи изнутри системы — и «чёрного ящика»: внешнюю проверку так, как систему видит пользователь. Google использует много белого ящика и немного, но обязательно, чёрного: внешняя проверка ловит то, что уже сломалось сейчас, а внутренние метрики показывают надвигающиеся проблемы и ошибки, скрытые повторными попытками.

Что измерять: четыре золотых сигнала, RED и USE

Книга Google SRE (глава 6, раздел «The Four Golden Signals») прямо говорит: если можете измерять только четыре метрики пользовательской системы, измеряйте задержку, трафик, ошибки и насыщение. Если по каждому сигналу есть алерт, сервис «как минимум прилично покрыт мониторингом».

Задержка (latency)

Определение по SRE book
Время обслуживания запроса; задержку успешных и неуспешных запросов считают раздельно
Как измерить в HTTP API на Node.js
Гистограмма http_request_duration_seconds с меткой status_code, p95/p99

Трафик (traffic)

Определение по SRE book
Нагрузка на систему в её единицах — для веб-сервиса обычно HTTP-запросы в секунду
Как измерить в HTTP API на Node.js
rate() от счётчика запросов

Ошибки (errors)

Определение по SRE book
Доля неуспешных запросов: явно (500), неявно (200 с неверным содержимым) или по политике (дольше обещанного)
Как измерить в HTTP API на Node.js
Доля ответов 5xx от всех ответов

Насыщение (saturation)

Определение по SRE book
Насколько сервис «заполнен»: самый ограниченный ресурс — память, CPU, очередь, диск
Как измерить в HTTP API на Node.js
Память процесса, задержка event loop, длина очереди задач, свободное место

Важная деталь из той же главы — «хвост». Средняя задержка 100 мс может прятать 1% запросов по 5 секунд, а среднее по CPU — один перегруженный сервер среди свободных. Поэтому задержку собирают гистограммой — числом запросов по корзинам длительности — и смотрят перцентили: p95 означает, что 95% запросов быстрее этого значения. Медленная ошибка, напоминают авторы, ещё хуже быстрой.

Есть две короткие схемы, которые часто путают с золотыми сигналами. Метод USE Брендана Грегга — для ресурсов: для каждого ресурса (CPU, диск, сеть) проверить Utilization (занятость), Saturation (очередь невыполненной работы) и Errors. Метод RED Тома Уилки (Grafana Labs) — для сервисов: Rate (запросов в секунду), Errors (сколько из них падает), Duration (распределение длительности). Сам автор RED советует использовать их вместе: RED показывает, как себя чувствуют пользователи, USE — как себя чувствуют машины.

Структурированные логи на pino: уровни и маскирование

Строка console.log("order failed", id) удобна человеку в терминале, но бесполезна для поиска по тысячам строк. Структурированный лог — это одна JSON-строка на событие с постоянными полями: время, уровень, сервис, request id, сообщение и данные. Такие строки системы сбора логов (Loki, Elasticsearch, облачные логи) разбирают без регулярных выражений и фильтруют по полям. Pino по умолчанию пишет именно JSON в стандартный вывод — а в контейнере stdout и есть правильное место для логов.

Пример этого урока — два маленьких сервиса на Express 5: orders отдаёт заказ и для этого ходит в stock за остатком. Один и тот же код запускается дважды с разными переменными окружения. Начнём с проекта и логгера.

bash
mkdir orders-observability && cd orders-observability
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node src/server.js"
npm install --save-exact express@5.3.0 pino@10.4.0 pino-http@11.0.0 @prometheus-io/client@0.16.1
Пакет @prometheus-io/client — это бывший prom-client: с версии 0.16 он стал подпроектом Prometheus и переименован.
src/logger.js
import { pino } from "pino";

export const logger = pino({
  name: process.env.SERVICE_NAME ?? "orders-api",
  level: process.env.LOG_LEVEL ?? "info",
  // ISO time and a level name instead of a number: easier to search
  timestamp: pino.stdTimeFunctions.isoTime,
  formatters: { level: (label) => ({ level: label }) },
  // secrets and personal data never reach the log
  redact: {
    paths: ["password", "*.password", "*.token", "*.phone"],
    censor: "[REDACTED]",
  },
});

fatal

Число
60
Когда использовать
Процесс не может продолжать работу и сейчас завершится

error

Число
50
Когда использовать
Запрос или задача не выполнены: ответ 5xx, исключение

warn

Число
40
Когда использовать
Странно, но обработано: ответ 4xx, повторная попытка, близко к лимиту

info

Число
30
Когда использовать
Значимые события: сервер запущен, запрос завершён, заказ создан

debug

Число
20
Когда использовать
Подробности для разработки; в продакшене обычно выключен

trace

Число
10
Когда использовать
Максимальная детализация; включают точечно и ненадолго

Уровень по умолчанию — info: строки ниже него pino не формирует вовсе, это экономит ресурсы. Здесь уровень берётся из LOG_LEVEL, чтобы включить debug без правки кода. Опция formatters.level пишет уровень словом вместо числа; в документации pino есть оговорка — при нескольких транспортах сразу числовое поле level нужно сохранить, но у нас один вывод.

Опция redact заменяет значения по путям на [REDACTED]. Путь *.phone закрывает поле phone на один уровень вложенности, а поле верхнего уровня нужно указать отдельно — поэтому в списке есть и password, и *.password. В проверке строка logger.info({ lead: { name: "Test", phone: "+70000000000" }, password: "x" }) превратилась в "phone":"[REDACTED]" и "password":"[REDACTED]".

Correlation id: одна нить через все сервисы

Запрос пользователя проходит через несколько сервисов, и каждый пишет свои логи. Чтобы собрать их вместе, запросу дают идентификатор — correlation id, или request id — и передают его во все вызовы дальше по цепочке. Здесь для этого служит заголовок X-Request-Id: если он пришёл и выглядит как идентификатор, используем его, иначе создаём новый UUID и возвращаем клиенту в ответе.

src/app.js
import { randomUUID } from "node:crypto";
import express from "express";
import { pinoHttp } from "pino-http";
import { logger } from "./logger.js";
import { metricsMiddleware, register } from "./metrics.js";

// accept an incoming id only if it looks like an id: no junk or forged log lines
const REQUEST_ID = /^[A-Za-z0-9-]{8,64}$/;
const QUIET = new Set(["/metrics", "/healthz", "/readyz"]);

export const state = { ready: false };

export function createApp({ stockUrl, failRate = 0 } = {}) {
  const app = express();

  app.use(
    pinoHttp({
      logger,
      genReqId(req, res) {
        const incoming = req.headers["x-request-id"];
        const id = typeof incoming === "string" && REQUEST_ID.test(incoming) ? incoming : randomUUID();
        res.setHeader("X-Request-Id", id);
        return id;
      },
      customLogLevel(req, res, err) {
        if (err || res.statusCode >= 500) return "error";
        if (res.statusCode >= 400) return "warn";
        return "info";
      },
      autoLogging: { ignore: (req) => QUIET.has(req.url) },
      // req.log carries only reqId; full req/res go to one summary line per request
      quietReqLogger: true,
      serializers: {
        req: (req) => ({ method: req.method, url: req.url }),
        res: (res) => ({ statusCode: res.statusCode }),
      },
    }),
  );
  app.use(metricsMiddleware);

  // liveness: the process is alive and serves requests
  app.get("/healthz", (req, res) => res.json({ status: "ok" }));
  // readiness: this instance can take traffic (started, not shutting down)
  app.get("/readyz", (req, res) => {
    if (!state.ready) return res.status(503).json({ status: "not ready" });
    res.json({ status: "ready" });
  });
  app.get("/metrics", async (req, res) => {
    res.set("Content-Type", register.contentType);
    res.send(await register.metrics());
  });

  // "stock": a slow dependency that sometimes fails
  app.get("/api/stock/:sku", async (req, res) => {
    await new Promise((r) => setTimeout(r, 20 + Math.random() * 80));
    if (Math.random() < failRate) throw new Error("stock db timeout");
    res.json({ sku: req.params.sku, available: 7 });
  });

  // "orders": calls stock and forwards the same X-Request-Id
  app.get("/api/orders/:id", async (req, res) => {
    const r = await fetch(`${stockUrl}/api/stock/${encodeURIComponent(req.params.id)}`, {
      headers: { "x-request-id": req.id },
      signal: AbortSignal.timeout(2000),
    });
    if (!r.ok) throw new Error(`stock service responded ${r.status}`);
    const stock = await r.json();
    req.log.info({ orderId: req.params.id, available: stock.available }, "order loaded");
    res.json({ id: req.params.id, status: "new", stock });
  });

  app.use((req, res) => res.status(404).json({ error: "not_found" }));
  // Express 5 forwards errors from async handlers here by itself
  app.use((err, req, res, next) => {
    res.err = err; // pino-http adds the error with its stack to the summary line
    res.status(500).json({ error: "internal_error", requestId: req.id });
  });

  return app;
}
  • genReqId — функция pino-http для идентификатора запроса; без неё он использует порядковый номер, а документация pino-http предупреждает, что для нескольких экземпляров приложения это плохой вариант
  • Входящий id проверяется регулярным выражением: в лог не попадут перевод строки, кавычки и мегабайтная строка от злоумышленника
  • quietReqLogger: true — в req.log остаётся только reqId, а полный req и res попадают в одну итоговую строку запроса
  • customLogLevel выбирает уровень итоговой строки: 5xx — error, 4xx — warn, остальное — info
  • autoLogging.ignore убирает из логов /metrics и проверки здоровья — их дёргают каждые несколько секунд
  • orders передаёт req.id в заголовке x-request-id при вызове stock — так id переходит границу сервиса

Обработчик ошибок кладёт исключение в res.err — pino-http проверяет это поле и пишет ошибку со стеком в итоговую строку запроса, так что на один упавший запрос приходится одна строка уровня error, а не две. Клиент получает только requestId без технических деталей: по нему поддержка найдёт запрос в логах. Ниже — реальный вывод: один запрос, два сервиса, общий reqId, и сразу видно, что первопричина — в stock.

terminal
$ FAIL_RATE=0.3 docker compose up -d --build --wait
$ curl -s -H "X-Request-Id: check-7f3a-0004" localhost:3000/api/orders/1042
{"error":"internal_error","requestId":"check-7f3a-0004"}
$ docker compose logs --no-log-prefix orders stock | grep check-7f3a-0004
{"level":"error","time":"2026-10-12T01:31:14.981Z","pid":1,"hostname":"e383b1a6fba4","name":"stock-api","reqId":"check-7f3a-0004","req":{"method":"GET","url":"/api/stock/1042"},"res":{"statusCode":500},"err":{"type":"Error","message":"stock db timeout","stack":"Error: stock db timeout\n    at file:///app/src/app.js:56:41"},"responseTime":44,"msg":"request errored"}
{"level":"error","time":"2026-10-12T01:31:14.982Z","pid":1,"hostname":"b41fddb93326","name":"orders-api","reqId":"check-7f3a-0004","req":{"method":"GET","url":"/api/orders/1042"},"res":{"statusCode":500},"err":{"type":"Error","message":"stock service responded 500","stack":"Error: stock service responded 500\n    at file:///app/src/app.js:66:22\n    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)"},"responseTime":46,"msg":"request errored"}
Реальный вывод: две полные JSON-строки из двух контейнеров с общим reqId.

Health и readiness: живой ли процесс и готов ли он к трафику

Оркестратору (Kubernetes, Docker, балансировщику) нужно знать о каждом экземпляре сервиса две разные вещи. Документация Kubernetes разделяет их на отдельные проверки, и эти же имена прижились для HTTP-эндпоинтов.

Liveness

Вопрос
Процесс жив и не завис?
Что делает оркестратор при провале
Перезапускает контейнер
В примере
GET /healthz → 200

Readiness

Вопрос
Экземпляр готов принимать запросы?
Что делает оркестратор при провале
Перестаёт слать трафик, но не убивает
В примере
GET /readyz → 200 или 503

Startup

Вопрос
Медленный старт уже закончился?
Что делает оркестратор при провале
Откладывает остальные проверки
В примере
Не используется: сервис стартует мгновенно
src/server.js
import { createApp, state } from "./app.js";
import { logger } from "./logger.js";

const port = Number(process.env.PORT ?? 3000);
const app = createApp({
  stockUrl: process.env.STOCK_URL ?? `http://localhost:${port}`,
  failRate: Number(process.env.FAIL_RATE ?? 0),
});

const server = app.listen(port, () => {
  state.ready = true;
  logger.info({ port }, "server started");
});

function shutdown(signal) {
  logger.info({ signal }, "shutting down");
  state.ready = false; // the balancer stops sending traffic, in-flight requests finish
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 10_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

При SIGTERM (его шлёт docker stop и Kubernetes при выкатке) сервис сначала объявляет себя неготовым, затем server.close() дожидается текущих запросов и только потом процесс выходит. Если что-то зависло, таймер через 10 секунд завершит процесс принудительно. В проверке остановленный контейнер записал в лог "signal":"SIGTERM","msg":"shutting down" и вышел с кодом 0.

Чёрный ящик снаружи тоже нужен: внешний uptime-сервис, который раз в минуту запрашивает публичный адрес и проверяет код 200. Он ловит то, чего не видит сервис изнутри, — истёкший SSL-сертификат, ошибку DNS, упавший балансировщик. Книга SRE замечает, что для цели доступности 99,9% проверять код ответа чаще одного-двух раз в минуту, вероятно, излишне.

Метрики Prometheus: /metrics и запросы PromQL

Prometheus работает по модели pull: раз в интервал он сам приходит на /metrics каждого сервиса, забирает текстовый список чисел и сохраняет их как временные ряды. Клиент для Node.js собирает стандартные метрики процесса — память, CPU, задержку event loop, сборку мусора — и даёт типы Counter, Gauge, Histogram и Summary.

src/metrics.js
import { Histogram, collectDefaultMetrics, register } from "@prometheus-io/client";

// memory, CPU, event loop lag, GC: Node.js process metrics
collectDefaultMetrics();

// one histogram gives all three RED signals:
// _count = requests (rate), _count{status_code=~"5.."} = errors, _bucket = duration
export const httpDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "HTTP request duration in seconds",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});

export function metricsMiddleware(req, res, next) {
  const end = httpDuration.startTimer();
  res.on("finish", () => {
    // route template, not the raw URL: /api/orders/:id, not /api/orders/1042
    const route = req.route ? req.baseUrl + req.route.path : "unmatched";
    end({ method: req.method, route, status_code: String(res.statusCode) });
  });
  next();
}

export { register };

Имена следуют правилам Prometheus: базовые единицы (секунды, а не миллисекунды) и суффикс единицы в имени. Метка route берёт шаблон маршрута, а не реальный путь. Это принципиально: каждая уникальная комбинация меток — отдельный временной ряд, и документация Prometheus прямо запрещает класть в метки значения с высокой кардинальностью — id пользователей, email, необработанные URL. Запросы, не попавшие ни в один маршрут, получают route="unmatched".

Теперь упакуем сервис в образ и поднимем рядом Prometheus. Docker Compose запускает три контейнера: orders, stock и prom/prometheus. Проверка здоровья в Compose вызывает /readyz через встроенный в Node.js fetch, поэтому в образе не нужны curl или wget.

Dockerfile
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
compose.yaml
services:
  orders:
    build: .
    environment:
      SERVICE_NAME: orders-api
      STOCK_URL: http://stock:3000
    ports:
      - "3000:3000"
    healthcheck: &ready
      test: ["CMD", "node", "-e", "fetch('http://localhost:3000/readyz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3
    depends_on:
      stock:
        condition: service_healthy

  stock:
    build: .
    environment:
      SERVICE_NAME: stock-api
      FAIL_RATE: ${FAIL_RATE:-0}
    healthcheck: *ready

  prometheus:
    image: prom/prometheus:v3.15.0
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus:/etc/prometheus:ro
    depends_on:
      - orders
      - stock
prometheus/prometheus.yml
global:
  scrape_interval: 5s      # demo value; the default is 1m
  evaluation_interval: 5s

rule_files:
  - alerts.yml

scrape_configs:
  - job_name: node-api
    static_configs:
      - targets: ["orders:3000", "stock:3000"]
terminal
$ curl -s localhost:3000/healthz; curl -s localhost:3000/readyz
{"status":"ok"}{"status":"ready"}
$ curl -s localhost:3000/metrics | grep 'route="/api/orders/:id"' | grep -E 'le="0.1"|_sum|_count'
http_request_duration_seconds_bucket{le="0.1",method="GET",route="/api/orders/:id",status_code="200"} 0
http_request_duration_seconds_sum{method="GET",route="/api/orders/:id",status_code="200"} 0.118051033
http_request_duration_seconds_count{method="GET",route="/api/orders/:id",status_code="200"} 1
Фрагмент /metrics: из сорока с лишним строк гистограммы показаны три.

Сгенерируем нагрузку с ошибками: переменная FAIL_RATE=0.3 заставляет stock падать примерно на трети запросов. Затем спросим Prometheus через HTTP API — те же выражения можно вставить в веб-интерфейс на localhost:9090.

bash
# two minutes of load: requests hit orders, which calls stock
end=$((SECONDS+120)); while [ $SECONDS -lt $end ]; do
  curl -s -o /dev/null localhost:3000/api/orders/$RANDOM
done
terminal
$ curl -s -G localhost:9090/api/v1/query --data-urlencode \
    'query=sum by (instance) (rate(http_request_duration_seconds_count{status_code=~"5.."}[1m])) / sum by (instance) (rate(http_request_duration_seconds_count[1m]))'
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"instance":"stock:3000"},"value":[1791768977.267,"0.24901960784313726"]},{"metric":{"instance":"orders:3000"},"value":[1791768977.267,"0.24754420432220037"]}]}}
$ curl -s -G localhost:9090/api/v1/query --data-urlencode \
    'query=histogram_quantile(0.95, sum by (instance, le) (rate(http_request_duration_seconds_bucket{route=~"/api/.*"}[5m])))'
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"instance":"stock:3000"},"value":[1791768977.334,"0.09608341223427372"]},{"metric":{"instance":"orders:3000"},"value":[1791768977.334,"0.09826923076923078"]}]}}
Реальные ответы Prometheus после двух минут нагрузки: около 25% ошибок, p95 — около 0,1 с.

Трафик, запросов/с

Запрос PromQL
sum by (instance) (rate(http_request_duration_seconds_count[1m]))

Доля ошибок

Запрос PromQL
sum(rate(http_request_duration_seconds_count{status_code=~"5.."}[1m])) / sum(rate(http_request_duration_seconds_count[1m]))

Задержка p95, с

Запрос PromQL
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

Насыщение: задержка event loop

Запрос PromQL
nodejs_eventloop_lag_p99_seconds

Цель скрапинга доступна

Запрос PromQL
up{job="node-api"}

rate() считает среднюю скорость роста счётчика в секунду за окно и сам учитывает сброс счётчика при перезапуске сервиса. Доля ошибок здесь считается по всем ответам, включая /metrics и проверки здоровья; в рабочем дашборде их обычно исключают фильтром по route.

Алерты: пороги, выдержка и шум

Правило алерта в Prometheus — выражение PromQL плюс условие. Пока выражение возвращает хоть один ряд, алерт активен для этого ряда. Поле for задаёт выдержку: сначала алерт находится в состоянии pending и переходит в firing, только если условие держится всё это время. Так одиночный всплеск не будит дежурного.

prometheus/alerts.yml
groups:
  - name: node-api
    rules:
      - alert: InstanceDown
        expr: up{job="node-api"} == 0
        for: 1m
        labels:
          severity: page
        annotations:
          summary: "{{ $labels.instance }} does not answer scrapes"

      - alert: HighErrorRate
        expr: |
          sum by (instance) (rate(http_request_duration_seconds_count{status_code=~"5.."}[1m]))
            / sum by (instance) (rate(http_request_duration_seconds_count[1m]))
            > 0.05
        for: 1m
        labels:
          severity: page
        annotations:
          summary: "{{ $labels.instance }}: more than 5% of responses are 5xx"
          description: "Error ratio is {{ $value | humanizePercentage }}"

      - alert: SlowRequestsP95
        expr: |
          histogram_quantile(0.95,
            sum by (instance, le) (rate(http_request_duration_seconds_bucket{route=~"/api/.*"}[5m]))
          ) > 0.5
        for: 10m
        labels:
          severity: ticket
        annotations:
          summary: "{{ $labels.instance }}: p95 latency above 500 ms"

Три правила закрывают разные сценарии: InstanceDown — Prometheus не может забрать метрики (сервис упал или недоступен), HighErrorRate — больше 5% ответов с ошибкой, SlowRequestsP95 — медленно, но работает, поэтому severity: ticket (задача в очередь), а не звонок. Окна в 1 минуту выбраны для демо, чтобы алерт сработал быстро; в рабочей системе окна и for обычно длиннее. Синтаксис проверяется командой promtool check config из того же образа.

terminal
$ curl -s localhost:9090/api/v1/alerts
{"status":"success","data":{"alerts":[{"labels":{"alertname":"HighErrorRate","instance":"stock:3000","severity":"page"},"annotations":{"description":"Error ratio is 24.9%","summary":"stock:3000: more than 5% of responses are 5xx"},"state":"firing","activeAt":"2026-10-12T01:34:25.655482362Z","value":"2.4901960784313726e-01"},{"labels":{"alertname":"HighErrorRate","instance":"orders:3000","severity":"page"},"annotations":{"description":"Error ratio is 24.75%","summary":"orders:3000: more than 5% of responses are 5xx"},"state":"firing","activeAt":"2026-10-12T01:34:25.655482362Z","value":"2.4754420432220037e-01"}]}}
Состояние алертов после двух минут нагрузки с FAIL_RATE=0.3.
  1. Сработал ли алерт по ошибкам

    После нагрузки HighErrorRate перешёл в firing для обоих сервисов, в аннотации — доля ошибок около 25%.

  2. Сработает ли алерт по падению

    docker compose stop stock — через минуту /api/v1/alerts показывает InstanceDown для stock:3000 в состоянии firing, а orders отвечает 500.

  3. Сбросится ли он сам

    Когда нагрузка прекратилась, HighErrorRate исчез из списка: без запросов выражение не возвращает рядов.

Prometheus только вычисляет правила. Рассылкой — в почту, мессенджер, систему дежурств — занимается отдельный компонент Alertmanager: он группирует алерты, глушит повторы и умеет подавлять следствия, когда известна причина.

Обратите внимание: HighErrorRate сработал дважды — для stock и для orders, хотя проблема одна. Руководство Prometheus по алертам советует будить по ошибкам, видимым пользователю, как можно выше по стеку и не дублировать страницы из нижних слоёв. Книга SRE добавляет вопросы, которые стоит задать перед каждым новым правилом:

  • Правило ловит срочную ситуацию, которую видят или вот-вот увидят пользователи, и её больше ничто не ловит
  • Этот алерт нельзя спокойно проигнорировать, зная, что он безвреден
  • На алерт можно и нужно реагировать сейчас, а не утром — иначе это задача, а не звонок
  • В аннотации есть понятное описание и ссылка на инструкцию (runbook)
  • Реакция требует размышления; если она механическая — её надо автоматизировать

Grafana, OpenTelemetry и упражнения

Веб-интерфейс Prometheus подходит для разовых запросов, а для постоянных дашбордов обычно берут Grafana: она подключает Prometheus как источник данных, и те же выражения PromQL становятся графиками. В документации Grafana есть пошаговое руководство «Get started with Grafana and Prometheus». Логи из stdout контейнеров собирают отдельным агентом в Loki или Elasticsearch, чтобы искать по reqId сразу по всем сервисам, а не через docker compose logs.

OpenTelemetry — открытый стандарт и набор SDK для генерации и экспорта трассировок, метрик и логов; хранилищем он не является и отдаёт данные в Prometheus, Jaeger или коммерческие системы. Когда сервисов больше двух-трёх, его трассировки заменяют самодельный X-Request-Id: каждый участок запроса становится span с длительностью, а контекст передаётся в заголовке traceparent. Документация отдельно отмечает, что библиотека логов OpenTelemetry для Node.js ещё в разработке, поэтому связка «pino для логов + OpenTelemetry для трасс» остаётся разумным выбором.

Как проверено4 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    src/logger.js, src/metrics.js, src/app.js, src/server.js (локальный запуск)

    временный проект, Node 24.18.0, express 5.3.0, pino 10.4.0, pino-http 11.0.0, @prometheus-io/client 0.16.1 (версии из npm view); PORT=3317 FAIL_RATE=0.5: 200/500 по ответам, X-Request-Id возвращается, чужой id принимается, некорректный (с пробелом и кавычкой) заменяется UUID; одна строка error со стеком на упавший запрос; /metrics с route-шаблонами и route="unmatched" для 404; redact: phone и password → [REDACTED]

  • запущено, работает

    Dockerfile, compose.yaml, prometheus/prometheus.yml, prometheus/alerts.yml

    promtool check config из prom/prometheus:v3.15.0 — SUCCESS, 3 rules; docker compose up -d --build --wait (Compose 5.3.1, проект kb-monitoring): все сервисы healthy, обе цели up; 2 мин нагрузки (~1100–1440 запросов) с FAIL_RATE=0.3 → запросы PromQL через /api/v1/query вернули ~8,9 req/s, долю ошибок ~0,25, p95 ~0,096–0,098 с; /api/v1/alerts: HighErrorRate firing для обоих экземпляров; docker compose stop stock → через 75 с InstanceDown firing, orders отвечает 500; SIGTERM → «shutting down», код выхода 0; общий reqId в логах orders и stock. Стек остановлен через down --rmi local

  • запущено, работает

    команды установки (npm init / npm pkg set / npm install --save-exact)

    выполнены в чистой папке, package.json с type=module, scripts.start и точными версиями

  • запущено, работает

    решение упражнения modify (HighErrorRate только для orders)

    фрагмент вставлен в группу правил, promtool check rules — SUCCESS, 1 rule; срабатывание с окном 5m не прогонялось

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.