Она умерла в воскресенье вечером, и никто не услышал ни звука. Детективная история о том, как поставить прослушку на собственное приложение: 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
Минута ожидания. Потом:
http://localhost:8080/actuator/prometheus - сырая прослушка
http://localhost:9090 - кабинет архивариуса
http://localhost:3000 - мастерская Грейс (admin/admin)
В /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 |
|
Текущее состояние | Gauge |
|
Время операций | Timer |
|
Распределение значений | Distribution Summary |
|
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. Она берёт сухие цифры архивариуса и делает из них картину, по которой за три секунды видно, горим или не горим.
Дашборд - инструмент, не украшение. Каждая панель отвечает на вопрос. Не отвечает - снимите со стены. Да, и ту красивую тоже. Особенно ту красивую.
Готовые дашборды
Не изобретайте велосипед. Велосипед уже изобретён, у него гарантия и сообщество.
Grafana → Dashboards → Import
ID: 4701 (JVM Micrometer)
Выбрать Prometheus datasource
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 | Уровень агрегации |
|
metric | Базовая метрика |
|
operations | Операции |
|
Использование
Было:
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 (через точки) |
|
|
Counter - БЕЗ |
|
|
|
|
|
|
|
|
Без суффикса для Gauge |
|
|
Главная ловушка именования в 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 - это инструкция для дежурного в три часа ночи. Для человека, который только проснулся, плохо соображает и немного вас ненавидит. Пишите так, чтобы он понял с первого раза. А пишете вы его, между прочим, себе: через полгода этим дежурным будете вы.
Структура:
Что означает алерт
Как диагностировать
Краткосрочное решение
Долгосрочное решение
Кому звонить, если ничего не помогло
Чеклист
Капитан Лёша распечатал его и повесил над столом. Рядом с фотографией той самой 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
Напутствие тем, у кого воскресный звонок ещё впереди:
Начните с JVM + HTTP метрик: они покрывают 80% преступлений.
Добавляйте бизнес-метрики постепенно: две-три ключевых.
Тестируйте алерты: убедитесь, что звонки доходят. И что inhibition действительно глушит эхо.
Пишите runbooks: будущий вы, поднятый по тревоге, не забудет этой услуги.
Дополнительные ресурсы
Документация:
Готовые дашборды:
PromQL:
SLO:
Демо-проект: все вещественные доказательства - в репозитории . Запустите docker-compose up и экспериментируйте. В Docker-профиле встроенный генератор сам создаёт задачи и дёргает эндпоинт с переменной задержкой и редкими ошибками: графики, перцентили, error rate и burn rate оживают сразу, без ручного «потыкать».
Если статья была полезна - ставьте плюс и подписывайтесь на телеграм канал
