Привет, Хабр! Меня зовут Владимир Калугин, я руковожу бэкенд-разработкой в МТС 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 минут вы провели с пользой. Очень ждем ваши вопросы и мнения в комментариях!