Сквозное трассирование с использованием OpenTelemetry
August 31, 2022
Независимо от того, реализуете ли вы микросервисы (и, вероятно, вам не стоит этого делать), ваша система, скорее всего, состоит из нескольких компонентов. Самая простая система, вероятно, включает в себя обратный прокси, приложение и базу данных. В этом случае мониторинг — это не просто хорошая идея; это необходимость. Чем больше компонентов, через которые может проходить запрос, тем сильнее эта необходимость.
Однако мониторинг — это только начало пути. Когда запросы начинают массово завершаться с ошибками, вам нужно агрегированное представление по всем компонентам. Это называется трассировкой, и это один из столпов наблюдаемости; два других — это метрики и логи.
В этом посте я сосредоточусь исключительно на трассировке и расскажу, как вы можете начать свой путь в мир наблюдаемости.
Спецификация W3C Trace Context
Решение для трассировки должно предоставлять стандартный формат для работы с различными технологическими стеками. Такой формат должен соответствовать спецификации, будь то формальной или де-факто.
Важно понимать, что спецификация редко появляется из ниоткуда. Обычно на рынке уже существует несколько различных реализаций. В большинстве случаев новая спецификация приводит к появлению дополнительной реализации, как описывает известный комикс XKCD:

Однако иногда случается чудо: рынок принимает новую спецификацию. В данном случае Trace Context — это спецификация W3C, и, похоже, она справилась с задачей:
Эта спецификация определяет стандартные HTTP-заголовки и формат значений для передачи контекстной информации, которая позволяет реализовать сценарии распределенной трассировки. Спецификация стандартизирует, как контекстная информация отправляется и изменяется между сервисами. Контекстная информация уникально идентифицирует отдельные запросы в распределенной системе, а также определяет способ добавления и передачи контекстной информации, специфичной для поставщика. -- https://www.w3.org/TR/trace-context/
Из документа вытекают два ключевых понятия:
- Трассировка следует за путем запроса, который проходит через несколько компонентов.
- Спан (span) связан с одним компонентом и связан с другим спаном через отношение родитель-потомок.

На момент написания этой статьи спецификация является рекомендацией W3C, что является финальной стадией.
Trace Context уже имеет множество реализаций. Одна из них — OpenTelemetry.
OpenTelemetry как золотой стандарт
Чем ближе вы к операционной части IT, тем выше вероятность, что вы слышали об OpenTelemetry:
OpenTelemetry — это набор инструментов, API и SDK. Используйте его для инструментирования, генерации, сбора и экспорта данных телеметрии (метрик, логов и трассировок), чтобы помочь вам анализировать производительность и поведение вашего программного обеспечения. OpenTelemetry доступен для нескольких языков и готов к использованию. -- https://opentelemetry.io/
OpenTelemetry — это проект, управляемый CNCF. До OpenTelemetry существовали два проекта:
- OpenTracing, ориентированный на трассировки, как следует из названия.
- OpenCensus, целью которого было управление метриками и трассировками.
Оба проекта объединились и добавили логи. Теперь OpenTelemetry предлагает набор "слоев", ориентированных на наблюдаемость:
- API для инструментирования на различных языках.
- Канонические реализации, также на разных языках.
- Инфраструктурные компоненты, такие как коллекторы.
- Форматы взаимодействия, такие как Trace Context от W3C.
Обратите внимание, что хотя OpenTelemetry является реализацией Trace Context, он делает больше. Trace Context ограничивается HTTP, в то время как OpenTelemetry позволяет спанам пересекать не веб-компоненты, такие как Kafka. Это выходит за рамки данного блога.
Пример использования
Мой любимый пример — это интернет-магазин, так что давайте оставим его. В данном случае магазин построен на микросервисах, каждый из которых доступен через REST API и защищен API Gateway. Чтобы упростить архитектуру для блога, я буду использовать только два микросервиса: catalog управляет продуктами, а pricing обрабатывает цены на продукты.
Когда пользователь заходит в приложение, главная страница загружает все продукты, получает их цены и отображает их.

Чтобы сделать вещи более интересными, catalog — это Spring Boot приложение, написанное на Kotlin, а pricing — это приложение на Python Flask.
Трассировка должна позволить нам отслеживать путь запроса через шлюз, оба микросервиса и, если возможно, базы данных.
Трассировка на уровне шлюза
Точка входа — это самая интересная часть трассировки, так как она должна генерировать идентификатор трассировки: в данном случае точкой входа является шлюз. Я буду использовать Apache APISIX для реализации демо:
Apache APISIX предоставляет богатые возможности управления трафиком, такие как балансировка нагрузки, динамический апстрим, канареечные релизы, разрыв цепи, аутентификация, наблюдаемость и т.д. -- https://apisix.apache.org/
Apache APISIX основан на архитектуре плагинов и предлагает плагин OpenTelemetry:
Плагин
opentelemetryможет использоваться для отправки данных трассировки в соответствии со спецификацией OpenTelemetry. Плагин поддерживает только бинарно-кодированный OLTP через HTTP. -- https://apisix.apache.org/docs/apisix/plugins/opentelemetry/
Давайте настроим плагин opentelemetry:
apisix: enable_admin: false #1 config_center: yaml #1 plugins: - opentelemetry #2 plugin_attr: opentelemetry: resource: service.name: APISIX #3 collector: address: jaeger:4318 #4
- Запустите Apache APISIX в автономном режиме, чтобы упростить демонстрацию. В любом случае, это хорошая практика в продакшене.
- Настройте
opentelemetryкак глобальный плагин. - Установите имя сервиса. Это имя будет отображаться в компоненте отображения трассировок.
- Отправляйте трассировки в сервис
jaeger. Следующий раздел опишет его.
Мы хотим трассировать каждый маршрут, поэтому вместо добавления плагина к каждому маршруту, мы должны настроить плагин как глобальный:
global_rules: - id: 1 plugins: opentelemetry: sampler: name: always_on #1
- Трассировка влияет на производительность. Чем больше мы трассируем, тем больше влияем. Следовательно, мы должны тщательно балансировать влияние на производительность и преимущества наблюдаемости. Однако для демо мы хотим трассировать каждый запрос.
Сбор, хранение и отображение трассировок
Хотя Trace Context — это спецификация W3C, а OpenTelemetry — это де-факто стандарт, на рынке существует множество решений для сбора, хранения и отображения трассировок. Каждое решение может предоставлять все три возможности или только часть из них. Например, стек Elastic обрабатывает хранение и отображение, но для сбора вам нужно полагаться на что-то другое. С другой стороны, Jaeger и Zipkin предоставляют полный набор для выполнения всех трех возможностей.
Jaeger и Zipkin появились раньше OpenTelemetry, поэтому каждый из них имеет свой формат передачи трассировок. Однако они предоставляют интеграцию с форматом OpenTelemetry.
В рамках этого блога конкретное решение не имеет значения, так как нам нужны только возможности. Я выбрал Jaeger, потому что он предоставляет все-в-одном Docker-образ: каждая возможность имеет свой компонент, но они все встроены в один образ, что значительно упрощает настройку.
Соответствующие порты образа следующие:
| Порт | Протокол | Компонент | Функция |
|---|---|---|---|
16686 | HTTP | query | предоставление интерфейса |
4317 | HTTP | collector | прием OpenTelemetry Protocol (OTLP) через gRPC, если включен |
4318 | HTTP | collector | прием OpenTelemetry Protocol (OTLP) через HTTP, если включен |
Часть Docker Compose выглядит так:
services: jaeger: image: jaegertracing/all-in-one:1.37 #1 environment: - COLLECTOR_OTLP_ENABLED=true #2 ports: - "16686:16686" #3
- Используйте образ
all-in-one. - Очень важно: включите сборщик в формате OpenTelemetry.
- Откройте порт для интерфейса.
Теперь, когда мы настроили инфраструктуру, мы можем сосредоточиться на включении трассировок в наших приложениях.
Трассировка в приложениях Flask
Сервис pricing — это простое приложение Flask. Оно предоставляет единственный эндпоинт для получения цены одного продукта из базы данных.
@app.route('/price/<product_str>') #1-2 def price(product_str: str) -> Dict[str, object]: product_id = int(product_str) price: Price = Price.query.get(product_id) #3 if price is None: return jsonify({'error': 'Product not found'}), 404 else: low: float = price.value - price.jitter #4 high: float = price.value + price.jitter #4 return { 'product_id': product_id, 'price': round(uniform(low, high), 2) #4 }
- Эндпоинт.
- Маршрут требует идентификатора продукта.
- Получение данных из базы данных с использованием SQLAlchemy.
- Настоящие системы ценообразования никогда не возвращают одну и ту же цену со временем; давайте немного рандомизируем цену для интереса.
Предупреждение: Получение одной цены за вызов крайне неэффективно. Это требует столько вызовов, сколько продуктов, но делает трассировку более интересной. В реальной жизни маршрут должен принимать несколько идентификаторов продуктов и получать все связанные цены в одном запросе-ответе.
Теперь пришло время инструментировать приложение. Доступны два варианта: автоматическое инструментирование и ручное инструментирование. Автоматическое — это низкие усилия и быстрый результат; ручное требует времени на разработку. Я бы рекомендовал начать с автоматического и добавлять ручное только при необходимости.
Нам нужно добавить несколько пакетов Python:
opentelemetry-distro[otlp]==0.33b0 opentelemetry-instrumentation opentelemetry-instrumentation-flask
Нам нужно настроить несколько параметров:
pricing: build: ./pricing environment: OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1 OTEL_RESOURCE_ATTRIBUTES: service.name=pricing #2 OTEL_METRICS_EXPORTER: none #3 OTEL_LOGS_EXPORTER: none #3
- Отправляйте трассировки в Jaeger.
- Установите имя сервиса. Это имя будет отображаться в компоненте отображения трассировок.
- Нас не интересуют ни логи, ни метрики.
Теперь вместо использования стандартной команды flask run мы оборачиваем ее:
opentelemetry-instrument flask run
Только с этим мы уже собираем спаны из вызовов методов и маршрутов Flask.
При необходимости мы можем вручную добавить дополнительные спаны, например:
from opentelemetry import trace @app.route('/price/<product_str>') def price(product_str: str) -> Dict[str, object]: product_id = int(product_str) with tracer.start_as_current_span("SELECT * FROM PRICE WHERE ID=:id", attributes={":id": product_id}) as span: #1 price: Price = Price.query.get(product_id) # ...
- Добавьте дополнительный спан с настроенной меткой и атрибутом.
Трассировка в приложениях Spring Boot
Сервис catalog — это реактивное приложение Spring Boot, разработанное на Kotlin. Оно предоставляет два эндпоинта:
- Один для получения одного продукта.
- Другой для получения всех продуктов.
Оба сначала ищут в базе данных продуктов, затем запрашивают вышеупомянутый сервис pricing для получения цены.
Как и в случае с Python, мы можем использовать автоматическое и ручное инструментирование. Начнем с низко висящих фруктов — автоматического инструментирования. На JVM мы достигаем этого через агент:
java -javaagent:opentelemetry-javaagent.jar -jar catalog.jar
Как и в Python, это создает спаны для каждого вызова метода и точки входа HTTP. Это также инструментирует вызовы JDBC, но у нас реактивный стек, и мы используем R2DBC. Для справки, GitHub issue открыт для добавления поддержки.
Нам нужно настроить поведение по умолчанию:
catalog: build: ./catalog environment: APP_PRICING_ENDPOINT: http://pricing:5000/price OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1 OTEL_RESOURCE_ATTRIBUTES: service.name=orders #2 OTEL_METRICS_EXPORTER: none #3 OTEL_LOGS_EXPORTER: none #3
- Отправляйте трассировки в Jaeger.
- Установите имя сервиса. Это имя будет отображаться в компоненте отображения трассировок.
- Нас не интересуют ни логи, ни метрики.
Как и в Python, мы можем улучшить ситуацию, добавив ручное инструментирование. Доступны два варианта: программное и на основе аннотаций. Первое немного сложнее, если мы не вводим Spring Cloud Sleuth. Давайте добавим аннотации.
Нам нужна дополнительная зависимость:
<dependency> <groupId>io.opentelemetry.instrumentation</groupId> <artifactId>opentelemetry-instrumentation-annotations</artifactId> <version>1.17.0-alpha</version> </dependency>
Будьте осторожны, артефакт был недавно перемещен из io.opentelemetry:opentelemetry-extension-annotations.
Теперь мы можем аннотировать наш код:
@WithSpan("ProductHandler.fetch") //1 suspend fun fetch(@SpanAttribute("id") id: Long): Result<Product> { //2 val product = repository.findById(id) return if (product == null) Result.failure(IllegalArgumentException("Product $id not found")) else Result.success(product) }
- Добавьте дополнительный спан с настроенной меткой.
- Используйте параметр как атрибут, с ключом
idи значением параметра во время выполнения.
Результат
Теперь мы можем поиграть с нашей простой демо, чтобы увидеть результат:
curl localhost:9080/products curl localhost:9080/products/1
Ответы не интересны, но давайте посмотрим на интерфейс Jaeger. Мы находим обе трассировки, по одной на вызов:

Мы можем углубиться в спаны одной трассировки:

Обратите внимание, что мы можем вывести последовательность потока без вышеупомянутой UML-диаграммы. Еще лучше, последовательность отображает вызовы внутри компонента.
Каждый спан содержит атрибуты, которые добавило автоматическое инструментирование, и те, которые мы добавили вручную:

Заключение
В этом посте я продемонстрировал трассировку, отслеживая запрос через API Gateway, два приложения на разных технологических стеках и их соответствующие базы данных. Я лишь поверхностно коснулся трассировки: в реальном мире трассировка, вероятно, будет включать компоненты, не связанные с HTTP, такие как Kafka и очереди сообщений.
Тем не менее, большинство систем так или иначе полагаются на HTTP. Хотя настройка не тривиальна, она и не слишком сложна. Трассировка HTTP-запросов через компоненты — это хорошее начало вашего пути к наблюдаемости вашей системы.
Полный исходный код для этого поста можно найти на GitHub.
Для дальнейшего изучения: