Она умерла в воскресенье вечером, и никто не услышал ни звука. Детективная история о том, как поставить прослушку на собственное приложение: 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 оживают сразу, без ручного «потыкать».

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