Предисловие

Страховая компания АльфаСтрахование (далее СК), в которой я работаю, продает страховые продукты не только в офисах, но и через сеть страховых агентов. Агенты могут оформлять полисы, используя 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 - говорит о том, что конфигурация будет включаться, если существует property management.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:

Трейс в интерфейсе Zipkin
Трейс в интерфейсе Zipkin

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

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

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

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

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

Заключение

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

Исходный код автоконфигурации доступен по ссылке.

Надеюсь, эта статья была Вам интересна.

Спасибо за внимание.

Комментарии (5)


  1. ultrinfaern
    02.11.2023 13:27

    Вроде как traceId передается в HTTP заголовке, и если его нет то вы и получите два трейса. Отсюда решение - проверить что этот заголовок передается.


  1. olegchir
    02.11.2023 13:27

    Что очень беспокоит, как много шагов нужно сделать, чтобы это включить. Статья такая толстая, что тянет на докторскую. Было бы круто иметь какой-то стартер, который бы с помощью AOP бегал по типично сконфигурированному приложению и добавлял всё, что нужно. Чтобы ты мог из Initializr сгенерить проект, прописать где-нибудь шаблон для названия traceId, и оно всё сразу заработало. Может быть, что-то такое уже есть?


    1. ultrinfaern
      02.11.2023 13:27

      Так есть такой - micrometer traсing, для 2.х - spring cloud sleuth


  1. tnt2ultra
    02.11.2023 13:27

    traceId можно передавать между сервисами через заголовок 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/


  1. romankspb
    02.11.2023 13:27

    А теперь лучше сразу запланируйте переход на jaeger/opentelemetry :)