Диагностируем проблемы в микросервисной архитектуре на Node.js с помощью OpenTracing и Jaeger

от автора

Всем привет! В современном мире крайне важна возможность масштабировать приложение по щелчку пальцев, ведь нагрузка на приложение может сильно отличаться в разное время. Наплыв клиентов, которые решили воспользоваться вашим сервисом, может принести как большую прибыль так и убытки. Разбиение приложения на отдельные сервисы решает проблемы с масштабированием, всегда можно добавить инстансов нагруженных сервисов. Это несомненно поможет справиться с нагрузкой и сервис не упадет от нахлынувших на него клиентов. Но микросервисы вместе с неоспоримой пользой, вносят и более сложную структуру приложения, а так же запутанность в их взаимосвязях. Что если даже успешно масштабировав свой сервис, проблемы продолжаются? Время ответа растет и ошибок становится все больше? Как понять, где именно проблема? Ведь каждый запрос к API может порождать за собой цепочку вызовов разных микросервисов, получение данных из нескольких БД и сторонних API. Может это проблема с сетью, или API вашего партнера не справляется с нагрузкой, а может это кеш виноват? В этой статье я постараюсь рассказать, как ответить на эти вопросы и быстро найти точку отказа. Добро пожаловать под кат.

Чтобы быстро определить точку отказа и решить проблему, необходимо собрать метрики прохождения каждого этапа запроса. Для решения этой задачи можно воспользоваться спецификацией OpenTracing. Этот инструмент описывает основные принципы и модели данных для работы с трассировками в распределенных системах, но не предоставляет реализации. В данной статье мы воспользуемся имплементацией для JavaScript, и будем писать примеры на TypeScript. Но для того, чтобы перейти к практике, необходимо разобраться с теорией.

Теория

Основными понятиями в спецификации OpenTracing являются Trace, Span, SpanContext, Carrier, Tracer.

  • Trace. Это временной интервал, в течение которого выполнялся один или несколько Span’ов, связанных между собой одним идентификатором traceId. Span’ы так же могут быть связаны между собой ссылками двух оcновных типов. ChildOf это обычная связь родитель — потомок. Она говорит о том, что для завершения родительского span’a требуется завершение дочернего. Связь FollowsFrom говорит лишь о том, что родительский span запустил другой span, но на завершение текущего он не влияет.

  • Span. Это основная и минимальная единица информации в спецификации OpenTracing. Span описывает интервал во времени, в котором происходила работа. Например, вызов функции, которая делает запрос в БД за данными, можно описать как span, сохранив в нем необходимую информацию. Span создается с помощью конкретной реализации OpenTracing, которая называется Tracer (об этом чуть позже). При создании интервала обязательным полем является имя (например название функции), также неявно в Span записывается timestamp создания интервала и идентификатор spanId. Каждый интервал содержит traceId, если span является дочерним, то в него записывается traceId родительского интервала, если родительского spana’а нет, генерируется новый. Когда функция завершила свою работы, у объекта span мы должны вызвать метод finish. Этот метод запишет в Span timestamp завершения работы, а так же отправит получившийся span в Трассировщик (если это предусмотренно конкретной реализацией). В span можно добавлять Теги или Логи, которые являются объектами типа key:value. Ключи, этих объектов, могут обладать семантическими свойствами, которые описаны в соглашении OpenTracing. Например, если во время выполнения функции возникла ошибка, то к Span’у, который описывает эту функцию, можно добавить тег error: true. Обработка таких тегов, описанных в спецификации, может быть реализована в трейсере, который вы используете. Отличием логов, добавленных к интервалу, является то, что вместе с логом добавляется и timestamp лога. То есть это конкретная временная точка между стартом span’a и его завершением. У обычного тега нет этой метки.

  • SpanContext. Это объект, описанный в спецификации OpenTracing, который содержит информацию, необходимую для связывания span’ов между собой при межсервисном взаимодействии. Контекст содержит идентификаторы traceId, spanId, а также любую информацию вида key:value, которую мы хотим передавать между микросервисами. В терминологии OpenTracing эта информация называется baggage. Если мы создаём новый интервал, но этот интервал дочерний по отношению к другому. Создав SpanContext, мы можем передать его новому span’у, указав его как родителя. За счет этого новый интервал получит ссылку на свой родительский span.

  • Carrier. Этот простой объект типа key:value содержит информацию, с помощью которой можно создать SpanContext. Carrier можно создать с помощью реализации tracer. В спецификации OpenTracing описаны два типа этого объекта. Первый — это FORMAT_TEXT_MAP, простой объект типа key:value. Полученный объект можно передавать вместе с запросом к другому сервису. Второй FORMAT_BINARY трансформирует контекст в бинарный вид. Это минимальный набор, который должен реализовать tracer. Есть и другие форматы преобразования контекста, например FORMAT_HTTP_HEADERS, который сериализует контекст в объект заголовков для передачи по http.

  • Tracer. Это конкретная имплементация спецификации OpenTracing, которая непосредственно предоставляет методы по созданию span’ов, генерации идентификаторов, создания контекстов и отправку завершенных интервалов на хранение в трассировщик (distributed tracing system) например Jaeger или Elastic APM. Tracer реализует два метода, с помощью которых мы можем преобразовывать объект контекста в carrier для передачи между сервисами. Это inject и extract

Extract принимает первым аргументом тип carrier’a, вторым сам carrier и возвращает объект контекста. Inject получает первым аргументом SpanContext, вторым тип желаемого объекта carrier и третьим пустой объект, в который будет добавлена вся необходимая информация из контекста, для дальнейшей передачи между сервисами. За счет работы этих функций мы можем связывать наши сервисы в единый трейс, состоящий из связанных между собой span’ов.

Используемые технологии

Для того, чтобы показать как это работает на практике, я напишу небольшой тестовый пример на typescript состоящий из трех микросервисов. Микросервисы будут общаться между собой через брокер NATS, а полученные трассировки будем отправлять в Jaeger.

NATS

Это быстрый, легкий и производительный брокер, написанный на golang. Через этот брокер можно реализовать две основные схемы взаимодействия микросервисов это Publish-Subscribe для асинхронных операций и Request-Reply для синхронных. NATS работает по простому текстовому протоколу, что упрощает разработку, а также предоставляет много разных полезных функций, таких как балансировку нагрузки и мониторинг подключенных сервисов. В тестовом проекте я буду использовать NATS как транспорт между сервисами, но в конечном счете для сбора трассировок транспорт не будет играть особой роли, это будет видно из примера. Для запуска проекта на локальной машине потребуется запустить NATS, что можно сделать с помощью Docker.

docker run -d --name nats -p 4222:4222 -p 6222:6222 -p 8222:8222 nats

Jaeger

Это система хранения и анализа трассировок, созданная и выпущенная в opensource компанией Uber. Jaeger предоставляет удобный интерфейс для анализа трассировок, а так же возможность отображать трассировки в виде графа зависимостей как отдельного метода, так и системы в целом, что можно использовать для самодокументирования системы в целом. В качестве хранилища трассировок Jaeger может использовать Cassandra, Elasticsearch а также просто хранить трейсы в памяти, что удобно для тестов. При большом количестве трассировок можно использовать Kafka, как буфер между коллектором, в который прилетают span’ы, и хранилищем. Также в библиотеках, реализующих трейсер Jaeger, можно настроить сэмплирование трейсов. Поддерживается несколько видов сэмплирования:

  • Const. Эта стратегия подойдет, если нужно хранить каждый трейс (если передать значение 1) или не сохранять ни один (значение 0)

  • Probabilistic. Значение этой стратегии говорит о том, какой процент трейсов Jaeger будет сохранять. Трейсы выбираются случайным образом. Например, при значении 0.1 будет сохранен только 1 трейс из 10.

  • Rate Limiting. Эта стратегия позволяет сохранять определенное значение полученных трейсов за секунду.

  • Remote. Стратегия говорит о том, что решение о сохранении трейса будет приниматься на стороне бэкенда Jaeger’a. Что позволяет гибко настраивать сэмплирование, не меняя настроек трейсера в коде приложения.

Для запуска тестового примера локально, также потребуется запустить Jaeger, например, с помощью Docker

docker run -d --name jaeger \   -e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \   -p 5775:5775/udp \   -p 6831:6831/udp \   -p 6832:6832/udp \   -p 5778:5778 \   -p 16686:16686 \   -p 14268:14268 \   -p 9411:9411 \   jaegertracing/all-in-one:1.8

Реализуем сбор трассировок

Напишем небольшой тестовый проект, что бы посмотреть как работает теория на практике. Приложение будет обрабатывать один пользовательский запрос по http. В ответ на get запрос по руту /devices/:regionId будет возвращаться json с данными, которые будут собраны из нескольких сервисов. Общая архитектура приложения такая.

Все три микросервиса подключены к NATS. Единственный endpoint приложения отвечает за получение массива подключенных устройств, доступных в регионе, с информацией о пользователе этого устройства. После того, как запрос за устройствами пришел в http шлюз, микросервис api через NATS вызывает метод получения данныx из микросервиса devices. Микросервис devices для получения данных делает запрос в БД (например, mongodb), за объектами подключенных устройств, затем по количеству полученных устройств делает параллельнные запросы в другую БД (например redis) за получением геолокации каждого устройства. Далее микросервис devices передаёт массив id устройств в микросервис users для получения подробной информации о владельцах устройств. После получения массива пользователей данные агрегируются и возвращаются в api. Это выдуманный тестовый пример, но он хорошо продемонстрирует полезность сбора трассировок из подобных распределенных систем.

Для начала опишем основные интерфейсы данных, с которыми будем работать

// Координаты устройства export interface Location {   lat: number;   lng: number; }  export interface Device {   id: string;   regionId: string;   userId: string;   connected: boolean; }  export interface User {   id: string;   name: string;   address: string; }  // Результат работы приложения export interface ConnectedDevice extends Device {   user: User;   connected: true;   location: Location; }

У микросервиса devices и users будет по одному методу

export const UsersMethods = {   getByIds: 'users.getByIds', };  export const DevicesMethods = {   getByRegion: 'devices.getByRegion', };

Теперь напишем класс транспорта, который будет реализовывать подключение к NATS, а также два метода publish и subscribe. С помощью этих методов можно отправить данные подписчику по определенной теме и подписаться на них соответственно.

import * as Nats from 'nats'; import * as uuid from 'uuid';  export class Transport {   private _client: Nats.Client;   public async connect() {     return new Promise(resolve => {       this._client = Nats.connect({         url: process.env.NATS_URL || 'nats://localhost:4222',         json: true,       });        this._client.on('error', error => {         console.error(error);         process.exit(1);       });        this._client.on('connect', () => {         console.info('Connected to NATS');         resolve();       });     });   }   public async disconnect() {     this._client.close();   }   public async publish<Request = any, Response = any>(subject: string, data: Request): Promise<Response> {     const replyId = uuid.v4();     return new Promise(resolve => {       this._client.publish(subject, data, replyId);       const sid = this._client.subscribe(replyId, (response: Response) => {         resolve(response);         this._client.unsubscribe(sid);       });     });   }   public async subscribe<Request = any, Response = any>(subject: string, handler: (msg: Request) => Promise<Response>) {     this._client.subscribe(subject, async (msg: Request, replyId: string) => {       const result = await handler(msg);       this._client.publish(replyId, result);     });   } }

Для создания http api будем использовать express. В index файле api нам нужно создать экземпляр класса Transport, через него мы будем вызвать метод микросервиса devices.

(async () => {   const transport = new Transport();   const port = 5000;    await transport.connect();   const api = express();    api.get('/devices/:regionId', async (request, response) => {     const result = await transport.publish<GetByRegion, ConnectedDevice[]>(DevicesMethods.getByRegion, {       regionId: request.params.regionId,     });      response.send(result);      return result;   });   api.listen(port, () => {     console.info(`Server started on port ${port}`);   }); })();

Далее реализуем микросервисы devices и users. Приведу пример кода только для devices, микросервис users реализуется аналогично.

Для работы с БД реализуем два репозитория. Один отвечает за работу с вымышленной mongodb, другой с redis

export class DeviceRepository {   private db = 'mongodb';   private devices: Device[] = [...];   public async getByRegion(regionId: string): Promise<Device[]> {     return new Promise(resolve => {       setTimeout(() => resolve(this.devices), 300);     });   } }

export class LocationRepository {   private db = 'redis';   private locations = new Map<string, Location>([...]);    public async getLocation(deviceId: string): Promise<Location> {     return new Promise(resolve => {       setTimeout(() => resolve(this.locations.get(deviceId)), 40);     });   } }

Теперь реализуем бизнес — логику микросервиса devices, которая будет находиться в функции getByRegion. Логика работы этого обработчика была описана ранее.

export async function getByRegion(request: Msg<GetByRegion>) {   try {     const deviceRepository = new DeviceRepository();     const locationRepository = new LocationRepository();      const regionId = request.regionId;     const devices = await deviceRepository.getByRegion(regionId);      const connectedDevices = await Promise.all(       devices.map(async device => {         const location = await locationRepository.getLocation(device.id);         return { ...device, location };       }),     );      const users: User[] = await transport.publish<GetByIds, User[]>(UsersMethods.getByIds, {       ids: devices.map(device => device.id),     });      return connectedDevices.map(device => {       const user = users.find(user => user.id === device.userId);       return {         ...device,         user,       };     });   } catch (error) {     console.error(error);     (this as any).createError(error);   } }

В index файле devices нам осталось создать экземпляр класса Transport и подписаться на тему.

export const transport = new Transport();  (async () => {   try {     await transport.connect();      transport.subscribe(DevicesMethods.getByRegion, getByRegion);   } catch (error) {     console.error(error);     process.exit(1);   } })();

Теперь, запустив пример и сделав запрос на наш единственный endpoint, можно ощутить значительную задержку, прежде чем json с ответом появится на экране. Для того, чтобы разобраться в чем причина, начнем собирать трейсы между сервисами, а для этого нам потребуется класс Tracer. В качестве реализации OpenTracing будем использовать библиотеку jaeger-client.

import { JaegerTracer, initTracer } from 'jaeger-client';  export class Tracer {   private _client: JaegerTracer;   constructor(private serviceName: string) {     this._client = initTracer(       {         serviceName,         reporter: {           agentHost: process.env.JAEGER_AGENT_HOST || 'localhost',           agentPort: parseInt(process.env.JAEGER_AGENT_PORT || '6832'),         },         sampler: {           type: 'const',           param: 1,         },       },       {},     );   }   get client() {     return this._client;   } }

Здесь мы создаём объект трассировщика с настройками адреса коллектора Jaeger, куда он будет отсылать завершенные интервалы. Для тестов будем использовать тип сэмплирования const. Чтобы не менять наш код транспорта и не добавлять логику по созданию span’ов в методах publish и subscribe, напишем два декоратора для этих методов. Единственное, что потребуется поменять в классе Transport, это добавить конструктор, который будет принимать необязательный параметр в виде объекта Tracer.

  constructor(private tracer?: Tracer) {}

Сами декораторы рассмотрим подробнее. Декоратор subscribePerfomance для метода subscribe

export function subscribePerfomance(target: any, propertyKey: string, descriptor: PropertyDescriptor) {   const origin = descriptor.value;   descriptor.value = async function() {     if (this.tracer) {       const { client } = this.tracer as Tracer;       const subject: string = arguments[0];       const handler: Handler = arguments[1];       const wrapperHandler = async (msg: Msg) => {         const childOf = client.extract(FORMAT_TEXT_MAP, msg[CARRIER]); // 1         if (childOf) {           const span = client.startSpan(subject, { childOf }); // 2           this[CONTEXT] = span; // 3           try {             const result = await handler.apply(this, [msg]); // 4             span.finish(); // 5             return result;           } catch (error) {             span.setTag(Tags.ERROR, true); // 6             span.log({               'error.kind': error,              });             span.finish();             throw error;           }         } else {           return handler(msg);         }       };       return origin.apply(this, [subject, wrapperHandler]);     }     return origin.apply(this, arguments);   }; }

  1. Через объект Tracer методом extract пытаемся извлечь из пришедшего в сообщении объекта carrier контекст. Если этого контекста нет, то мы просто возвращаем обратно результат вызова оригинального обработчика.
  2. Создаём новый объект span и при создании указываем ему как родительский полученный SpanContext
  3. Записываем полученный span в специальную переменную в this. Он нам понадобится, если из этого обработчика будут вызываться другие методы.
  4. Выполняем оригинальный обработчик
  5. Завершаем наш span.
  6. В случае, если оригинальная функция вернула ошибку, записываем в span тег ошибки, а также саму ошибку и завершаем span

    Декоратор publishPerfomance для метода publish

    export function publishPerfomance(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const origin = descriptor.value; let isNewSpan = false; descriptor.value = async function() { if (this.tracer) {   const { client } = this.tracer as Tracer;   const subject: string = arguments[0];   let data: Msg = arguments[1];   let context: Span | SpanContext | null = this[CONTEXT] || null; // 1   if (!context) {     context = client.startSpan(subject); // 2     isNewSpan = true;   }    const carrier = {};   client.inject(context, FORMAT_TEXT_MAP, carrier); // 3   data[CARRIER] = carrier; // 4   try {     const result = await origin.apply(this, [subject, data]);     if (isNewSpan) {       (context as Span).finish();     }     return result;   } catch (error) {     if (isNewSpan) {       const span = context as Span;       span.setTag(Tags.ERROR, true);       span.log({         'error.kind': error,       });       span.finish();     }     throw error;   } } return origin.apply(this, arguments); }; }

  7. Извлекаем из this контекст. Это нужно для того, чтобы получить родительский контекст в случае, если мы делаем publish из обработчика, который является так же и подписчиком. Как раз наш случай. Когда api делает запрос в devices, у микросервиса устройств уже есть родительский контекст, но он должен вызвать метод микросервиса users.
  8. Если у нас нет родительского контекста, мы создаём новый span.
  9. Создаём объект carrier для передачи его вместе с объектом запроса. Если context окажется пустым, трейсер создаст новый. Сгенерирует новый traceId.
  10. Модифицируем запрос, добавляя в него созданный объект carrier.

Осталось только задекорировать методы в классе Transport, перезапустить сервис и сделать запрос. Далее можно открыть интерфейс Jaeger и найти трейс, соответствующий нашему запросу. Он должен выглядеть так.

Мы видим, что основную часть времени выполнения запроса занимает метод микросервиса устройств getByRegion. А точнее 97.22% от времени выполнения всего запроса. Значит, проблема в этом методе. Более того, на таймлайне видно, что перед тем, как вызвать метод микросервиса users, был большой интервал во времени, когда мы обращаемся к БД. Но в какой базе именно проблема? Это можно узнать только собрав span’ы c методов репозиториев. Для этого напишем декоратор.

export function repositoryPerfomance({ client }: Tracer) {   return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {     const original = descriptor.value;     descriptor.value = async function() {       if (this.parent[CONTEXT]) { // 1         const span = client.startSpan(propertyKey, {            childOf: this.parent[CONTEXT], // 2         });         span.setTag(Tags.DB_TYPE, this.db); // 3         try {           const result = await original.apply(this, arguments);           span.finish();           return result;         } catch (error) {           span.setTag(Tags.ERROR, true);           span.log({             'error.kind': error,           });           span.finish();           throw error;         }       } else {         return original.apply(this, arguments);       }     };   }; }

  1. Здесь мы проверяем, выполняется ли вызов метода в рамках другого span’a. Если родительского span’а нет, выполняем оригинальный метод и возвращаем результат.
  2. Создаём новый дочерний span.
  3. Присваиваем тег с названием базы, с которой работает репозиторий. В спецификации OpenTracing есть и другие полезные теги. Можно добавить тег с ip базы или текстом запроса.

Задекорировав методы репозиториев в Jaegere, трейс будет выглядеть следующим образом.

Теперь мы видим гораздо больше информации. И на графе и на таймлайне можно увидеть, в чем заключается проблема нашего приложения. 74.38% метода микросервиса devices занимает запрос к базе за списком устройств. Весь код, приведенный в качестве примера, можно посмотреть в репозитории на github.

В качестве транспорта мы использовали NATS, но из тестового приложения видно, что сам способ общения микросервисов не имеет особого значения для сбора трассировок. Модели данных и принципы работы с ними, описанные в спецификации OpenTracing, применимы и для других видов транспорта. Будь то запросы по http или асинхронные события через очереди. В больших распределенных приложениях крайне важно, при сбоях, быстро найти источник проблем. Ведь каждая минута простоя — это недовольный клиент, а значит, потеря денег. Быстро найти проблему, скорее всего, будет самой сложной задачей, которую можно решить быстрее и легче, обладая дополнительной информацией из собранных трассировок. Также, взглянув на граф трассировки, можно легко понять, какие микросервисы участвуют в работе метода. Если сравнить схему, которая была нарисована при проектировании и итоговый граф из Jaeger, можно увидеть, что между ними почти нет разницы.

Сбор и анализ трассировок из распределенного приложения похож на проведение МРТ с контрастом в медицине, с помощью которого можно не только решить текущие проблемы, но и выявить серьезное заболевание на ранней стадии.

ссылка на оригинал статьи https://habr.com/ru/post/489552/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *