Дело о молчаливой JVM: мониторинг Spring Boot с Prometheus и Grafana. Production-нуар

от автора

Она умерла в воскресенье вечером, и никто не услышал ни звука. Детективная история о том, как поставить прослушку на собственное приложение: 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.ymlspring:  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:      - monitoringnetworks:  monitoring:    driver: bridgevolumes:  prometheus_data:  grafana_data:  alertmanager_data:

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

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

# prometheus/prometheus.ymlglobal:  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.ymlalerting:  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 gaugejvm_memory_used_bytes{area="heap",id="G1 Eden Space",} 2.5165824E7jvm_memory_used_bytes{area="heap",id="G1 Old Gen",} 1.6777216E7# HELP http_server_requests_seconds  # TYPE http_server_requests_seconds histogramhttp_server_requests_seconds_bucket{method="GET",uri="/api/tasks",status="200",le="0.05"} 45.0http_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() — агрегация:

# Общий RPSsum(rate(http_server_requests_seconds_count[5m]))# RPS по endpointsum(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

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

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

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

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

# Время в GCrate(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))# p95histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le))# p99histogram_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 молча перестанут работать.@Componentpublic 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@Componentpublic 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@Transactionalpublic 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

@Configurationpublic 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) {}// Слушатель@Componentpublic 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: 1datasources:  - 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: 1providers:  - 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.ymlgroups:  - 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.ymlglobal:  resolve_timeout: 5mroute:  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.ymlgroups:  - 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.ymlgroups:  - 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 попадает что-то высококардинальное,// и чинить нужно причину, а не поднимать лимит.@Beanpublic MeterFilter cardinalityLimiter() {    return MeterFilter.maximumAllowableTags(        "http.server.requests", "uri", 100,         MeterFilter.deny()    );}@Beanpublic 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 секунд. Мониторинг, который нагружает систему, — это пожарный, который поджигает здание, чтобы проверить сигнализацию.

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

@Beanpublic 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 conditionprivate int activeUsers;// Хорошоprivate final AtomicInteger activeUsers = new AtomicInteger();// Для high-contentionprivate final LongAdder requestCount = new LongAdder();

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

@Configuration@EnableWebSecuritypublic 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/bashGRAFANA_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 оживают сразу, без ручного «потыкать».

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

ссылка на оригинал статьи https://habr.com/ru/articles/1046239/