Выпускайте Кракена: опыт использования KrakenD

от автора

Привет, Хабр! Меня зовут Владимир Калугин, я руковожу бэкенд-разработкой в МТС Travel.

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

Сегодня расскажу про KrakenD — готовое решение, которое мы используем для реализации API-шлюза, важной штуки для продуктов с микросервисной архитектурой. Уверен, что наш опыт может пригодиться разработчикам из других сервисов.

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

Архитектура 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, можно запускать самим и проверять):

Сравнение производительности API-шлюзов
Сравнение производительности API-шлюзов

И, наконец, про расширяемость и открытость. 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-текстом. Обидно, но вспоминаем, что мы не просто так выбрали расширяемое решение с открытом кодом. У нас тут два пути:

  1. Вырубить стандартные access-логи и написать простой плагин – по факту middleware, который будет писать эти access-логи;

  2. Сделать изменения в исходном коде.

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

Окей, с логами и метриками разобрались, теперь все хорошо? Не совсем, остались еще две проблемы.

Первая — то, как по умолчанию 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.

Схема объединения ответов от нескольких backend
Схема объединения ответов от нескольких backend

Окей, с причинами такого поведения разобрались, а что с этим делать? У нас не все 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, ничего больше. Также считаются легковеснее других.

Общий pipeline обработки запроса клиента
Общий pipeline обработки запроса клиента

Какой же тип плагина нам подходит? Вы, наверное, уже могли догадаться, в данном случае даже не надо ломать голову, — можно просто посмотреть, к какому типу плагина относится функционал 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/


Комментарии

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

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