Разработка на Java для Kubernetes

Все о разработке высоконагруженных сервисов на Java в распределенных средах на основе Kubernetes и Istio

Кодируем отброс метрик в 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<String, Counter> 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<String, AtomicLong> 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<String, Timer> operationTimeMap = new ConcurrentHashMap<>();

List<Payment> 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<String, DistributionSummary> 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<String> TIMER_METRIC_NAMES = Set.of(OPERATION_TIME);
    private static final Set<String> 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<MicrometerCollector> 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<String> 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<NettyReactiveWebServerFactory> {

    private final MetricProperties metricProperties;
    private final Function<String, String> 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

Поделиться

LEAVE A REPLYYour email address will not be published. Required fields are marked *Your Name

Copyright © 2025 Разработка на Java для Kubernetes