Привет, Хабр! На связи команда Seller API, а именно её тимлид Саша Валов и старший разработчик Никита Денисенко. В этой статье мы разберём одну из проблем большого API и расскажем, как мы её решили.
Вступление
Seller API — это продукт, предоставляющий программный интерфейс для работы с маркетплейсом Ozon. Он позволяет системам продавца и Ozon обмениваться информацией.
Seller API насчитывает более 200 методов. Эти методы удовлетворяют множество бизнес-потребностей и предоставляют доступ к широкому спектру сервисов Ozon. В основном методы API проксируют запросы к этим сервисам.
Проблема
Нашей команде необходимо оперативно давать ответы на вопросы а-ля «В какие сервисы мы, разработчики команды Seller API, ходим под капотом этой API-ручки?» или, наоборот, «В контексте каких API-ручек мы ходим в конкретный сервис или даже метод сервиса?». Эти вопросы с завидной регулярностью поступают от ребят из технической поддержки, которым необходимо расследовать неполадку, и ребят из продукта, которые систематизируют требования к новым функциональностям или к рефакторингу старых.
Чтобы ответить на любой из этих вопросов, нам чаще всего приходится смотреть код. Дежурный разработчик сначала идёт в proto-файлы, описывающие контракт API (да, мы в Ozon описываем и внешние, и внутренние API с помощью proto-файлов). Погружаясь глубже вплоть до кода клиентов нижележащих сервисов, он находит нужные методы, в которые мы проксируем запросы. И, наоборот, из кода клиента необходимого сервиса, поднимаясь по слоям, разработчик понимает, где и как используется метод нижележащего сервиса.
Да, у нас есть трейсинг (мы используем Jaeger), который может помочь в поиске ответов, но этот процесс тоже превращается в небольшое исследование, поскольку каждый конкретный трейс не даёт общей картины, а связан с одним методом API или даже отдельным кейсом в рамках метода.
Документация у нас тоже есть, но её ведение и организация актуальной навигации по ней, учитывая объём часто меняющихся взаимодействий, — задача нетривиальная и трудоёмкая. Нам нужны автоматические механизмы.
Решить проблему можно сбором метрик. В Ozon огромное количество различных метрик и решений, связанных с observability. У нас есть метрики нашего сервиса и сервисов, в которые ходит наш API, — но нет метрик, связывающих их воедино. Мы не видим общей картины.
Решение
Мы поняли, что нам необходимо придумать своё решение. Что у нас для этого есть? Первое, что приходит в голову, — это Prometheus. Можно завести новую метрику, например типа counter (счетчик) с лейблами: api_method, target_service, target_service_method, которая будет хранить факт вызова нижележащего сервиса за последние N секунд. После экспорта данной метрики можно будет доставать данные с помощью PromQL и отрисовывать их в Grafana. Звучит неплохо!
Но получалось, что таким образом мы начали бы плодить новые метрики, которых и так много.
Ну что ж, мы пошли думать дальше. А что, если те же лейблы превратить в поля таблицы? У нашего сервиса есть своя база данных Postgres, где мы сами решаем, что и как хранить. Звучит приемлемо.
Приступим к решению. Для начала нам нужно создать таблицу:
create table endpoint_map(
id bigint generated by default as identity primary key,
api_method text not null,
target_service text not null,
target_service_method text not null,
created_at timestamp with time zone default CURRENT_TIMESTAMP not null
);
Прежде чем что-то писать в таблицу, необходимо определить, как мы будем понимать, какой метод API обращается к какому методу сервиса. Как это можно сделать?
В Ozon для внешних API, доступных по HTTP, мы используем gRPC-Gateway. Если кратко, это плагин для gRPC, позволяющий описывать gRPC и RESTful API в одном proto-файле. Документация: https://grpc-ecosystem.github.io/grpc-gateway/.
Из proto-файла с помощью плагина gRPC-Gateway генерируется код, в котором описываются HTTP-хендлеры. Из того же файла генерируется код gRPC-сервера. gRPC-Gateway выступает в роли reverse proxy (обратного прокси) и преобразует HTTP-трафик в gRPC-трафик.
Какой путь проходит запрос к публичному API?
Упрощённо его можно представить так:
Запрос от API-клиента приходит в gRPC-Gateway.
gRPC-Gateway преобразует HTTP-запрос в gRPC-запрос.
Прежде чем запрос отправится дальше, он попадает в middleware (промежуточное ПО), которое может выполнить необходимые действия с ним, такие как логирование, проверка токена или, как в нашем случае, обогащение контекста запроса нужным именем метода API.
Затем запрос отправляется в сервисный слой — ту логику, которая непосредственно его обрабатывает.
После обработки запроса действия выполняются в обратном порядке.
На стороне сервисного слоя, где располагается логика обработки запроса, могут находиться gRPC-клиенты конечных сервисов, в которые Seller API проксирует запросы данных. В каждый такой клиент можно добавить middleware (в парадигме gRPC их называют interceptors), которое, зная об исходном контексте запроса, может достать имя нужного нам метода API и выполнить с ним необходимые действия.
Попробуем закодить вышеописанное.
HTTP middleware
func NewHTTPMiddleware(ctx context.Context) func(handler http.Handler) http.Handler {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// ...
// положим в контекст имя метода API
apiMethod = req.Method + " " + req.RequestURI
req = req.WithContext(context_helper.HTTPEndpointToContext(req.Context(), apiMethod))
// ...
}
}
}
gRPC client middleware
func NewEndpointMapUnaryInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
apiMethod := utcontext_helperils.HTTPEndpointFromContext(ctx)
targetService := cc.Target()
// insert(ctx, apiMethod, targetService, method)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
Очевидно, что нам не нужно записывать в базу каждый запрос, прилетевший в API. В middleware gRPC-клиента есть кеш, в который мы кладём отметку о том, что связка apiMethod + targetService + targetMethod уже была добавлена в таблицу. Нам нужно знать о факте вызова запросов в рамках одного дня, а не их количества.
Дело за малым — отобразить всё это в Grafana. Для этого добавляем (если ещё не добавлен) новый источник данных в виде нашей БД. Разумеется, с правами только на чтение. Создаём новый график и, выбрав источник данных, пишем запрос:
select api_method "ручка Seller API",
target_service "нижележащий сервис",
target_method "ручка нижележащего сервиса"
from endpoint_map
where to_char(created_at, 'yyyy-MM-DD') = '$date'
group by api_method, target_service, target_method
Здесь $date — это переменная, которую можно указать в разделе Variables в настройках дашборда. Сделано это для удобства, чтобы можно было выбрать дату из выпадающего списка и увидеть все вызовы API в этот день.
Получилась такая красота:
Первые два столбца — активные. То есть, нажав на какое-то значение, мы попадём на страницу либо с сервисом, либо со Swagger-документацией.
Выводы
Таким недорогим и не требующим большого количества ресурсов способом мы значительно упростили жизнь как минимум трём командам: разработки, продукта и техподдержки.
Мы создали единую точку входа, где собраны ответы на вопросы, упомянутые в начале статьи. Теперь каждый сотрудник может самостоятельно зайти в Grafana и увидеть таблицу с нужными данными — без обращения к нам или к коду. Это решение уменьшило количество запросов от техподдержки и аналитиков к команде разработки и ускорило процесс поиска и устранения проблем для разработчиков.
Комментарии (8)
Yohohori-san
25.12.2024 10:27что мешало добавить сквозной request-id в логи каждого сервиса и смотреть по логам цепочки вызовов?
aleksandrvalov
25.12.2024 10:27У нас есть трейсинг, цепочки вызовов мы можем смотреть. Однако это не позволяет легко ответить на вопрос, например, «В контексте каких API-ручек мы ходим в конкретный сервис или даже метод сервиса?». Да, можно провести нехитрое исследование и все выяснить, трейсы есть, логи есть. Процесс «выяснения» мы и автоматизировали в этом кейсе.
olku
Чем не подошло OpenTelemetry, где это делается автоматически от HTTP до запроса к базе?
aleksandrvalov
Трейсинг запросов у нас есть. В нашем кейсе мы агрегируем данные для наглядности
olku
OpenTelemetry это не только трассировка, но и агрегирование и визуализации графа связей. Автоинструментирование включается тремя строчками. Правильно ли я понял, что альтернативы собственному решению не были оценены?
aleksandrvalov
В этом смысле оценку проводили. В нашем случае, самой адекватной альтернативой «изобретению своего велосипеда» является использование платформенных решений Озона. И мы обязательно так и сделаем, когда оно появится. Конкретно OpenTelemetry внедрить только в наш сервис, поверх или рядом с платформенными решениями - это более трудоемкая задача, а где-то и избыточная, чем написать небольшую свою мидлварю для решения конкретной проблемы.
А так, то что OpenTelemetry крутой инструмент и лучше пользоваться им, чем что-то изобретать - с этим абсолютно согласен
aleksandrvalov
граф связей, кстати говоря, в нашем распоряжении тоже есть, но он тоже не в полной мере дает наглядность в разрезе API методов
olku
Спасибо за ответы. Граф статистический и в OpenTelemetry, нужно сбрасывать после релиза, неиспользуемые связи не видны.