Предисловие
Страховая компания АльфаСтрахование (далее СК), в которой я работаю, продает страховые продукты не только в офисах, но и через сеть страховых агентов. Агенты могут оформлять полисы, используя 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 {
}
}
KafkaSender
Вручную конфигурируем 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
служит для определения имени наблюдаемого объекта. Тест данной конфигурации рассмотрим позднее, в этой же статье.
Подробнее про Observability в Spring Boot 3.
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
.
Нижеуказанную зависимость сохраним, но добавим ей <scope>provided</scope>
. Данное действие указывает на то, что 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
- говорит о том, что конфигурация будет включаться, если существует propertymanagement.tracing.enabled
в значенииtrue
;@AutoConfigureBefore(ZipkinAutoConfiguration.class)
- говорит о том, что обсудили в разделе "Предварительные действия" (см.выше).
Далее мы импортируем вышеописанные конфигурации:
KafkaSenderConfiguration.class
;ObservedAspectConfiguration.class
.
Регистрируем автоконфигурацию
Чтобы Spring Boot увидел автоконфигурацию, нам нужно добавить файл /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
добавим следующие зависимости:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- автоконфигурация из предыдущего шага -->
<dependency>
<groupId>ru.alfastrah.api</groupId>
<artifactId>spring-boot-tracing-starter</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
Далее добавим 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")
).
Наступило ли наше светлое будущее?
На самом деле только частично. Да, трейсы и возможность быстрого их подключения у нас есть, но представленная конфигурация добавляет их для конкретного приложения. Она не позволяет собрать их воедино в единую цепочку вызовов (чтобы под одним traceId увидеть span'ы из разных приложений).
Иными словами: если поднять 2 приложения, подключив к ним стартер, при этом одно будет вызывать другое, мы сможем увидеть 2 разных traceId: 1го и 2го соответственно.
Конфигурация, позволяющая увязать все воедино - наша следующая цель, которую только предстоит реализовать. Уважаемый читатель, если у Вас есть предложения по этому поводу - буду благодарен им в комментариях.
Заключение
Мы рассмотрели вариант добавления трейсов в Spring Boot 3 приложение с возможностью последующего переиспользования этой функциональности. Трейсы передаются в Zipkin через Kafka. Функциональность также позволяет добавлять трейсы межкомпонентного взаимодействия внутри приложения.
Исходный код автоконфигурации доступен по ссылке.
Надеюсь, эта статья была Вам интересна.
Спасибо за внимание.
Комментарии (5)
olegchir
02.11.2023 13:27Что очень беспокоит, как много шагов нужно сделать, чтобы это включить. Статья такая толстая, что тянет на докторскую. Было бы круто иметь какой-то стартер, который бы с помощью AOP бегал по типично сконфигурированному приложению и добавлял всё, что нужно. Чтобы ты мог из Initializr сгенерить проект, прописать где-нибудь шаблон для названия traceId, и оно всё сразу заработало. Может быть, что-то такое уже есть?
tnt2ultra
02.11.2023 13:27traceId можно передавать между сервисами через заголовок
traceparent
https://www.w3.org/TR/trace-context/#traceparent-header
а если нужно попроще (и если сервисов много разных - soap, rest, grpc, kafka, webflux и т.д.), то вместо micrometer можно использовать opentelemetry-javaagent.jar https://opentelemetry.io/docs/instrumentation/java/automatic/
ultrinfaern
Вроде как traceId передается в HTTP заголовке, и если его нет то вы и получите два трейса. Отсюда решение - проверить что этот заголовок передается.