Всем привет! В текущей статье, хотелось бы поделиться опытом решения задачи ограничения вывода метрик в Prometheus для сервиса, написанного в функциональном стиле на Spring Webflux.
Дисклеймер:
Hidden text
Весь код придуман, все совпадения случайны.
Казалось бы – подключил Actuator, Micrometer, Prometheus зависимости, прописал в management пропертях включение метрик и радуешься выводу всего нужного для мониторинга по соответсвующему ресту. Однако, после наката метрик на окружение, внезапно приложение начинает стремительно деградировать, графану начинает спамить огромным количеством избыточной информации, коллеги из QA наблюдают существенное замедление скорости отклика запросов к бэкенду, соотвественно принимается решение по откату фичи.
Неприятно, но бывает, давайте исправлять ситуацию.
После анализа того, что успело попасть в графану и информации по мониторингу ресурсов серверов во время инцидента, выяснилось, что для нашей системы:
Стандартный шаг гистограммы не подходит – на вызов одного реста пишется слишком много метрик, нужно ограничивать!
Бонусом коллеги из службы мониторинга попросили прикрутить ещё несколько вещей:
Добавить экшн, к которому стучится рест (название метода рест контроллера, куда приходят запросы), тип запроса (на других сервисах иногда использовался soap, но у нас был rest, поэтому можно вынести в константу), поправить интервалы sla, выставить пределы по перцентилям и т.п.
Обозначилась необходимость разделить метрики внешних вызовов к нашему API, от внутренних вызовов нашего бэкенда к другим сервисам. Тут нужно пояснить, что наш сервис был гейтвеем, который агрегировал данные со внутренних сервисов и выдавал фронту готовые собранные json со всей информацией.
Ну что ж, задачи поставлены, теперь их необходимо реализовать.
Начнём с разделения метрик вызовов нашего API и внутренних вызовов сторонних сервисов.
Для внешних вызовов фронтом API бэкенда существуют стандартные http.server.requests проперти актуатора. В данной задаче, необходимо было в них прокинуть только экшн, тот метод контроллера, который мапился по урлу на бэкенде.
Создаём кастомный метрик фильтр для таких запросов и добавляем ему необходимый экшн тег:
@Component
public class MetricCustomServerProvider implements WebFluxTagsContributor {
@Override
public Iterable<Tag> httpRequestTags(ServerWebExchange exchange,
Throwable ex) {
Tag action = Tag.of("action", /* как сюда пробросить название метода контроллера ? */);
return Collections.singletonList(action);
}
}
На этом этапе возник вопрос, а как собственно передавать значение метода в отдельный фильтр, отвязанный от бизнес логики? Реактивный контекст в Webflux работает только на уровне одного стрима, поэтому пробросить через него в метрики название метода не выйдет. Решение пришло чуть позже: пробрасывать в контекст названия метода контроллера, при обращении к эндпоинту, после доставать из контекста на этапе обработки ответа значение и заполнять его в хедеры ответа, которые фильтр метрик сможет достать из ServerWebExchange объекта.
Теперь код фильтра стал выглядеть так:
@Component
public class MetricCustomServerProvider implements WebFluxTagsContributor {
@Override
public Iterable<Tag> httpRequestTags(ServerWebExchange exchange,
Throwable ex) {
Tag action = Tag.of("action", createAction(exchange));
return Collections.singletonList(action);
}
private String createAction(ServerWebExchange exchange) {
final HttpHeaders headers = exchange.getResponse().getHeaders();
String action = null;
if (headers.containsKey(ContextParameter.AСTION_HEADER)) {
action = Optional.ofNullable(headers.get(ContextParameter.ACTION_HEADER))
.map(h -> h.get(0))
.orElse(null)
;
}
return action;
}
}
Здесь, вынесем название хедера в отдельный класс констант, чтобы использовать его же в реактивном контексте далее.
public final class ContextParameter {
public final static String AСTION_HEADER = "sa";
}
Теперь необходимо проставить название метода в хедер ответа. В этом плане мне повезло, все ресты стучались через ResponseWrapper, оборачивающий ответ в соответствующий объект json. Вот он быстрый вариант для внедрения хедера ответа с нужным значением экшна!
public interface CommonExchange {
default Mono<ServerResponse> response(HttpStatus status,
Object body,
ErrorResponseModel error) {
AtomicReference<String> actionValue = new AtomicReference<>();
return Mono
.subscriberContext(context -> {
if (!context.hasKey(ContextParameter.AСTION_HEADER)) {
actionValue.set("");
} else {
Map<Object, Object> map = context.get(ContextParameter.AСTION_HEADER);
actionValue.set((String) map.get(ContextParameter.AСTION_HEADER));
}
return context;
})
.flatMap(ok -> ServerResponse
.status(status)
.header(ContextParameter.AСTION_HEADER, actionValue.get())
.contentType(APPLICATION_JSON)
.syncBody(new ResponseModel(body, error))
);
}
}
Осталось только пробросить в реактивный контекст название метода по хедеру, но перед этим маленькое, но важное отступление:
На нашем проекте используется функциональный стиль написания контроллеров. Для обычных контроллеров можно сделать тоже самое при условии использования WebClient (Вы же дейстительно хотите обрабатывать вызовы реактивно ? :) ).
В роутер-функциях он используется из коробки.
Сократим код до пары эндпоинтов, чтобы выделить то что нужно:
@EnableWebFlux
@Configuration
public class WebConfig implements WebFluxConfigurer {
@Autowired
private ServerProperties serverProperties; // константы сервера приложения
@Bean
public RouterFunction<ServerResponse> orderRoute(OrderHandler orderHandler) {
return route()
.GET(serverProperties.getUrl() + "/orders", accept(APPLICATION_JSON), req -> InContextAction.process("getOrders", req, orderHandler.getOrders(req)))
.GET(serverProperties.getUrl() + "/orders/active", accept(APPLICATION_JSON), req -> InContextAction.process("getActiveOrders", req, orderHandler.getActiveOrders(req)))
// … другие методы
.build();
}
Тут нас интересует класс InContextAction, который сохраняет в контекст название нашей роутер функции. К сожалению, реактивный контекст не сохраняет название функции хендлера, только хеш и тип, поэтому ничего не оставалось, как проставить явно названия каждой.
public class InContextAction {
public static Mono<ServerResponse> process(String name,
ServerRequest request,
Mono<ServerResponse> action) {
return action
.subscriberContext(ctx -> {
Map<Object, Object> apiMethodMap = new HashMap<>();
apiMethodMap.put(ContextParameter.AСTION_HEADER, name);
request.exchange()
.getRequest()
.mutate()
.header(ContextParameter.AСTION_HEADER,
apiMethodMap.get(ContextParameter.AСTION_HEADER)
.toString()
);
return ctx.put(ContextParameter.AСTION_HEADER, apiMethodMap);
});
}
}
Вот и всё. С серверной метрикой закончено. Теперь осталось провести клиентскую, но тут можно обойтись без контекста, так как вызывать код внутренних сервисов мы будем через явно указанный WebClient.
Метрика для вызовов внутренних сервисов
Начнём с новой константы контекста:
public final class ContextParameter {
public final static String AСTION_HEADER= "sa";
public final static String INNER_ACTION_HEADER = "ca";
}
Затем зарегистрируем новую метрику http.inner.requests:
@Configuration
@EnableAsync
@EnableScheduling
public class MetricConfig {
@Bean
public MetricsWebClientFilterFunction clientMetric(PrometheusMeterRegistry registry,
WebClientExchangeTagsProvider provider) {
return new MetricsWebClientFilterFunction(
registry,
provider,
"http.inner.requests",
AutoTimer.ENABLED
);
}
}
Создадим отдельный фильтр для клиентских метрик:
@Component
public class MetricCustomClientProvider extends DefaultWebClientExchangeTagsProvider {
@Override
public Iterable<Tag> customTags(ClientRequest request,
ClientResponse response,
Throwable throwable) {
Tag type = Tag.of("type", "rest");
Tag host = Tag.of("host", WebClientExchangeTags.clientName(request).getValue());
Tag action = Tag.of("action", generateAction(request));
Tag method = WebClientExchangeTags.method(request);
Tag status = WebClientExchangeTags.status(response, throwable);
Tag outcome = WebClientExchangeTags.outcome(response);
return Arrays.asList(method, status, outcome, type, host, action);
}
private String generateAction(ClientRequest request) {
return Optional.ofNullable(request.headers().get(ContextParameter.INNER_ACTION_HEADER))
.map(headers -> headers.get(0))
.orElse("");
}
}
Теперь внедрим в вызов WebClient новый хедер со значением, который будет подхватываться клиентсиким метрик фильтром:
@Autowired
private MetricsWebClientFilterFunction clientMetricFilter;
// .. код сервиса
DefaultUriBuilderFactory encodedUriFactory = new DefaultUriBuilderFactory("http://localhost:8081/actuator/health");
return WebClient.builder()
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(16 * 1024 * 1024)) // 16MB
.uriBuilderFactory(encodedUriFactory)
.filter(clientMetricFilter)
.build()
.get()
.header(ContextParameter.INNER_ACTION_HEADER, "healthcheck")
.exchange()
.flatMap(clientResponse -> clientResponse.bodyToMono(String.class));
В финале, настроим шаг гистограммы метрик, с помощью стандартных properties актуатора:
# Метрики внешних вызовов к API
management.metrics.distribution.percentiles.http.server.requests=0.3,0.9
management.metrics.distribution.percentiles-histogram.http.server.requests=true
management.metrics.distribution.sla.http.server.requests=250ms,500ms,1000ms,2500ms,5000ms,10000ms
management.metrics.distribution.maximum-expected-value.http.server.requests=1
# Метрики внутренних вызовов сервисов
management.metrics.distribution.percentiles.http.inner.requests=0.3,0.9
management.metrics.distribution.percentiles-histogram.http.inner.requests=true
management.metrics.distribution.sla.http.inner.requests=250ms,500ms,1000ms,2500ms,5000ms,10000ms
management.metrics.distribution.maximum-expected-value.http.inner.requests=1
Результаты
В качестве результатов, прилагаю скрины с метриками Prometheus, на хелсчеке актуатора:
Здесь вызов API, без токена не пройдёт Spring Security, поэтому некоторые параметры останутся пустыми.
Если же мы выполним успешный запрос, то получим:
Результат метрики обращения к внутреннему сервису:
Заключение
В итоге, данное решение удовлетворило все требования службы мониторинга, не грузило ресурсы больше чем нужно, все остались довольны. Однако, этого могло и не случиться, если бы в самом начале я задал три вопроса при анализе новой функциональности:
Достаточно ли прокинуть метрики из коробки "как есть" ?
Будут ли кастомные метрики?
Какова вероятность проброса в метрики сквозной информации (в данном случае из реактивного контекста)?
Поэтому перед настройкой мониторинга на Prometheus в Webflux желательно иметь ответы на данные вопросы.
Надеюсь, эта статья поможет тем, кому необходимо быстро и в краткие сроки внедрить кастомные метрики Prometheus в приложение на Webflux.
Если Вы не согласны с теми решениями, что были приняты и есть способы проще, то рад буду прочесть Ваши ответы в комментариях.