📱

Get Our Mobile App

Take your business learning on the go!

Download on the App StoreGet it on Google Play

Микросервисы на пальцах. API-Gateway, API-Composition, BFF. Теория и практика на FastApi, KrakneD.

Максим Иглин | Backend25:01

Transcription

Привет! Количество современных приложений, построенных на микросервисной архитектуре, очень велико. Микросервисы стали для нас уже больше обыденностью, чем чем-то редким. Данная серия роликов про паттерны взаимодействия компонентов в микросервисной архитектуре. И сегодня у нас API Gateway, API Composition и BFF — три базовых шаблона, которые можно встретить почти на каждом проекте, использующем микросервисную архитектуру. Предлагаю пройтись по теории и быстренько накидать реальный пример на тестовой инфраструктуре, то есть поиграться.

Смоделируем проблему: у нас есть маркетплейс, на главной страничке которого расположено огромное количество компонентов: профиль пользователя, компонент со списком товаров, корзина, личные скидки и персональные предложения, а также каталог со списком категорий, фильтрами и так далее. Наш маркетплейс построен на микросервисной архитектуре. А это нам говорит о том, что за поставку и обработку данных отвечает конкретный сервис. Примерно таким образом: сервис профиля клиента, сервис каталога, сервис корзин, сервис Дискаунт — изолированную по бизнес-домену зону ответственности и поставляет или обрабатывает ресурсы, за которые отвечает, предоставляя клиенту внешние API.

Какие у нас есть проблемы? Первое: для отображения одной странички клиенту необходимо получить данные сразу из нескольких микросервисов. То есть информацию по профилю пользователя, списка товаров из каталога, персональные скидки из сервиса Дискаунт и корзину пользователя из сервиса корзины. Чтобы показать добавленные товары без дополнительной обработки, клиент вынужден отправить четыре отдельных запроса по разным адресам, дождаться ответа, а затем объединить данные и раскидать их по компонентам. Очень часто бывает, что в рамках одного компонента нам нужны данные сразу из нескольких сервисов, и в реальных больших приложениях запросов могут быть десятки. Если мы говорим про мобильную сеть, то количество запросов значительно уменьшает загрузку экрана и несёт накладные расходы на установку соединения, сериализации, десериализации данных, а также повторные запросы в случае неудачи.

Вторая проблема: клиенты имеют разные потребности в данных. То есть, например, мобильные устройства имеют ограничения экрана, соответственно, могут отобразить меньший объём информации, а также нам необходимо позаботиться о пропускной способности, то есть передавать как можно меньше данных из-за нагрузок на сеть, например, только название товара и его цена без подробного описания. В свою очередь, десктоп-клиенты имеют сильно большие экраны и могут поместить сильно большее количество информации, такой как изображение, отзывы, рейтинги и прочее. Если API каждого из сервисов предоставляют слишком обширные данные или наоборот, недостаточное количество данных, то клиенту придётся выполнять дополнительную обработку. А тут всё просто: лишние данные увеличивают объём передаваемой информации в рамках одного запроса, то есть влекут за собой большие косты при нестабильном соединении, а нехватка данных, в свою очередь, приводит к дополнительным запросам. Таким образом, один и тот же API может быть неэффективным для разных типов клиентов. Мы могли бы на стороне конкретного сервиса, то есть прямо на клиенте, реализовать две версии API для каждого клиента. Однако данный процесс очень трудоёмкий и может повлечь за собой переписывание бизнес-логики, если наше приложение написано не оптимально.

Третье: динамическое расположение сервисов. Обычно микросервисы разворачиваются в облачной среде, где адреса сервисов могут динамически изменяться. Количество экземпляров может меняться в зависимости от нагрузки, причём в реальном времени. У нас могут подниматься дополнительные инстансы, а могут отключаться, и требуется дополнительная логика для обнаружения сервисов. А это точно не та задача, которую должен решать клиент. Нам не хватает единой фиксированной точки входа.

Для решения этих проблем нам поможет API Gateway, или API шлюз. Что он из себя представляет? Как правило, это ещё один сервис в нашей архитектуре. API Gateway может быть либо Open Source решение, их уже огромное количество, либо может быть полноценным самописным веб-сервисом. Добавим API Gateway в нашу схему и посмотрим, какой профит мы можем извлечь.

И первое — это единая точка входа во все сервисы. Клиенту теперь не нужно знать адрес каждого из сервисов. Ему достаточно постучаться в API Gateway, который перенаправит запрос в конкретный сервис.

Второе — это сокращение количества сетевых запросов. Выше мы сказали о проблеме, когда в рамках одного компонента клиенту необходимо получить данные из разных сервисов, объединить ответ и вывести это дело. Данную логику мы можем вынести на сторону API Gateway. Данный подход называется API Composition, то есть композиция API, где API шлюз выступает в роли API композера, выполняет какую-то дополнительную агрегацию наших ответов или просто-напросто мёржит их и отправляет ответ клиенту. Таким образом, мы сократим косты на количество сетевых запросов от клиента. Да, разумеется, запросы от API Gateway к сервисам остаются, но всё это дело работает в рамках текущей инфраструктуры, а это, как правило, сильно быстрее.

Третье: на стороне API Gateway мы можем реализовать BFF, тот самый Backend for Frontend. Мы говорили о проблеме, когда у нас встречаются разные типы клиентов, и разным типам клиентов необходим разный ответ от одного API. Данную логику мы можем вынести на API Gateway. Например, у нас есть API V1 users и API V2 users. Первая версия API используется для мобильных клиентов, вторая версия API используется для десктопных клиентов. При этом запрос от клиента попадает в API Gateway, а API Gateway перенаправляет запрос один и тот же сервис, в один и тот же endpoint сервиса пользователей, достаёт данные и уже внутри себя, как раз-таки, вот эта вот прослойка реализует логику по преобразованию данных.

Четвёртое: мы можем вынести секьюрную составляющую на API Gateway, например, авторизацию. Зачем нам продолжать вести запросы от клиента до приложения, когда мы можем их заранее отбить прямо на уровне API Gateway? Кроме того, у нас, допустим, есть JWT token, имеющий какой-то payload. Мы можем взять и размазать логику реализации этого токена, вытаскивание из него какого-то payload в рамках каждого сервиса. Таким образом, у нас будет дублирование одной и той же логики. Мы можем взять и вынести логику парсинга JWT токена прямо на API Gateway и передавать информацию, например, в заголовках или где-то ещё.

Пятое: кэширование. Кэширование тоже может лечь на плечи API Gateway. Однако стоит позаботиться о том, куда данный кэш мы будем складывать и как данный кэш мы будем кэшировать, и стоит предусмотреть инвалидацию кэша. Как правило, это очень редкий кейс, и именно кэширование выносят на сторону приложений.

И шестое, например, это Rate Limit — ограничение количества запросов в секунду, в минуту от пользователя, когда мы не хотим, чтобы один и тот же пользователь спамил нам в сервисы в одну минуту огромное количество раз. Rate Limit мы можем вынести на API Gateway и отбивать запрос на его уровне.

Теперь поговорим о ключевых минусах. Прежде всего, API Gateway — это дополнительный слой, который появляется в нашей инфраструктуре. А это значит, что он влечёт за собой: первое — косты на поддержку, ведение документации и наличие экспертизы; и второе — может показаться, что API Gateway, будучи единой точкой входа, является неким бутылочным горлышком в плане пропускной способности, то есть ограниченного количества одновременно входящих запросов, а также единой точкой отказа. Однако это разруливается путём просто-напросто аппликации. Мы можем наплодить инстансов данного гейтвея, перед ним ставить балансировщик и каким-нибудь round robin раскидывать запросы по инстансам. Плюс нам необходимо предусмотреть быстрый откат на предыдущую версию нашего Gateway при каком-то релизе, потому что это всё-таки отдельный сервис, который имеет внутри себя кодовую базу, и вполне вероятно, что в продакшн может случайно залететь какая-то фича, которая ломает Gateway целиком.

Стоит ещё раз подчеркнуть, что реализация всех вышеперечисленных на стороне API Gateway не является серебряной пулей. Всегда отталкивайся от своих потребностей и возможностей. Кроме того, выше я упоминал, что клиентами являются исключительно frontend-приложения, мобильные приложения или то, с чем работает пользователь. Однако это не так. Клиентами могут быть абсолютно другие сервисы, внешние системы, системы, с которыми мы интегрируем, и даже внутри инфраструктуры сервисы вполне себе могут общаться через API Gateway. Очень часто так и поступают.

А теперь предлагаю пощупать это дело и развернуть пару сервисов и API Gateway перед ними на реальном сервере. А мы не будем поднимать полноценный кластер Kubernetes на нескольких стендах, а развернём всё в рамках обычного Docker Compose. Docker-ся сетка покроет все вопросы сервис Discovery, то есть нам не нужно будет париться про обнаружение сервисов, а также позволит эмулировать межсервисное взаимодействие. Вполне себе для своих pet-проектов или личной практики просто поиграться вполне себе подойдёт, но на реальных продуктах, конечно же, под понимаете нормальную инфраструктуру. Полную инструкцию, как всё это дело развернуть, я прикреплю в своём Telegram-канале, ссылка на который будет в описании.

Давайте посмотрим, что мы будем разворачивать. У нас будет два микросервиса: User Service, то есть какой-то сервис пользователей, и Delivery Service — сервис доставки. Микросервисы у нас будут на FastAPI, которые мы запустим с помощью Docker Compose. Перед ними у нас будет стоять API Gateway. В нашем случае возьмём готовое решение KrakenD, возможности которого нам вполне себе хватит, чтобы показать, как работает API Gateway и реализовать паттерн API Composition, то есть прямо взять и соединить два ответа APIшек в рамках Gateway. Далее у нас стоит Nginx, он может отдавать статику, то есть frontend-приложение, Swagger UI, но в нашем случае это будет просто backward proxy, который будет просто принимать запросы, проксировать на серверы и хранилище. Ну а нам понадобится именно облачный сервер.

Итак, для начала зарегистрируем на платформе selectel.ru, после чего авторизуемся и перейдём в Панель управления. Здесь выбираем облачная платформа, нажимаем создать сервер и переходим к конфигурации. По региону выберем Москву, по источнику выбираем максимально голый Debian 12. Кто-то может выбрать Ubuntu, что-нибудь ещё, но я предпочитаю максимально голый Linux. Далее берём один CPU, 2 ГБ оперативной памяти, нам этого вполне хватит. По диску я рекомендую взять 30 ГБ, чтобы нам хватило на наши Docker образы. По сети нам нужен один публичный статический IP. Так, и сразу можем добавить SSH ключ для быстрого доступа. Нажимаем в плюсик, переходим ID, целиком копируем его и вставляем. Так, о'кей, ключ добавили. Так, здесь мы выберем день, создать сервер. Теперь в панели управления мы видим выделенный сервер и его публичный IP, а это значит, что можем подключиться по SSH. SSH я буду демонстрировать всё под root, но разумеется, создавайте пользователя и убирайте доступ из-под root по паролю. Root собака, подключились. О'кей, теперь давайте произведём минимальные настройки сервера и перейдём к нашим сервисам. Всё, что нам необходимо сейчас установить на данном этапе — это Git и Docker. Перейдём на официальную инструкцию Docker и установим его. Все команды я приложу, разумеется, в инструкции. О'кей, Docker у нас встал. Теперь перейдём к самим сервисам. По всем канонам микросервисной архитектуре мы имеем четыре репозитория, чтобы каждый из репозиториев могла обслуживать конкретная команда: микросервис пользователей, микросервис доставки, наш Kraken и Nginx. О'кей, давайте начнём deploy с Delivery Service. Склонируем к себе и посмотрим, что он из себя представляет. По факту, небольшое приложение на FastAPI, из зависимости только Uvicorn, FastAPI. Делаем ручку Health Check для проверки работоспособности нашего сервиса. В дальнейших видео она нам ещё понадобится для корректного развёртывания и одну ручку, которая отдаёт доставки по пользователю. Ну, то есть у нас на вход идёт какой-то User ID и моковые совершенно данные с доставками по этому идентификатору пользователя. А давайте посмотрим на Dockerfile. Здесь ничего сверхъестественного: Python 3.12, копируем зависимости, устанавливаем зависимости, копируем содержимое app.py и запускаем с помощью Uvicorn. Завершаем всё это дело в Docker Compose. Укажем здесь обязательно container_name. Данный container_name будет являться по факту доменным именем внутри нашей Docker-ской сети и укажем Docker-скую сеть. Она у нас внешняя, потому что создадим мы её вручную, плюс откроем 8001 порт. Соответственно, в нашем Dockerfile запускается наше приложение на 8001 порту. Всё, что нам нужно написать, псевдо pipeline, с помощью которого мы будем деплоить. У нас всего один job — это deploy. Здесь у нас по факту будет всего Docker Compose up -d --build. Разумеется, так не делаем на реальных проектах, это всего лишь демонстрационная инфраструктура.

Теперь давайте добавим Runner, чтобы наш pipeline мог выполниться автоматически и всё быстро деплоить автоматом. Перейдём обратно в GitLab, у нас Delivery Service, settings, CI/CD, Runners, нажмём New Project Runner, быстренько Run and Test Jobs, Create Runner. Создаём Runner, получаем токен для нашего Runner. Перейдём обратно на сервер и прямо на машину установим GitLab Runner, который с помощью Shell будет запускать наши deploy jobs, чтобы мы не парились с каким-нибудь GitOps, нам это не интересно. Сегодня наша задача — быстренько протыкать и пощупать сервисы. Качаем исходники нашего Runner, устанавливаем архив, можно впоследствии удалить. Пока у нас всё это дело устанавливается, можем зайти в main.py и посмотреть, что даёт наш Health Check. Здесь Delivery Service is up, чтобы мы поняли, что запустилось конкретное приложение. Так, всё установилось. Проверим Runner --help, всё работает отлично. Теперь дадим доступ нашему Runner к Docker, выдадим права и перезапустим. Зарегистрируем сам Runner. GitLab Runner register URL gitlab.com и токен, который нам выдал GitLab. Зайдём и скопируем его. Тут пропустим Enter, если хотите, можете заполнить, нам это не интересно. Здесь выбираем Shell. Всё. Так, теперь давайте внесём какие-нибудь изменения. Попробуем push и проверим, что наш pipeline действительно работает. Здесь сделаем троечку, сделаем git clone, git add, git commit -m "test" и git push. Кстати, здесь видим, что Run created. Переходим обратно в репозиторий, видим, запустился наш единственный job deploy. Посмотрим на логи. Мы видим, что Docker нам сказал, что Network up, Network dec not. Значит, что мы забыли её создать. Так, зайдём обратно на сервер и просто-напросто создадим сеточку Docker. docker network create app_network — та самая, которая указана была в нашем compose. Зайдём обратно, перем наш в. Теперь зайдём на frontend, проверим, что у нас всё работает. Так, у нас там был 8001 порт, уже видим, всё хорошо. Зайдём, проверим с Swagger. Да, всё работает, health check есть, проверим. Да, всё работает. О'кей, теперь выполним всё то же самое для сервиса пользователей. Прежде всего, выберем Runner в settings, CI/CD, Runner. Тот же самый Runner, который у нас указан в Delivery. По самому проекту в API Health Check User Service is up и вторая API, которая даёт какую-то персону по пользователю по его идентификатору, абсолютно моковые данные. По Dockerfile всё тоже самое, с исключением порта 8008. По compose тоже всё тоже самое, за исключением container_name. Здесь мы укажем users_service, чтобы он являлся доменом внутри Docker-ской сетки, и здесь откроем ещё портик. Не хватает открытого порта. Так, сейчас Docker Compose возьмём прямо отсюда. Портик — это закинем вот сюда. Всё, открыли 8008. О'кей, по pipeline нашему великолепному всё тоже самое. Давайте добавим git clone, git add, git commit -m "test" и pushнём всё это дело на CI и проверим, что оно задеплоилось. Возвращаемся в нашу репу, видим, что у нас по pipeline. Заходим в deploy, смотрим, что всё о'кей. Возвращаемся. Нам нужен наш IP-адрес, мы его берём, вставляем. Так, что у нас там было? 8001 и 8008. Всё отлично, всё работает. По health check мы можем понять, что у нас up и здесь у нас соответственно delivery. Всё, мы имеем два микросервиса, порты которых торчат наружу, то есть по факту уже какой-то клиент может взять и долбиться в них поочерёдно, причём по разным портам, то бишь разным адресам. Это не очень круто, значит, нам нужно развернуть Kraken, а именно наш желанный API Gateway. Посмотрим на репозиторий Kraken'а. А по нашему псевдо pipeline всё тоже самое. В compose указываем container_name kraken, тот же самый домен, который у нас будет виден внутри Docker-ской сетки, 8080, сетка app_network, которой пользуются два других сервиса, и посмотрим на Dockerfile. Здесь мы просто берём последнюю версию нашего Kraken'а из registry, а копируем конфигурацию Kraken'а, которая лежит в нашем файле kraken.json и команда запуска абсолютно простая. Давайте быстренько пройдёмся по конфигуратору, на котором мы Kraken запускаем. `ttl` — это время жизни кэша, 300 секунд, и важный массив `endpoints`, в котором регистрируются все эндпоинты для прокси в наши конкретные сервисы. Давайте сделаем таким образом, что по эндпоинту `kraken/delivery/health_check` наш запрос будет перенаправляться в Docker-ской сетке мы постучим в Delivery Service в доменное имя на 8001 порт по эндпоинту `health_check`. То же самое сделаем для User Service. То есть у нас `users/health_check`, а всё это дело мы перенаправляем. А по факту, точка входа у нас единая — порт 8080, и вот эти вот две API с `delivery/health_check` и с `users/health_check`. А вот эти префиксы в руле отражают название сервисов, которые мы долбим. О'кей, давайте внесём какой-нибудь фикс. Прежде всего, зайдём в GitLab, выберем наш Runner, Runner этот мы зам 77, нам нужен, отлично. Переходим обратно. Давайте что-нибудь тут поправим, не знаю, какие-нибудь слова гадкие напишем. Могли бы в принципе перезапустить job внутри CI. Теперь зайдём обратно в репозиторий. Так, о'кей, запустилась. Давайте опять же возьмём наш IP-адрес, постучим 8080, port not found. О'кей, теперь постучим по `users`. Так, надо скопировать, чтобы не ошибиться. Отлично, мы теперь прокси запрос через наш в сервис пользователей. Да, давайте проверим здесь. Delivery, отлично работает, при этом порт и адрес остаётся постоянным, и это адрес Kraken'а, то есть нашего API Gateway. Так, о'кей, теперь предлагаю зайти и закрыть доступ извне в Delivery и просто закроем порты наружу, и всё, нам это не интересно. Быстренько всё это дело закоммитить. Минус, зачем нам давать возможность стучаться извне, если мы можем этого не давать? То же самое повторим с User сервисом. Docker Compose, порты закрываем, отлично. Доступ закрыт, и доступ закрыт, при этом здесь всё работает: Delivery Health Check и Users. Отлично. Теперь постучаться в наши сервисы можно только через Gateway. Ещё быстренько развернём наш backward proxy. Здесь простой конфиг Nginx. По location `/api/gateway` мы кроем запросов Kraken, и вот этой регуляркой отбиваем `api_gateway` prefix, чтобы запросы именно в сам Kraken заходили вот в таком виде, в котором они указаны у нас вот здесь, то есть `delivery/health_check`, а по факту будет с Kraken и так далее. По Dockerfile просто-напросто копируем конфиг, запускаем Nginx с демоном, и compose точно такой же, как и был, порт открываем восьмидесятый, сетка та же самая, ничего сверхъестественного. Давайте в этот раз не будем ничего коммитить, просто зайдём в перепугу. Отлично. Теперь на восьмидесятом порту, который браузер подставляет автоматически, по префиксу `/api/gateway/users/health_check` мы можем достучаться до нашего микросервиса с пользователями через соответствующий. Давайте проверим, что у нас всё работает. Да, всё. Давайте ещё, наверное, снаружи закроем порт Kraken'а, чтобы вообще всё было максимально секьюрно. Перейдём в наш Kraken, закроем наш 8080 и запушим всё это дело сразу в Main. Вот это правильный подход, я считаю. Just Now. Проверяем 8080, всё закрыт, единая точка входа — Nginx. О'кей, теперь мы развернули полноценно два сервиса, поставили перед ними как единую точку входа API Gateway, то есть наш Kraken, и все запросы ходят через него. Кроме того, ещё поставили Nginx, который ещё в свою очередь может быть Load Balancer'ом. О'кей, теперь давайте реализуем API Composition. Вернёмся в конфиг Kraken'а и попробуем сделать композицию этих двух API. У нас есть endpoint `/users/{user_id}` который отдаёт список доставок пользователя, а также есть endpoint в User Service, который отдаёт у нас персональные данные пользователя. Например, нам нужен какой-то компонент отобразить, в котором есть и персоналка, и список доставок. О'кей, и теперь заходим на наш Kraken и добавляем следующий endpoint. У нас будет endpoint `/user/delivery/{user_id}`. `{user_id}` — это параметр пути, который динамически подставляется в самой URL. Здесь нам нужно указать `backend`, и в `backend` фактически массив из двух API. Первое — это прокси на сервис пользователей на `{user_id}`. Всё отлично. Что сделает Kraken? Возьмёт при получении запроса по данному эндпоинту, сходит в этот сервис по этому эндпоинту, сходит в этот сервис и просто-напросто смержит две API. Давайте задеплоим. Всё отлично, Just Now. Теперь нам нужен endpoint `/user/delivery/1` и, например, подставим идентификатор 1. Отформатирую ответочку API, и мы видим, что Kraken смержил нам два JSONчика, которые отдавали два сервиса по отдельности. Вот `deliveries` из сервиса доставки, и вот `first_name`, `last_name` из сервиса, который отдаёт персону. И всего за несколько минут мы развернули демо окружение на реальном сервере, используя самые простые инструменты и пощупали API Gateway на практике. Помните, что увеличивая количество слоёв в системе, вы повышаете её сложность. Перед применением каждого из паттернов подумайте над тем, что вам действительно необходимо. На этом у меня всё.