Привет, Хабр! Меня зовут Никита Летов, я техлид бэкенд-разработки сервисов дистанционного банковского обслуживания Росбанка для физических лиц, или как модно сейчас говорить, ретейла. Этот пост входит в серию постов по разработке бэкенд-микросервисов на Java и Spring и является адаптацией моего доклада с JPoint 2023.

По традиции, дисклеймер. Хочу предупредить, что этот пост не является cookbook и не предоставляет идеально приготовленное решение какой-либо бизнес-проблемы. Это разбор одной технологии, которая при правильном использовании может помочь вам в решении реальной проблемы. А может и не помочь — всё зависит от ее природы.

Как обычно, если вам не терпится посмотреть и попробовать все на практике, тут вы можете найти проекты со всеми исходниками, инфраструктурой, Gatling-тестами и Grafana-метриками:

Если будут сложности или вопросы по проекту — добро пожаловать в комментарии или личные сообщения, постараюсь всем помочь и ответить на вопросы. В этот раз мониторинг получился просто прекрасным, полностью на Grafana OSS (Grafana, Loki, Tempo). Рекомендую всем ознакомиться и по возможности пробовать у себя на проектах как замену ELK/Graylog и Zipkin/Jaeger.

В этом посте я расскажу, что такое входная точка в приложение, когда в ней появляется необходимость и какие вообще задачи решает паттерн API Gateway. Мы рассмотрим классический блокирующий подход на примере гейтвея Netflix Zuul 1.x, проблемы, связанные с его эксплуатацией, а также реактивный Spring Cloud Gateway и сложности перехода на него. В заключение сравним два подхода.

Итак, как обычно, начнем с разбора возникшей проблемы.

Когда-то наш любимый банк начинал разработку ДБО с монолитного приложения. Для балансировки нагрузки использовался nginx, который распределял клиентов по монолитам, и архитектура выглядела максимально просто:

image21.png
Чем проще, тем проще

Далее, идя в ногу со временем, банк решает сделать модное/молодежное приложение ДБО на микросервисах. И всё бы хорошо, но возникает вопрос: как запускать в него клиентов. У нас появляется куча Deployment-ов (DC) в OpenShift с некоторым количеством инстансов приложения в каждом (Pod) и, соответственно, у каждого инстанса — свой IP. Как клиент будет понимать, в какой DC и Pod ему нужно попасть?

image19.png
Кому напомнило Mario? Что же в коробке с ?

Теоретически, можно создать множество конфигов на nginx или другом балансировщике. Представьте, какая это будет портянка и как потом это поддерживать. Или, может, выставить каждый микросервис наружу со своим поддоменом? А как нагрузку между подами разруливать? Эти варианты не представляются реалистичными, к счастью.

Ответ на этот вопрос уже давно есть – это edge (пограничный) микросервис, который будет заниматься маршрутизацией клиентов и не только :) Тут мы подходим к паттерну API Gateway.

Что такое API Gateway

Чтобы понять суть этого подхода, представьте себе терминал международного аэропорта. Чтобы полететь в другую страну, мы не просто приезжаем на такси в терминал и пересаживаемся на первый попавшийся самолет. Сначала нам нужно завершить регистрацию и получить посадочный талон. Затем мы проходим таможню, паспортный контроль, где получаем отметку о вылете из страны. Направляемся к своему гейту, где снова проходим проверку паспорта и билетов, и, наконец, попадаем в самолет.

Идея терминала международного аэропорта просто идеально отражает суть API Gateway:

  • Унификация — один интерфейс для всех сервисов. Вы можете зайти в терминал и улететь в любую страну.

  • Маршрутизация — перенаправление запросов на целевые сервисы. Вы узнаёте, к какому гейту идти и на какой самолет сесть, пройдя регистрацию на рейс.

  • Валидация — проверка обязательных параметров. Визовый, паспортный контроль и т. д.

  • Авторизация и аутентификация. Нашу личность устанавливают, чтобы проверить, можем ли мы вообще куда-то лететь.

  • Трассировка запросов/ответов. Мы получаем отметку о вылете из страны и прилете в страну.

  • Стандартизация — обрезка лишнего. Если у нас есть нечто запрещенное для ввоза, это конфискуют.

  • Обогащение запросов/ответов — добавление заголовков или куки. Опционально, мы можем что-нибудь докупить в duty free, чтобы по прилете в место назначения быть ко всему готовыми.

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

image24.png
Gateway умер, да здравствует новый Gateway

Теперь перечислим задачи, поставленные перед API Gateway в нашем банке:

  • Маршрутизация клиентских запросов в сервисы. Гейтвей должен маршрутизировать запросы в сервисы разных версий в зависимости от версии клиентского приложения.

  • Проверка авторизации клиентов. Гейтвей выступает в роли Oauth2.0 (OpenID) Resource Server, а в API-сервисы уже поступает информацию о пользователе. Это упрощает разработку бэкенд-приложений.

  • Трассировка запросов/ответов: гейтвей должен уметь вешать на каждый запрос/ответ ярлык с уникальным идентификатором. Так мы сразу сможем проследить запрос, если у клиента возникнут с ним проблемы.

  • Логирование параметров запроса и ответа, чтобы быстрее и проще разбирать возможные проблемы. Это сильно упрощает жизнь поддержки.

  • Предоставление метрик, с помощью которых мы могли бы оценивать загрузку и работоспособность.

  • Возможность обогащения запросов в виде дополнительных куки, заголовков; или же подмена значений в теле запросов–ответов.

Цели ясны, подход тоже, а что же с конкретным решением? Брать что-то с рынка или писать самим? Архитекторы рвались в бой с новыми интересными идеями, а разработчики были готовы на всё ради успеха проекта, так что решили писать сами.

Вопрос, на чем основываться, особо и не стоял. В 2017–2018 гг. лидером в API Gateway направлении был Netflix Zuul — часть проекта Netflix Spring Cloud, который обеспечивает динамическую маршрутизацию, мониторинг, безопасность и другие кросс-функциональные возможности микросервисных приложений. Собственно, выбор был сделан в пользу Zuul.

Zuul имеет отличную интеграцию со Spring, огромное комьюнити и базу знаний, использует классический подход к разработке и востребован во многих компаниях до сих пор. Zuul поддерживает фильтры на разных этапах запросов — пред-фильтры (pre), фильтры постобработки (post) и фильтры обработки исключений (error). А главное, что это классический гейтвей, основанный на стеке сервлетов; он использует Apache Tomcat и в своей работе полностью полагается на парадигму request per thread (один запрос – один поток).

Что такое контекст сервлетов

Раз уж заговорили про сервлеты, думаю, есть смысл напомнить о них тем, кто забыл, и рассказать на простых примерах тем, кто не знал. Контекст сервлетов предоставляет среду для выполнения сервлетов внутри контейнера сервлетов — в том же Apache Tomcat — а также доступ к общим ресурсам. При разработке Spring MVC приложения мы, как правило, не используем ServletContext напрямую, а полагаемся на абстракции Spring — контроллеры и аннотации — @RestController, @GetMapping, @RequestParam и т. д. Но если мы не работаем с implicitly объявленным сервлетом, это не значит, что его нет. Он всегда приходит в любой REST-контроллер, и мы легко можем к нему обратиться. 

По сути, сервлеты — это объекты, которые для нас создает контейнер сервлетов – Apache Tomcat. Они соответствуют http-запросу и ответу от сервера. Для нас как для Java Spring разработчиков основными объектами являются HttpServletRequest и HttpServletResponse.

Работает это так: клиент присылает нам запрос в Tomcat. Он генерирует сервлеты, которые в дальнейшем летят по всей цепочке блоков нашего spring-приложения — проходят через цепочку фильтров и диспетчер сервлетов, где определяется целевой контроллер, куда попадет сервлет. Interceptor производит последние модификации и, наконец, сервлет попадает в контроллер, откуда извлекается DTO, которую мы запросили в сигнатуре метода. Затем ответ идет в обратном порядке. Постарался отобразить все на иллюстрации:

image16.png
Тот самый путь http-запроса

Поскольку мы говорим про гейтвей, нам интересна только первая половина этой схемы. Клиент отправляет запрос в наш гейтвей, работающий на Tomcat, запрос проходит через цепочку фильтров и попадает дальше не в диспетчер, а в HttpClient гейтвея, чтобы отправиться в сервис, для которого предназначен запрос.

image18.png
Приближение схемы для Gateway

Для обмена информацией между фильтрами Zuul использует потокобезопасный RequestContext. RequestContext напрямую с Zuul не связан, но заполняется данными из HttpServletRequest, HttpServletResponse и ServletContext, которые создал для нас Tomcat. RequestContext живет внутри одного потока, и в этом главная его прелесть! Мы можем обратиться к нему в любой момент, вытащить весь запрос или ответ и сделать с ними что угодно максимально прозрачно и понятно.

Организация фильтров

Остановлюсь подробнее на фильтрах. Фильтр — это компонент, который может быть использован для обработки входящих и исходящих HTTP-запросов и ответов перед тем, как они достигнут контроллеров или вернутся клиенту. Zuul интегрирован в Spring Boot, поэтому для обработки запросов мы можем использовать не только ZuulFilter, на котором написан Zuul, но также фильтры Spring, javax.servlet.Filter и фильтры из пакета o.s.web.filter, такие как GenericFilterBean или OncePerRequestFilter. 

Рассмотрим пример реализации класса Zuul Filter. Он имеет тип, порядок, флаг включения и логику, которую мы должны определить в методе run — например, обратиться к RequestContext, извлечь HttpServletResponse, что-то в нем изменить:

public class SampleZuulFilter extends ZuulFilter {
   @Override
   public String filterType() {
      return FilterConstants.PRE_TYPE;
   }
   @Override
   public int filterOrder() {
      return PRE_DECORATION_FILTER_ORDER + 1;
   }
  @Override
   public boolean shouldFilter() {
      return true;
   }
   @Override
   public Object run() {
      log.info("Logic executed here");
      return null;
   }
}

Этот фильтр немного отличается от стандартного: в Zuul Filter на вход ничего не приходит. Фильтры Spring и javax принимают на вход ServletRequest и ServletResponse — объекты с необходимой информацией о сервлетах, а также цепочку фильтров, объект, необходимый для передачи request и response между другими фильтрами. 

public class SampleFilter implements Filter {

   @Override
   public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
         throws IOException, ServletException {
         
      try {
         log.info("Pre. filter logic executed!");
         chain.doFilter(request, response);
         log.info("Post. filter logic executed!";

      } catch (Exception ex) {
         log.warn("Filter will be skipped due the exception”, ex);
         chain.doFilter(request, response)
      }
   }
}

В этом примере используется стандартное логирование, по логике предобработки. По сути, она всегда идет перед вызовом chain.doFilter — этот метод отправляет опрос дальше по цепочке. Всё, что после него, — это уже постобработка. Частая ошибка при реализации фильтров через try/catch — забыть написать chain.doFilter в catch. В таком случае запрос зависнет и дальше по фильтру не пойдет.

Порядок выполнения фильтра (@Order)

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

image3.png
Цепочка фильтров, на пальцах

Вот схема цепочки фильтров. Самое важное в ней — это порядок. Когда запрос идет через pre-фильтры, порядок всегда определяется от меньшего к большему, от отрицательного к положительному. Post-фильтры Zuul идут после того, как проксируемый сервис сделал свою обработку, и их порядок также строится от отрицательного к положительному — так как фильтры содержат только одну логику.

С Spring и Javax/Jakarta фильтрами ситуация другая. Когда запрос идет до проксируемого сервиса, порядок стандартен — от отрицательного к положительному. Но после проксируемого сервиса, когда у нас уже заполнен HttpServletResponse, порядок работы изменяется от положительного к отрицательному. На иллюстрации видно, что фильтр, который первым обрабатывал запрос, будет последним обрабатывать ответ.

Если у нас есть два фильтра одного типа с одинаковым порядком, то порядок выполнения фильтров становится непредсказуемым. Это происходит, когда порядок фильтров не определяют или определяют неверно — например, при логировании. 

Service Discovery. Или как нам искать сервисы

Чтобы наш гейтвей видел сервисы, куда ему нужно отправлять запросы, необходима Service Discovery. Для этого чаще всего используется сервис Netflix Eureka. Мы же решили его не использовать, а положиться на стандартные средства OpenShift на Kube-DNS — сервис Kubernetes, отвечающий за сопоставление Deployment Config Name и IP-адреса поды внутри данного Deployment Config (DC).

image14.png
Kube-DNS, на пальцах

По имени DC мы обращаемся к сервису, после чего Kube-DNS возвращает нужные поды, рассчитывает балансировку и делает другие важные вещи. Можно не подключать Eureka и другие зависимости.

Описание нашего стенда

Перед демонстрациями опишу наш стенд. В докере крутится четыре сервиса — два быстрых и два медленных (v0 с задержкой в 3 с, v1 с задержкой в 6 с). Также есть сервис авторизации. Сам гейтвей мы запускаем из IDEA, единичные запросы будем делать через Postman. Метрики будут собираться в Grafana средствами Prometheus. Для нагрузочного тестирования используем инструментарий Gatling.

image22.png
И прошу заметить, всё на обычном DELL Latitude на i7

В прод или не сейчас? Netflix Zuul

В Zuul гейтвее определено несколько фильтров:

@Override
    public int filterOrder() {
        return 0;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        RequestContext ctx = RequestContext.getCurrentContext();
        List<Pair<String, String>> zuulResponseHeaders = ctx.getZuulResponseHeaders();
        if (zuulResponseHeaders != null) {
            String traceId = tracer.currentSpan().context().traceIdString();
            zuulResponseHeaders.add(new Pair<>("Trace-Id", traceId));
        }
        return null;
    }
}

И вот маршруты:

zuul:
  host:
    connect-timeout-millis: 10000
    socket-timeout-millis: 30000
  routes:
    auth:
      path: /auth/**
      stripPrefix: true
      url: http://localhost:8443/realms/bank_realm/protocol/openid-connect/token
    fast-rest-service:
      path: /fast-service/**
      stripPrefix: true
      url: http://localhost
    slow-rest-service-v0:
      path: /slow-service/v0/**
      stripPrefix: true
      url: http://localhost:8280
    slow-rest-service-v1:
      path: /slow-service/v1/**
      stripPrefix: true
      url: http://localhost:8281

Начнем с тестовых единичных запросов и посмотрим, как работает наш фильтр трассировки и фильтр установки версии проксируемого сервиса:

image15.png
Всё отлично проставляется и приходит в response

По trace-id в Grafana можно сразу отследить запрос и посмотреть его маршрут. Доступны и логи по запросу.

image12.png
Как я уже говорил ранее, это очень здоровский, кликабельный мониторинг

То же самое будет при обращении к медленным сервисам, только с соответствующей задержкой.

image8.png
В Prometheus мы можем оценить доступность гейтвея

Теперь посмотрим, как гейтвей покажет себя при нагрузке. Ниже пример запроса в Gatling с прохождением авторизации и простого запроса в сервис.

public class GatewayThrottle extends Simulation {

  HttpProtocolBuilder httpProtocol =
      http
          // Here is the root for all relative URLs
          .baseUrl("http://localhost:8081")
          // Here are the common headers
          .acceptHeader("*/*")
          .acceptEncodingHeader("gzip, deflate, br")
          .userAgentHeader(
              "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:16.0) Gecko/20100101 Firefox/16.0");

  // A scenario is a chain of requests and pauses
  ScenarioBuilder scn = scenario("Scenario Name")
            .exec(http("fastRequest")
                  .get("/fast-service/")
                  .header("x-client-version", "0.0.1")
                  .header("Authorization","Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvTXlRTHB6TXJmWlJ2SkxXbGxZdXRuS1NHbFlHNE5fZ0J0SkIweHpLdHBBIn0.eyJleHAiOjE2ODE4NDY3MDYsImlhdCI6MTY4MTc2MzkwNiwianRpIjoiNGRhZDY3N2EtNWZhZi00MGRkLTljMzUtMTU2NTdkZDg4ZTNlIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDQzL3JlYWxtcy9iYW5rX3JlYWxtIiwiYXVkIjpbIm9hdXRoMi1yZXNvdXJjZSIsImFjY291bnQiXSwic3ViIjoiNzY5NzE3MDQtYjdkZC00OGY2LTkyNTEtY2U0ODI1NzAzODg2IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiamhvbl9kb2UiLCJzZXNzaW9uX3N0YXRlIjoiNWIwMmQ2ODgtMjRiMi00MDlkLWFlOTUtNTRiZTgwNmJhOTNhIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIvKiJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1iYW5rX3JlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSByZWdpc3RyYXRpb24gYmFzaWMgYmFzaWNfcmVhZCBlbWFpbCIsInNpZCI6IjViMDJkNjg4LTI0YjItNDA5ZC1hZTk1LTU0YmU4MDZiYTkzYSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IkpvaG4gRG9lIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiam9obmRvZSIsImdpdmVuX25hbWUiOiJKb2huIiwiZmFtaWx5X25hbWUiOiJEb2UifQ.nUqK2rXNvMCveGKrMylTsbp3aoh_8Q4IV-qa5KZIxvPVxZdlP82WR0ujx6nvbwS1Jx_zZL9afxOhxdeNt-1WDGS7oaVGHFfpIYCtnW0pcHX-_MHsRZlraVzKf56GHKfqIAC4S9S5oP-4kT-V2ryOMisQEBz4g2RefsvlK1pr4MbwY5OjxuppwAQiMepVkrj45NJpOuJ752dWmsR09HKv0Ti7-25gMoekoYU-YMLoz2yd-2kcO-mknbS_FA1skSw6d2NS3L93OpZJJkDOgRORDjkjOy04QmEe7rsiVjAb_jFlHj-B6fsr5kPQF-dDvvFQjUbZJYgl6-Wdi-DYjNPQZA"))
            .exec(http("slowRequest")
                  .get("/slow-service/v0")
                  .header("Authorization","Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvTXlRTHB6TXJmWlJ2SkxXbGxZdXRuS1NHbFlHNE5fZ0J0SkIweHpLdHBBIn0.eyJleHAiOjE2ODE4NDY3MDYsImlhdCI6MTY4MTc2MzkwNiwianRpIjoiNGRhZDY3N2EtNWZhZi00MGRkLTljMzUtMTU2NTdkZDg4ZTNlIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDQzL3JlYWxtcy9iYW5rX3JlYWxtIiwiYXVkIjpbIm9hdXRoMi1yZXNvdXJjZSIsImFjY291bnQiXSwic3ViIjoiNzY5NzE3MDQtYjdkZC00OGY2LTkyNTEtY2U0ODI1NzAzODg2IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiamhvbl9kb2UiLCJzZXNzaW9uX3N0YXRlIjoiNWIwMmQ2ODgtMjRiMi00MDlkLWFlOTUtNTRiZTgwNmJhOTNhIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyIvKiJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1iYW5rX3JlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSByZWdpc3RyYXRpb24gYmFzaWMgYmFzaWNfcmVhZCBlbWFpbCIsInNpZCI6IjViMDJkNjg4LTI0YjItNDA5ZC1hZTk1LTU0YmU4MDZiYTkzYSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IkpvaG4gRG9lIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiam9obmRvZSIsImdpdmVuX25hbWUiOiJKb2huIiwiZmFtaWx5X25hbWUiOiJEb2UifQ.nUqK2rXNvMCveGKrMylTsbp3aoh_8Q4IV-qa5KZIxvPVxZdlP82WR0ujx6nvbwS1Jx_zZL9afxOhxdeNt-1WDGS7oaVGHFfpIYCtnW0pcHX-_MHsRZlraVzKf56GHKfqIAC4S9S5oP-4kT-V2ryOMisQEBz4g2RefsvlK1pr4MbwY5OjxuppwAQiMepVkrj45NJpOuJ752dWmsR09HKv0Ti7-25gMoekoYU-YMLoz2yd-2kcO-mknbS_FA1skSw6d2NS3L93OpZJJkDOgRORDjkjOy04QmEe7rsiVjAb_jFlHj-B6fsr5kPQF-dDvvFQjUbZJYgl6-Wdi-DYjNPQZA"));


  {
    setUp(scn.injectOpen(constantUsersPerSec(80).during(10)).protocols(httpProtocol));
  }
}

В сценарии выше клиент делает 80 запросов в секунду в slow-сервис и fast-сервис в течение 10 секунд. Запросы полетели, посмотрим в Prometheus, что происходит:

Слишком много красного
Слишком много красного

Интересно, что упал и slow-сервис, и гейтвей, через который летят запросы. Получается, что медленно отвечающий сервис положил нам гейтвей. По итогам теста, который длился сильно дольше заданных 10 секунд, не прошел 331 запрос. Все остальные были успешны:

image2.png
Ошибки по таймауту клиента

Почему упал гейтвей?

Tomcat, как контейнер сервлетов, работает на фиксированном количестве потоков. Заранее определяется, сколько потоков он может использовать — больше 200 обычно не рекомендуется.

Распишем, что произошло:

  • Прилетает запрос от client 1;

  • Tomcat выделяет ему поток, отправляет в гейтвей и затем в проксированный сервис через http-клиент. 

  • Гейтвей ожидает ответа от прокси сервиса для client 1;

  • Прилетает запрос от client 2;

  • Прилетает запрос от client 3 (4,5,6,7…). И с ними в случае медленно отвечающего REST сервиса происходит то же самое, что с клиентом 1 — они все начинают висеть в ожидании.

  • Постепенно число потоков Tomcat заканчивается и клиенты ожидают на уровне TCP-стека. Получат они свой поток или нет, зависит уже от таймаута на соединение у клиентов и от времени ожидания самого проксируемого сервиса.

image23.png
Houston, we have a problem

Так почему же упал гейтвей? На самом деле он не упал, а просто перестал отвечать на метрики Prometheus. Если бы он крутился в OpenShift, то OpenShift выдал бы ошибку, что liveness-метрики недоступны (вы ведь используете у себя liveness-метрики?) и сервис будет перезагружен. Соответственно, это постоянно происходило бы из-за одного медленного сервиса при высокой нагрузке.

Как решить проблему? Первый вариант — горизонтальное масштабирование через увеличение количества инстансов гейтвея. Кстати, именно так поступал Zuul до второй версии. Кроме того, можно использовать Deprecated Circuit Breaker Hystrix. Но это решит проблему лишь отчасти, так как работает он также на блокирующем API. И это решение все-таки уже имеет статус deprecated.

Оптимизация и уменьшение таймаутов подключения/ответа — третий, более адекватный вариант. Подрежем таймауты, и, хоть запрос может отвалиться, сервис не упадет.

Эти варианты могут сработать, но по факту это просто отсрочка большой проблемы. Количество клиентов будет расти, и вам придется каждый раз решать ее заново. Более надежный подход — переход на non-blocking API gateway.

Как работает non-blocking API gateway на примере Spring Cloud Gateway

Неблокирующая схема организована вне контекста сервлетов и работает на Netty, построенном на проекте Reactor, — это асинхронный фреймворк, который завязан на event loop’ы (петли событий), а не на фиксированное количество потоков. Поток тут может быть вовсе один. Один процессорный поток держит одну петлю. В ней собрана пачка каналов, которая принимает входящее TCP-соединение, декодирует http-запросы (это происходит быстро) и пробрасывает их дальше.

image17.png
Как работает non-blocking подход, на пальцах

Запрос приходит по http в канал, из канала в event loop, а затем в WebFlux-приложение (реактивный подход написания Spring-приложений, основанный на том же Project Reactor) в виде ServerHttpRequest — новый объект, заменяющий нам HttpServletRequest. Это происходит неблокирующим образом: event loop получает событие из канала, обрабатывает его, создает себе «ждуна» — флаг ожидания, что response пришел, — отправляет его дальше по цепочке и готов снова принимать запросы. WebFlux оборачивает его в ServerWebExchange, который содержит в себе и request и response, и дальше по цепочке пробрасывает до WebClient — тот же HttpClient, но умеющий работать асинхронно.

В итоге получается аналог контекста сервлетов, но работает он уже неблокирующим образом, через реактивные операторы. Кроме того, все происходит асинхронно, так что хоть на одном процессорном потоке все будет работать. Что ж, звучит как план.

Реактивный гейтвей и блокирующие API. Надеюсь это не проблема..? xD

Мы собираемся использовать микросервисы на MVC и гейтвей на Flux. Не будет ли конфликтов?

В реальности это не будет нас ограничивать, так как Spring Cloud Gateway использует WebFlux WebClient, что обеспечивает асинхронное, неблокирующее и эффективное ожидание ответов с поддержкой таймаутов. А при отправке запроса WebClient создает реактивный поток, в котором не ожидает ответа от проксируемого сервиса. Пока мы ожидаем ответа, WebClient освобождается и не блокирует потоки. Единственное, где мы можем испытать нагрузку, — это на количестве открытых сокетов на самом Gateway.

image11.png
Надеюсь, это не проблема

Spring Cloud Gateway

Spring Cloud Gateway — это проект в рамках экосистемы Spring Cloud, предоставляющий API Gateway решение для микросервисной архитектуры. В конце 2018 года он заменил Zuul, но до сих пор не обрел масштабную популярность. Не верите? Обратитесь к StackOverFlow — там до сих пор обсуждают старый добрый Zuul 1.0 :)

Spring Cloud Gateway использует реактивный стек на основе Project Reactor и Netty, что обеспечивает высокую производительность, асинхронную и неблокирующую обработку запросов. Он поддерживает глобальные и маршрутные фильтры. «Из коробки» содержит много встроенных фильтров — 36 — которые покрывают большинство бизнес-требований к гейтвею. Активировать эти фильтры можно без написания нового кода!

Переход на Spring Cloud Gateway не так прост, как может показаться. Подход к разработке отличается от классического, практически всю логику фильтров придется переписать, а не просто обновить зависимости. Также большинство используемых зависимостей из Cloud времен Netflix Zuul уже устарело, и вам необходимо будет обновляться. Например, вместо o.s.cloud:spring-cloud-starter-sluth нужно использовать io.micrometer:micrometer-tracing, а вместо o.springframework.security.oauth:* — o.s.boot:spring-boot-starter-security. И в связи с этим решать новые проблемы — трейсинга, логирования, форматов и т. д.

На замену сервлетам здесь приходит WebFlux, построенный на основе реактивного стека на базе Project Reactor и Netty. А вместо работы с RequestContext или ServletRequest/ServletResponse мы теперь обращаемся к интерфейсу WebFlux – ServerWebExchange, который содержит HttpServerRequest, HttpServerResponse, атрибуты, сессию и т. д.

Глобальные фильтры

Есть глобальные фильтры, которые всегда применяются ко всем маршрутам, а есть маршрутные фильтры, которые применяются к отдельным маршрутам или тоже ко всем (через application.yml либо через Java config). В фильтрах не нужно дополнительно прописывать path или что-либо еще. Можно просто сделать к маршруту приписку и активировать фильтр. Напомню, что целых 36 фильтров уже есть в нашем распоряжении. Все фильтры также имеют порядок, и иллюстрация с цепочкой фильтров выше остается актуальна.

Пример реализации глобального фильтра с минимальной логикой — простое логирование:

@Component
public class ExampleGlobalFilter implements GlobalFilter, Ordered {

   @Override
   public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

      log.info("PRE Logic executed!");         
      return chain.filter(exchange) 

         .then(Mono.fromRunnable(() -> {
            log.info("POST logic executed!");
         }));

   }

   @Override
   public int getOrder() {

         return -1;
   }
}

Фильтры мы пишем теперь в реактивно-функциональном стиле. На вход у нас приходит сервер WebExchange с цепочкой. Мы пишем pre-логику и возвращаем chain.filter(exchange). Это уже реактивный стрим, к которому применим реактивный оператор then. Соответственно, чтобы реализовать post-логику, мы пишем then, потом fromRunnable или fromCallable и саму логику. Также важно в конце указать порядок для глобального фильтра.

Маршрутные фильтры

Маршрутные фильтры реализуются чуть иначе: они расширяют GatewayFilter Factory.

@Component
public class ExampleLoggerGatewayFilterFactory extends
AbstractGatewayFilterFactory

   @Override
   public GatewayFilter apply(Object config) {

      return (exchange, chain) -> {
         log.info("PRE Logic executed");
         return chain.filter(exchange)
            .then(Mono.fromRunnable(() -> {
                log.info("POST Logic executed");
            }));
        };
    }
}

Имя фильтра определяется словами перед GatewayFilterFactory — его вы будете указывать в конфиге. Метод apply разрешает GatewayFilter. Он написан в функциональном стиле и возвращает нам exchange и цепочку. Также здесь выделяет pre- и post-логика — отличий от глобального фильтра немного.

Маршрутные фильтры с конфигом

Эти фильтры могу применять к себе какие-то параметры. Всё так же, только добавляется статический класс config, который может принимать параметр.

@Component
public class ExampleLoggerGatewayFilterFactory extends AbstractGatewayFilterFactory<ExampleLoggerGatewayFilterFactory.Config> {

    @Override
    public GatewayFilter apply(Config config) {
        return new OrderedGatewayFilter((exchange, chain) -> {
            log.info("PRE Logic executed with {}", config.getParam());
            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                log.info("POST Logic executed with {}",  config.getParam());
                }));
       }, -1);

   }

   @Setter
   @Getter
   public static class Config {
      private String param;
   }
}

Этот фильтр является упорядоченным: он возвращает нам новый OrderedFilter. Из конфига мы можем взять какой-нибудь параметр в нашей логике, например, если нам надо добавлять разные куки или прописывать разные домены к запросам. Всё это можно делать точечно прямо в конфиге, без написания дополнительного кода. И это реально классно! (Не забывайте, что программисты должны быть ленивыми :))

Пример фильтра с логированием запроса:

spring:
   cloud:
      gateway:
         routes:
            - id: example_route
              uri: http://example.org
              predicates:
                 - Path=/example/**
              filters:
                 - ExampleLogger=honors

Мы просто добавляем фильтр, указываем его имя и через знак равенства — параметр в конфиге. На выходе получаем:

Немного нелогичных логов
Немного нелогичных логов

Встроенные фильтры

Здесь для примера приведу фильтр троттлинга, RequestRateLimiter. На основании подключения к Redis он троттлит количество запросов, которые могут прийти в сервис:

spring:
   cloud:
      gateway:
         routes:
            - id: example_route
              uri: http://example.org
              predicates:
                 - Path=/example/**
              filters:
                 - ExampleLogger=honors
                 - name: RequestRateLimiter
                    args:
                       redis-rate-limiter:
                       replenishRate: 10
                       burstCapacity: 20
                       requestedTokens: 1

Если запросов будет больше, по умолчанию сервис будет выдавать ошибку 429. Для этого нужно просто добавить в конфиг шесть последних строчек и написать KeyResolver — это, собственно, определение ключа в Redis:

@Configuration
public class RateLimiterConfig {
    @Bean
    KeyResolver keyResolver() {
        return exchange ->

        Mono.just("RequestLimiterKey");
    }
}

Фильтры, включенные в SpringCloudGateway, получились очень удобными. Для сравнения с реализацией RateLimiter в Zuul достаточно привести объем кода, который вам придется написать в лучшем случае и без учета конфигурации Redis:

image20.png
Это еще не ошибиться надо, чтобы весь прод не положить

Попробуем подобный фильтр в деле. Как я писал в самом начале, все проекты можно найти в моем GitHub. Обратите внимание: в данном репо есть несколько веток, переходя по которым вы будете пополнять гейтвей новым функционалом и сможете провести свои эксперименты.

cloud:
    gateway:
      enabled: true
      httpclient:
        connect-timeout: 10000
        response-timeout: 30s
      routes:
        - id: auth
          uri: http://localhost:8443
          predicates:
            - Path=/auth/**
          filters:
            - RewritePath=/auth, /realms/bank_realm/protocol/openid-connect/token
        - id: fast-rest-service
          uri: http://epsilon.BH
          predicates:
            - Path=/fast-service/**
          filters:
            - StripPrefix=1
            - CustomUrl
        - id: slow-rest-service-v0
          uri: http://epsilon.BH:8280
          predicates:
            - Path=/slow-service/v0/**
          filters:
            - StripPrefix=2
        - id: slow-rest-service-v1
          uri: http://epsilon.BH:8281
          predicates:
            - Path=/slow-service/v1/**
          filters:
            - StripPrefix=2

Пробрасываем запрос — всё работает, и быстрый сервис, и медленный:

image13.png
It works!

 Трассировка с логированием тоже функционируют. Схема стала поинтересней, в ней теперь отображаются и трейсинги прохода в сервис безопасности. С логами тоже всё в порядке.

image10.png
Все прелести Tempo

Попробуем протестировать с нагрузкой. Используем тот же скрипт Gatling на 1000 запросов, что и с Zuul.

Упал сам, не клади друга
Упал сам, не клади друга

В результате прилег только slow-сервис, но не гейтвей. Значит, изначальную проблему мы решили.

image9.png
Остались только ошибки таймаута клиента

Все полученные ошибки связаны с таймаутом. Гейтвей пропустил запросы, но у сервиса не получилось. Как это исправить? Мы можем активировать RateLimiter — тогда клиент будет получать Status 429 по Throttling, но хотя бы сервис не упадет. Еще один вариант решения и один из несомненных плюсов использования Resilence4J — это актуальный CircuitBreaker, и его Spring Cloud Gateway поддерживает из коробки! Применение данного CB можно посмотреть в проекте на GitHub, там же для него есть отличный мониторинг. А hystrix канул в Лету.

Spring Boot 3: поддержка native-образа из коробки

Spring Boot 3 имеет поддержку компиляции в native-образ средствами AOT Compiler. Пожертвовав аннотациями @ConditionalOnProperty и @Profile, которые в большинстве случаев в Gateway не используются, мы можем запускать сервисы в 10–30 раз быстрее, чем запускается Jar!

Для сравнения мой лог запуска Jar:

image4.png
Неплохо, на самом деле

И запуск Native контейнера:

image1.png
Но вот это просто Porsche 911 4S Turbo!

3,1 секунды против 0,15 секунд!!! Это просто потрясающе! Когда возникают проблемы, Native позволяет быстро откатиться и избежать многих секунд простоя на проде, которые, как правило, очень дорого стоят — как денег компании, так и ваших нервов!

Преимущества и недостатки Zuul

Преимущества:

  • Легко писать фильтры, так как у нас всегда под рукой контекст и там всегда лежит запрос.

  • При помощи фильтров можно реализовать абсолютно любую логику преобразования запроса-ответа.

Недостатки:

  • Количество RPS к приложению жестко привязано к доступным потокам.

  • Ограничения в использовании Circuit Breaker — альтернативный Histrix не поддерживается.

  • Весь функционал надо писать самим, из коробки идет минимум.

  • Нет поддержки SSE/WebSocket — один из главных недостатков, который и привел нас к Spring Cloud.

Преимущества и недостатки Spring Cloud Gateway

Преимущества:

  • Реактивный стек, запросы не привязаны к потокам.

  • Большая гибкость настройки, можно писать глобальные и частные фильтры, присоединять их.

  • Поддержка resilince4j.

  • Отличная интеграция с Spring Boot / Spring Cloud.

  • Много фильтров (кастомного функционала) уже идут из коробки.

Недостатки:

  • Несовместимость с прошлым решением (Spring Cloud Zuul) — надо переписывать заново весь функционал. Но на самом деле это не так сложно, как кажется. В крайнем случае мы знаем, к кому сходить за помощью:

  • Нужно изучать что-то новое :)

Последствия перехода

  • Современный non-blocking API gateway на актуальном Spring Boot 3. 

  • Надежная защита от веерного отказа, когда один или несколько сервисов роняют все приложение — здесь все происходит в неблокирующем асинхронном режиме.

  • Поддержка всех технологий типа WebSocket и SSE

  • Возможность постепенной реализации Flux от клиента и до бэкенд-сервисов. Интересный инструмент, которым некоторые до сих пор не пользуются.

  • Бонус в виде перехода в Native из коробки. Девопсам придется немного повозиться с настройкой пайплайна, но лишь один раз.

Весь код к этому докладу доступен в моем гитхабе. Если появятся вопросы, пишите в комментах, либо в почту, твиттер или телеграм.

Комментарии (6)


  1. Algrinn
    11.12.2023 10:40

    Немного о вертикальном масштабировании:

    1. Tomcat. Он медленный. Вместе со Spring-ом разница от самых производительных решений может быть в 20 раз.

    2. Undertow. Он побыстрее. Но Spring всё равно всё испортит.

    3. Netty. Он очень производительный. Хорошо дружит с Vert.x-ом.

    Информация взята с Web Framework Benchmark - a тут тестируют все веб фреймворки на всех языках программирования, штук 400. Spring выступает как-то крайне неубедительно. Раньше там был и Spring Webflux, плёлся в хвосте, но потом он куда-то пропал, видимо, чтобы не дискредитировать Spring.


  1. Algrinn
    11.12.2023 10:40

    В общем, пока используем Spring Cloud Gateway. Потому что с Vert.x Gateway наверняка будут проблемы с функциональностью. А там через 10 лет посмотрим, в какую сторону пойдут микросервисы и шаблоны проектирования микросервисов. Как лучше всего проектировать кластер. Нужна ли Kafka или лучше без неё. Пока единого стандарта нет. Ну кроме стандарта делай всё на Spring-e и не парь людям мозги. :-D


  1. AntonVodyanoy
    11.12.2023 10:40

    Почему не использовать для маршрутизации средства самого OpenShift?
    не рассматривали маршрутизацию с помощью OpenShift Service Mesh?


    1. ggo
      11.12.2023 10:40

      По опыту, наблюдая проекты в которых API Gateway запилен на базе java-фреймворков, пришел для себя к предположению, что выбор решения был за чуваками, шарящими в java, но не шарящими чуть в сторону.

      Это не хорошо, не плохо. Просто наблюдение. Нельзя шарить везде и во всем. А других шарящих чуваков в команде не оказалось.

      В требованиях только вот это "Гейтвей должен маршрутизировать запросы в сервисы разных версий в зависимости от версии клиентского приложения" относительно нетривиально. Остальное штатным готовым API Gateway'ем решается.


  1. Danilka37
    11.12.2023 10:40

    Использовал Netty + Reactor на своем проекте. Производительность, удобство, я был в восторге, пока дело не дошло до слоя БД. Сначала использовали блокирующий Hibernate - все приложение от точки входа до БД было неблокирующим и БД стала узким горлышком, т.к. обычный Hibernate не поддерживает неблокирующие транзакции. Я перевел проект на Hibernate Reactive, и тут началось. Проблем и багов было много и они были критические. В итоге так и не получилось перевести эту связку на реактивщину.

    Если не использовать Hibernate или вообще не иметь бд в микро, однозначно на mvc я больше не вернусь.


    1. Algrinn
      11.12.2023 10:40

      Да упаси Господи использовать этот Hibernate в реактивных проектах. Hibernate это просто тормознутый генератор уродливых SQL запросов, который упрощает работу программисту на типичных бизнес проектах и усложняет на проектах высоких нагрузок. Нужно смотреть на реактивные драйвера для БД вместо JDBC - R2DBC.