Дело о молчаливой JVM: мониторинг Spring Boot с Prometheus и Grafana. Production-нуар. alertmanager.. alertmanager. grafana.. alertmanager. grafana. Java.. alertmanager. grafana. Java. JVM.. alertmanager. grafana. Java. JVM. micrometer.. alertmanager. grafana. Java. JVM. micrometer. Open source.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus. promql.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus. promql. slo.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus. promql. slo. spring boot.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus. promql. slo. spring boot. метрики.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus. promql. slo. spring boot. метрики. мониторинг.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus. promql. slo. spring boot. метрики. мониторинг. Программирование.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus. promql. slo. spring boot. метрики. мониторинг. Программирование. Серверная оптимизация.. alertmanager. grafana. Java. JVM. micrometer. Open source. Prometheus. promql. slo. spring boot. метрики. мониторинг. Программирование. Серверная оптимизация. Системное администрирование.

Она умерла в воскресенье вечером, и никто не услышал ни звука. Детективная история о том, как поставить прослушку на собственное приложение: Prometheus, Grafana, Micrometer, алерты, SLO. Все улики в комплекте, демо-проект прилагается. Совпадения с вашим продакшеном не случайны.


Пролог. Тело

Город спал. Я – нет.

Воскресенье, восемь вечера. Дождь стучал в окно, как healthcheck по мёртвому эндпоинту: методично и без надежды на ответ. На столе остывал ужин. Зазвонил телефон. Лёша, тимлид. Лёша по воскресеньям не звонит. По воскресеньям он отец, муж и человек. Если звонит, значит, человеком сегодня побыть не выйдет ни ему, ни мне.

— У нас труп, — сказал он. — Кто? — Production. Лежит. Не отвечает.

Я выехал немедленно. То есть открыл ноутбук, не вставая с дивана. В нашем деле это и есть «выехал немедленно».

Картина преступления была чистой. Слишком чистой. JVM лежала остывшая, без признаков жизни. Ни предсмертной, ни криков в логах, ни одного свидетеля. OutOfMemoryError, гласило заключение, которое мы добыли только через четыре часа вскрытия. Четыре часа моей жизни. Ужин к тому времени окоченел вместе с потерпевшей.

Но вот что не давало мне покоя, когда я отмотал плёнку GC-логов назад. Heap был забит на 95% двое суток. Двое суток жертва ходила по городу с ножом в спине и улыбалась прохожим. Двое суток любой мог взглянуть на неё и сказать: «Дамочка, да вы же еле дышите». Никто не взглянул. Потому что смотреть было некому.

В понедельник утром Лёша собрал всех в участке и сказал то, что должны были сказать давным-давно:

— Я больше не хочу узнавать о трупах от мэра. Я хочу узнавать о них от осведомителей. Желательно, пока они ещё живы.

Так открылось это дело. Дело № 1142, «Молчаливая JVM». Я вёл его шесть недель. Действующие лица: Лёша — капитан, на которого давят сверху. Даня — стажёр с горящими глазами, ещё верит, что правду можно найти в логах. Борис Аркадьевич — мэр города, человек, далёкий от перцентилей, но близкий к бюджету. И Серёга — мой старый напарник из соседнего управления. Серёга когда-то вёл дело о кардинальности. С тех пор у него седина и привычка вздрагивать от слов «user_id».

И ещё в этом деле будут трое, кого я завербовал. Архивариус по имени Прометей: фотографическая память, помнит всё, что видел, но только за последний месяц, такой у него контракт. Осведомительница Грейс: рисует картину города лучше любого штатного художника. И диспетчер на коммутаторе: будит нужных людей в нужное время. Не путать с ненужными людьми в ненужное время, этому коммутатор тоже обучен, но об этом позже.

Почему именно Prometheus, Grafana и Micrometer?

Я спросил Серёгу. Серёга затянулся кофе, как сигаретой, и сказал: «Потому что бесплатно. Потому что стандарт. И потому что Micrometer уже сидит в твоём Spring Boot». От себя добавлю: эту троицу спрашивают на собеседованиях. А зарплата – лучший стимул к расследованию, что бы там ни писали в детективах про жажду справедливости.

Приложение без мониторинга – это город без единого фонаря. Жить можно. Недолго.

Поехали. Через десять минут у вас будет прослушка. Через час – сеть осведомителей, с которой можно спать по ночам. Почти. Совсем спокойно в этом городе спят только те, у кого нет production.


Эпизод 1. Прослушка: первые метрики за 10 минут

В понедельник после обеда стажёр Даня подошёл ко мне с лицом человека, готового к худшему:

— Нам теперь писать какого-то агента? Собирать данные руками? Это же недели.

Нет, парень. И в этом первый поворот сюжета: прослушка уже стоит. Её поставили до нас. Осталось воткнуть наушники.

Место действия

Дело развернётся вокруг TODO-приложения. Объект намеренно скучный: задачи создаются, выполняются, удаляются. Скучные объекты я люблю. Вся интересная жизнь у них, как водится, тайная.

todo-monitoring-demo/
├── src/main/java/com/example/todo/
│   ├── controller/
│   │   ├── TaskController.java          # CRUD по задачам
│   │   └── DemoController.java          # генератор латентности и 5xx для демо
│   ├── service/TaskService.java
│   ├── repository/TaskRepository.java
│   ├── model/                           # Task, TaskStatus, TaskPriority
│   ├── event/                           # TaskCreatedEvent, TaskCompletedEvent, ...
│   └── config/
│       ├── SecurityConfig.java
│       ├── MeterRegistryConfig.java
│       ├── RepositoryMetricsAspect.java
│       ├── CustomWebMvcTagsContributor.java
│       ├── MetricsEventListener.java
│       └── DemoTrafficGenerator.java    # демо-нагрузка, чтобы графики ожили
├── src/main/resources/
│   └── application.yml
├── Dockerfile
├── docker-compose.yml
├── prometheus/
│   ├── prometheus.yml
│   ├── alert-rules.yml
│   ├── recording-rules.yml
│   ├── slo-rules.yml
│   └── alertmanager.yml
└── grafana/
    ├── dashboards/
    │   ├── dashboard.yml
    │   └── todo-app-dashboard.json
    └── datasources.yml

Шаг 1: Зависимости

Две строчки. Всего две. Не пять, не десять. Две. В мире, где для «Hello World» нужно 200 мегабайт node_modules, это почти подозрительно.

<!-- Spring Boot Actuator - основа мониторинга -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<!-- Micrometer Prometheus Registry - экспорт метрик -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Actuator сам настраивает десятки жучков: JVM, HTTP-запросы, connection pools. И выводит всё на endpoint /actuator/prometheus. Spring Boot 3.x тянет совместимый Micrometer без лишних вопросов, версии указывать не нужно.

Даня смотрел на эти две зависимости с подозрением. Правильно смотрел. Когда в нашем деле что-то достаётся легко, жди счёта. Счёт будет позже.

Шаг 2: Конфигурация

# application.yml
spring:
  application:
    name: todo-monitoring-demo
  
  datasource:
    hikari:
      pool-name: TodoHikariPool
      maximum-pool-size: 10
      minimum-idle: 2
      register-mbeans: true  # Включает JMX MBeans (не обязателен для Prometheus-метрик)

management:
  endpoints:
    web:
      exposure:
        # Открываем ТОЛЬКО необходимое. Никаких env и loggers "на всякий случай" -
        # в env могут быть секреты. Подробнее - в эпизоде про безопасность.
        include: health, info, prometheus, metrics
  endpoint:
    prometheus:
      enabled: true
    health:
      show-details: when_authorized
  
  metrics:
    tags:
      application: ${spring.application.name}
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.5, 0.75, 0.95, 0.99
      # SLO-границы создают точные бакеты le="...". Без них запросы
      # вида http_server_requests_seconds_bucket{le="0.5"} вернут пустоту -
      # а на них держатся SLI по латентности из эпизода про SLO.
      # Значения должны совпадать с теми, что используются в slo-rules.yml.
      slo:
        http.server.requests: 100ms, 200ms, 500ms, 1s, 2s, 5s

Запомните из этого протокола четыре строки:

  • exposure.include – какие двери открыть. Минимум: prometheus и health.

  • percentiles-histogram – включает гистограмму для перцентилей.

  • slo – добавляет «именные» бакеты le=. Захотите потом считать «долю запросов быстрее 500 мс», а без этой строки не посчитается ничего – молча, без единой ошибки в логах. В нашем городе самые опасные провалы те, что молчат.

  • metrics.tags – общие теги. Когда сервисов станет двадцать, вы поставите этой строчке памятник.

Шаг 3: Docker Compose

Обратите внимание: никакого version: '3.8' в шапке. Compose v2 на неё только ворчит.

services:
  app:
    build: .
    container_name: todo-app
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=docker
    # Лимит памяти + -XX:MaxRAMPercentage в Dockerfile = предсказуемый max heap.
    # Без лимита JVM возьмёт 25% RAM хоста, и алерты на heap будут мериться
    # неизвестно от чего.
    mem_limit: 512m
    networks:
      - monitoring
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  prometheus:
    image: prom/prometheus:v2.48.0
    container_name: prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./prometheus/alert-rules.yml:/etc/prometheus/alert-rules.yml:ro
      - ./prometheus/recording-rules.yml:/etc/prometheus/recording-rules.yml:ro
      - ./prometheus/slo-rules.yml:/etc/prometheus/slo-rules.yml:ro
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      # 31 день, чтобы 30-дневное окно SLO (ratio_rate30d) имело данные
      - '--storage.tsdb.retention.time=31d'
      - '--storage.tsdb.retention.size=10GB'
      - '--web.enable-lifecycle'
    networks:
      - monitoring
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
      interval: 30s
      timeout: 10s
      retries: 3

  grafana:
    image: grafana/grafana:10.2.2
    container_name: grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin
      - GF_USERS_ALLOW_SIGN_UP=false
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
      - ./grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro
    networks:
      - monitoring
    depends_on:
      - prometheus
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3

  alertmanager:
    image: prom/alertmanager:v0.26.0
    container_name: alertmanager
    ports:
      - "9093:9093"
    volumes:
      - ./prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
      - alertmanager_data:/alertmanager
    command:
      - '--config.file=/etc/alertmanager/alertmanager.yml'
      - '--storage.path=/alertmanager'
    networks:
      - monitoring

networks:
  monitoring:
    driver: bridge

volumes:
  prometheus_data:
  grafana_data:
  alertmanager_data:

Health checks нужны для порядка, volumes – чтобы архив пережил перезапуск. Архивариус без архива – просто человек с хорошей памятью и плохим контрактом.

Шаг 4: Контракт с архивариусом

# prometheus/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s
  external_labels:
    monitor: 'todo-app-monitor'

rule_files:
  - /etc/prometheus/alert-rules.yml
  - /etc/prometheus/recording-rules.yml
  - /etc/prometheus/slo-rules.yml

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'todo-app'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 5s
    static_configs:
      - targets: ['app:8080']
    basic_auth:
      username: 'prometheus'
      password: 'prometheus'

scrape_interval: 5s стоит для демо, чтобы картинка шевелилась. В production ставьте 15-30 секунд. Чаще ходишь к осведомителю – больше знаешь. Но каждый визит стоит объекту ресурсов: scrape – это обычный HTTP-запрос к вашему приложению, и он не бесплатный.

И предостережение, оплаченное Серёгиными нервами: не вешайте двух топтунов на один объект. «Один job для Docker, один для локального запуска, удобно же». А потом SLO-правила, которые агрегируют по application, посчитают один и тот же трафик дважды. RPS удвоится, доля ошибок поедет, и вы неделю будете искать, почему сводка врёт ровно в два раза.

Шаг 5: Включаем

docker-compose up -d

Минута ожидания. Потом:

В /actuator/prometheus вы увидите примерно это:

# HELP jvm_memory_used_bytes The amount of used memory
# TYPE jvm_memory_used_bytes gauge
jvm_memory_used_bytes{area="heap",id="G1 Eden Space",} 2.5165824E7
jvm_memory_used_bytes{area="heap",id="G1 Old Gen",} 1.6777216E7

# HELP http_server_requests_seconds  
# TYPE http_server_requests_seconds histogram
http_server_requests_seconds_bucket{method="GET",uri="/api/tasks",status="200",le="0.05"} 45.0
http_server_requests_seconds_bucket{method="GET",uri="/api/tasks",status="200",le="0.1"} 67.0

Даня смотрел на этот поток минуты две. Потом поднял глаза:

— Подожди. Это всё… уже было? Всё это время? — Всё это время, парень. — И в то воскресенье? — И в то воскресенье. Город говорил. Слушать было некому.

Ноль строк кода, а у вас уже на прослушке JVM, HTTP, пулы потоков и соединений. За это я и люблю Spring Boot: он делает грязную работу тихо, как хороший информатор. А вот дальше начинается работа для нас. Потому что прослушка без аналитика – это шум. Дорогой, красиво заархивированный шум.


Эпизод 2. PromQL: язык допроса

Во вторник Даня открыл кабинет архивариуса, потыкал в интерфейс, вышел и честно доложил: «Я ничего не понял». Нормально. Прометей отвечает только тому, кто правильно спрашивает. PromQL – язык допроса: похож на SQL, но короче и злопамятнее. Кто знает SQL, заговорит за десять минут. Кто не знает – за двадцать. А вот нюансы будут догонять ещё месяц, и один из них чуть не закрыл нам всё дело. Расскажу в конце эпизода.

Типы данных

Instant Vector – показания на конкретный момент:

http_server_requests_seconds_count

Range Vector – показания за период (для rate):

http_server_requests_seconds_count[5m]

Scalar – просто число:

42

Селекторы и фильтрация

# Все показания с именем
http_server_requests_seconds_count

# Точное совпадение
http_server_requests_seconds_count{status="200"}

# Регулярное выражение
http_server_requests_seconds_count{status=~"2.."}

# Исключение
http_server_requests_seconds_count{uri!~"/actuator.*"}

# Комбинация
http_server_requests_seconds_count{method="GET", status="200", uri="/api/tasks"}

Ключевые функции

rate() – скорость изменения counter. Главный вопрос любого допроса: не что ты сделал, а как быстро ты это делаешь.

# RPS (запросов в секунду)
rate(http_server_requests_seconds_count[5m])

Counter только растёт. Никогда не уменьшается. Как досье. rate() вычисляет, насколько быстро он растёт, и это почти всегда то, что вам нужно на самом деле.

increase() – абсолютный прирост:

# Запросов за час
increase(http_server_requests_seconds_count[1h])

sum(), avg(), max(), min() – агрегация:

# Общий RPS
sum(rate(http_server_requests_seconds_count[5m]))

# RPS по endpoint
sum(rate(http_server_requests_seconds_count[5m])) by (uri)

histogram_quantile() – перцентили:

# p99 латентность
histogram_quantile(0.99, 
  sum(rate(http_server_requests_seconds_bucket[5m])) by (le)
)

Допросы на каждый день

RPS:

sum(rate(http_server_requests_seconds_count[5m]))

Error Rate %:

sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) 
/ 
sum(rate(http_server_requests_seconds_count[5m])) 
* 100

Heap usage %:

sum by (instance) (jvm_memory_used_bytes{area="heap"})
/ 
sum by (instance) (jvm_memory_max_bytes{area="heap"} != -1)
* 100

Почему heap допрашивается с sum, а не делится в лоб – отдельный эпизод, и он будет следующим. Спойлер: мы поделили в лоб и получили ложный донос.

Дело о молчании, которое притворялось нулём

Теперь обещанный нюанс. Вопрос на засыпку: что ответит sum(), если серий не существует? Ноль? Логично – сумма ничего равна нулю.

Не ноль. Пустоту. В этом городе «никто ничего не видел» и «все видели, что ничего не было» – два разных показания, и между ними пропасть. Напишете алерт «трафика нет»: sum(rate(...)) == 0, и он не сработает именно тогда, когда трафика нет совсем. Серий нет, суммы нет, сравнения нет, алерта нет. Свидетель не говорит «ноль». Свидетель не пришёл.

Лекарство – подставной свидетель or vector(0):

(sum(rate(http_server_requests_seconds_count{uri!~"/actuator.*"}[5m])) or vector(0)) == 0

Запомните этот трюк. Он ещё выстрелит в эпизоде про сигнализацию.

Этим набором закрывается процентов девяносто допросов. Оставшиеся десять – это когда захочется вложенных запросов на пол-экрана и ощущения собственной гениальности. Не сейчас.


Эпизод 3. Вскрытие JVM и первый ложный донос

В ночь со среды на четверг мне позвонила сигнализация: «Heap 98%!». Я подскочил, как от выстрела. Открыл ноутбук. Руки набирали ssh быстрее, чем просыпалась голова.

Heap был в порядке.

Ложный донос. И знаете, кто настучал? Мы сами. Наш первый алерт на память, гордость всего отдела. Мы взяли jvm_memory_used_bytes{area="heap"} и поделили на jvm_memory_max_bytes{area="heap"} в лоб. А это, как выяснилось на очной ставке, не одна метрика. Это три персоны: Eden, Survivor, Old Gen. По отдельной серии на каждый пул. И Prometheus услужливо сравнил каждого с его собственным потолком.

А Eden перед сборкой мусора заполнен под завязку. Всегда. Это его работа: наполняться и опустошаться.

Вторая половина граблей, чтобы дважды не вызывать понятых: у пулов без фиксированного максимума (в G1 это Eden и Survivor) jvm_memory_max_bytes равен -1. Минус один. Просуммируете без фильтра, и знаменатель уйдёт в самоволку. Поэтому канонический допрос выглядит так:

# Используемая память (несколько серий - по одной на пул!)
jvm_memory_used_bytes{area="heap"}

# Максимально доступная
jvm_memory_max_bytes{area="heap"}

# Процент использования - С СУММОЙ по пулам
sum by (instance) (jvm_memory_used_bytes{area="heap"})
/
sum by (instance) (jvm_memory_max_bytes{area="heap"} != -1)
* 100

Мелочь? Мелочь. Но из таких мелочей состоит разница между сетью осведомителей и генератором ложных доносов. Утром я показал исправление Дане. Даня записал. Серёга, проходивший мимо с кофе, бросил через плечо: «А, Eden. Все через это проходят. Как первый обыск без ордера».

Heap: район, где живут все ваши объекты

Каждый new Object() – новый жилец. Строки, коллекции, DTO, кэши. Все прописаны здесь, и всех нужно когда-то выселять. Выселением занимается Garbage Collector. Но иногда он не справляется. И тогда OutOfMemoryError. Тот самый. Воскресный.

Когда волноваться:

  • > 80% – жёлтый свет. Стоит присмотреться. Может, ничего. А может, утечка.

  • > 95% – красный. GC пашет без перекуров, приложение еле дышит.

  • Рост без снижения – утечка памяти. Тут уже не «возможно», а ориентировка на подозреваемого.

Non-Heap: пригород, о котором забывают

Non-Heap содержит:

  • Metaspace – метаданные классов

  • Code Cache – скомпилированный JIT-код

  • Thread stacks

# Metaspace
jvm_memory_used_bytes{area="nonheap", id="Metaspace"}

Metaspace растёт без остановки? Утечка классов. Бывает при hot reload. Бывает при кривых class loaders. А бывает без видимой причины – как дождь в этом городе.

GC: уборщик, которому платят паузами

Garbage Collector работает бесплатно. Шутка. Берёт паузами: пока он убирает, приложение стоит.

# Время в GC
rate(jvm_gc_pause_seconds_sum[5m])

# Количество пауз
rate(jvm_gc_pause_seconds_count[5m])

# Средняя длительность паузы
rate(jvm_gc_pause_seconds_sum[5m]) / rate(jvm_gc_pause_seconds_count[5m])

Средняя пауза больше 500 мс – проблема. Пользователи замечают.

Сигнализация, теперь без ложных доносов, с суммой по пулам:

- alert: LongGCPauses
  expr: |
    rate(jvm_gc_pause_seconds_sum[5m]) 
    / rate(jvm_gc_pause_seconds_count[5m]) > 0.5
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "Long GC pauses detected"
    description: "Average GC pause > 500ms"

- alert: HighHeapUsage
  expr: |
    sum by (application, instance) (jvm_memory_used_bytes{area="heap"})
    /
    sum by (application, instance) (jvm_memory_max_bytes{area="heap"} != -1) > 0.8
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "High JVM Heap Usage"
    description: "Heap usage > 80% (current: {{ $value | humanizePercentage }})"

Обратите внимание на humanizePercentage. $value в аннотации – доля от 0 до 1. Напишете {{ $value }}%, и дежурный в три ночи прочитает «heap usage 0.85%», пожмёт плечами и уснёт обратно. Где-то прямо сейчас спит дежурный, которому рация честно доложила про 0.95%. Спит. А зря.

Threads: когда все люди заняты

# Живые потоки
jvm_threads_live_threads

# Пиковое значение
jvm_threads_peak_threads

# Daemon потоки
jvm_threads_daemon_threads

jvm_threads_live_threads упёрся в потолок? Thread starvation.

Direct Memory: теневой район

# Буферы Netty, Kafka client и т.д.
jvm_buffer_memory_used_bytes{id="direct"}
jvm_buffer_count_buffers{id="direct"}

Direct Memory в heap не прописана. Но закончиться может. Используете WebFlux или gRPC? Следите. Не используете? Всё равно следите.

JVM Flags: табельное оружие

# Heap dump при OOM - чтобы было что изучать на вскрытии
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof

# Heap как процент от лимита контейнера - а лимит задайте в compose/k8s
-XX:MaxRAMPercentage=75.0

# Детальный GC logging
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=10M

# Native Memory Tracking
-XX:NativeMemoryTracking=summary

Про MaxRAMPercentage отдельная ремарка. Сигнализация «heap больше 80%» имеет смысл, только когда у heap есть потолок. Контейнер без лимита памяти – это JVM, которая берёт 25% RAM хоста и живёт на широкую ногу за чужой счёт. Сначала потолок, потом проценты. Не наоборот.


Эпизод 4. HTTP: что видела улица

В среду в участок зашёл мэр. Борис Аркадьевич, человек, который измеряет всё в деньгах и голосах избирателей. Капитан Лёша гордо развернул перед ним график heap.

— А что видят люди? — спросил мэр.

Пауза. Хорошая. С эхом.

JVM-метрики – про здоровье участка. HTTP-метрики – про то, что творится на улицах. Мэру плевать, сколько у вас heap. Мэру важно, чтобы горожане не жаловались. Желательно никогда.

RED Method: три вопроса со старой визитки

Rate – сколько народу проходит:

sum(rate(http_server_requests_seconds_count[5m]))

Errors – скольких обидели:

sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) 
/ sum(rate(http_server_requests_seconds_count[5m])) * 100

Duration – сколько ждали:

histogram_quantile(0.99, 
  sum(rate(http_server_requests_seconds_bucket[5m])) by (le))

Перцентили: почему среднее – лжесвидетель

Объяснял Дане на пальцах. Девяносто девять запросов по 10 мс, один – 10 секунд. Среднее: 109 мс. Протокол чистый, можно нести мэру. А один горожанин прождал 10 секунд и уехал в соседний город. Навсегда.

p99 – вот честный свидетель. «99% запросов быстрее этого значения». Это показания самого невезучего из ста.

# Медиана (p50)
histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket[5m])) by (le))

# p95
histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le))

# p99
histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le))

p50 = 50 мс, а p99 = 5 с? У вас tail latency. В среднем по городу спокойно, а на окраинах стреляют.

Кастомные теги: особые приметы

По умолчанию Spring Boot вешает на HTTP-метрики теги: method, uri, status, exception, outcome. Иногда нужны дополнительные приметы: версия API, тип операции.

// ВАЖНО: наследуемся от DefaultServerRequestObservationConvention, а НЕ просто
// реализуем интерфейс ServerRequestObservationConvention. Дефолтный метод
// интерфейса возвращает ПУСТОЙ набор тегов - стандартные method/uri/status/outcome
// добавляет именно конкретный класс. Реализуете интерфейс напрямую - стандартные
// теги пропадут, status исчезнет из метрик, и тогда error rate, RED-метрики
// по статусу и SLI по 5xx молча перестанут работать.
@Component
public class CustomWebMvcTagsContributor extends DefaultServerRequestObservationConvention {

    @Override
    public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) {
        // Стандартные теги (method, uri, status, outcome) от базового класса
        KeyValues keyValues = super.getLowCardinalityKeyValues(context);
        
        HttpServletRequest request = context.getCarrier();
        
        // Версия API
        String apiVersion = extractApiVersion(request.getRequestURI());
        keyValues = keyValues.and(KeyValue.of("api.version", apiVersion));
        
        // Тип операции
        String operationType = determineOperationType(
            request.getMethod(), 
            request.getRequestURI()
        );
        keyValues = keyValues.and(KeyValue.of("operation.type", operationType));
        
        return keyValues;
    }

    private String extractApiVersion(String uri) {
        if (uri != null && uri.contains("/v")) {
            int vIndex = uri.indexOf("/v");
            if (vIndex >= 0 && vIndex + 2 < uri.length()) {
                int endIndex = uri.indexOf('/', vIndex + 1);
                if (endIndex < 0) endIndex = uri.length();
                String version = uri.substring(vIndex + 1, endIndex);
                if (version.matches("v\d+")) return version;
            }
        }
        return "v0";
    }

    private String determineOperationType(String method, String uri) {
        if (uri == null || method == null) {
            return "unknown";
        }

        // Actuator-трафик помечаем отдельно - чтобы потом легко исключать
        if (uri.startsWith("/actuator")) {
            return "monitoring";
        }

        if (uri.contains("/tasks")) {
            return switch (method.toUpperCase()) {
                case "GET" -> uri.matches(".*/tasks/\d+$") ? "task_read" : "task_list";
                case "POST" -> uri.endsWith("/complete") ? "task_complete" : "task_create";
                case "PUT" -> "task_update";
                case "DELETE" -> "task_delete";
                default -> "task_other";
            };
        }
        return "other";
    }

    @Override
    public String getName() {
        // Сохраняем стандартное имя метрики, иначе все запросы
        // к http_server_requests_* в дашбордах и правилах ослепнут
        return "http.server.requests";
    }
}

Важно: только low-cardinality! Никаких user_id, request_id. Почему – будет отдельный эпизод, у Серёги на эту тему есть дело, после которого он поседел. Если коротко: Prometheus не выдержит. Не фигурально.


Эпизод 5. Бар «У Хикари»: узкое место, которое все ищут

База данных – бутылочное горлышко. Про него все знают, его все ищут, а находят обычно когда уже поздно: под нагрузкой и на проде. Connection pool – первое место, куда стоит зайти. Я называю его «бар “У Хикари”»: десять столиков, бармен с секундомером, и очередь на входе появляется всегда внезапно.

Узкое место у нас, кстати, нашлось быстро. В четверг Даня влетел в кабинет: «Смотри, pending растёт!» И мы впервые увидели проблему до того, как она стала трупом. Ощущение ни с чем не сравнимое. Как раскрыть дело до того, как оно завелось.

HikariCP

# Активные соединения (сейчас работают)
hikaricp_connections_active

# Ожидающие (стоят в очереди)
hikaricp_connections_pending

# Простаивающие (свободны)
hikaricp_connections_idle

# Всего в пуле
hikaricp_connections

Здоровая картина: active колеблется, pending на нуле, в idle есть запас.

Проблема: active = max, pending > 0. Все столики заняты, у входа очередь. И каждый в этой очереди – горожанин, который ждёт. А горожане ждать не любят. Никто не любит.

Конфигурация

spring:
  datasource:
    hikari:
      pool-name: TodoHikariPool
      maximum-pool-size: 10
      minimum-idle: 2
      register-mbeans: true  # Это про JMX. Метрики hikaricp_* в Prometheus идут не отсюда

Маленькое уточнение для протокола, чтобы не было разочарований. Метрики hikaricp_connections_* появляются в Prometheus не из-за register-mbeans. Их подключает сам Spring Boot, как только в деле появляется MeterRegistry. А register-mbeans – это про JMX, контора из другой эпохи. Уберёте флаг, метрики пула останутся на месте. Проверено лично.

AOP для Repository: топтун у каждой двери

Хотите знать, какой именно запрос тормозит? Поимённо? Ставим наружное наблюдение.

@Aspect
@Component
public class RepositoryMetricsAspect {

    private final MeterRegistry meterRegistry;

    public RepositoryMetricsAspect(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Around("execution(* com.example.todo.repository.*Repository.*(..))")
    public Object measureRepositoryCall(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        String repositoryName = signature.getDeclaringType().getSimpleName();
        String methodName = signature.getName();
        String operationType = determineOperationType(methodName);

        Timer.Sample sample = Timer.start(meterRegistry);
        String outcome = "success";
        
        try {
            return joinPoint.proceed();
        } catch (Throwable ex) {
            outcome = "error";
            meterRegistry.counter("repository.errors.total",
                    "repository", repositoryName,
                    "method", methodName,
                    "exception", ex.getClass().getSimpleName()
            ).increment();
            throw ex;
        } finally {
            // Только гистограмма, без publishPercentiles: перцентили посчитает
            // Prometheus через histogram_quantile, и их можно агрегировать
            // между инстансами. Клиентские перцентили рядом с гистограммой -
            // просто лишние серии.
            sample.stop(Timer.builder("repository.method.duration")
                    .description("Repository method execution time")
                    .tag("repository", repositoryName)
                    .tag("method", methodName)
                    .tag("operation", operationType)
                    .tag("outcome", outcome)
                    .publishPercentileHistogram()
                    .register(meterRegistry));
        }
    }

    private String determineOperationType(String methodName) {
        String lower = methodName.toLowerCase();
        if (lower.startsWith("find") || lower.startsWith("get") || lower.startsWith("count")) 
            return "read";
        if (lower.startsWith("save") || lower.startsWith("update")) 
            return "write";
        if (lower.startsWith("delete")) 
            return "delete";
        return "other";
    }
}

Теперь каждый вызов Repository под наблюдением: метрика repository.method.duration, разбивка по методам. Видно, кто тормозит. С должностью и кличкой.

PromQL для базы

# Утилизация пула %
hikaricp_connections_active / hikaricp_connections_max * 100

# Время ожидания соединения
rate(hikaricp_connections_acquire_seconds_sum[5m]) 
/ rate(hikaricp_connections_acquire_seconds_count[5m])

# Топ медленных методов
topk(5, 
  histogram_quantile(0.95, 
    sum(rate(repository_method_duration_seconds_bucket[5m])) by (le, method)))

Эпизод 6. Деньги: язык, который понимает мэрия

Пятница, отчёт в мэрии. Борис Аркадьевич изучает нашу новую доску улик. Долго. Потом поднимает глаза:

— Сколько у нас RPS? — Пятьсот, — гордо говорит капитан Лёша. — Это хорошо? — … — Я спрашиваю: это хорошо или плохо? — Это… пятьсот.

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

Технические метрики – для участка. «Сколько задач создали и сколько выполнили» – для мэрии. Разные вопросы, разные ответы, разные люди. И жалованье нам, между прочим, подписывает мэрия. Так что учим их язык. Язык называется «бизнес-метрики», и в Micrometer для него четыре падежа.

Четыре типа метрик Micrometer

Counter – только растёт. Как досье. Назад не отматывается.

Gauge – текущее значение. Как температура: сейчас 36.6, через час 37. Может расти, может падать.

Timer – время и количество. Секундомер с памятью.

Distribution Summary – распределение значений. Не времени, а значений: размер запроса, количество товаров в корзине, сумма чека.

TaskService с полным набором

@Service
@Transactional
public class TaskService {

    private final TaskRepository taskRepository;
    private final MeterRegistry meterRegistry;
    private final ApplicationEventPublisher eventPublisher;
    
    // Counters
    private Counter tasksCreatedCounter;
    private Counter tasksCompletedCounter;
    private Counter tasksDeletedCounter;
    
    // Gauge через AtomicInteger
    private final AtomicInteger activeTasksGauge = new AtomicInteger(0);
    
    // Timer
    private Timer taskProcessingTimer;
    
    // Distribution Summary
    private DistributionSummary taskPrioritySummary;

    public TaskService(TaskRepository taskRepository, 
                       MeterRegistry meterRegistry,
                       ApplicationEventPublisher eventPublisher) {
        this.taskRepository = taskRepository;
        this.meterRegistry = meterRegistry;
        this.eventPublisher = eventPublisher;
    }

    @PostConstruct
    public void initMetrics() {
        // Внимание: имя НЕ заканчивается на ".total".
        // Micrometer сам добавит суффикс _total для Prometheus.
        // "tasks.created" станет метрикой tasks_created_total.
        // Назовёте "tasks.created.total" - получите tasks_created_total_total. Дважды. Зачем?
        tasksCreatedCounter = Counter.builder("tasks.created")
                .description("Total number of created tasks")
                .register(meterRegistry);

        tasksCompletedCounter = Counter.builder("tasks.completed")
                .description("Total number of completed tasks")
                .register(meterRegistry);

        tasksDeletedCounter = Counter.builder("tasks.deleted")
                .description("Total number of deleted tasks")
                .register(meterRegistry);

        Gauge.builder("tasks.active.count", activeTasksGauge, AtomicInteger::get)
                .description("Current number of active tasks")
                .register(meterRegistry);

        taskProcessingTimer = Timer.builder("tasks.processing.time")
                .description("Task processing time from creation to completion")
                .publishPercentiles(0.5, 0.75, 0.95, 0.99)
                .register(meterRegistry);

        taskPrioritySummary = DistributionSummary.builder("tasks.priority.distribution")
                .description("Distribution of task priorities")
                .publishPercentiles(0.5, 0.75, 0.95)
                .register(meterRegistry);

        // Инициализация gauge из БД (однократно, при старте).
        // countActiveTasks() считает PENDING + IN_PROGRESS. Именно их,
        // а не "всё, что не DONE" - отменённая задача не активная,
        // хотя и не завершённая. Семантика gauge должна совпадать
        // с логикой инкрементов/декрементов ниже, иначе gauge уедет.
        activeTasksGauge.set((int) taskRepository.countActiveTasks());
    }

    @Transactional
    public Task createTask(String title, String description, TaskPriority priority) {
        Task task = new Task(title, description, priority);
        Task savedTask = taskRepository.save(task);

        tasksCreatedCounter.increment();
        taskPrioritySummary.record(priority.getWeight());
        meterRegistry.counter("tasks.created.by.priority",
                "priority", priority.name().toLowerCase()
        ).increment();
        activeTasksGauge.incrementAndGet();
        eventPublisher.publishEvent(new TaskCreatedEvent(savedTask));

        return savedTask;
    }

    @Transactional
    public Task completeTask(Long id) {
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new TaskNotFoundException(id));

        if (task.getStatus() == TaskStatus.DONE) {
            // Без этой проверки повторный complete декрементировал бы
            // gauge активных задач второй раз. Метрики этого не прощают.
            throw new IllegalStateException("Задача уже завершена");
        }

        task.setStatus(TaskStatus.DONE);
        task.setCompletedAt(LocalDateTime.now());

        Duration processingTime = Duration.between(
            task.getCreatedAt(), 
            task.getCompletedAt()
        );
        taskProcessingTimer.record(processingTime);
        tasksCompletedCounter.increment();
        activeTasksGauge.decrementAndGet();
        eventPublisher.publishEvent(new TaskCompletedEvent(task, processingTime));

        return taskRepository.save(task);
    }

    @Transactional
    public void deleteTask(Long id) {
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new TaskNotFoundException(id));

        // Декрементируем gauge только для действительно активных задач.
        // Условие "status != DONE" было бы ошибкой: отменённая (CANCELLED)
        // задача в gauge не входит, и её удаление увело бы счётчик в минус.
        if (task.getStatus() == TaskStatus.PENDING ||
            task.getStatus() == TaskStatus.IN_PROGRESS) {
            activeTasksGauge.decrementAndGet();
        }

        taskRepository.delete(task);
        tasksDeletedCounter.increment();
    }

    public static class TaskNotFoundException extends RuntimeException {
        public TaskNotFoundException(Long id) {
            super("Task not found: " + id);
        }
    }
}

Работает? Работает. А теперь два предупреждения, которые в учебниках печатают мелким шрифтом, а в жизни кровью.

Первое: транзакции. Все эти counter.increment() исполняются внутри @Transactional-метода, до коммита. Транзакция откатилась, а счётчик уже увеличен. Задачи в базе нет, в протоколе есть. Один откат – погрешность. Тысяча откатов – фальшивый отчёт, в который верит весь город, потому что он красиво нарисован. Для большинства дел это приемлемая цена простоты. Для точных бизнес-метрик есть способ чище: события с @TransactionalEventListener(AFTER_COMMIT). О нём через эпизод, и там будет дело о пяти задачах-призраках.

Второе: несколько инстансов. activeTasksGauge живёт в памяти одного инстанса. Поднимете три реплики над общей базой, и каждая будет видеть только свои операции, а после рестарта переинициализируется полным числом задач из БД. Сумма по инстансам превратится в абстрактную живопись: дорого, непонятно, к реальности отношения не имеет. Для одной реплики работает отлично. Для нескольких – читайте gauge из БД (с кэшем, не на каждый scrape) или стройте его поверх counters в PromQL.

Шпаргалка

Сценарий

Тип

Пример

Подсчёт событий

Counter

orders.created

Текущее состояние

Gauge

users.online.count

Время операций

Timer

order.processing.time

Распределение значений

Distribution Summary

order.items.count

Counter отвечает на вопрос «сколько всего», gauge – «сколько прямо сейчас».

На следующем отчёте Лёша показал мэру график «создано задач / выполнено задач». Борис Аркадьевич кивнул: «Вот. Вот это я понимаю». Мы нашли общий язык.


Эпизод 7. Своя агентура: кастомные метрики

MeterRegistry – это картотека. Сюда стекается всё, отсюда уходит к архивариусу. Настроишь правильно – работает на тебя. Настроишь неправильно – работает против тебя, причём с энтузиазмом новичка.

Конфигурация MeterRegistry

@Configuration
public class MeterRegistryConfig {

    // Тег application здесь НЕ задаём - он уже задан в application.yml
    // (management.metrics.tags.application). Два источника правды для
    // одного тега - это не надёжность. Это два места, где можно ошибиться.
    @Bean
    public MeterRegistryCustomizer<MeterRegistry> commonTags() {
        return registry -> registry.config()
                .commonTags(
                        "team", "backend",
                        "version", "1.0.0"
                );
    }

    @Bean
    public MeterRegistryCustomizer<MeterRegistry> metricsFilters() {
        return registry -> registry.config()
                // Защита от cardinality explosion
                .meterFilter(MeterFilter.maximumAllowableTags(
                    "http.server.requests", "uri", 100, MeterFilter.deny()))
                
                // Игнорируем по-настоящему лишнее (logback-метрики).
                // НЕ отключайте jvm.gc.pause - на ней держатся алерт по GC,
                // recording rule и панель в Grafana. Отключите - и узнаете о паузах
                // GC последним. Как правило, уже во время самого инцидента.
                .meterFilter(MeterFilter.denyNameStartsWith("logback"))
                
                // Гистограммы - ТОЛЬКО для бизнес-таймеров tasks.*
                // Включить percentilesHistogram для ВСЕХ таймеров - значит
                // подарить каждому по 60-70 серий le="..." на каждую комбинацию
                // тегов. Свой собственный cardinality explosion, сделанный
                // своими руками. Из лучших побуждений. Как обычно.
                .meterFilter(new MeterFilter() {
                    @Override
                    public DistributionStatisticConfig configure(
                            Meter.Id id, DistributionStatisticConfig config) {
                        if (id.getType() == Meter.Type.TIMER 
                                && id.getName().startsWith("tasks.")) {
                            return DistributionStatisticConfig.builder()
                                    .percentilesHistogram(true)
                                    .build()
                                    .merge(config);
                        }
                        return config;
                    }
                });
    }
}

И ещё одна оперативная справка про перцентили, пока картотека открыта. Их два сорта: клиентские (publishPercentiles) и серверные (гистограмма + histogram_quantile). Клиентские считаются в приложении и не агрегируются между инстансами: p99 трёх реплик из трёх клиентских p99 не собрать. Серверные считаются из бакетов и агрегируются как угодно. Правило простое: гистограмма – основной инструмент, клиентские перцентили рядом с ней – лишние серии без новой информации.

@Timed vs программный подход

Timed – просто и быстро:

@Timed(value = "task.service.create", description = "Time to create task")
public Task createTask(...) {
    // автоматически измеряется
}

Программный подход – когда нужен контроль:

public Task createTask(...) {
    return Timer.builder("task.service.create")
            .tag("priority", priority.name())
            .register(meterRegistry)
            .recordCallable(() -> {
                // логика
                return savedTask;
            });
}

Timed – для простых случаев. Программный – когда нужны динамические теги. Оба варианта законны. Незаконный – не измерять вообще.

Дело о пяти призраках, или Event-driven метрики

Обещанная история. Четверг, вторая половина дня. Даня врывается с дашбордом наперевес, как с ордером:

— Смотри! Создано 4 512 задач. А в базе 4 507. Куда делись пять задач?! — Никуда не делись, Даня. — То есть как? — Их никогда не было. Ты ищешь пятерых, которых не существовало.

Пять транзакций откатились. Валидация, конфликт, чья-то нервная ретрай-логика. Мотив неважен, важен механизм: counter инкрементился до коммита, а откат счётчик назад не отматывает. Counter ничего не отматывает.

Лекарство: Spring Events плюс правильная аннотация. Бизнес-логика публикует событие, слушатель ведёт учёт и учитывает только то, что реально зафиксировано в базе:

// События
public record TaskCreatedEvent(Task task) {}
public record TaskCompletedEvent(Task task, Duration processingTime) {}
public record TaskStatusChangedEvent(Task task, TaskStatus previousStatus, TaskStatus newStatus) {}

// Слушатель
@Component
public class MetricsEventListener {

    private final MeterRegistry meterRegistry;

    public MetricsEventListener(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    // НЕ @EventListener, а @TransactionalEventListener(AFTER_COMMIT).
    // Событие публикуется внутри @Transactional-метода сервиса. Обычный
    // @EventListener сработает немедленно - и при откате транзакции
    // счётчик останется увеличенным, хотя задачи в БД нет.
    // AFTER_COMMIT считает только то, что реально зафиксировано.
    // fallbackExecution = true - чтобы слушатель работал и вне транзакции.
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, 
                                fallbackExecution = true)
    public void handleTaskCreated(TaskCreatedEvent event) {
        meterRegistry.counter("events.tasks.created.by.priority",
                "priority", event.task().getPriority().name().toLowerCase()
        ).increment();
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, 
                                fallbackExecution = true)
    public void handleTaskCompleted(TaskCompletedEvent event) {
        meterRegistry.timer("events.tasks.completed.duration",
                "priority", event.task().getPriority().name().toLowerCase()
        ).record(event.processingTime());
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, 
                                fallbackExecution = true)
    public void handleStatusChanged(TaskStatusChangedEvent event) {
        meterRegistry.counter("events.tasks.status.changed",
                "from", event.previousStatus().name().toLowerCase(),
                "to", event.newStatus().name().toLowerCase()
        ).increment();
    }
}

Почему это хорошо:

  • Separation of Concerns: сервис чист.

  • Тестируемость: мокаете и тестируете отдельно.

  • Гибкость: новый слушатель? Добавили. Сервис не трогали.

  • Точность: AFTER_COMMIT означает, что метрика не врёт при откатах. Прямые инкременты из прошлого эпизода этим похвастаться не могут. Простота против точности, выбирайте по делу.


Эпизод 8. Грейс: доска с фотографиями и красными нитками

Собрать показания – полдела. В каждом приличном детективе есть стена: фотографии, карта города, красные нитки между кнопками. У нас эту стену рисует Грейс. Grafana. Она берёт сухие цифры архивариуса и делает из них картину, по которой за три секунды видно, горим или не горим.

Дашборд – инструмент, не украшение. Каждая панель отвечает на вопрос. Не отвечает – снимите со стены. Да, и ту красивую тоже. Особенно ту красивую.

Готовые дашборды

Не изобретайте велосипед. Велосипед уже изобретён, у него гарантия и сообщество.

  1. Grafana → Dashboards → Import

  2. ID: 4701 (JVM Micrometer)

  3. Выбрать Prometheus datasource

  4. Import

Готово. JVM-мониторинг из коробки. Ещё:

  • 11378 – Spring Boot Statistics

  • 6417 – Kubernetes Cluster

Структура правильной доски

Наша стена устаканилась в шесть рядов. Сверху вниз, от «горим или нет» к деталям:

Row 1: Overview – первый взгляд. Три секунды, чтобы понять, всё ли в порядке.

  • RPS (Stat panel)

  • Error Rate % (Stat panel)

  • p99 Latency (Stat panel)

  • Active Tasks (Stat panel)

Row 2: JVM Health – здоровье машины.

  • Heap Memory (Time series)

  • Threads (Time series)

  • GC Pauses (Time series)

Row 3: HTTP Details – что видит пользователь.

  • Latency Percentiles (Time series)

  • Request Rate by Endpoint (Time series)

Row 4: Database – что происходит под капотом.

  • Connection Pool (Time series)

  • Repository Duration (Time series)

Row 5: Business – что интересует мэрию.

  • Tasks Created/Completed (Time series)

  • Tasks by Priority (Bar chart)

Row 6: SLO / Error Budget – выполняем ли мы обещания (про SLO отдельный эпизод, потерпите).

  • Availability, Error Budget Remaining, Burn Rate

Важная улика, на которой мы сами споткнулись: панели Overview должны исключать actuator-трафик (uri!~"/actuator.*"), как и панели латентности. Иначе Prometheus, который скрейпит метрики каждые 5 секунд, своими быстрыми двухсотками приукрашивает вам и RPS, и error rate. Получается топтун, который следит сам за собой и пишет в отчёте, что объект вёл себя образцово.

Variables: одна доска на все районы

{
  "templating": {
    "list": [
      {
        "name": "application",
        "type": "query",
        "query": "label_values(jvm_memory_used_bytes, application)",
        "refresh": 1
      },
      {
        "name": "instance",
        "type": "query", 
        "query": "label_values(jvm_memory_used_bytes{application="$application"}, instance)",
        "refresh": 1
      }
    ]
  }
}

Один дашборд. Все сервисы. Переключаетесь через выпадающий список.

Grafana Provisioning: доска в Git

Дашборды в Git – это культура. Не в головах, не в закладках, не «у Васи спроси, он делал». Вася уволится. Git не уволится.

grafana/datasources.yml:

apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    editable: true   # в демо удобно; в production - false, источник правды в Git
    jsonData:
      # Должен совпадать со scrape_interval вашего приложения.
      # Поставите больше - Grafana будет рисовать грубее, чем собираете.
      timeInterval: "5s"
      httpMethod: "POST"

grafana/dashboards/dashboard.yml:

apiVersion: 1

providers:
  - name: 'TODO App Dashboards'
    orgId: 1
    folder: 'TODO App'
    folderUid: 'todo-app'
    type: file
    disableDeletion: false
    updateIntervalSeconds: 30
    allowUiUpdates: true
    options:
      path: /etc/grafana/provisioning/dashboards

Экспорт:

curl -s -H "Authorization: Bearer $API_KEY" 
  "http://localhost:3000/api/dashboards/uid/$UID" 
  | jq '.dashboard' > dashboard.json

Эпизод 9. Коммутатор: дело о двадцати двух звонках

Доска – хорошо. Но детектив не может смотреть на доску круглые сутки. У детектива есть жизнь. По крайней мере, так написано в его трудовом договоре. Для всего остального существует диспетчер на коммутаторе – Alertmanager. Он звонит, когда в городе беда. Вовремя.

В ночь на вторник случился обряд посвящения. Тестовый стенд прилёг на двадцать минут, и капитану Лёше позвонили двадцать два раза. Приложение упало – звонок. Вслед за ним heap – звонок. Латентность – звонок. Пул соединений – звонок. Error rate – два звонка, warning и critical, чтобы наверняка. И всё это каждые пять минут по кругу.

Утром Лёша вошёл в участок походкой человека, который за ночь двадцать два раза узнал одну и ту же новость:

— Я понял две вещи. Первая: сигнализация работает. Вторая: так жить нельзя.

Так мы познакомились с routing, silencing и inhibition. Но сначала сами ориентировки.

Alert Rules

# prometheus/alert-rules.yml
groups:
  - name: jvm_alerts
    rules:
      # Суммируем по пулам! Без sum алерт сравнивает каждый пул с его
      # собственным максимумом, а Eden перед сборкой всегда почти полон.
      # Подробности - в эпизоде про JVM.
      - alert: HighHeapUsage
        expr: |
          sum by (application, instance) (jvm_memory_used_bytes{area="heap"})
          /
          sum by (application, instance) (jvm_memory_max_bytes{area="heap"} != -1) > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High JVM Heap Usage ({{ $labels.instance }})"
          description: "Heap > 80% for 5 minutes (current: {{ $value | humanizePercentage }})"
          runbook_url: "https://wiki.example.com/runbooks/jvm-heap"

      - alert: CriticalHeapUsage
        expr: |
          sum by (application, instance) (jvm_memory_used_bytes{area="heap"})
          /
          sum by (application, instance) (jvm_memory_max_bytes{area="heap"} != -1) > 0.95
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Critical JVM Heap Usage"
          description: "Heap > 95% - OOM imminent!"

  - name: http_alerts
    rules:
      # Исключаем /actuator: Prometheus сам скрейпит метрики каждые 5 секунд,
      # и этот поток быстрых успешных запросов разбавляет долю ошибок.
      - alert: HighErrorRate
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5..", uri!~"/actuator.*"}[5m])) 
          / sum(rate(http_server_requests_seconds_count{uri!~"/actuator.*"}[5m])) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Error rate above 5%"

      - alert: HighLatencyP99
        expr: |
          histogram_quantile(0.99, 
            sum(rate(http_server_requests_seconds_bucket{uri!~"/actuator.*"}[5m])) by (le)) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "p99 latency above 2s"

      # Алерт "трафика нет совсем" - помните дело о молчании из эпизода
      # про PromQL? Вот где оно стреляет. Без "or vector(0)" этот алерт
      # молчал бы именно тогда, когда серий нет вообще.
      - alert: NoRequests
        expr: |
          (sum(rate(http_server_requests_seconds_count{uri!~"/actuator.*"}[5m])) or vector(0)) == 0
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "No HTTP requests received"

  - name: database_alerts
    rules:
      - alert: ConnectionPoolExhausted
        expr: hikaricp_connections_pending > 0
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Connection pool has pending requests"

  - name: application_alerts
    rules:
      - alert: ApplicationDown
        expr: up{job="todo-app"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Application is down"

Конфигурация Alertmanager

# prometheus/alertmanager.yml
global:
  resolve_timeout: 5m

route:
  group_by: ['alertname', 'severity']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'default-receiver'
  
  routes:
    - match:
        severity: critical
      receiver: 'critical-receiver'
      group_wait: 10s
      repeat_interval: 1h

    - match:
        severity: warning
      receiver: 'warning-receiver'

receivers:
  - name: 'default-receiver'
    email_configs:
      - to: 'team@example.com'
        send_resolved: true

  - name: 'critical-receiver'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/...'
        channel: '#alerts-critical'
        title: '🚨 {{ .Status | toUpper }}: {{ .CommonAnnotations.summary }}'
        text: |
          {{ range .Alerts }}
          *Alert:* {{ .Labels.alertname }}
          *Severity:* {{ .Labels.severity }}
          *Description:* {{ .Annotations.description }}
          {{ end }}
        send_resolved: true
    
    pagerduty_configs:
      - service_key: 'your-pagerduty-key'
        send_resolved: true

  - name: 'warning-receiver'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/...'
        channel: '#alerts-warning'
        send_resolved: true

Silencing: тишина по ордеру

Плановые работы? Выпишите тишине ордер.

curl -X POST http://localhost:9093/api/v2/silences 
  -H "Content-Type: application/json" 
  -d '{
    "matchers": [
      {"name": "alertname", "value": "HighHeapUsage", "isRegex": false},
      {"name": "instance", "value": "app:8080", "isRegex": false}
    ],
    "startsAt": "2024-01-15T10:00:00Z",
    "endsAt": "2024-01-15T12:00:00Z",
    "createdBy": "admin",
    "comment": "Плановое обслуживание"
  }'

Или через UI: http://localhost:9093/#/silences

Inhibition: глушим эхо

Вернёмся к двадцати двум звонкам. Приложение упало – это одна проблема. Но вслед за ней голосит всё, что от приложения зависит. За одно преступление двадцать вызовов на место.

Inhibition подавляет лишнее:

inhibit_rules:
  # Критический heap подавляет предупреждение о heap
  - source_match:
      alertname: CriticalHeapUsage
    target_match:
      alertname: HighHeapUsage
    equal: ['instance']

  # Критический error rate подавляет warning по error rate
  - source_match:
      alertname: CriticalErrorRate
    target_match:
      alertname: HighErrorRate
    equal: ['application']
  
  # Если приложение down - молчим обо всём остальном
  - source_match:
      alertname: ApplicationDown
    target_match_re:
      alertname: (HighHeapUsage|HighErrorRate|HighLatency.*)
    equal: ['instance']

  # Если база недоступна - не ругаемся на пул
  - source_match:
      alertname: DatabaseDown
    target_match:
      alertname: ConnectionPoolExhausted
    equal: ['instance']

А теперь тонкость, на которой ловятся даже ветераны. Мы попались лично: я написал «универсальное» правило «critical подавляет warning», equal: ['alertname']. Красиво. Лаконично. Не работает. Потому что equal требует точного совпадения alertname, а пары называются по-разному: CriticalHeapUsage и HighHeapUsage – два разных имени. Универсальное правило молча не подавило ничего, и Лёше позвонили оба. Снова. Он принял это как мужчина: молча показал мне счётчик пропущенных.

Поэтому пары задаём явно. Скучно? Скучно. Зато работает.

Одна проблема – один звонок. Всё остальное подавлено. Тишина и порядок.


Эпизод 10. Архив: Recording Rules

Некоторые допросы стоят дорого. histogram_quantile по миллионам точек – это не бесплатно. А если этот допрос проводится в трёх дашбордах и двух алертах? Пять раз спрашивать свидетеля об одном и том же… так работают только очень плохие следователи и очень дорогие адвокаты.

Recording Rules – это протокол допроса, подшитый в дело. Спросили один раз, записали, дальше все читают запись. Раз в 15 секунд свежая страница.

Когда нужны

  • Запрос используется в нескольких местах.

  • Запрос тяжёлый.

  • Нужна историческая агрегация.

Синтаксис

# prometheus/recording-rules.yml
groups:
  - name: http_recording_rules
    interval: 15s
    rules:
      # RPS
      - record: job:http_requests:rate5m
        expr: sum(rate(http_server_requests_seconds_count[5m])) by (job, application)

      # Error rate
      - record: job:http_errors:rate5m_ratio
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) by (job, application)
          /
          sum(rate(http_server_requests_seconds_count[5m])) by (job, application)

      # p99
      - record: job:http_latency:p99_5m
        expr: |
          histogram_quantile(0.99, 
            sum(rate(http_server_requests_seconds_bucket[5m])) by (le, job, application))

      # p50
      - record: job:http_latency:p50_5m
        expr: |
          histogram_quantile(0.50, 
            sum(rate(http_server_requests_seconds_bucket[5m])) by (le, job, application))

  - name: jvm_recording_rules
    rules:
      # Heap usage ratio - и снова фильтр != -1, у пулов без максимума
      # jvm_memory_max_bytes равен -1 и портит сумму
      - record: job:jvm_heap:usage_ratio
        expr: |
          sum(jvm_memory_used_bytes{area="heap"}) by (job, application, instance)
          /
          sum(jvm_memory_max_bytes{area="heap"} != -1) by (job, application, instance)

      # GC time ratio
      - record: job:jvm_gc:time_ratio_5m
        expr: sum(rate(jvm_gc_pause_seconds_sum[5m])) by (job, application)

  - name: business_recording_rules
    rules:
      # Task creation rate
      - record: app:tasks:creation_rate_5m
        expr: sum(rate(tasks_created_total[5m])) by (application)

      # Task completion rate
      - record: app:tasks:completion_rate_5m
        expr: sum(rate(tasks_completed_total[5m])) by (application)

      # Backlog growth
      - record: app:tasks:backlog_growth_rate_5m
        expr: |
          sum(rate(tasks_created_total[5m])) by (application)
          - sum(rate(tasks_completed_total[5m])) by (application)

Naming Convention

Формат: level:metric:operations

Часть

Описание

Примеры

level

Уровень агрегации

job, instance, app

metric

Базовая метрика

http_requests, jvm_heap

operations

Операции

rate5m, p99_5m, ratio

Использование

Было:

histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, job))

Стало:

job:http_latency:p99_5m

Короче. Быстрее. И не нужно каждый раз вспоминать синтаксис.


Эпизод 11. Договор с городом: SLO, SLI и 43 минуты

Финальный отчёт в мэрии. Борис Аркадьевич просмотрел все наши доски, нитки и фотографии и задал вопрос, который стоил всех предыдущих:

— Хорошо. А обещания, которые мы дали клиентам, мы их держим? Да или нет?

Вот он, главный вопрос. Без перцентилей, без histogram_quantile. Да или нет. На такие вопросы отвечает SLO: договор, под которым стоит ваша подпись.

Определения

SLI (Service Level Indicator) – что измеряем:

  • Availability: доля успешных запросов.

  • Latency: доля быстрых запросов.

SLO (Service Level Objective) – целевое значение:

  • Availability: 99.9%

  • Latency p99: < 500ms

Error Budget – сколько можно ошибаться:

  • SLO 99.9% = 0.1% ошибок допустимо.

  • 30 дней × 24ч × 60мин = 43 200 минут.

  • Error Budget = 43 200 × 0.001 = 43 минуты.

Сорок три минуты за месяц. Всего сорок три. На всё. На баги, на деплои, на «ну тут мы не ожидали». Сорок три минуты, и бюджет исчерпан. Потом начинаются разговоры, после которых хочется сменить фамилию и город. Я в таких участвовал. Фамилию оставил, привычку считать бюджет приобрёл.

Recording Rules для SLI

# prometheus/slo-rules.yml
groups:
  - name: slo_recording_rules
    rules:
      # Availability SLI.
      # ВАЖНО: исключаем /actuator - Prometheus сам скрейпит метрики каждые
      # 5 секунд, healthcheck дёргает /actuator/health. Это постоянный поток
      # быстрых успешных запросов, который улучшает SLI, не имея никакого
      # отношения к пользователям. SLO - про пользователей.
      - record: sli:http_availability:ratio_rate5m
        expr: |
          sum(rate(http_server_requests_seconds_count{status!~"5..", uri!~"/actuator.*"}[5m])) by (application)
          /
          sum(rate(http_server_requests_seconds_count{uri!~"/actuator.*"}[5m])) by (application)

      # Latency SLI: доля запросов < 500ms
      # (бакет le="0.5" существует только если в application.yml задана
      #  SLO-граница 500ms - см. эпизод про прослушку)
      - record: sli:http_latency_500ms:ratio_rate5m
        expr: |
          sum(rate(http_server_requests_seconds_bucket{le="0.5", uri!~"/actuator.*"}[5m])) by (application)
          /
          sum(rate(http_server_requests_seconds_count{uri!~"/actuator.*"}[5m])) by (application)

      # Burn rate - короткое окно: насколько быстро
      # мы тратим бюджет ПРЯМО СЕЙЧАС.
      - record: slo:error_budget:burn_rate_5m
        expr: |
          (1 - sli:http_availability:ratio_rate5m) / (1 - 0.999)

  # Длинные окна выносим в отдельную группу с interval: 1m.
  # rate[30d] - дорогой запрос, и пересчитывать его каждые 15 секунд -
  # это ровно тот случай "запрос тяжелее ответа" из эпизода про recording
  # rules. Бюджет за месяц не меняется за 15 секунд. Раз в минуту - щедро.
  - name: slo_recording_rules_long
    interval: 1m
    rules:
      # Availability за ОКНО SLO (30 дней). Требует retention >= 30d.
      # Нюанс: на свежем стенде rate[30d] считается по тем данным, что есть.
      # Первые недели error budget - оценка, а не факт. Имейте в виду.
      - record: sli:http_availability:ratio_rate30d
        expr: |
          sum(rate(http_server_requests_seconds_count{status!~"5..", uri!~"/actuator.*"}[30d])) by (application)
          /
          sum(rate(http_server_requests_seconds_count{uri!~"/actuator.*"}[30d])) by (application)

      # Error Budget remaining (для SLO 99.9%).
      # ВАЖНО: бюджет - величина за месяц, а не за 5 минут.
      # Поэтому считаем по 30-дневному окну, а не по rate5m.
      - record: slo:error_budget:remaining_ratio
        expr: |
          1 - (
            (1 - sli:http_availability:ratio_rate30d)
            / (1 - 0.999)
          )

Здесь легко перепутать два разных допроса. «Сколько бюджета осталось за месяц?» – это remaining_ratio по 30-дневному окну. «Как быстро мы его жжём прямо сейчас?» – это burn rate по короткому окну. Считать остаток месячного бюджета по пятиминутке – всё равно что закрывать дело по одной улике. Присяжные не оценят. Prometheus тоже.

Алерты на Burn Rate

Сразу предупрежу о соблазне. Хочется написать просто burn_rate_5m > 10 и сдать в архив. Не делайте так. Алерт на одно короткое окно шумит: дёрнулся error rate на минуту, дежурному позвонили, а проблемы уже нет. Канонический приём из учебника Google SRE – multi-window: подтверждать всплеск двумя окнами, коротким и длинным. Если показания дали оба свидетеля, дело настоящее: будим человека. Если только один – переждём и понаблюдаем.

# Fast burn (page): бюджет горит очень быстро.
# 14.4x = месячный бюджет сгорит примерно за 2 дня.
# Подтверждаем коротким (5m) И длинным (1h) окном.
- alert: HighErrorBudgetBurn
  expr: |
    slo:error_budget:burn_rate_5m > 14.4
    and
    slo:error_budget:burn_rate_1h > 14.4
  for: 2m
  labels:
    severity: critical
  annotations:
    summary: "Fast error budget burn"
    description: |
      Burn rate > 14.4x confirmed on 5m and 1h windows.
      At this rate, monthly error budget will be exhausted in ~2 days.

# Slow burn (ticket): медленная деградация, 6x на окнах 30m и 6h.
- alert: ElevatedErrorBudgetBurn
  expr: |
    slo:error_budget:burn_rate_30m > 6
    and
    slo:error_budget:burn_rate_6h > 6
  for: 15m
  labels:
    severity: warning
  annotations:
    summary: "Elevated error budget burn"

- alert: ErrorBudgetExhausted
  expr: slo:error_budget:remaining_ratio < 0
  for: 5m
  labels:
    severity: critical
  annotations:
    summary: "Error budget exhausted"

- alert: ErrorBudgetHalfConsumed
  expr: slo:error_budget:remaining_ratio < 0.5
  for: 1h
  labels:
    severity: warning
  annotations:
    summary: "More than 50% error budget consumed"
    description: "{{ $value | humanizePercentage }} of error budget remaining"

Burn Rate = 14.4 означает: бюджет ошибок тратится в четырнадцать раз быстрее, чем можно. При таком темпе месячный бюджет закончится за два дня. Откуда взялось именно 14.4? Из Google SRE Workbook: 14.4x на окне 1h сжигает 2% месячного бюджета за час. Достаточно страшно, чтобы будить человека. Recording rules промежуточных окон (30m, 1h, 6h) лежат в slo-rules.yml демо-проекта.

Дашборд SLO

В демо-дашборде это отдельный ряд «SLO / Error Budget»: три панели, по одной на каждый вопрос. «Как мы сейчас?», «сколько бюджета осталось?» и «как быстро тратим?».

Panel 1: Current Availability

sli:http_availability:ratio_rate5m * 100

Thresholds: Green > 99.9%, Yellow > 99%, Red < 99%

Panel 2: Error Budget Remaining

slo:error_budget:remaining_ratio * 100

Thresholds: Green > 50%, Yellow > 20%, Red < 20%

Panel 3: Burn Rate

slo:error_budget:burn_rate_5m

С линией на 1 (нормальный темп) и 14.4 (критический).

Борис Аркадьевич из всех панелей полюбил именно Error Budget. «Это я понимаю, — сказал он. — Это как деньги». Мы не стали его поправлять. Потому что он прав.


Эпизод 12. Дело о кардинальности: Серёгина исповедь

Когда у нас всё более-менее заработало, в участок зашёл Серёга. Сел. Посмотрел на доски. Кивнул. Достал из внутреннего кармана не флягу, а термос с чаем – годы берут своё. И сказал:

— Хорошо у вас. А теперь я расскажу, как мы однажды убили Prometheus. Своими руками. Из лучших побуждений. Все худшие дела в этом городе начинаются со слов «из лучших побуждений».

Мониторинг может навредить. Звучит как парадокс, но если засунуть user_id в теги метрик, Prometheus сожрёт всю память. И тогда у вас не будет ни мониторинга, ни приложения. Только тишина, дождь и Серёга, который рассказывает эту историю новым командам.

Cardinality Explosion

Каждая уникальная комбинация labels – отдельная time series. Пять значений method × двадцать значений endpoint × десять значений status = 1000 серий. Нормально. Живём.

А теперь добавьте user_id. Миллион пользователей × 1000 = миллиард серий. Prometheus не скажет «нет». Prometheus не скажет ничего. Он просто молча умрёт. Как та JVM из пролога. У них вообще много общего: оба уходят не прощаясь.

Серёгина бригада сделала именно это. «Нам хотелось видеть латентность по каждому клиенту, — говорит он, глядя куда-то сквозь стену. — Мы её увидели. Секунд тридцать видели. Потом не видели уже ничего».

Плохо:

// НИКОГДА так не делайте!
meterRegistry.counter("api.requests",
    "user_id", userId,        // Миллионы значений
    "request_id", requestId   // Уникален для каждого запроса
).increment();

Хорошо:

meterRegistry.counter("api.requests",
    "method", "GET",          // ~5 значений
    "endpoint", "/api/tasks", // ~20 значений
    "status", "200"           // ~10 значений
).increment();

Правило: в тегах только то, что имеет ограниченное число значений. Method, status, endpoint. Не user_id. Не session_id. Не request_id. Никогда. Даже если очень хочется. Даже если очень-очень хочется и продакт смотрит на вас глазами кота из «Шрека».

Ориентировка. Повесьте на монитор:

Что в теги можно – конечное, предсказуемое число значений:

  • HTTP method: GET, POST, PUT, DELETE. Пять штук, и те по праздникам.

  • Status code: 200, 404, 500. Десяток.

  • Endpoint, но без path variables! /api/tasks/{id}, а не /api/tasks/42.

  • Environment: prod, stage, dev. Три слова.

Что в теги нельзя – значений столько, сколько окурков под окнами участка:

  • user_id, session_id, request_id: у каждого свой, и завтра их больше.

  • timestamp: уникален всегда. По определению.

  • Любое unbounded-значение: если не можете назвать верхнюю границу, это не тег. Это бомба с часовым механизмом и вашими отпечатками.

Разница простая. Можно – то, что вы способны перечислить на пальцах. Нельзя – то, что считается «много» и завтра станет «ещё больше».

Защита через MeterFilter

После Серёгиной исповеди мы поставили предохранители. Людям надо доверять. Но в этом городе доверие оформляют письменно.

// Внимание: после сотого уникального uri новые метрики будут МОЛЧА
// отброшены. Это страховка от взрыва, а не норма жизни: если лимит
// срабатывает - значит, в uri попадает что-то высококардинальное,
// и чинить нужно причину, а не поднимать лимит.
@Bean
public MeterFilter cardinalityLimiter() {
    return MeterFilter.maximumAllowableTags(
        "http.server.requests", "uri", 100, 
        MeterFilter.deny()
    );
}

@Bean
public MeterFilter denyHighCardinality() {
    return new MeterFilter() {
        @Override
        public MeterFilterReply accept(Meter.Id id) {
            if (id.getTag("user_id") != null || id.getTag("request_id") != null) {
                log.warn("Blocked high-cardinality tag: {}", id);
                return MeterFilterReply.DENY;
            }
            return MeterFilterReply.NEUTRAL;
        }
    };
}

Gauge с запросом к БД

// Плохо: запрос каждые 15 секунд
Gauge.builder("tasks.count", () -> taskRepository.count())
    .register(meterRegistry);

// Хорошо: кэшированное значение
private final AtomicLong taskCount = new AtomicLong();

Gauge.builder("tasks.count", taskCount, AtomicLong::get)
    .register(meterRegistry);

// Обновляем при изменениях
public void createTask(...) {
    taskCount.incrementAndGet();
}

Первый вариант – запрос к БД каждые 15 секунд. Мониторинг, который нагружает систему, – это пожарный, который поджигает здание, чтобы проверить сигнализацию.

Отключение ненужного

@Bean
public MeterFilter disableUnneeded() {
    return MeterFilter.deny(id -> {
        String name = id.getName();
        return name.startsWith("jvm.memory.pool") ||
               name.startsWith("jvm.buffer") ||
               name.contains("logback");
    });
}

Не всё, что можно подслушать, нужно записывать. Мониторинг – не коллекционирование. Собирайте то, на что будете смотреть. Остальное – макулатура.


Эпизод 13. Кодекс: что отличает детектива от человека с лупой

Именование метрик

Правило

Имя meter’а в коде

Метрика в Prometheus

snake_case (через точки)

http.requests

http_requests_total

Counter – БЕЗ _total в имени

orders.created

orders_created_total

_seconds для времени

request.duration (Timer)

request_duration_seconds

_bytes для размера

response.size

response_size_bytes

Без суффикса для Gauge

active.users

active_users

Главная ловушка именования в Micrometer. Мы в неё наступили, я обещал вести протокол честно. Суффикс _total для счётчиков добавляет сам Micrometer, в имя его писать НЕ нужно. Даня назвал счётчик orders.created.total, и в Prometheus вышло orders_created_total_total. Дважды. И все recording rules, алерты и панели, которые искали orders_created_total, нашли пустоту. График ровный. Полдня думали, что бизнес встал, а бизнес работал.

В демо-проекте теперь живёт тест, который проверяет именно это: что метрики экспортируются под ожидаемыми именами и без задвоенного суффикса. Смешно? А вы попробуйте полдня разыскивать заказы, которые никуда не пропадали. Перестаёт быть смешно. Становится тестом.

Соглашения – вещь скучная. Но без них через полгода вы сами не опознаете, что за фигурант этот reqDurMs и по какому делу проходит. А request_duration_seconds читается сходу.

Thread Safety

// Плохо - race condition
private int activeUsers;

// Хорошо
private final AtomicInteger activeUsers = new AtomicInteger();

// Для high-contention
private final LongAdder requestCount = new LongAdder();

Безопасность Actuator

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/actuator/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/actuator/info").permitAll()
                .requestMatchers("/actuator/prometheus").hasRole("METRICS")
                .requestMatchers("/actuator/**").hasRole("ADMIN")
            )
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().hasRole("USER")
            )
            .httpBasic(Customizer.withDefaults())
            .csrf(csrf -> csrf.disable());
        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails user = User.builder()
            .username("user")
            .password(passwordEncoder.encode("user"))
            .roles("USER")
            .build();
        
        UserDetails prometheus = User.builder()
            .username("prometheus")
            .password(passwordEncoder.encode("prometheus"))
            .roles("METRICS")
            .build();
        
        UserDetails admin = User.builder()
            .username("admin")
            .password(passwordEncoder.encode("admin"))
            .roles("ADMIN", "METRICS", "USER")
            .build();
        
        return new InMemoryUserDetailsManager(user, prometheus, admin);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

/actuator/health открыт для всех: load balancer должен знать, живо ли приложение. /actuator/prometheus – только для Prometheus, по пропуску. Всё остальное – только для админа. И не открывайте в exposure.include эндпоинты «на всякий случай»: в /actuator/env могут лежать пароли. А пароли в этом городе – единственное, что ещё стоит беречь.

И честная оговорка про цену охраны. Basic auth с BCrypt – это проверка пароля на каждый scrape. BCrypt медленный намеренно, такая у него профессия: десятки миллисекунд CPU на проверку. Скрейпите каждые 5 секунд – получаете постоянный фоновый налог, и он же добавляется к латентности эндпоинта метрик. Для демо нормально. Для production посмотрите в сторону отдельного management-порта (management.server.port), закрытого на сетевом уровне, или mTLS. Пусть железо занимается делом, а не сверяет один и тот же пропуск двенадцать раз в минуту.


Эпизод 14. Перед выходом на дежурство: Production Checklist

Retention Policy

prometheus:
  command:
    # 31 день - минимум, при котором 30-дневное окно SLO имеет данные
    - '--storage.tsdb.retention.time=31d'
    - '--storage.tsdb.retention.size=10GB'

Считаете SLO по 30-дневному окну – держите retention хотя бы 31 день, иначе бюджет ошибок будет считаться по неполному делу. Без SLO хватит и 15 дней для оперативной работы. Для долгосрочного архива – Thanos, Cortex, Mimir. Хранить метрики за год в Prometheus можно, но не нужно.

Backup дашбордов

#!/bin/bash
GRAFANA_URL="http://localhost:3000"
API_KEY="your-api-key"

dashboards=$(curl -s -H "Authorization: Bearer $API_KEY" 
  "$GRAFANA_URL/api/search?type=dash-db" | jq -r '.[].uid')

for uid in $dashboards; do
  curl -s -H "Authorization: Bearer $API_KEY" 
    "$GRAFANA_URL/api/dashboards/uid/$uid" 
    > "backup/dashboard-$uid.json"
done

Мониторинг мониторинга

Да. Сторож тоже смертен. И если он упадёт, вы не узнаете: звонить о его падении должен был он сам. Рекурсия. Старый вопрос «кто сторожит сторожей?» в этом городе имеет конкретный ответ: второй сторож.

- alert: PrometheusDown
  expr: up{job="prometheus"} == 0
  for: 1m
  labels:
    severity: critical

- alert: GrafanaDown  
  expr: up{job="grafana"} == 0
  for: 1m
  labels:
    severity: critical

Для полной уверенности – external monitoring. Uptime Robot, Pingdom. Кто-то снаружи должен проверять тех, кто проверяет.

Runbooks

Каждый алерт – ссылка на runbook.

annotations:
  runbook_url: "https://wiki.example.com/runbooks/high-heap"

Runbook – это инструкция для дежурного в три часа ночи. Для человека, который только проснулся, плохо соображает и немного вас ненавидит. Пишите так, чтобы он понял с первого раза. А пишете вы его, между прочим, себе: через полгода этим дежурным будете вы.

Структура:

  1. Что означает алерт

  2. Как диагностировать

  3. Краткосрочное решение

  4. Долгосрочное решение

  5. Кому звонить, если ничего не помогло

Чеклист

Капитан Лёша распечатал его и повесил над столом. Рядом с фотографией той самой JVM. Чтобы помнить.

  • [ ] Actuator endpoints защищены

  • [ ] Prometheus scrape работает (Targets)

  • [ ] Grafana datasource настроен

  • [ ] Grafana provisioning настроен (дашборды в Git)

  • [ ] Дашборды созданы

  • [ ] Recording rules настроены

  • [ ] Alert rules настроены (heap, errors, app down)

  • [ ] Inhibition rules настроены (и проверены! помните про equal)

  • [ ] Contact points работают (тестовый алерт)

  • [ ] SLO/SLI определены

  • [ ] Error Budget мониторится

  • [ ] Retention policy настроен

  • [ ] Бэкапы дашбордов автоматизированы

  • [ ] Runbooks написаны

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


Эпилог. Снова воскресенье

Прошло два месяца. Воскресенье, восемь вечера. Дождь тот же. Горячий ужин. Зазвонил телефон. Лёша.

Сердце ёкнуло по старой памяти. Рефлексы в нашем деле уходят последними.

— Видел алерт? — спрашивает Лёша. — Видел. HighHeapUsage, warning, рост со вторника. — Утечка? — Похоже. Я уже глянул доску: Metaspace стабилен, растёт Old Gen. Завтра с утра возьму heap dump и проведу опознание. — То есть… сегодня ничего делать не надо? — Сегодня ничего делать не надо. До 95% ему ещё дней пять. Взяли на подходе. — Слушай, — говорит Лёша, и я слышу, как он улыбается. — А ведь раньше мы бы узнали об этом в следующее воскресенье. В районе полуночи. От мэра. — Раньше — да.

Я повесил трубку и доел ужин. Горячий, прошу занести в протокол.

Вот и вся разница, если разобраться. Не в том, что преступления исчезли: они не исчезают. А в том, когда вы о них узнаёте. За пять дней до развязки, за чашкой кофе. Или через четыре часа после, лицом в ноутбук. Мониторинг не делает систему надёжнее. Мониторинг делает вас тем, кто узнаёт первым. Остальное – дело техники.

Материалы дела № 1142, по эпизодам:

  • Быстрый старт: Prometheus + Grafana за 10 минут

  • PromQL: чтение, запись и дело о молчании, которое притворялось нулём

  • JVM: heap (с суммой по пулам!), GC, threads, off-heap

  • HTTP: RED method, перцентили, кастомные теги

  • Database: HikariCP, AOP для repository

  • Бизнес-метрики: Counter, Gauge, Timer, Distribution Summary и честность про транзакции

  • Кастомные метрики: MeterRegistry, event-driven подход с AFTER_COMMIT

  • Grafana: дашборды, variables, provisioning

  • Alertmanager: routing, silencing, inhibition (явными парами!)

  • Recording Rules: оптимизация запросов

  • SLO/SLI: Error Budget, multi-window Burn Rate

  • Грабли: cardinality, naming, security

Напутствие тем, у кого воскресный звонок ещё впереди:

  1. Начните с JVM + HTTP метрик: они покрывают 80% преступлений.

  2. Добавляйте бизнес-метрики постепенно: две-три ключевых.

  3. Тестируйте алерты: убедитесь, что звонки доходят. И что inhibition действительно глушит эхо.

  4. Пишите runbooks: будущий вы, поднятый по тревоге, не забудет этой услуги.


Дополнительные ресурсы

Документация:

Готовые дашборды:

PromQL:

SLO:


Демо-проект: все вещественные доказательства – в репозитории . Запустите docker-compose up и экспериментируйте. В Docker-профиле встроенный генератор сам создаёт задачи и дёргает эндпоинт с переменной задержкой и редкими ошибками: графики, перцентили, error rate и burn rate оживают сразу, без ручного «потыкать».

Если статья была полезна – ставьте плюс и подписывайтесь на телеграм канал

Автор: George_Prikashchenkov

Источник