Кодируем отброс метрик в Prometheus
В первой части данной статьи было рассказано о подходе к стандартизации бизнес метрик, рассмотрены 4 типа метрик, используемых в зависимости о того какая статистическая информация требуется. Теперь рассмотрим практическую часть — программный отброс метрик, настройку перцентилей и гистограмм, открытие REST API для публикации метрик в Prometheus из Spring Boot приложения, а также типовые ошибки и проблемы при работе с метриками.
Подключение метрик в проект
Для откидывания метрик наибольшее распространение получило API Micrometer. Чтобы его использовать необходимо подключить следующую зависимость:
io.micrometer:micrometer-core
Этого достаточно для откидывания метрик, но далее нам нужно организовать интеграцию с системой сбора метрик, откуда они будут поступать в систему визуализации. В качестве системы сбора и хранения метрик отличным вариантом будет Prometheus, а для визуализации графиков будем использовать Grafana.
Чтобы добавить в проект API, позволяющее публиковать метрики через открытие специальных REST контроллеров, нужно подключить модуль:
org.springframework.boot:spring-boot-starter-actuator
А для того, чтобы была возможность экспортировать метрики именно в формате Prometheus требуется также подключить:
io.micrometer:micrometer-registry-prometheus
Prometheus собирает метрики приложений по модели pull. Это значит, что приложение предоставляет REST точки входа, к которым обращается Prometheus с определенной (настраиваемой) периодичностью, чтоб получить значения всех метрик и записать в свою базу данных.
Для открытия REST контроллера для Prometheus нужно в конфигурационный файл spring-boot приложения добавить:
management:
server:
port: 8081
endpoints:
web:
exposure:
include:
- health
- prometheus
metrics:
export:
prometheus:
enabled: true
В настройках указано, что метрики и проверки живучести (readness и liveness пробы) будут доступны по http на порту 8081. При этом нужно открыть URL /actuator/prometheus, по которому метрики будет забирать Prometheus.
MeterRegistry
Ключевым классом в API Micrometer является абстрактный класс MeterRegistry. Через его методы, которые будут рассмотрены ниже, можно отбрасывать метрики. Для каждого потребителя метрик существует своя реализация MeterRegistry – PrometheusMeterRegistry, JmxMeterRegistry и.т.д. Классы для различных потребителей метрик находятся в библиотеках с названиями формата mictometer-registry-<Потребитель>. Например:
micrometer-registry-prometheus
micrometer-registry-jmx
Также есть еще SimpleMeterRegistry, который можно использовать в тестовых целях и CompositeMeterRegistry, который поднимает Spring Boot при наличии нескольких реализаций MeterRegistry. CompositeMeterRegistry является оберткой для других MeterRegistry и внутри себя содержит ссылки на все другие экземпляры MeterRegistry. Таким образом, при наличии единственного MeterRegistry аутоваринг:
@Autowire
private MeterRegistry meterRegistry;
будет указывать на соответствующий MeterRegistry, например, PrometeusMeterRegistry.
Если же в приложении присутствует несколько MeterRegistry, то приведенный ваше аутоваринг будет ссылаться на экземпляр CompositeMeterRegistry.
Соглашение о наименовании метрик
Прежде чем перейти к рассмотрению API для откидывания метрик необходимо договориться о правилах наименования и параметризации показателей. С каждой метрикой, кроме ее бизнес смысла необходимо связать определенный контекст, по которому можно будет впоследствии осуществлять фильтрацию и строить графики. И тут может быть несколько подходов:
1) Бизнес смысл метрики закладывается в ее наименовании, а контекст в тэгах (tags), привязываемых к метрике. Например:
Наименование метрики | Тип метрики | Контекст | Описание |
operation_code | Counter | Наименование бизнес-процесса | Код завершения бизнес-процесса |
operation_time | Timer | Наименование бизнес-процесса | Время выполнения бизнес-процесса |
operation_packet_size | Distribution summary | Наименование бизнес-процесса Тип пакета — входящий/исходящий | Размер входящих и исходящих пакетов |
2) Метрики именуются по типам (разные наименования для каждого типа метрики). Бизнес смысл метрики и контекст закладываются в тэги:
Наименование метрики | Тип метрики | Бизнес смысл метрики (передается в контексте) | Прочий контекст | Описание |
counter_value | Counter | operation_code | Наименование бизнес-процесса | Код завершения бизнес-процесса |
timer_value | Timer | operation_time | Наименование бизнес-процесса | Время выполнения бизнес-процесса |
summary_value | Distribution summary | operation_packet_size | Наименование бизнес-процесса Тип пакета — входящий/исходящий | Размер входящих и исходящих пакетов |
3) И бизнес смысл метрики и ее контекст передаются в названии метрики и разделяются спецсимволом (подчеркивание, точка итд). Например:
Наименование метрики | Тип метрики | Описание |
operation_code_get_payments | Counter | Код завершения бизнес-процесса получения информации о платежах по контрагенту |
operation_time_get_payments | Timer | Время выполнения бизнес-процесса получения информации о платежах по контрагенту |
operation_outbound_packet_size | Distribution summary | Количество платежей, найденных в рамках бизнес-процесса получения информации о платежах по контрагенту |
Третий подход не удобен с точки зрения написания запросов для графиков и фильтрации данных, второй плох тем, что у одинаковых по названию метрик может быть разный набор тегов, а это недопустимо по причине, о которой расскажем ниже, поэтому мы остановимся на первом варианте, когда в название метрики закладывается ее бизнес смысл, а остальной контекст указывается в тэгах. В качестве единственного обязательного тэга нужно выделить наименование бизнес процесса, к которому относится метрика, остальные тэги можно добавлять в зависимости от конкретной бизнес-логики, например, это может быть период за который запрашиваются платежи, если по бизнес логике периоды являются фиксированными — день, неделя, месяц, квартал, год. Тогда фильтруя метрику operation_packet_size по периоду мы можем получить информацию о среднем количестве (или перцентиле) платежей по контрагенту за период.
API Micrometer
Теперь рассмотрим использование API Micrometer для разных типов метрик.
Счетчик (Counter)
Счетчик — самый простой тип метрик. Используется для фиксации показателей, которые могут только возрастать. Это могут быть коды результатов выполнения бизнес-процессов, а также коды ошибок. Приведенный ниже код демонстрирует получение счетчика и увеличения его значения на 1:
private final Map operationCodesMap = new ConcurrentHashMap<>();
operationCodesMap.computeIfAbsent("operation_code.get_payments", k -> meterRegistry.counter("operation_code",
Tags.of("business_process", "get_payments"))).increment();
Вызов meterRegistry.counter возвращает счетчик с именем operation_code и значением тэга business_process равным get_payments или создает его, если счетчик не существует. Таким образом, уникальным идентификатором метрики является комбинация ее имени и набора тегов с определенным именем и значениями. В коде сразу сделана оптимизация — meterRegistry.counter вызывается один раз и созданный Counter сохраняется в синхронизированном Map, поскольку быстрее его получить из мапы, чем вызовом функции counter. Важно, чтобы ключ в мапе однозначно идентифицировал метрику, а для этого он должен содержать имя метрики и значения всех тэгов в определенном порядке.
Датчик (Gauge)
Датчик отличается от счетчика тем, что его значение может как расти, так и уменьшаться. Типичным примером бизнес метрики типа Gauge может служить размер очереди документов или событий на обработку. Если очередь находится в таблице БД (что является плохой практикой), то размером очереди будет количество записей в таблице, для очереди в Java это будет текущий размер экземпляра подкласса Queue, для очереди в Кафке размер это лаг по конзюмер группе. API для откидывания метрики типа Gauge идентично, но есть нюанс. Класс Gauge содержит внутри себя слабую ссылку на экземпляр подкласса Number и в мапе можно хранить именно его, а не сам датчик:
private final Map operationCodesMap = new ConcurrentHashMap<>();
operationCodesMap.computeIfAbsent("operation_code.get_payments", k -> meterRegistry.gauge("operation_code",
Tags.of("business_process", "get_payments"), new AtomicLong())).incrementAndGet();
Таймер (Timer)
Таймер позволяет фиксировать время операции и хранить статистику по частотному распределению значений, на основе которой можно вычислять перцентили. Если перцентили и частотное распределение нас не интересует, то таймер лучше не использовать, а фиксировать время выполнения в метрике Gauge, вычисляя его разницей System.currentTimeMillis() в начале и конце участка кода.
Таймер предоставляет несколько вариантов функций record и recordCallable, которые сами замеряют время выполнения определенного участка кода. Например:
private final Map operationTimeMap = new ConcurrentHashMap<>();
List payments = operationTimeMap.computeIfAbsent("operation_time.get_payments",
k -> meterRegistry.timer("operation_time",
Tags.of("business_process", "get_payments"))).recordCallable(businessService::getPayments);
В данном примере мы выполняем businessService.getPayments, возвращаем результат в массив payments и замеряем время выполнения.
Приведенный выше код применим только в синхронном коде, если же код построен на реактивном API, то для откидывания метрики времени существует ряд других функций, о которых будет рассказано в отдельной статье.
Сводка по распределению (Distribution summary)
Когда необходимо собрать статистику как часто значение метрики входит в определенный диапазон применяется Distribution summary. Рассмотренный выше Timer, это тоже разновидность Distribution summary, но только для времени. В некоторых случаях мы не сможем замерить время с помощью API таймера. Например, нам необходимо получить разницу по времени между датой отправки некоторого события и датой окончания его обработки. Событие отправляет один микросервис, а обрабатывает другой. В таком случае мы можем вычислить разницу программно и записать ее в метрику типа Distribution summary:
private final Map processingTimeMap = new ConcurrentHashMap<>();
processingTimeMap.computeIfAbsent("processing_time.get_payments",
k -> meterRegistry.summary("processing_time",
Tags.of("business_process", "process_payments"))).record(processimgTime);
Настройка метрик Timer и Distribution summary
Таймер и сводка являются гистограммами, поэтому для правильной их работы необходима предварительная настройка, которая может включать:
1) minExpectedValue и maxExpectedValue – минимальное и максимальное значение, частотность которого отслеживается. Например, если нас интересует гистограмма по размеру пакета документов, поступающих на обработку и мы знаем, что их количество не превышает 1000, то надо указать minExpectedValue=1 и maxExpectedValue=1000. Если мы используем единую настройку для всех гистограмм в проекте, то диапазон значений надо предусмотреть максимально универсальный
2) serverLevelObjectives – конкретные значения, по которым отслеживается частота. Например, нас может интересовать как часто размер пакета документов не превышал 1, 10,20,30…100
3) percentiles – какие перцентили должно API Micrometer рассчитывать (если перцентили рассчитываются на уровне Spring Boot приложения, а не в Prometheus). Например: 0.95 и 0.99
4) expiry – время устаревания статистики для перцентилей и гистограмм
Ниже приведена реализация MeterFilter, которая универсально настраивает Distribution summary и Timer для Spring Boot приложения.
@Component
public class HistogramMeterFilter implements MeterFilter {
private static final Set TIMER_METRIC_NAMES = Set.of(OPERATION_TIME);
private static final Set SUMMARY_METRIC_NAMES = Set.of(OPERATION_PACKET_SIZE);
private final MetricProperties metricProperties;
public HistogramMeterFilter(MetricProperties metricProperties) {
this.metricProperties = metricProperties;
}
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
if (isDashboardSummary(id)) {
DistributionStatisticConfig.Builder configBuilder = DistributionStatisticConfig.builder()
.expiry(metricProperties.histogramProperties().expiry());
if (metricProperties.histogramProperties().maxExpectedValue() != null) {
configBuilder = configBuilder.maximumExpectedValue(
metricProperties.histogramProperties().maxExpectedValue());
}
if (metricProperties.histogramProperties().minExpectedValue() != null) {
configBuilder = configBuilder.minimumExpectedValue(
metricProperties.histogramProperties().minExpectedValue());
}
if (metricProperties.histogramProperties().serviceLevelObjectives() != null &&
metricProperties.histogramProperties().serviceLevelObjectives().length > 0) {
configBuilder = configBuilder.serviceLevelObjectives(
metricProperties.histogramProperties().serviceLevelObjectives());
}
if (metricProperties.histogramProperties().percentiles() != null &&
metricProperties.histogramProperties().percentiles().length > 0) {
configBuilder = configBuilder.percentiles(metricProperties.histogramProperties().percentiles());
}
return config.merge(configBuilder.build());
} else if (isDashboardTimer(id)) {
DistributionStatisticConfig.Builder configBuilder = DistributionStatisticConfig.builder()
.expiry(metricProperties.histogramProperties().expiry());
if (metricProperties.histogramProperties().maxExpectedValue() != null) {
configBuilder = configBuilder.maximumExpectedValue(
metricProperties.histogramProperties().maxExpectedValue()
/ metricProperties.histogramProperties().timerRangeDiv());
}
if (metricProperties.histogramProperties().minExpectedValue() != null) {
configBuilder = configBuilder.minimumExpectedValue(
metricProperties.histogramProperties().minExpectedValue()
/ metricProperties.histogramProperties().timerRangeDiv());
}
if (metricProperties.histogramProperties().serviceLevelObjectives() != null &&
metricProperties.histogramProperties().serviceLevelObjectives().length > 0) {
configBuilder = configBuilder.serviceLevelObjectives(
stream(metricProperties.histogramProperties().timerServiceLevelObjectives())
.map(value -> value
* metricProperties.histogramProperties().timerBucketsMultiplier()).toArray());
}
if (metricProperties.histogramProperties().percentiles() != null &&
metricProperties.histogramProperties().percentiles().length > 0) {
configBuilder = configBuilder.percentiles(
metricProperties.histogramProperties().percentiles());
}
return config.merge(configBuilder.build());
}
return config;
}
protected boolean isDashboardTimer(Meter.Id id) {
return id.getType() == Meter.Type.TIMER && TIMER_METRIC_NAMES.contains(id.getName());
}
protected boolean isDashboardSummary(Meter.Id id) {
return id.getType() == Meter.Type.DISTRIBUTION_SUMMARY && SUMMARY_METRIC_NAMES.contains(id.getName());
}
}
Настройки фильтр берет из application.yaml и они могут выглядеть примерно так:
app:
histogramProperties:
metric:
expiry: 1m
percentiles: 0.5, 0.8, 0.95
minExpectedValue: 1
maxExpectedValue: 10000
serviceLevelObjectives: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 225, 250, 275, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000
timerServiceLevelObjectives: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 225, 250, 275, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000
timerRangeDiv: 1000
timerBucketsMultiplier: 1000000
Важно знать, что значения serviceLevelObjectives для таймера указываются в наносекундах. но чтобы не указывать в настройках числа с множеством нулей введен множитель timerBucketsMultiplier, на который надо умножить каждое значение из timerServiceLevelObjectives, а timerRangeDiv задает на сколько нужно для таймера разделить minExcpectedValue и maxExpectedValue. В итоге указанные выше настройки таймера предполагают минимальное значение 1 мс и максимальное значение 10 сек.
Ограничения по набору тэгов для PrometheusMeterRegistry
Класс PrometheusMeterRegistry ожидает, что для всех метрик с одинаковым названием будет один и тот же набор тэгов. Это важная особенность, не зная которую, придется долго разбираться куда пропадают метрики. Ниже приведен фрагмент кода их PrometheusMeterRegistry, который реализует вышесказанное:
private void applyToCollector(Meter.Id id, Consumer consumer) {
collectorMap.compute(getConventionName(id), (name, existingCollector) -> {
if (existingCollector == null) {
MicrometerCollector micrometerCollector = new MicrometerCollector(id, config().namingConvention(),
prometheusConfig);
consumer.accept(micrometerCollector);
return micrometerCollector.register(registry);
}
List tagKeys = getConventionTags(id).stream().map(Tag::getKey).collect(toList());
if (existingCollector.getTagKeys().equals(tagKeys)) {
consumer.accept(existingCollector);
return existingCollector;
}
meterRegistrationFailed(id,
"Prometheus requires that all meters with the same name have the same"
+ " set of tag keys. There is already an existing meter named '" + id.getName()
+ "' containing tag keys ["
+ String.join(", ", collectorMap.get(getConventionName(id)).getTagKeys())
+ "]. The meter you are attempting to register" + " has keys ["
+ getConventionTags(id).stream().map(Tag::getKey).collect(joining(", ")) + "].");
return existingCollector;
});
}
Не вполне понятно чем вызвана необходимость такого подхода, но, в любом случае, разработчики должны позаботиться об одинаковом наборе тэгов для метрик с одинаковыми именами. Именно поэтому слишком универсальные названия метрик — не очень хороший подход. Если все же приложение спроектировано так, что одинаковый набор тэгов не гарантируется, то можно реализовать MeterFilter, в котором будут добавляться обязательные тэги к метрикам. О фильтрах для метрик и их возможностях мы расскажем в отдельной статье.
PrometheusNamingConvension
Различные хранилища для метрик предполагают определенные соглашения об их наименованиях. В частности, для Prometheus принято разные значимые части в названиях метрик разделять символом подчеркивания, в то время как, скажем, для Jmx стандартом считается разделение точкой. Например, метрика, которая в Jmx должна выглядеть как:
operation.packet.size
для Prometheus должна называться:
operation_packet_size
Но, как быть, если мы используем несколько MeterRegistry в проекте, а название отбрасываемой метрики, которое мы указываем в коде, должно быть единым.
Тут на помощь нам приходит интерфейс NamingConvention и его автоматически подключаемая реализация для Prometheus. Для откидывания в Прометей точки в названии метрики заменяются на подчеркивания. Соответственно, мы спокойно можем откидывать метрику с именем operation.packet.size, не беспокоясь о том, что в Prometheus она попадет под названием operation_packet_size.
Тестируем эндпоинт Actuator Prometheus
Как говорилось выше, Prometheus работает по pull модели, то есть сам забирает метрики, доступные по определенному URL. Spring Boot открывает это URL по адресу actuator/prometheus на служебном порту (по-умолчанию, это порт 8081, но его можно поменять настройкой management.port). Если мы запустим наше Spring Boot приложение, то сами можем сделать запрос:
http://localhost:8081/actuator/prometheus
и посмотреть в каком виде в Prometheus выгружаются метрики:
# HELP operation_code_total
# TYPE operation_code_total counter
operation_code_total{business_process="service-front:find-all",code="0",direction="inbound",value="OK",} 3723.0
operation_code_total{business_process="service-front",code="0",direction="inbound",value="OK",} 2976.0
operation_code_total{business_process="service-front:get-payments",code="101",direction="outbound",value="NOT_FOUND",} 2159.0
operation_code_total{business_process="service-front:get-payments",code="0",direction="outbound",value="OK",} 3299.0
operation_code_total{business_process="service-front",code="100",direction="inbound",value="VALIDATION_ERROR",} 247.0
operation_code_total{business_process="service-front:find-all",code="101",direction="inbound",value="NOT_FOUND",} 1735.0
Как мы видим, откидываемая через API Micrometer метрика выгружается в Prometheus под другим именем — вместо operation_code мы видим operation_code_total. Более того, одной откидываемой метрики в общем случае соответствует несколько метрик в Prometheus
Нужно учитывать эту особенность и правильно указывать имена метрик в запросах к Prometheus. Например:
sum(increase(operation_code_total{service=~"$service", pod=~"$pod", business_process=~"$operation",direction="inbound"}[$__range])) by (business_process, value)
Кстати, если наше Spring Boot приложение размещено в Kubernetes, то мы также можем обратиться к actuator prometheus либо через curl из консоли пода, либо прокинув порт:
kubectl port-forward service-facade-0.01-76c66cdcfb-dp584 8081 -n myproject
Ошибки утечки памяти, связанные с метриками
Нужно понимать, что уникальной метрикой будет считаться связка названия и тэгов с определенным набором значений и, разумеется, для хранения каждой такой метрики требуется определенный набор памяти. Но, что будет, если в качестве значений тэгов у нас будет использоваться неограниченный список значений. Например, в тэг с названием url в качестве значений будут записываться адреса, содержащий уникальный номер документа. Разных номеров документов могут быть миллионы и для всех метрик просто не хватит памяти кучи.
Чтобы не допускать такой ситуации нужно обрезать переменные части в значениях тэгов. Это можно делать в MeterFilter или сразу при откидывании метрики.
При использовании WebFlux, когда метрики включающие путь к ресурсу отбрасываются неявно, для такой цели можно имплементировать интерфейс WebServerFactoryCustomizer<NettyReactiveWebServerFactory>:
@Component
public class LimitMeterServerCustomizer implements WebServerFactoryCustomizer {
private final MetricProperties metricProperties;
private final Function uriTagTruncateFunction;
public LimitMeterServerCustomizer(MetricProperties metricProperties) {
this.metricProperties = metricProperties;
this.uriTagTruncateFunction = UriTagTruncateFunctions.truncateUri(metricProperties.serverPaths());
}
@Override
public void customize(NettyReactiveWebServerFactory factory) {
if (!metricProperties.serverPaths().isEmpty()) {
factory.addServerCustomizers(httpServer -> httpServer.metrics(true,
uriTagTruncateFunction));
}
}
В данном примере в настройки serverPaths хранятся фиксированные части URL-ов, до которых нужно обрезать URL