Трейсы в Spring Boot 3 с использованием Zipkin и Kafka в качестве транспорта

Трейсы в Spring Boot 3 с использованием Zipkin и Kafka в качестве транспорта

Рассказываем об использовании трейсов в ежедневной работе компании. Автор статьи – главный разработчик управления информационных систем розничного блока «АльфаСтрахование» Никита Носов.

Предисловие

Страховая компания «АльфаСтрахование», в которой я работаю, продает страховые продукты не только в офисах, но и через сеть страховых агентов. Агенты могут оформлять полисы, используя REST API. Этот API разрабатывает и поддерживает команда, в которой мне посчастливилось трудиться.

Статья содержит контекст и технику, поэтому если Вас интересует только техническая часть, можно смело переходить к разделу «Пререквизиты».

Статья не содержит описания Zipkin и концепции трейсов. Прочитать про Zipkin можно на официальном сайте.

Контекст

Немного о том, как продается страховой продукт

Основными операциями при продаже являются:

• Расчет стоимости страхового продукта;

• Сохранение данных страхового полиса в core-системах СК;

• Оплата;

• Печать документов.

Специфика оформления предполагает выполнение вышеуказанных операций с временными задержками. К примеру, рассчитав страховой продукт, мы даем клиенту время на принятие решения. В случае положительного решения, на основании расчета производится сохранение, оплата и т. д. Мы используем микросервисную архитектуру, разделяя операции по сервисам, ответственным за них.

Стоит отметить, что каждый такой сервис в свою очередь обращается в десятки других core-сервисов. Основной фреймворк, используемый для написания таких сервисов — Spring Boot.

Когда что-то идет не так

Вышеописанные цепочки вызовов порой возвращают HTTP 500, после чего стартует классический процесс разбора — что же пошло не так. Стандарт нашей компании предполагает наличие логов и трейсов, таким образом добраться до причины ошибки не представляет особого труда. Далее с этим пониманием в системы вносятся исправления (или производятся консультации конечного потребителя) .

Сервисов много, Zipkin — один

Для трассировки приложений используется Zipkin. Транспортом является Kafka. Такой подход считается общепринятым в рамках организации. Наше светлое будущее в аспекте трейсов выглядит следующим образом: имея трейсы со всех сервисов, мы можем легко отследить цепочки вызовов и с минимальными временными затратами понять, что пошло не так (если все-таки что-то пошло не так).

Пререквизиты

Итак, что мы имеем на входе:

• Java 17;

• Сервис, написанный на Spring Boot 3.1.2;

• Корпоративный Zipkin, принимающий трейсы через транспорт Kafka (в т. ч. тестовый стенд);

• Корпоративная Kafka с топиком, в который следует писать трейсы (в т. ч. тестовый топик на тестовых серверах);

• Потребность в автоконфигурации (т. к. сервисов много, не хотелось бы писать реализацию в каждом) — будет рассмотрена на втором шаге.

Шаг 1. Реализация на базе существующего сервиса

В данном разделе (как и в последующих) мы не будем погружаться в детали реализации существующего сервиса. Достаточно будет вышеуказанных вводных, которые сформируют техническое окружение, в котором мы будем находиться.

Задача первого шага

Добавить конфигурации, с помощью которых сервис сможет отправлять трейсы в Zipkin с транспортом Kafka. Предусмотреть возможность собирать трейсы внутри приложения, т. е. не только между сервисами, но и внутри, между компонентами одного сервиса.

Реализация первого шага

Dependencies

Для реализации нам потребуются следующие зависимости. Стоит отметить, что при написании сервиса был использован parent org. springframework. boot:spring-boot-starter-parent:3.1.2, из которого все версии подтянулись сами. Ниже они указаны для справки.

<!-- Для добавления observation в приложение --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> <version>3.1.2</version> </dependency> <!-- Для создания observations с использованием @Observed --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> <version>3.1.2</version> </dependency> <dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId> <version>3.0.9</version> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-brave</artifactId> <version>1.1.3</version> </dependency> <dependency> <groupId>io.zipkin.reporter2</groupId> <artifactId>zipkin-reporter-brave</artifactId> <version>2.16.3</version> </dependency> <dependency> <groupId>io.zipkin.reporter2</groupId> <artifactId>zipkin-sender-kafka</artifactId> <version>2.16.3</version> </dependency>

Properties

Потребуются для настройки подключения к топику Kafka, в который будем отправлять трейсы. Вынесем их в отдельную ветку конфигураций. Получим нечто похожее на:

@ConfigurationProperties("custom.tracing") public record CustomTracingProperties( /* Kafka Producer username */ String username, /* Kafka Producer password */ String password, /* Kafka Producer topic */ String topic, /* Kafka Producer bootstrap servers */ String bootstrapServers ) { }

Что в конечном счете будет заполняться из фрагмента application. yaml:

custom: tracing: username: login в kafka password: пароль от логина topic: топик, в который пишем трейсы bootstrap-servers: broker1,broker2

@Test: CustomTracingProperties должны собирать свойства из application. yaml

@SpringBootTest( properties = { "custom.tracing.bootstrap-servers=server1,server2", "custom.tracing.password=pass", "custom.tracing.username=user", "custom.tracing.topic=topic" } ) @EnableConfigurationProperties(CustomTracingProperties.class) class CustomTracingPropertiesTest { @Autowired private CustomTracingProperties properties; @Test void should_FillProps() { Assertions.assertThat(properties) .isNotNull() .satisfies(props -> { Assertions.assertThat(props.username()) .isNotBlank() .isEqualTo("user"); Assertions.assertThat(props.password()) .isNotBlank() .isEqualTo("pass"); Assertions.assertThat(props.topic()) .isNotBlank() .isEqualTo("topic"); Assertions.assertThat(props.bootstrapServers()) .isNotEmpty() .contains("server1", "server2"); }); } @ConfigurationPropertiesScan(basePackageClasses = CustomTracingProperties.class) @TestConfiguration static class TestConfig { } }

Kafka sender

Вручную конфигурируем KafkaSender. Потребуется указать действительный способ аутентификации в Kafka. В данном примере используется:

• security. protocol=SASL_plaintext;

• sasl. mechanism=SCRAM-SHA-256.

Зная все вводные, собираем конфигурацию:

@Configuration(proxyBeanMethods = false) @EnableConfigurationProperties({KafkaProperties.class, CustomTracingProperties.class}) @RequiredArgsConstructor public class KafkaSenderConfiguration { private final CustomTracingProperties customTracingProperties; @Bean("zipkinSender") public Sender kafkaSender(KafkaProperties config, Environment environment) { // Adding properties of Kafka for tracing final Map<String, Object> properties = config.buildProducerProperties(); // Bootstrap-servers получаем из CustomTracingProperties, разбирая строку в лист properties.put("bootstrap.servers", STRING_TO_LIST.apply(customTracingProperties.bootstrapServers(), ",")); // Key/Value serializers properties.put("key.serializer", ByteArraySerializer.class.getName()); properties.put("value.serializer", ByteArraySerializer.class.getName()); // SASL properties properties.put("sasl.jaas.config", String.format("org.apache.kafka.common.security.scram.ScramLoginModule required username=%s password=%s;", customTracingProperties.username(), customTracingProperties.password())); properties.put("sasl.mechanism", "SCRAM-SHA-256"); // Security properties.put("security.protocol", "SASL_PLAINTEXT"); // Client Id final String serviceName = environment.getProperty("spring.application.name"); properties.put("client.id", serviceName); // Building sender with properties return KafkaSender .newBuilder() .topic(customTracingProperties.topic()) .overrides(properties) .build(); } }

Пример выше показывает, как с помощью KafkaSender. newBuilder() мы заполняем нужные для подключения к Kafka свойства, в т. ч. используя CustomTracingProperties и константы. Константы в примере записаны в виде литералов, чтоб было нагляднее.

Примечание. Строка с брокерами разбирается в лист таким образом:

BiFunction<String, String, List<String>> STRING_TO_LIST = (sequence, delimiter) -> Arrays.stream(sequence.split(delimiter)) .map(String::trim) .toList();

@Test: KafkaSender должен быть корректно сконфигурирован

@SpringBootTest( classes = KafkaSenderConfiguration.class, properties = { "custom.tracing.bootstrap-servers=specified-server1,specified-server2", "custom.tracing.password=pass", "custom.tracing.username=user", "custom.tracing.topic=topic-for-traces", "spring.application.name=some-app" } ) class KafkaSenderConfigurationTest { @Qualifier("zipkinSender") @Autowired private Sender sender; @Test void shouldConfigureSender() { Assertions.assertThat(sender) .isNotNull() .isInstanceOf(KafkaSender.class) .satisfies(kafkaSender -> then(kafkaSender) .extracting("topic") .isEqualTo("topic-for-traces") ) .extracting("properties") .isInstanceOfSatisfying(Properties.class, properties -> { then(properties.get("bootstrap.servers")) .asList() .hasSize(2) .contains("specified-server1", "specified-server2"); then(properties.get("key.serializer")) .isEqualTo("org.apache.kafka.common.serialization.ByteArraySerializer"); then(properties.get("value.serializer")) .isEqualTo("org.apache.kafka.common.serialization.ByteArraySerializer"); then(properties.get("security.protocol")) .isEqualTo("SASL_PLAINTEXT"); then(properties.get("sasl.jaas.config")) .isEqualTo("org.apache.kafka.common.security.scram.ScramLoginModule required username=user password=pass;"); then(properties.get("client.id")) .isEqualTo("some-app"); }); } }

Observation

Для того, чтобы иметь возможность смотреть трейсы взаимодействия компонентов внутри одного сервиса, также добавим конфигурацию:

@Configuration(proxyBeanMethods = false) @EnableAspectJAutoProxy public class ObservedAspectConfiguration { @Bean public ObservedAspect observedAspect(ObservationRegistry observationRegistry) { return new ObservedAspect(observationRegistry); } }

Таким образом, аннотируя методы (или классы) с помощью @Observed, получим трейсы конкретного метода (или всех методов класса).

@Component public class SomeClass { // Обратите внимание на нижеуказанную строку @Observed(name = "observation-name") public void foo() { System.out.println("bar"); } }

Параметр name в аннотации @Observed служит для определения имени наблюдаемого объекта. Тест данной конфигурации рассмотрим позднее, в этой же статье.

Management

В Spring Boot 3 трейсы были вынесены в Micrometer Tracing, про это не следует забывать. Я добавил такие настройки в application. yaml:

management: tracing: enabled: true sampling: probability: 1.0

Проверим результат и переходим к стартеру

Реализовав функциональность и убедившись в том, что трейсы сервиса доступны в Zipkin, соберем все в автоконфигурацию. Вероятно, Вам покажется странным что на данном шаге не приведены результаты. Они обязательно будут приведены позже, когда будем тестировать автоконфигурацию.

Шаг 2. Создаем автоконфигурацию

Задача второго шага

Создать автоконфигурацию, которая может быть подключена к готовому сервису, написанному на Spring Boot, как отдельная библиотека (стартер).

Реализация второго шага

Создадим новый Spring Boot проект, добавим класс MyTracingAutoConfiguration. class. Перенесем в проект все конфигурации и зависимости, описанные выше. Сделаем так, чтобы конфигурация включалась при management. tracing. enabled=true.

Нижеуказанную зависимость сохраним, но добавим ей provided. Данное действие указывает на то, что actuator у нас и так присутствует в classpath сервиса, к которому будем подключать стартер.

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> <scope>provided</scope> </dependency>

Момент включения автоконфигурации

Хочу обратить Ваше внимание на то, что конфигурацию мы будем производить до ZipkinAutoConfiguration. class во избежание конфликта бинов (restTemplateSender vs. kafkaSender). Сделаем это с помощью аннотации @AutoConfigureBefore.

Автоконфигурация

@AutoConfiguration @ConditionalOnProperty(name = "management.tracing.enabled", havingValue = "true") @AutoConfigureBefore(ZipkinAutoConfiguration.class) @Import({KafkaSenderConfiguration.class, ObservedAspectConfiguration.class}) public class MyTracingAutoConfiguration { }

В примере выше мы обозначили следующие условия:

• @ConditionalOnProperty — говорит о том, что конфигурация будет включаться, если существует property management. tracing. enabled в значении true;

• @AutoConfigureBefore(ZipkinAutoConfiguration. class) — говорит о том, что обсудили в разделе “Предварительные действия” (см. выше) .

Далее мы импортируем вышеописанные конфигурации:

• KafkaSenderConfiguration. class;

• ObservedAspectConfiguration. class.

Регистрируем автоконфигурацию

Чтобы это действительно было автоконфигурацией, нам нужно добавить файл /src/main/resources/META-INF/spring/org. springframework. boot. autoconfigure. AutoConfiguration. imports с указателем на класс MyTracingAutoConfiguration (указываем каноническое имя класса автоконфигурации).

Некоторые тесты

Ниже приведены тесты, которые, по моему мнению, важно указать в статье, т. к. они наглядно демонстрируют работу приложения.

@SpringBootConfiguration в тестовом окружении

Для тестирования автоконфигурации я добавлю класс TestConfiguration. class, аннотированный @SpringBootConfiguration в тестовый пакет, в котором размещаются тесты автоконфигурации. Подробнее про это можно посмотреть в этом видео.

@SpringBootConfiguration public class TestConfiguration { }

Как тестировать @Observed?

Тест поможет понять, каким образом мы используем аннотацию @Observed и получаем трейсы на уровне взаимодействия компонентов приложения.

Шаг 1. Потребуется добавить зависимость

<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-integration-test</artifactId> <scope>test</scope> </dependency>

Шаг 2. Создадим тестовую аннотацию @EnableTestObservation, которая подготовит контекст для теста

Подготовка тесового контекста заключается в добавлении конфигурационного бина observationRegistry (TestObservationRegistry из зависимости, которую добавили для тестирования). Также импортируем нашу ObservedAspectConfiguration и добавляем аннотацию @AutoConfigureObservability.

Мы делаем отдельную аннотацию для того, чтобы переиспользовать ее в нескольких тестах.

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @AutoConfigureObservability @Import({ ObservedAspectConfiguration.class, EnableTestObservation.ObservationTestConfiguration.class }) public @interface EnableTestObservation { @TestConfiguration class ObservationTestConfiguration { @Bean TestObservationRegistry observationRegistry() { return TestObservationRegistry.create(); } } }

Шаг 3. Напишем тест и аннотируем его @EnableTestObservation

@EnableTestObservation @SpringBootTest(classes = ObservedAspectConfigurationTest.SomeClass.class) @ImportAutoConfiguration(ZipkinAutoConfiguration.class) class ObservedAspectConfigurationTest { @Autowired private SomeClass someClass; @Autowired private ApplicationContext context; @Test void shouldObserve() { someClass.foo(); final TestObservationRegistry observationRegistry = context.getBean(TestObservationRegistry.class); TestObservationRegistryAssert.assertThat(observationRegistry) .hasObservationWithNameEqualTo("observation-name") .that() .hasBeenStarted() .hasBeenStopped(); } @TestComponent public static class SomeClass { @Observed(name = "observation-name") public void foo() { System.out.println("bar"); } } }

В тесте мы проверили, что при вызове метода foo() тестового компонента someClass, observation стартовала и завершилась.

Протестируем условие включения/игнорирования конфигурации

@Test: Конфигурация должна включаться при наличии management. tracing. enabled=true

@SpringBootTest( classes = MyTracingAutoConfiguration.class, properties = { "custom.tracing.bootstrap-servers=specified-server1,specified-server2", "custom.tracing.password=pass", "custom.tracing.username=user", "custom.tracing.topic=topic-for-traces", "spring.application.name=some-app", "management.tracing.enabled=true" } ) @EnableTestObservation class MyTracingAutoConfigurationEnabledTest { @Autowired private ApplicationContext applicationContext; @Test void shouldConfigureSender() { Assertions.assertThat(applicationContext.getBean(Sender.class)) .isNotNull() .isInstanceOf(KafkaSender.class); } @Test void shouldConfigureObservation() { Assertions.assertThat(applicationContext.getBean(ObservedAspect.class)) .isNotNull(); } }

@Test: Конфигурация НЕ должна включаться при отсутствии management. tracing. enabled=false

@SpringBootTest(properties = "management.tracing.enabled=false") class MyTracingAutoConfigurationDisabledTest { @Autowired private ApplicationContext context; @Test void shouldNotConfigureSender() { assertThatThrownBy(() -> context.getBean(KafkaSender.class)) .isInstanceOf(NoSuchBeanDefinitionException.class) .hasMessage("No qualifying bean of type 'zipkin2.reporter.kafka.KafkaSender' available"); } }

Проверяем работоспособность

Создадим новый проект, в pom. xml добавим зависимости, указанные в статье + автоконфигурацию, написанную ранее. Также добавим lombok для удобства. Далее добавим application. yaml, укажем необходимые нам properties:

custom: tracing: username: login password: password topic: topic bootstrap-servers: broker1,broker2 logging: level: org.apache.kafka.clients.NetworkClient: debug root: info pattern: # Добавим следующий паттерн чтобы отображались traceId и spanId level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" management: tracing: # Включаем трейсы enabled: true sampling: probability: 1.0 spring: application: name: demo-tracing-app

Добавим сервис FooService для проверки работоспособности аннотации @Observed:

@Service @Log4j2 public class FooService { @Observed(name = "observation-name") public void internalFoo() { log.info("this is an internalFoo log"); } }

Напишем простейший контроллер:

@RestController @RequiredArgsConstructor public class TracingController { private final FooService fooService; @GetMapping(path = "/foo") public String foo() { fooService.internalFoo(); return "bar"; } }

Запустим приложение и отправим запрос

curl http://localhost:8080/foo $ bar

Проверим логи. В application. yaml Вы могли увидеть, что уровень логов для org. apache. kafka. clients. NetworkClient: debug. Данная настройка указана для того, чтобы убедиться, что сообщение в Kafka было отправлено. Убедимся, что запрос был:

2023-09-22T21:31:43.270+03:00 DEBUG [demo-tracing-app,,] 16342 --- [emo-tracing-app] org.apache.kafka.clients.NetworkClient: [Producer clientId=demo-tracing-app] Sending produce request with header RequestHeader … etc.

Продолжаем изучать логи. Найдем traceId (650ddd8f171924cbfc5d355d33fb9d9b), по которому будем искать трейс в Zipkin:

2023-09-22T21:31:43.260+03:00 INFO [demo-tracing-app, 650ddd8f171924cbfc5d355d33fb9d9b, c2987710f3440cd1] 15011 --- [nio-8080-exec-1] c. e.d. t.application. service. FooService: this is an internalFoo log

Найдем трейс в Zipkin:

На картинке выше показан трейс, содержащий 2 интервала (span) : родительский (parent) и дочерний (тот самый, заданный с помощью @Observed(name = «observation-name»)) .

Трейсы в Spring Boot 3 с использованием Zipkin и Kafka в качестве транспорта

Наступило ли наше светлое будущее?

На самом деле только частично. Да, трейсы и возможность быстрого их подключения у нас есть, но представленная конфигурация добавляет их для конкретного приложения. Она не позволяет собрать их воедино в единую цепочку вызовов (чтобы под одним traceId увидеть span’ы из разных приложений).

Иными словами: если поднять 2 приложения, подключив к ним стартер, при этом одно будет вызывать другое, мы сможем увидеть 2 разных traceId: 1го и 2го соответственно.

Конфигурация, позволяющая увязать все воедино — наша следующая цель, которую только предстоит реализовать. Уважаемый читатель, если у Вас есть предложения по этому поводу — буду благодарен им в комментариях.

Заключение

Мы рассмотрели вариант добавления трейсов в Spring Boot 3 приложение с возможностью последующего переиспользования этой функциональности. Трейсы передаются в Zipkin через Kafka. Функциональность также позволяет добавлять трейсы межкомпонентного взаимодействия внутри приложения.

55
Начать дискуссию