Как стать автором
Обновить
1683.18
МТС
Про жизнь и развитие в IT

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

Время на прочтение12 мин
Количество просмотров16K

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

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

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

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

Архитектура API-шлюза. Источник: www.crmsoftwareblog.com
Архитектура API-шлюза. Источник: www.crmsoftwareblog.com

Задумываться об 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-шлюзов. Источник: www.krakend.io
Сравнение производительности API-шлюзов. Источник: www.krakend.io

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

Теги:
Хабы:
Всего голосов 19: ↑18 и ↓1+19
Комментарии10

Публикации

Информация

Сайт
www.mts.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия