Краткое содержание статьи
• В эпоху быстрорастущих сервисов важно контролировать состояние системы.
• Логи и метрики помогают следить за различными параметрами, такими как запросы в секунду, потребление памяти и т.д.
• Наблюдаемость позволяет легко устранять баги и решать проблемы.
• OpenTelemetry - стандарт сбора данных о работе приложений.
• Пролет - единица работы (выполнения) операции, содержит имя, данные о времени, структурированные сообщения и метаданные.
• Jaeger(Егерь) - инструмент для анализа данных о работе приложений.
• OpenTelemetry состоит из идей уже существующих реализаций, таких как OpenTracing и OpenCensus.
• Основная задача - предоставить набор стандартизированных SDK, API и интерфейсов для подготовки и отправки данных в серверную часть для мониторинга системой.
Начнем
Добрый день! Меня зовут Дмитрий Сигаев и в этом блоге я делюсь своим опытом разработки высоконагруженных приложений и систем. Сегодня мы поговорим об OpenTelemetry в проектах Golang, использующих микросервисную архитектуру, а также о том, как настроить трассировку и мониторинг и подружить их с существующей инфраструктурой в проекте.
В эпоху быстрорастущих сервисов важно иметь возможность контролировать состояние системы в любой момент времени. Одними из инструментов для достижения этого являются логи и метрики, которые помогают нам следить за многими параметрами, такими как количество запросов в секунду (RPS), потребление памяти, процент закешированных вызовов и так далее. Иными словами, логи и метрики добавляют нашей системе такую важную характеристику, как наблюдаемость (Observability)
Наблюдаемость позволяет нам легко устранять баги и решать новые проблемы, отвечая на вопрос “Почему это происходит?”.
Представим, что спустя месяцы работы среднее время ответа сервиса увеличилось в 2 раза, о чем мы узнали по метрикам. Сходу можно предположить, что проблема на стороне базы данных, но в сложных проектах с большим количеством интегрированных технологий причина перестает быть настолько явной. Что, если начали тормозить несколько частей нашего сервиса? В таких ситуациях общее время выполнения запроса не указывает нам ни проблемное место, ни сторону, куда нужно копать. На помощь нам приходит ещё один инструмент под названием трейсинг (tracing).
В этой статье я расскажу об основах интеграции трейсинга в приложения, написанные на языке Go c помощью инструментария OpenTelemetry и Jaeger. Большая часть взята из официальных документаций, поэтому вы всегда сможете подробнее изучить эту тему.
OpenTelemetry
OpenTelemetry (OTel) - стандарт сбора данных о работе приложений, который состоит из идей уже существующих реализаций, таких как OpenCensus и OpenTracing. Основная задача - предоставить набор стандартизированных SDK, API и инструментов для подготовки и отправки данных в сервисы (observability back-end) для мониторинга систем. об Observability читаем здесь
В OTel входят библиотеки для многих популярных языков (C++, Java, Python, .NET, PHP, Rust полный список), а также готовые инстументы от сообщества для трейсинга популярных библиотек и фреймворков. Прежде чем начать писать код, предлагаю ознакомиться с главными компонентами в OpenTelemetry, которые мы будем использовать в будущем. Первым важным компонентом OpenTelemetry является span.
OpenTelemetry – стандарт, который появился относительно недавно: в конце 2020 года. При этом он получил широкое распространение и поддержку множества вендоров ПО для трейсинга и мониторинга.
Span
Span представляет собой единицу работы (выполнения) какой-то операции. Он отслеживает конкретные события (events), которые выполняет запрос, рисуя картину того, что произошло за время, в течение которого эта операция была выполнена. Он содержит имя, данные о времени, структурированные сообщения и другие метаданные (атрибуты) для предоставления информации об отслеживаемой операции.
Пример информации, которую содержит span http запроса:
Они делятся на два типа: Parent (root) span и Child span. Дочерние спаны создаются при вложенных вызовах функции, которые наследуются от ранее созданного родительского. В реализации для языка Go их передают через context.Context.
Правило простое: если в переданном контексте уже есть спан, то создаем дочерний от него. В противном же случае создаем родительский (root) span.
Каждый span имеет span_id и trace_id. Важно знать, что от начала создания родительского и до конца дочерних спанов trace_id не меняется, в то время как span_id для каждого спана уникален. Такие свойства позволяют объединить вложенности и ветвления нескольких вызовов:
Например, в микросервисной архитектуре мы можем передавать trace_id между сервисами, чтобы в конце получить полное дерево вызовов. Аналогично, отправляя одинаковый идентификатор с клиента, можно получить данные о всех его вызовах для аналитики (и не только).
Статус может иметь один из двух статусов: Error и Ok . Как можно понять из названия, статус Error сигнализирует о том, что во время выполнения операций произошла ошибка, а Ok наоборот - все операции выполнились успешно
Jaeger
Ранее говорилось, что OpenTelemetry только готовит (не хранит!) и отправляет данные в observability back-end. Одним из примеров такого бэкенда является Jaeger.
На момент написания статьи последней версией была 1.63.0. Подробнее об установке можно узнать в документации
Установка
Docker
1 | docker run --rm --name jaeger \ |
Docker-compose
1 | version: '3.8' |
1 | docker compose up -d |
Локальная установка: Скачать и запустить готовый бинарник https://www.jaegertracing.io/download/
При открытии http://localhost:16686 вы должны увидеть такую картину:
Пишем приложение
Для примера я написал маленький сервис, который сохранят и отдает заметки из Redis.
Структура:
‘’’shell
├── storage
│ └── redis.go
├── server
│ └── http.go
├── models
│ └── note.go
├── main.go
├── go.sum
├── go.mod
├── docker-compose.yaml
└── cmd
└── main.go
1 |
|
storage/redis.go
1 | package storage |
server/http.go
1 | package server |
cmd/main.go
1 | package main |
docker-compose.yaml
1 |
|
Поднимаем контейнеры с Redis и Jaeger
1 | docker compose up -d |
И запускаем наш сервис
1 | go run cmd/main.go |
После старта можем добавить заметку:
curl запросы
Добавление заметки
1 | curl --location --request POST 'localhost:8080/create' \ |
Получение заметки
1 | curl --location --request GET 'localhost:8080/get?note_id=7411ff79-fd1d-46ab-b9f8-21105cd770ce' |
Начнем интеграцию OpenTelemetry с создания экспортера (exporter) , который отправляет (экспортирует) данные в observability back-end и имеет простой интефейс
1 | type SpanExporter interface { |
Здесь мы видим ещё один вид спанов - ReadOnlySpan. Это не отдельный тип, а просто интефейс только для получения информации о спане.
Теперь создадим exporter для Jaeger:
1 | package tracer |
Теперь создадим провайдера (provider), который будет создавать трейсеры (tracers)
1 | package tracer |
Разберем код:
1 | return tracesdk.NewTracerProvider( |
При создании провайдера мы добавляем опции:
- WithBatcher - отправляет данные экспортеру пачками, повышая производительность
- WithResourse - добавляет в каждый трейс данные (ресурсы)
1 | resource.Merge( |
resource.Merge объединяет несколько ресурсов. Вместе со стандартными мы добавляем версию библиотеки и название сервиса, чтобы добавить возможность фильтровать трейсы каждого сервиса.
Теперь осталось всего лишь создать трейсер:
1 | package tracer |
otel.SetTracerProvider(tp) - очень важная строка. Она задает глобальный провайдер, чтобы другие библиотеки с инструментарием могли начинать свои трейсы
Добавим в main.go вызов InitTracer :
1 | func main() { |
Ищем готовый инстументарий:
Перед тем, как писать свой велосипед, попробуй найти готовый в интернете
Всего можно выделить три способа поиска готового инструментария
Посмотреть в https://github.com/open-telemetry/opentelemetry-go-contrib/tree/main/instrumentation
Найти в репозитории нужной библиотеки пакет otelXxx (Xxx - название библиотеки)
Загуглить :) golang название_библиотеки OpenTelemetry
Например, давайте найдем инструментарий для github.com/gofiber/fiber:
Видим, что сторонний репозиторий заархивирован, но его код был добавлен в официальный
Многие библиотеки не имеют readme.md файла, поэтому всегда лучше смотреть в папку example. Находим строку app.Use(otelfiber.Middleware(“my-server”)) . Добавим её в наш код!
1 | import "github.com/gofiber/contrib/otelfiber" |
После перезапуска выполним несколько запросов и вернемся в Jaeger:
После нажатия на Find Traces увидим список наших трейсов:
Для перехода в нужный нам трейс достаточно нажать на его название.
Как это работает?
Ранее мы создали мидлварь для фреймворка fiber, который перехватывает входящий запрос, создает родительский спан и передает его в контексе. Заметьте, что без строки otel.SetTracerProvider этот трейс нигде не будет записываться.
В метаданные спана он включил данные запроса и ответа, которые могут оказаться важными при исправлении проблем.
Теперь добавим трассировку для редиса: с помощью второго метода поиска узнаем, что нужный пакет уже лежит в репозитории go-redis - https://github.com/go-redis/redis/tree/master/extra/redisotel. Добавим его в код:
1 | import "github.com/go-redis/redis/extra/redisotel/v9" |
Выполним ещё раз запросы и посмотрим на трейсы. Не забудьте нажать кнопку Find Traces ещё раз (или перезагрузить страницу)
Теперь мы видим не только данные фибера, но и запросы в бд!
Как это работает?
redisotel добавляет хук для нашего клиента, чтобы при вызове любой операции создавать ещё один спан с нужными данными.
В нашем примере мы рассмотрели только две библиотеки, но вы можете найти несколько сотен для популярных библиотек. Примеры:
otelgrpc - унарный interceptor для запросов gRPC
otelmongo - трейсер для клиента mongodb
otelhttp - middleware для net/http
otelsarama - трейсер для sarama, популярного клиента kafka
otelfiber, otelgin, otelbeego, otelecho - мидлвари для веб-фреймворков
otelmemcached - трейсер для клиента memcached
otelkit - трейсер для микросервисного фреймворка kit
otelgocql - трейсер для клиента Cassandra
otelpgx - трейсер для клиента PostresSQL pgx (и pgxpool)
И много других!
Ручной трейсинг
Казалось бы, что с таким инструментарием мы уже на порядок повышаем observability нашего сервиса. Но часто возникают ситуации, когда нужно создать свои спаны с более подробной информацией. Взглянем на интерфейс трейсера:
1 |
|
Добавим его в структуру FiberHandlers
1 | type FiberHandler struct { |
Теперь создадим свой спан:
1 | ctx, span := h.tracer.Start(fiberctx.UserContext(), "GetNote") |
Важные моменты:
- Во всех последующих функциях нужно передавать контекст, полученный из tracer.Start
- defer span.End() завершает спан. Время выполнения спана = время вызова span.End() - время вызова tracer.Start()
Для добавление своей информации в спан нужно воспользоваться функцией trace.WithAttributes()
1 | ctx, span := h.tracer.Start(fiberctx.UserContext(), "GetNote", trace.WithAttributes( |
Теперь посмотрим на новый трейс:
События (events):
Зачастую наши функции делают несколько вызовов других. Для измерения времени каждой есть возможность разделить спан на события с помощью span.AddEvent(). В параметрах можно также задать атрибуты:
1 | func (h FiberHandler) GetNote(fiberctx *fiber.Ctx) error { |
Если откроем подробности нового спана, то увидим следующее
Цифры здесь - это не время выполнения, а количество времени, пройденное с начала спана (иными словами offset). Например, реальное время выполнения запроса в редис = время спана - предыдущий сдвиг = 914 - 43 = 871 микросекунд.
Состояния спанов и ошибки
Остановим Redis и попробуем выполнить запрос:
1 | $ docker ps --format '{{.ID}} {{.Names}}' |
По трейсу можно понять, что клиент редиса попытался 4 раза восстановить соединение, но в конце вернул ошибку. Подробности можем увидеть в родительском спане:
Но почему мы видим тип *fiber.Error? Ранее подключенный middleware от otelfiber проверяет ошибку нашего обработчика (handler’а). Если err != nil, то он записывает ошибку в спан с помощью метода span.RecordError(err) . Добавим эту строку в наш код:
1 | import "go.opentelemetry.io/otel/codes" |
Обратите внимание на дополнительный вызов span.SetStatus . Из описания RecordError:
An additional call to SetStatus is required if the Status of the Span should be set to Error, as this method does not change the Span status
Ранее говорилось, что спан имеет один из двух статусов: Ok и Error . В коде выше мы дополнительно задаем этот статус (хотя Jeager считает трейс с ошибкой даже без него).
В запись ошибки также можно добавить артибуты, как в спан и его ивенты:
1 | span.RecordError(err, trace.WithAttributes( |
Теперь ошибка будет записываться не только через инструментарий, но и в нашем коде. Если вы поднимаете все ошибки наверх, то вызывать span.RecordError можно только на уровне транспорта или сервисов. В противном случае (если ошибки логируются на месте), может быть полезно добавление записи на каждом этапе.
Sampling rate
При высоких нагрузках выполнять трейс на каждый запрос крайне избыточно. OpenTelemetry имеет параметр Sampling probability. Существует несколько способов его настройки:
- Через tracesdk.TraceIDRatioBased
1
2
3
4
5
6provider := tracesdk.NewTracerProvider(
......
tracesdk.WithSampler(
tracesdk.TraceIDRatioBased(0.1),
),
) - Через переменные окружения
1
2
3OTEL_TRACES_SAMPLER=traceidratio \
OTEL_TRACES_SAMPLER_ARG=0.1 \
go run cmd/main.go
Например, при значении 0.1 из 10000 трейсов будут записаны только 1000*0.1=100 . При 1.0 будут записаны все трейсы, а с 0.0 - никакие.
Значение, которое должен иметь коефициент, зависит от нагрузки вашего приложения. Зачастую его можно узнать обычным подбором и сравнением с временем ответа сервиса без трейсинга. Значение по-умолчанию равно 1.0
Заключение
Код: https://github.com/kubernest/tracing-example
В этой статье я хотел показать, что страшное слово трейсинг - всего лишь полчаса чтения документации. Добавив буквально несколько строчек в клиенты используемых продуктов мы можем легко отследить любое выполнение с малейшими подробностями, а с ручными спанами вообще узнать всю нужную информацию не залезая в код.
Много информации было взято из официальных документаций:
https://opentelemetry.io/docs/instrumentation/go/
https://www.jaegertracing.io/docs/1.41/getting-started/
В следующей статье мы рассмотрим трейсинг между микросервисами, который (спойлер) тоже реализуется одной строкой.