Привет, Хабр! Меня зовут Владимир Калугин, я руковожу бэкенд-разработкой в МТС Travel.
Этот сервис появился у МТС в прошлом году, сейчас у нас можно забронировать отели по всей России, а также за рубежом. В базе уже более 16000 предложений различных отелей, хостелов и апартаментов.
Сегодня расскажу про KrakenD — готовое решение, которое мы используем для реализации API-шлюза, важной штуки для продуктов с микросервисной архитектурой. Уверен, что наш опыт может пригодиться разработчикам из других сервисов.

Зачем нужен API-шлюз?

Задумываться об API-шлюзе нужно, если у вас микросервисная архитектура, а для взаимодействия между фронтом и бэком используется API. Почему именно в этой ситуации? Частично ответ нам дают самые популярные функции API-шлюзов:
-
реализация единой точки входа для клиентов API;
-
сокрытие топологии микросервисов;
-
rate limiting;
-
мониторинг;
-
логирование;
-
авторизация.
Если у вас микросервисная архитектура и общение с фронтом происходит через API, то разные микросервисы будут предоставлять разные «кусочки» данных и бизнес-логики. Для отображения страницы фронт часто «ходит» в разные микросервисы, чтобы собрать эти самые кусочки воедино и отобразить их на странице для клиента. То есть ваша API размазана по нескольким микросервисам. Именно в этой ситуации вы, скорее всего, захотите, чтобы было единое место доступа к этим данным, заодно скрывающее устройство микросервисов от фронта. Эту задачу и решает API-шлюз.
Единая точка доступа позволяет нам строго контролировать доступ к нашему API (привет, rate limiting, мониторинг, логирование и так далее), а сокрытие топологии позволяет нам реорганизовывать наши микросервисы, не затрагивая фронтенд-часть приложения, как это часто бывает в мире микросервисной разработки.
У кого щупальца длиннее?
Теперь поговорим, почему именно KrakenD и сравним его с конкурентами. Мы выбрали KrakenD для МТС Travel из кандидатов среди аналогов — Tyk и Kong по следующим причинам:
-
KrakenD написан на Go, а на Golang базируются многие сервисы МТС, Travel — не исключение. Так что у нас большая команда разработчиков для написания и кастомизации продукта, а это важно, в чем мы убедимся дальше;
-
KrakenD — быстрый, он быстрее аналогичного решение на Kong или Tyk;.
-
KrakenD расширяемый и открытый.
Давайте теперь чуть подробнее поговорим о каждой причине. Начнем со стека. KrakenD написан на Go, у него доступны исходники (в Community version), плагины к нему тоже пишутся на Go. Для нас это открывает безграничные возможности по расширению функционала. Tyk тоже написан на Go и плагины аналогично можно писать на нем, а вот Kong нет. Kong — это Lua‑приложение, которое крутится на nginx. И основной инструмент его расширения поэтому — Lua плагины. Хоть и есть варианты написания плагинов на Go, JS, Python, но у них есть особенности имплементации, которые приводят к потере производительности. Так что Kong мы перестали рассматривать.
Если говорить про производительность в сравнении с конкурентами, то в наших тестах KrakenD оказался быстрее конкурентов, что в целом коррелирует с картиной, которую приводят сами разработчики KrakenD на своем сайте (кстати там же есть ссылки на benchmark, можно запускать самим и проверять):

И, наконец, про расширяемость и открытость. KrakenD имеет две версии, Enterprise и Community Version. Первая — платная и с закрытыми исходниками, другая — полностью открытая, хоть и без некоторых фишек (о них поговорим далее). Плагины пишутся исключительно на Go и реализованы через нативный механизм Go Plugin: вы пишите пакетик на Go, а потом компилируете его в .so файл библиотеки. У Tyk похожая история, он тоже написан на Go и плагины можно писать на Go и не только на нем.
В итоге наш выбор пал на KrakenD.
Опыт использования и сложности
Первое, что мы сделали, — форкнули к себе community-версию KrakenD отсюда. Из-за этого мы не могли воспользоваться уже готовым docker-образом, поэтому нам нужно было его собрать самим. На практике это оказалось очень легко. Dockerfile и Makefile уже есть «из коробки», поэтому вся задача свелась к прописыванию наших проксей внутри Dockerfile и настройки нашей CI/CD для запуска Makefile, когда мы добавляем изменения в репозиторий.
Образ получили, дальше нужно запустить KrakenD (Release the Kraken!) .Наш зверь легко запускается, фактически нужно выполнить команду krakend run -c krakend.json и указать конфигурационный файл. Ох, нужно ведь читать документацию, как правильно заполнить этот конфигурационный файл! А нет, подождите, для этого есть прекрасный графический инструмент, где всю конфигурацию можно сделать через UI-интерфейс. Это очень удобно, особенно в самом начале, когда нужно получить базовую конфигурацию.

Итак, шлюз запустили. Самое время поговорить про проблемы, с которыми мы столкнулись в первое время использования. У нас для метрик используется Prometheus, для экспорта метрик из KrakenD достаточно прописать в конфигурационном файле согласно документации (кстати, она у проекта очень хорошая) следующее:

А вот с логами возникла маленькая неприятность. Нам нужен json-формат для сбора логов, чтобы искать по ним, используя Kibana. И вроде бы KrakenD даже позволяет это сделать, но нет. Системные логи будут писаться в json-формате, а вот access-логи, что нам гораздо важнее, так и будут писаться plain-текстом. Обидно, но вспоминаем, что мы не просто так выбрали расширяемое решение с открытом кодом. У нас тут два пути:
-
Вырубить стандартные access-логи и написать простой плагин – по факту middleware, который будет писать эти access-логи;
-
Сделать изменения в исходном коде.
Мы выбрали первый вариант, так как не хотели без лишней нужды трогать исходники проекта. Потом будет проще обновляться.
Окей, с логами и метриками разобрались, теперь все хорошо? Не совсем, остались еще две проблемы.
Первая — то, как по умолчанию KrakenD работает с ошибками, которые возвращают ваши микросервисы (в терминологии KrakenD они называются просто backends). В ситуации, когда KrakenD мерджит ответы от нескольких backends в рамках одного endpoint, если часть backends вернули ошибки, — они будут проигнорированы. KrakenD просто смерджит то, что успешно пришло, и пометит ответ как частичный, так как не все backends ответили.
Если же все backends вернули ошибку (особенно актуально, если у вас для endpoint указан всего один backend), то KrakenD всегда вернет 500 http-статус код. При этом у вас может быть потребность вернуть клиенту ошибку, если даже один бекенд не ответил. А в ситуации с одним backend, наверняка, захочется пробрасывать ошибку от backend клиенту, а не возвращать 500 статус код в ситуации, где backend возвращает 404, например.
Почему KrakenD так делает? Потому что он рассматривает как основной сценарий ситуацию, когда у вас для endpoint указано несколько backendы. И KrakenD не знает, какой код вернуть, если условно один сервис ответил статусом 200, другой — 404, а третий — 500. И Кракен выбирает просто вернуть частичный ответ составленный из ответа только первого backend со статусом 200. «Частичный ответ же лучше чем ничего?», – как бы говорит нам KrakenD.

Окей, с причинами такого поведения разобрались, а что с этим делать? У нас не все endpoint обращаются к нескольким backends. В каких‑то случаях — только к одному. И вообще, мы тут REST-взаимодействие с фронтоном строим, ошибка 500 — это явно не то, чего ожидает фронт, если, условно, просто не найден отель. Посмотрим что предлагает сам KrakenD в документации:
-
Использовать no-op encoding. В этом режиме KrakenD работает просто как прокси, то есть передает запрос, как есть, и возвращает результат, как есть. Фактически почти все функции нашего шлюза работать в этом режиме не будут (кроме роутинга, авторизации, rate limit), поэтому этот вариант нам не подходит.
-
Встроить ошибки от backend в результирующий ответ под произвольным ключом. Может быть полезно, если вы хотите с ними произвести какие-то манипуляции, но надо понимать что в этом случае ответ для клиента все еще будет 200 OK. Нам, соответственно, этот вариант тоже не подходит.
-
Если для endpoint указан только один backend, то можно пробросить код ошибки до клиента без тела ошибки, пробросить, наоборот, только тело, без кода ошибки, или сразу и то, и другое.
Кажется последний пункт решает нашу проблему? И да, и нет. Во-первых, решение работает только для одного backend. Если у вас для endpoint указано несколько backends и вы захотите возвращать ошибку клиенту и указывать, что запрос не выполнен (потому что, например, частичный ответ не имеет смысла), то увы, тут KrakendD ничего иного не предложит (кроме Lua-скриптинга =)). Во-вторых даже для одного endpoint тело ошибки возвращается всегда как text/plain, а не json, например. Что не критично, но может вызвать проблемы, если ваш клиент ожидает увидеть корректный Content-Type заголовок. Поэтому нам пришлось написать свой плагин, чтобы устранить недостатки.
Последняя проблема с которой мы столкнулись: «из коробки» KrakenD всегда следует редиректам, которые приходят ему от backend. Если вы хотите, чтобы вместо этого редирект вернулся на клиент и именно клиент проходил по редиректу, то «Ведьмаку заплатите чеканной монетой». Такая опция есть только в Enterprise-версии. Но не беда, можно, опять же, написать плагин и пользоваться преимуществами Open source-решения. И самое главное, сейчас мы с вами вместе и напишем такой плагин!
Бонус-трек: пишем простой плагин для KrakenD
Итак, нам нужно повторить функционал Enterprise-версии, то есть написать плагин. В KrakenD четыре типа плагинов (в зависимости от места, в которое они встраиваются во время обработки запроса от клиента, так называемый pipeline):
-
HTTP server plugins. Эти плагины принадлежат уровню роутинга. Фактически как только запрос попадает в KrakenD, сразу запускаются эти плагины. Можно делать, что захотите.
-
HTTP client plugins. Принадлежат proxy-уровню. Позволяют изменить, как KrakenD взаимодействует с конкретным backend’ом. Этот тип плагина не позволит вам изменить процесс агрегации ответа от нескольких backends, так как скоуп плагина, повторюсь, — только взаимодействие с одним backend’ом.
-
Response Modifier plugins. Этот тип плагинов позволяют изменить только ответ от backends, ничего больше. Считаются легковеснее других.
-
Request Modifier plugins. Этот тип плагинов позволяет изменить только запрос перед его отправкой к backends, ничего больше. Также считаются легковеснее других.

Какой же тип плагина нам подходит? Вы, наверное, уже могли догадаться, в данном случае даже не надо ломать голову, — можно просто посмотреть, к какому типу плагина относится функционал no-redirect в Enterprise-версии (да-да, почти все функции KrakenD реализованы как плагины, хоть и встроенные):

Открываем документацию для нашего типа плагина и копируем оттуда пример как есть:
// SPDX-License-Identifier: Apache-2.0 package main import ( "context" "encoding/json" "errors" "fmt" "html" "io" "net/http" ) // ClientRegisterer is the symbol the plugin loader will try to load. It must implement the RegisterClient interface var ClientRegisterer = registerer("krakend-client-example") type registerer string var logger Logger = nil func (registerer) RegisterLogger(v interface{}) { l, ok := v.(Logger) if !ok { return } logger = l logger.Debug(fmt.Sprintf("[PLUGIN: %s] Logger loaded", ClientRegisterer)) } func (r registerer) RegisterClients(f func( name string, handler func(context.Context, map[string]interface{}) (http.Handler, error), )) { f(string(r), r.registerClients) } func (r registerer) registerClients(_ context.Context, extra map[string]interface{}) (http.Handler, error) { // check the passed configuration and initialize the plugin name, ok := extra["name"].(string) if !ok { return nil, errors.New("wrong config") } if name != string(r) { return nil, fmt.Errorf("unknown register %s", name) } // check the cfg. If the modifier requires some configuration, // it should be under the name of the plugin. E.g.: /* "extra_config":{ "plugin/http-client":{ "name":"krakend-client-example", "krakend-client-example":{ "path": "/some-path" } } } */ // The config variable contains all the keys you hace defined in the configuration: config, _ := extra["krakend-client-example"].(map[string]interface{}) // The plugin will look for this path: path, _ := config["path"].(string) logger.Debug(fmt.Sprintf("The plugin is now hijacking the path %s", path)) // return the actual handler wrapping or your custom logic so it can be used as a replacement for the default http handler return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // The path matches, it has to be hijacked and no call to the backend happens. // The path is the the call to the backend, not the original request by the user. if req.URL.Path == path { w.Header().Add("Content-Type", "application/json") // Return a custom JSON object: res := map[string]string{"message": html.EscapeString(req.URL.Path)} b, _ := json.Marshal(res) w.Write(b) logger.Debug("request:", html.EscapeString(req.URL.Path)) return } // If the requested path is not what we defined, continue. resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Copy headers, status codes, and body from the backend to the response writer for k, hs := range resp.Header { for _, h := range hs { w.Header().Add(k, h) } } w.WriteHeader(resp.StatusCode) if resp.Body == nil { return } io.Copy(w, resp.Body) resp.Body.Close() }), nil } func main() {} type Logger interface { Debug(v ...interface{}) Info(v ...interface{}) Warning(v ...interface{}) Error(v ...interface{}) Critical(v ...interface{}) Fatal(v ...interface{}) }
Пример хорошо документирован, поэтому в целом должно быть понятно, что здесь происходит. А если нет, то в документации к этому примеру даны дополнительные комментарии. Поэтому пробежимся по примеру кратко: создается тип registerer (строка по факту), с методами RegisterLogger, RegisterClients, RegisterClients. Значение Registerer — это название нашего плагина, в данном случае «krakend-client-example». Этот тип присваивается глобальной переменной ClientRegisterer, именно ее будет искать KrakenD, чтобы загрузить плагин данного типа. При загрузке плагина будет вызван метод RegisterLogger для проброса логгера и метод RegisterClients, который зарегистрирует метод registerClients как фабрику для получения обработчика http-запроса, когда потребуется обработать запрос со стороны клиента. Вся логика плагина в нем и заключена.
Логгер нам для нашего плагина не потребуется, поэтому удалим метод регистрации логгера. Далее внутри RegisterClients в нашем примере идет получение конфигурации плагина, оттуда достается настройка для какого url не нужно делать запрос к backend, а просто вывести заглушку. Для остальных url запрос уходит на backend и ответ от него копируется в ResponseWriter вместе с заголовками и статусом ответа. Отлично, последняя часть нам как раз и нужна, а вот получение конфигурации для плагина и вывод заглушки для некоторых url нам не нужно. Удаляем эти куски, в итоге наш пример после удаления лишнего выглядит сейчас вот так:
package main import ( "context" "io" "net/http" ) // ClientRegisterer is the symbol the plugin loader will try to load. It must implement the RegisterClient interface var ClientRegisterer = registerer("krakend-client-example") type registerer string func (r registerer) RegisterClients(f func( name string, handler func(context.Context, map[string]interface{}) (http.Handler, error), )) { f(string(r), r.registerClients) } func (r registerer) registerClients(_ context.Context, extra map[string]interface{}) (http.Handler, error) { // return the actual handler wrapping or your custom logic so it can be used as a replacement for the default http handler return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // If the requested path is not what we defined, continue. resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Copy headers, status codes, and body from the backend to the response writer for k, hs := range resp.Header { for _, h := range hs { w.Header().Add(k, h) } } w.WriteHeader(resp.StatusCode) if resp.Body == nil { return } io.Copy(w, resp.Body) resp.Body.Close() }), nil } func main() {}
Осталось немного — заставить возвращать редирект на клиент, а не проходить по нему за клиента. Причина, по которой KrakenD так делает, на самом деле в том, что шлюз для своих запросов использует http-клиент по-умолчанию из http-пакета, прямо как в нашем текущем примере. А http-клиент по-умолчанию следует редиректам. Благо легко изменить это поведение, переопределив функцию CheckRedirect в http-клиенте после его создания вот так:
client := http.DefaultClient client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse
Внесем эти изменения в наш пример:
package main import ( "context" "io" "net/http" ) // ClientRegisterer is the symbol the plugin loader will try to load. It must implement the RegisterClient interface var ClientRegisterer = registerer("krakend-client-example") type registerer string func (r registerer) RegisterClients(f func( name string, handler func(context.Context, map[string]interface{}) (http.Handler, error), )) { f(string(r), r.registerClients) } func (r registerer) registerClients(_ context.Context, extra map[string]interface{}) (http.Handler, error) { // return the actual handler wrapping or your custom logic so it can be used as a replacement for the default http handler return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { client := http.DefaultClient client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } resp, err := client.Do(req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Copy headers, status codes, and body from the backend to the response writer for k, hs := range resp.Header { for _, h := range hs { w.Header().Add(k, h) } } w.WriteHeader(resp.StatusCode) if resp.Body == nil { return } io.Copy(w, resp.Body) resp.Body.Close() }), nil } func main() {}
Вот и все! Наш плагин готов, осталось его собрать и подключить к KrakenD. Собираем плагин такой командой: go build -buildmode=plugin -o my-plugin.so. На выходе получаем .so файл c нашим плагином. Стоит также отметить, что плагин нужно собирать с такой же версией Go, версиями сторонних библиотек и архитектурой, как и сам KrakenD, иначе подключить плагин не удастся.
Для загрузки нашего плагина добавляем в конфигурацию шлюза в файл krakend.json следующие строчки, где /opt/krakend/plugins — путь, где KrakenD будет искать наш.so файл.
И осталось включить наш плагин для backend:
{ "endpoint": "/api/redirect", "method": "GET", "output_encoding": "no-op", "backend": [ { "url_pattern": "/redirect", "encoding": "no-op", "method": "GET", "host": [ "http://localhost:8080" ], "extra_config": { "plugin/http-client": { "name": "krakend-client-example" } } } ] },
Не забудьте также указать encoding как no-op, чтобы KrakenD не превратил наш статус 302 Found в 500 ошибку, как он делает в обычном режиме, если код ответа от backend не 200.
Теперь при обращении на ручку /api/redirect мы получим редирект «как есть» на клиенте. KrakenD больше не будет делать редиректы за нас.
Спустя четыре месяца работы МТС Travel с KrakenD мы убедились в высокой надежности API-шлюза. Главным преимуществом решения для нас стали открытость исходного кода и механизм плагинов – в ближайшее время мы планируем написать как минимум еще один плагин, для объединения OpenAPI спецификаций backends.
Спасибо, что дочитали наш лонгрид, надеюсь, эти 15 минут вы провели с пользой. Очень ждем ваши вопросы и мнения в комментариях!
ссылка на оригинал статьи https://habr.com/ru/company/ru_mts/blog/716512/
Добавить комментарий