Под Новый Год мы с женой пересматриваем одну популярную магическую сагу — хочется знаете ли в жизни волшебства. В IT волшебства тоже хватает — от танцев с бубнами до самых современных технологий, принцип работы которых проще объяснить тёмной магией. Так, например, бытует мнение, что настройка service mesh — это магия, подвластная лишь волшебникам DevOps. Но у нас в Альфа-Банке разработчики исторически не маглы, а, как минимум, полукровки, поэтому имеют право приобщиться к волшебному миру.

В этой серии статей я хочу поделиться опытом изучения технологии service mesh, а именно Istio, на примере практической задачи, возникшей какое то время назад в одной из систем Альфа Банка, системным архитектором которой я являюсь.

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

Вторая статья будет посвящена нагрузочному тестированию и выводам об эффективности полученного решения.

Примечание. Вопросы установки Istio и конфигурации управляющих компонентов, а также базовые принципы работы Istio за рамками этой серии. 

Почему Istio?

По данным, приведенным в статье «Тренды Kubernetes и контейнеризации в 2023 году», Istio остается лидером рынка в области service mesh и опережает по популярности конкурентов более чем в 3 раза. А учитывая, что общий уровень использования технологии service mesh в кластерах Kubernetes, в целом, всё ещё достаточно низкий — около 13% общего числа компаний — то внедрение альтернативных реализаций создает бОльшие риски. Однако, мы следим за рынком и, возможно, когда-нибудь исследуем и сравним наиболее перспективные реализации. О результатах обязательно сообщим в нашем блоге.

О базовых принципах работы Istio хорошо написано, например, в статье «Istio в разрезе: что умеет и не умеет самый популярный Service Mesh». Для понимания дальнейшего материала надо, как минимум, иметь представление о том, что такое sidecar контейнер, как через него идет трафик и причем тут envoy.

Альфа-Бизнес

Система, о которой пойдёт речь — Альфа-Бизнес, интернет-банк для бизнеса. Это одна из самых крупных систем банка. Реализована на микросервисной архитектуре, в настоящий момент переживает переходный период, связанный с упорядочиванием архитектуры в соответствии с подходом DOMA, предложенным компанией Uber. Приведу краткую выжимку из оригинальной статьи, достаточную для перехода к нашей практической задаче.

Основные принципы и терминология:

  1. Вместо того, чтобы рассматривать отдельные микросервисы, мы рассматриваем их группы. И называем их доменами (domains).

  2. Далее, мы объединяем домены в так называемые слои (layers). Слой, которому принадлежит домен, определяет, какие зависимости доступны для микросервисов в этом домене. Получившуюся архитектуру мы называем многослойной (layer design).

  3. У доменов имеются четкие интерфейсы, которые служат единой точкой входа в группу микросервисов. Это так называемые шлюзы (gateways).

Домен предоставляет единую точку входа для потребителей и каналы событий, скрывающие внутреннее устройство домена (на приведенной ниже схеме и далее в статье рассматривается только синхронное взаимодействие между доменами). Также это позволяет уменьшить возможное число связей и сложность итоговой системы, и дает гибкость при работе над внутренней реализацией домена. 

Концептуальная схема DOMA
Концептуальная схема DOMA

В Alfa Business больше 100 таких доменов, состоящих, в сумме, из более чем 1000 микросервисов. Пока мы выделяем три слоя:

  • Слой представления — уровень канальной логики и интерфейсов;

  • Продуктовый слой — функциональность, специфичная для продукта, но независимая от представления, канала (благотворительность, бухгалтерия, инвойсы). Переиспользуется каналами.

  • Платформенный слой — переиспользуемая бизнес-функциональность, не специфичная для какого-либо продукта (уведомления, профиль, ролевая модель, подпись). Переиспользуется продуктами.

DOMA позволяет уменьшить возможное количество связей между микросервисами и определяет правомерность интеграций между доменам, но не предлагает вариантов реализации разграничения доступа.

DOMA и Kubernetes

При наложении подхода DOMA на инфраструктуру Kubernetes мы получаем следующий вариант представления домена.

Домены в Kubernetes
Домены в Kubernetes

Домен в Kubernetes — это отдельный неймспейс, выставляющий наружу API для синхронного взаимодействия посредством некого гейтвея.

В таком случае у нас появляется возможность управления доступом между доменами на сетевом уровне посредством Kubernetes NetworkPolicy. 

Однако такой подход не позволяет гранулярно управлять доступом на уровне отдельных API методов. Решением является использование реализации гейтвея, позволяющей работать с трафиком на http уровне и выполнять функции аутентификации и авторизации запросов.

Дополнительно мы решили ввести ещё один уровень защиты и реализовать на гейтвее rate limit в разрезе сервисов потребителей. Это позволяет командам организовать прозрачный процесс масштабирования мощностей в условиях растущей нагрузки и повысить стабильность сервисов поставщиков.

Spring cloud gateway

Ещё до перехода на Kubernetes в банке был хорошо развит Spring Cloud стек:

  • предоставляющий реализацию гейтвея на базе Spring Cloud Gateway;

  • с подключенным Spring Security фильтром для реализации OAuth 2.0 Client Credentials Flow для аутентификации и авторизации по JWT токену;

  • и кастомным фильтром для реализации rate limit в разрезе ключа, получаемого из http заголовков.

В качестве централизованного хранилища бакетов со счетчиками используется Redis. Конфигурация для гейтвея раздается с помощью Spring Cloud Config Server — это тоже наследие докубернетевских времен.

Схема Spring Cloud (взаимодействие с IDP опущено)
Схема Spring Cloud (взаимодействие с IDP опущено)

Задача

Схема со Spring гейтвеем работает, но имеет некоторые недостатки. Часть из них, обусловленную спецификой наших процессов и систем, я оставлю в тайне, а приведу следующие:

  • Возможность hot reload для обновления конфигурации требует взаимодействия с actuator API и имеет ограничения (про hot reload из ConfigMap в Spring Cloud Kubernetes знаем, но и там не все гладко).

  • Высокая ресурсоемкость Spring приложений.

Почитав обзоры на разные реализации service mesh, можно обнаружить, что функции аутентификации, авторизации и rate limiting вполне естественные и поддерживаются из коробки большинством реализаций. Поэтому мы решили попробовать заменить Spring гейтвей на более нативное решение, потенциально решающее проблемы текущей реализации.

Начнем

К размышлениям о выборе реализации service mesh, приведенным в начале статьи, стоит ещё добавить, что в нашем Kubernetes кластере уже стоял Istio. Пока sidecar-ы не делают ничего, кроме как проксируют весь трафик и пишут телеметрию для Kiali, это такой инструмент для визуализации Istio service mesh.

Ещё у нас уже был Helm чарт для раскатки базовых Kubernetes ресурсов, формирующих инфраструктурное представление домена: Namespace, NetworkPolicy, Ingress и некоторые вспомогательные ресурсы. 

Всё это завернуто в Argo CD, инструмент, реализующий GitOps для Kubernetes. Позволяет довольно легко централизованно управлять неймспейсами и их инфраструктурной обвязкой. 

Starter pack
Starter pack

Если проводить аналогию с волшебным миром, то нам повезло — волшебная палочка для работы с Istio у нас уже есть. Остаётся выучить правильные заклинания для управления трафиком.

Заклинания для Istio — это специальные Kubernetes CRD, представляющие дружелюбную абстракцию над устройством Istio (забегая вперед скажу, что это не совсем так).

Мы решили двигаться последовательно и начали с аутентификации и авторизации.

Аутентификация и авторизация

Формализуем требования.

Нам нужно настроить аутентификацию и авторизацию по протоколу OAuth 2.0 Client Credentials Flow — на основании HTTP-заголовка — Authorization: Bearer {jwt-token}.

В рамках процесса аутентификации проверяется:

  1. Наличие заголовка в запросе.

  2. Подпись токена.

  3. Время жизни токена (exp claim).

  4. Доверенный издатель токена (iss claim).

В случае непрохождения проверок возвращается ответ с 401 HTTP статусом.

В рамках процесса авторизации проверяется наличие необходимых (в соответствии с настроенными правилами авторизации) ролей в токене (claim realm_access.roles). В случае непрохождения проверок возвращается ответ с 403 HTTP статусом.

При проектировании API мы используем смешанный подход. Очевидные CRUD методы мы делаем в RESTful стиле. В более сложных случаях не пытаемся натянуть сову на глобус и делаем подобие JSON RPC — HTTP-method POST, имя команды (глагол) в path, всё остальное в body. 

При настройке аутентификации и авторизации мы хотим иметь возможность как гранулярной настройки правил для отдельных API методов (комбинаций path + HTTP-method), так и настройки общих правил для групп методов. Основной интерес тут представляет именно path. Сформулируем возможные сценарии:

  1. Полное совпадение (exact match) — используется для гранулярной настройки как в RESTful, например /api/v1/users, так и в JSON RPC, например /api/v1/sendSms.

  2. Совпадение по префиксу (prefix match) — используется для настройки групп методов, например /api/v1/.

  3. Совпадение по шаблону (template match) — для RESTful сценариев с path параметрами, например для получением вложенных ресурсов — /api/v1/users/{id}/documents.

При этом часть служебных методов должна быть публичной (без аутентификации), а для авторизации должны поддерживаться вложенные claim-ы.

RequestAuthentication

Это заклинание отвечает за аутентификацию входящего запроса и, в целом, достаточно простое и понятное. Итоговый результат, закрывающий наши требования, выглядит следующим образом (здесь и во всех следующих примерах опущена секция metadata и в некоторых случаях подставлены плейсхолдеры вместо реальных значений).

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
spec:
  jwtRules:
    - issuer: {issuer}
      jwks: {jwks_json_string}     
  selector:
    matchLabels:
      app: myapp

Описание конфигурации:

  • issuer — доверенный издатель токена. Определяется конфигурацией IDP (Identity Provider) и проставляется в iss claim JWT токена.

  • jwks_json_string — JSON-объект, представляющий публичный ключ для проверки подписи JWT токена. Определяется также на стороне IDP, как правило можно получить через API, либо в интерфейсе IDP.

Стоит отметить, что RequestAuthentication позволяет вместо jwks использовать jwksUri, указывающий на конечную точку API IDP, возвращающую JWKS. Такой вариант, по идее, должен упрощать ротацию ключей (например, в случае компрометации).

Но есть нюансы.

В случае с автоматической и регулярной ротацией ключей на стороне IDP, вас вряд ли устроит поведение по умолчанию, при котором обновление JWKS происходит на стороне istiod раз в 20 минут. Istio позволяет настраивать этот параметр с помощью переменной PILOT_JWT_PUB_KEY_REFRESH_INTERVAL, но для оперативного обновления JWKS заданное время должно быть как можно меньше, а это значит, что istiod будет очень часто тревожить наш IDP. 

Istio предлагает еще одну переменную PILOT_JWT_ENABLE_REMOTE_JWKS, позволяющую делегировать функцию по запросу jwksUri envoy, но в остальном всё так же — нам предлагается задать время жизни JWKS в кэше, по истечению которого envoy пойдет в IDP. Однако это кажется ещё более плохой идеей, так как в этом случае тревожить наш IDP будут сразу все сервисы, для которых настроен RequestAuthentication.

Резюмируя, комьюнити в ожидании решения, соответствующего спецификации openid-connect, и позволяющего обновлять JWKS по факту получения неизвестного kid в заголовках JWT. 

Кстати, в таком случае делегирование запроса envoy обретает смысл, так как именно он занимается проверкой подписи. Однако все issue, которые удалось найти, например, jwks cache related errors should result in refetch of jwks_uri #29436 и Problem with JWT token authentication in the pilot & envoy #10547, пока закрыты.

Для нашего проекта мы решили ограничиться статическим значением JWKS. В случае ошибки валидации токена возвращается ответ с 401 HTTP-статусом и соответствующим телом.

  • Jwt is expired — истекшее время жизни токена.

  • Jwt issuer is not configured — недоверенный издатель.

  • Jwt verification fails — невалидная подпись.

Важно отметить, что RequestAuthentication не делает наличие JWT токена в запросе обязательным и валидирует его только при наличии. Чтобы не пропускать запросы без JWT токена, нам потребуется ещё одно заклинание — AuthorizationPolicy.

AuthorizationPolicy 

Это заклинание позволяет определить для каких API методов сервиса нужна аутентификация и задать правила авторизации. 

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
spec:
  action: ALLOW
  rules:
    - from:
        - source:
            notRequestPrincipals:
              - '*'
      to:
        - operation:
            paths:
              - /admin/health
              - /specs/*
    - from:
        - source:
            requestPrincipals:
              - '*'
      to:
        - operation:
            methods:
              - GET
            paths:
              - /api/v1/users
      when:
        - key: request.auth.claims[realm_access][roles]
          values:
            - ALLOW_USERS
   selector:
    matchLabels:
      app: myapp

Описание конфигурации:

  • Политика является разрешающей (ALLOW).

  • Разрешены неаутентифицированные (без Authorization заголовка) запросы к /admin/health и /specs*.

  • GET запросы с path /api/v1/users (exact match) должны иметь Authorization заголовок с валидным JWT токеном, содержащим сlaim realm_access.roles со значением ALLOW_USERS.

Для запроса работает следующий алгоритм принятия решения (для одного сервиса может быть несколько AuthorizationPolicy, в том числе с разным action).

  • Если запрос попадает под политику DENY — запретить запрос.

  • Если для данного сервиса нет политик ALLOW — разрешить запрос.

  • Если запрос попадает под политику ALLOW — разрешить запрос.

  • Все остальные запросы — запретить.

При попадании запроса под условия из нескольких правил (rules), итоговый результат будет определен логическим ИЛИ для всех WHEN выражений. В случае неуспешной авторизации возвращается ответ с 403 HTTP-статусом и телом RBAC: access denied.

Приведенный пример реализует сценарий с exact match, а что с остальными? 

Prefix match так же поддерживается, выглядеть будет так.

paths:
  - /api/v1/*

А вот с template match нас ждёт первое разочарование — настроить правило авторизации для такого path через AuthorizationPolicy не выйдет.

Поддерживается префикс (smth*), поддерживается постфикс (*smth), а по середине (smth*else) пока никак. Всё, что мы смогли обнаружить по этому вопросу — это настоявшийся 4 летний issue.

Совсем недавно (3 недели назад на момент написания статьи) в issue предложили вариант по обходу данного ограничения с помощью расширения для envoy (доступно с версии envoy 1.24.0). Но мы прорабатывали решение сильно раньше и придумали свой workaround, о котором расскажем чуть позже, а пока двигаемся дальше. 

Rate Limit

Начнём снова с требований.

Нам необходимо определять квоты (количество запросов в единицу времени) на деплоймент (т.е независимо от количества инстансов) и осуществлять ограничение запросов в разрезе определённых API методов и клиентов (сервисов потребителей), идентифицируемых по HTTP-заголовку. В случае превышения лимита возвращается ответ с 429 HTTP-статусом.

Сценарии с path такие же, как для аутентификации и авторизации — exact, prefix и template.

RateLimitMaxima

Примерно такое заклинание мы ожидали увидеть в документации Istio, но, внезапно, нам предлагают освоить EnvoyFilter. А ещё оказывается есть два вида rate limit — local и global. Давайте по порядку.

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

Для тех, кто не ознакомился с базовыми принципами работы Istio, но несмотря на это дочитал до данного момента, envoy — это высокопроизводительный прокси с собственным богатым API, реализация sidecar контейнера в Istio. 

Я, в целом, не рекомендую использовать Istio без понимания принципов работы envoy, хотя, как мы видели для ряда задач, Istio и предоставляет самодостаточные абстракции.

Local rate limit

Этот вид rate limit работает непосредственно в рамках одного конкретного инстанса envoy (per pod). В контексте нашей задачи мы решили использовать его для контроля общей нагрузки (от всех потребителей сервиса) на основании результатов Max Capacity тестов. 

Таким образом, можно иметь некую гарантию, что нагрузка на сервис никогда не превысит полученного в рамках нагрузочного тестирования предельно допустимого объема rps. 

Да, с оговорками, что разные API методы могут иметь разное влияние на производительность, и таким образом фактически было бы неплохо учитывать пропорцию, но, в целом, такой грубый подход применим и даёт дополнительный уровень защиты.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
spec:
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: envoy.filters.network.http_connection_manager
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.local_ratelimit
          typed_config:
            '@type': type.googleapis.com/udpa.type.v1.TypedStruct
            type_url: >-
              type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
            value:
              stat_prefix: myapp
    - applyTo: HTTP_ROUTE
      match:
        context: SIDECAR_INBOUND
        routeConfiguration:
          name: inbound|8080||
          vhost:
            name: inbound|http|80
            route:
              name: default
              action: ANY
      patch:
        operation: MERGE
        value:
          typed_per_filter_config:
            envoy.filters.http.local_ratelimit:
              '@type': type.googleapis.com/udpa.type.v1.TypedStruct
              type_url: >-
                type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
              value:
                filter_enabled:
                  default_value:
                    denominator: HUNDRED
                    numerator: 100
                  runtime_key: local_rate_limit_enabled
                filter_enforced:
                  default_value:
                    denominator: HUNDRED
                    numerator: 100
                  runtime_key: local_rate_limit_enforced
                stat_prefix: myapp
                token_bucket:
                  fill_interval: 1s
                  max_tokens: 1000
                  tokens_per_fill: 1000
  workloadSelector:
    labels:
      app: myapp

Конфигурация определяет два патча.

Первый патч применяется к цепочке фильтров envoy (HTTP_FILTER): для входящего трафика envoy (context: SIDECAR_INBOUND) добавляется фильтр envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit в цепочку перед фильтром envoy.filters.network.http_connection_manager.

Второй патч применяется к маршруту envoy (HTTP_ROUTE) и позволяет настроить добавленный первым патчем фильтр для конкретного маршрута.

Давайте для начала разберемся, откуда вообще в envoy берутся маршруты для входящего трафика?

Маршруты для входящего трафика (inbound) строятся на основании Kubernetes Service соответствующего приложения. Для нашего приложения myapp существует Service, маршрутизирующий HTTP-трафик с 80 порта на 8080 порт контейнера приложения. На основании этого Istio сформирует routeConfiguration с именем inbound|8080|| и vhost с именем inbound|http|80, для которого будет создан маршрут с именем default, ведущий в наш контейнер. Это я ещё упростил и опустил такую сущность envoy как cluster.

Кстати, чтобы увидеть полную картину и научиться лучше понимать envoy можно использовать уже упомянутый мной инструмент Kiali. Он позволяет увидеть сырую конфигурацию envoy, что очень полезно для отладки.

Описание конфигурации:

  • Для маршрута default в рамках routeConfiguration inbound|8080|| и vhost inbound|http|80 задаётся rate limit 1000rps (tokens_per_fill / fill_interval).

  • Рейт лимит фильтр применяется (filter_enabled) и функционирует в режиме ограничения (filter_enforced) для 100% трафика.

Отмечу, что во избежание взаимовлияния, на порт 8080 должен идти только бизнесовый трафик, служебный (хелсчек, метрики и т.д) рекомендуется вынести на отдельный порт. В таком случае у envoy появится ещё один routeConfiguration, на маршруте которого не будет настроен rate limit. Иначе из-за превышения лимита в сервис могут перестать проходить хелсчеки и он уйдет в рестарт.

В случае превышения лимита (отсутствия доступных токенов в бакете) возвращается ответ с 429 HTTP-статусом и телом local_rate_limited.

Global rate limit

Этот вид rate limit как раз позволяет реализовать наши основные требования. Предназначен для работы с распределенной нагрузкой, в нашем случае на все инстансы деплоймента (per service).

Заклинание чуть сложное, разбираем по частям.

Первый патч аналогично применяется к цепочке фильтров envoy (HTTP_FILTER) для входящего трафика envoy (context: SIDECAR_INBOUND) и добавляет фильтр envoy.extensions.filters.http.ratelimit.v3.RateLimit в цепочку перед фильтром envoy.filters.http.router.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
spec:
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: envoy.filters.network.http_connection_manager
              subFilter:
                name: envoy.filters.http.router
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.ratelimit
          typed_config:
            '@type': >-
              type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
            domain: {domain}
            failure_mode_deny: false
            rate_limit_service:
              grpc_service:
                envoy_grpc:
                  authority: {authority}
                  cluster_name: {ratelimit_service_cluster}              
                transport_api_version: V3
            timeout: 30ms
  workloadSelector:
    labels:
      app: myapp

В случае с global rate limit на уровне этого патча также задается важная конфигурация фильтра.

  • domain — домен в рамках service mesh (неймспейс или сервис), используется для возможности разграничений настроек rate limit в рамках одного ratelimit service.

  • failure_mode_deny: false — определяет поведение в случае ошибки/таймаута от ratelimit service, false — разрешить запрос.

  • authority — заголовок :authority при обращении к ratelimit service ratelimit_service_cluster — имя envoy кластера для обращения к ratelimit service.

  • transport_api_version: V3 — версия используемого rate limit API envoy.

  • timeout: 30ms — таймаут на обращение к ratelimit service, по истечение которого отрабатывает политика failure_mode_deny.

Так, что за ratelimit service такой? Это как раз тот компонент, который позволяет вести учет общих лимитов для нескольких инстансов сервиса. Но envoy не поднимет за нас ratelimit service, он лишь определяет контракт для gRPC API, к которому будет обращаться из фильтра для принятия решения о пропуске или отклонении запроса. Реализацию мы вольны выбирать/создавать сами, но пока оставим этот вопрос открытым.

Второй патч применяется к маршруту envoy (HTTP_ROUTE) и позволяет настроить как будет формироваться запрос к ratelimit service для конкретного маршрута в рамках входящего трафика envoy (context: SIDECAR_INBOUND).

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
spec:
  configPatches:
    - applyTo: HTTP_ROUTE
      match:
        context: SIDECAR_INBOUND
        routeConfiguration:
          name: inbound|8080||
          vhost:
            name: inbound|http|80
            route:
              name: default
              action: ANY
      patch:
        operation: MERGE
        value:
          route:
            rate_limits:
              - actions:
                  - request_headers:
                      descriptor_key: path
                      header_name: ':path'                  
	             - request_headers:
                      descriptor_key: method
                      header_name: ':method'
                  - request_headers:
                      descriptor_key: clientid
                      header_name: clientid
  workloadSelector:
    labels:
      app: myapp

Этот маршрут уже нам знаком, но теперь для него определяется новая конфигурация — rate_limits. Эта конфигурация позволяет определить правила формирования запроса к ratelimit service на основании исходного запроса. В нашем случае мы формируем запрос (дескрипторы, пары ключ-значение) из заголовков :path, :method и clientid.

  • Первые два «псевдозаголовка» добавляются к запросу автоматически внутри envoy на основании пути и HTTP-метода запроса, соответсвенно.

  • Третий заголовок — часть входящего запроса и идентифицирует клиента (сервис потребитель).

Дальше в дело вступает ratelimit service, а значит больше откладывать нельзя и нужно определяться с реализацией.

Ratelimit service

В документации Istio приводится ссылка на реализацию от разработчиков envoy. Написана на Go, умеет обновлять конфигурацию из Kubernetes ConfigMap на лету (hot reload), умеет работать с разной HA архитектурой Redis. Должно подойти, как минимум, стоит попробовать.

Конфигурацию деплоймента и сервиса я позволю себе опустить, в целом, всё достаточно стандартно. Отмечу только, что для prometheus метрик пришлось добавить в pod дополнительный контейнер — statsd-exporter. Если кому будет интересно, пишите, поделюсь конфигурацией.

С Redis остановились на Redis Sentinel, как на более компактной и ресурсоемкой HA конфигурации для маленьких объемов данных, подключив к нашему Helm чарту зависимость bitnami/redis.

Достаточной для нас конфигурацией является: 

  • 3 инстанса sentinel (кворум для failover — 2), 3 инстанса redis (1 мастер, 2 реплики);

  • персистентность полностью отключена, так как потерять счетчики актуальные в течение секунды не критично.

На данном этапе мы столкнулись только с одной небольшой проблемой, не покрытой в документации — ratelimit service не поддерживает аутентификацию к sentinel, к самому redis аутентификация работает, есть issue.

Дальше самая интересная часть — конфигурация ratelimit service. Здесь мы должны реализовать нашу бизнес логику. Начнем снова с exact match, с ним проблем быть не должно.

apiVersion: v1
kind: ConfigMap
data:
  myapp-config.yaml: |
    domain: myapp
    descriptors:
    - key: path
      value: /api/v1/users
      descriptors:
      - key: method
        value: GET
        descriptors:
        - key: clientid
          value: {clientid}
          rate_limit:
            unit: second
            requests_per_unit: 20

Да, domain — это то значение, которое мы определили в конфигурации rate limit фильтра envoy. В одном конфигурационном файле могут быть описаны дескрипторы только для одного домена. Поэтому, если в качестве домена был выбран сервис, как в примере выше, то в ConfigMap надо добавлять по файлу на сервис. Файлы конфигурации ищутся в определяемой настройками директории по паттерну .yaml

Дальше пробуем prefix match. Документация ratelimit service говорит о поддержке wildcard(*).

- key: path
  value: /api/v1/*

Но вот только работает это не так, как мы ожидали. В таком случае, для каждого запроса, попавшего под значение дескриптора с wildcard, будет сформирован собственный счетчик. То есть клиент получит квоту 20 rps для каждого уникального запроса с path префиксом /api/v1/. Это совсем не то, что нам нужно.

Template match само собой тоже не поддерживается. Даже если бы wildcard можно было вставить в середину, то логика его работы всё равно нам не подходит.

Какие у нас есть варианты? Можно попробовать доработать ratelimit service. Это Open Source, ну и кажется, что ожидаемое нами поведение при использовании wildcard вполне логичное и имеет место быть. Но мы всё-таки уже довольно опытные волшебники, попробуем что-то наколдовать.

Если текущую реализацию wildcard использовать нельзя, то значения дескрипторов в конфигурации ratelimit service должны быть статичными, а значит, мы должны сформировать соответствующие значения уровнем выше при построении запроса к ratelimit service.

Для наших сценариев определим следующие целевые значения дескрипторов.

Exact match — так и оставляем само значение без изменений

descriptors:
  - key: path
    value: /api/v1/users

Prefix match — необходимо добавить признак того, что указанное значение это префикс, (*) уже зарезервирован, пусть будет максимально читаемо

descriptors:
  - key: path
    value: prefix=/api/v1/

Template match — используем стандартную подстановку плейсхолдера вместо path параметра

descriptors:
  - key: path
    value: /api/v1/users/{id}/documents

Остаётся научиться формировать такие значения. 

Как уже было показано ранее, EnvoyFilter позволяет нам определить правила формирования дескрипторов для отдельных маршрутов, но маршрут пока один. Если для каждого path, в разрезе которого мы настраиваем rate limit, будет свой маршрут, то мы сможем сформировать необходимые нам дескрипторы следующим образом. 

Exact match.

route:
  rate_limits:
    - actions:
        - request_headers:
            descriptor_key: path
            header_name: ':path' 

Prefix match.

route:
  rate_limits:
    - actions:		
        - header_value_match:
            descriptor_key: path
            descriptor_value: prefix=/api/v1/
            expect_match: true
            headers:
              - name: ':path'
                prefix_match: /api/v1/

Template match.

route:
  rate_limits:
    - actions:		
        - header_value_match:
            descriptor_key: path
            descriptor_value: /api/v1/users/{id}/documents
            expect_match: true
            headers:
              - name: ':path'
                safe_regex_match:
            		regex: /api/v1/users/.*/documents

Осталось создать маршруты. В Istio точно есть для этого заклинание, так как маршрутизация трафика — это одна из главных функций service mesh.

VirtualService

Это заклинание позволяет управлять маршрутизацией как раз таки за счёт добавления маршрутов в конфигурацию envoy. Попробуем создать сразу по маршруту для каждого из сценариев.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
spec:
  hosts:
    - myapp.mynamespace.svc.cluster.local
  http:
    - match:
        - uri:
            exact: /api/v1/users
      name: /api/v1/users
      route:
        - destination:
            host: myapp.mynamespace.svc.cluster.local
            port:
              number: 80
    - match:
        - uri:
            prefix: /api/v1/
      name: /api/v1/*
      route:
        - destination:
            host: myapp.mynamespace.svc.cluster.local
            port:
              number: 80
    - match:
        - uri:
            regex: /api/v1/users/.*/documents
      name: /api/v1/users/{id}/documents
      route:
        - destination:
            host: myapp.mynamespace.svc.cluster.local
            port:
              number: 80

Описание конфигурации:

  • hosts — список имен хостов (заголовок Host), для которых настраивается маршрутизация. В нашем случае доменное имя сервиса.

  • http — список создаваемых маршрутов (httpRoute).

  • httpRoute.match — условие для применения маршрута, в соответствии с реализуемым сценарием.

  • httpRoute.name — имя маршрута, используется как селектор при применении патча для HTTP_ROUTE.

  • httpRoute.route — список целей маршрута. В нашем случае это исходный сервис.

Идём в Kiali и видим, что маршруты не появились. Мы слишком увлеклись и совершили распространенную ошибку при работе с маршрутизацией. 

Не все заклинания работают на входящем трафике, вот VirtualService как раз не работает. И это, в целом, даже логично. Если входящий трафик уже пришел в наш pod, то будет странно и не очевидно перенаправлять его куда-то ещё. Так можно и петлю создать, если смаршрутизировать обратно в вызывающий сервис. Важно всегда помнить, что все правила маршрутизации работают только на исходящем трафике (outbound).

Sidecar gateway

Внимательный читатель уже мог заметить, что пока все заклинания мы пытались применять на входящем трафике sidecar-а. Таким образом, в нашем неймспейсе даже нет как такового гейтвея, можно сказать что он распределенный, на sidecar-ах. Собственно это и привлекательно, нет дополнительных компонентов. Но всё ли хорошо с такой архитектурой?

Sidecar gateway
Sidecar gateway

На собственном опыте мы выяснили, что функционал, связанный с маршрутизацией, доступен только для исходящего трафика. То есть если запрос идёт извне нашего service mesh, то дополнительные правила маршрутизации не отработают. Это может быть критично, например для canary релизов.

А для внутреннего трафика эта проблема проявляется иначе, особенно в больших service mesh-ах. Для возможности отработать на исходящем трафике sidecar сервиса потребителя должен знать правила маршрутизации всех сервисов поставщиков. Это увеличивает потребление RAM sidecar-ами и нагрузку на сеть и istiod при изменении конфигурации сервисов поставщиков. 

Изначально в Istio все было ещё хуже — каждый sidecar хранил и получал обновления конфигурации для всех сервисов внутри service mesh. Со временем появилась возможность ограничивать видимость правил маршрутизации, но копипаста в конфигурации sidecar-ов сервисов потребителей всё еще остается узким местом в крупных service mesh-ах. 

А что, если нам вернуться к более буквальному восприятию паттерна API Gateway и реализовать единую точку входа в каждый неймспейс? В таком случае на исходящем трафике гейтвея мы сможем применить все те заклинания, которые не работают на входящем трафике. Их видимость будет ограничена неймспейсом, а все потребители будут взаимодействовать только с гейтвеем. А еще гейтвей можно независимо горизонтально масштабировать, что невозможно для envoy в sidecar-ах.

В процессе реализации такого подхода мы обнаружили, что подобную схему Istio предложил в Ambient Service Mesh — это новый режим работы Data plane, без sidecar-ов. Там функции гейтвея на входе в неймспейс выполняет waypoint proxy. Но Ambient пока ещё в alpha статусе, так что наша реализация имеет место быть.

Istio Gateway

Для создания единой точки входа нам потребуются три действия.

Раз

Сначала раскатим envoy, являющийся реализацией istio gateway. Для этого мы подключаем к нашему helm чарту ещё одну зависимость istio-official/gateway.

Из интересного — есть возможность использования механизма sidecar injection для внедрения конфигурации контейнера. Такой подход позволяет единообразно управлять конфигурациями всех envoy в кластере, как в sidecar-ах, так и в гейтвеях.

Два

Применяем заклинание Gateway, чтобы настроить envoy для работы с ingress трафиком.

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
spec:
  selector:
    app: istio-gateway
  servers:
    - hosts:
        - {hostname}
      port:
        name: http-80
        number: 80
        protocol: HTTP

Описание конфигурации:

  • selector — селектор для привязки к подам envoy.

  • server.port — порт, на котором слушает envoy.

  • server.hosts — список имен хостов, с которых принимается трафик (заголовок Host).

Важно отметить, что Gateway не открывает у envoy указываемые порты, они должны быть открыты непосредственно у контейнера envoy.

Три

И в конце нам пригодится уже изученное заклинание VirtualService для настройки правил маршрутизации в envoy на исходящем трафике. Опять же приведем маршруты с учётом сразу всех сценариев.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
spec:
  exportTo:
    - .
  gateways:
    - {gateway}
  hosts:
    - {hostname}
  http:
    # exact
    - match:
        - uri:
            exact: /myapp/api/v1/users
      name: /myapp/api/v1/users
      rewrite:
        uri: /api/v1/users
      route:
        - destination:
            host: myapp.mynamespace.svc.cluster.local
            port:
              number: 80
   # prefix
    - match:
        - uri:
            prefix: /myapp/api/v1/
      name: /myapp/api/v1/*
      rewrite:
        uri: /api/v1/
      route:
        - destination:
            host: myapp.mynamespace.svc.cluster.local
            port:
              number: 80
   # template
    - match:
        - uri:
            regex: /myapp/api/v1/users/.*/documents
      name: /myapp/api/v1/users/{id}/documents
      rewrite:
        uriRegexRewrite:
		match: /myapp(/|$)(.*)
		rewrite: /\2
      route:
        - destination:
            host: myapp.mynamespace.svc.cluster.local
            port:
              number: 80

Тут есть принципиально важные отличия от нашей предыдущей попытки настроить маршруты для sidecar-а.

  • exportTo: [.] — мы ограничиваем видимость для данной конфигурации собственным неймспейсом.

  • gateway — имя гейтвея, на исходящем трафике которого применяются правила маршрутизации. В sidecar-ы данная конфигурация не попадёт.

  • В правилах маршрутизации при определении path добавился префикс /myapp, позволяющий на гейтвее различать маршруты для разных сервисов и опция rewrite для отбрасывания этого префикса при маршрутизации запроса в целевой сервис

И ещё немного

Теперь на полученном гейтвее с настроенными маршрутами мы можем добавить конфигурацию global rate limit. Как и для sidecar-а мы, с помощью EnvoyFilter, создаём два патча, изменения будут совсем незначительные.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
spec:
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: GATEWAY
        listener:
          filterChain:
            filter:
              name: envoy.filters.network.http_connection_manager
              subFilter:
                name: envoy.filters.http.router
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.ratelimit
          typed_config:
            '@type': >-
              type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
            domain: mynamespace
            failure_mode_deny: false
            rate_limit_service:
              grpc_service:
                envoy_grpc:
                  authority: {authority}
                  cluster_name: {ratelimit_service_cluster}              
                transport_api_version: V3
            timeout: 30ms
  workloadSelector:
    labels:
      app: gateway

Изменения конфигурации:

  • context: GATEWAY — патч применяется для конфигурации гейтвея.

  • domain — домен в таком случае можно приравнять к неймспейсу, а настройки лимитов для сервисов в ratelimit service будут различаться по введенному path префиксу.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
spec:
  configPatches:
    # exact match
    - applyTo: HTTP_ROUTE
      match:
        context: GATEWAY
        routeConfiguration:
          name: http.80
          vhost:
            name: gateway.mynamespace.svc.cluster.local:80
            route:
              action: ANY
              name: /myapp/api/v1/users
      patch:
        operation: MERGE
        value:
          route:
            rate_limits:
              - actions:
                  - request_headers:
                      descriptor_key: path
                      header_name: ':path'
                  - request_headers:
                      descriptor_key: method
                      header_name: ':method'
                  - request_headers:
                      descriptor_key: clientid
                      header_name: clientid
    # prefix match
    - applyTo: HTTP_ROUTE
      match:
        context: GATEWAY
        routeConfiguration:
          name: http.80
          vhost:
            name: gateway.mynamespace.svc.cluster.local:80
            route:
              action: ANY
              name: /myapp/api/v1/*
      patch:
        operation: MERGE
        value:
          route:
            rate_limits:
              - actions:
                  - header_value_match:
                      descriptor_key: path
                      descriptor_value: prefix=/myapp/api/v1/
                      expect_match: true
                      headers:
                        - name: ':path'
                          prefix_match: /myapp/api/v1/
                  - request_headers:
                      descriptor_key: method
                      header_name: ':method'
                  - request_headers:
                      descriptor_key: clientid
                      header_name: clientid
    # template match
    - applyTo: HTTP_ROUTE
      match:
        context: GATEWAY
        routeConfiguration:
          name: http.80
          vhost:
            name: gateway.mynamespace.svc.cluster.local:80
            route:
              action: ANY
              name: /myapp/api/v1/users/{id}/documents
      patch:
        operation: MERGE
        value:
          route:
            rate_limits:
              - actions:
                  - header_value_match:
                      descriptor_key: path
                      descriptor_value: /myapp/api/v1/users/{id}/documents/
                      expect_match: true
                      headers:
                        - name: ':path'
                          safe_regex_match:
                            regex: /myapp/api/v1/users/.*/documents
                  - request_headers:
                      descriptor_key: method
                      header_name: ':method'
                  - request_headers:
                      descriptor_key: clientid
                      header_name: clientid

Изменения конфигурации:

  • context: GATEWAY — аналогично патчи применяется для конфигурации гейтвея.

  • Маршрут, для которого задается конфигурация rate limit. Определяется селектором по имени (его мы задавали при создании VirtualService) в рамках routeConfiguration http.80 (имя сформировано Istio на основании Gateway) и vhost gateway.mynamespace.svc.cluster.local:80 (доменное имя сервиса для gateway).

Для полноты картины ещё раз приведу полную конфигурацию для ratelimit service.

apiVersion: v1
kind: ConfigMap
data:
  config.yaml: |
    domain: mynamespace
    descriptors:
    # exact match
    - key: path
      value: /myapp/api/v1/users
      descriptors:
      - key: method
        value: GET
        descriptors:
        - key: clientid
          value: {clientid}
          rate_limit:
            unit: second
            requests_per_unit: 10
    # path match
    - key: path
      value: prefix=/myapp/api/v1/
      descriptors:
      - key: method
        value: GET
        descriptors:
        - key: clientid
          value: {clientid}
          rate_limit:
            unit: second
            requests_per_unit: 10
    # template match
    - key: path
      value: /myapp/api/v1/users/{id}/documents
      descriptors:
      - key: method
        value: GET
        descriptors:
        - key: clientid
          value: {clientid}
          rate_limit:
            unit: second
            requests_per_unit: 10

 Ура, мы создали и настроили Istio Gateway, посмотрим на итоговую схему.

Istio gateway
Istio gateway

Промежуточные итоги

C помощью Istio мы получили две схемы, реализующие паттерн API Gateway в контексте архитектурного подхода DOMA, в той или иной мере закрывающие наши требования.

Краткая характеристика и особенности схем.

Sidecar gateway — весь функционал реализован на sidecar-ах. Таким образом в схеме отсутствует выделенный компонент, потенциально являющийся единой точкой отказа в рамках домена. При этом для сценариев prefix match и template match функционал global rate limit переносится на sidecar outbound трафик вызывающего сервиса, что имеет определенные недостатки для крупных service mesh-ей.

Istio gateway – в неймспейсе создаётся единая точка входа. При этом функционал можно распределить между istio gateway и sidecar-ами. Потребители взаимодействуют только с istio gateway, таким образом все правила маршрутизации внутри неймспейса скрыты от потребителей, что частично решает проблему масштабирования service mesh-а.

Сравнение производительности и эффективности схем будет представлено в следующей статье.

Выводы о функционале Istio

Istio крутой инструмент с широкими возможностями, но, далеко не в последнюю очередь, благодаря envoy API. Чтобы чувствовать себя максимально свободно, придётся углубиться в архитектуру envoy и в совершенстве овладеть EnvoyFilter и дебагом в Kiali.

При этом, если изучить release notes, то видно, что определенный функционал, доступный в более ранних версиях только через EnvoyFilter, впоследствии становится частью конфигурации Istio.

Отдельно хочется упомянуть отсутствие поддержки сценария c path параметрами для RESTful API. Возможно RESTful уже не в моде и не сильно востребован, тот же gRPC встречается всё чаще. Возможно, зачастую проще перепроектировать API и отказаться от концепции path параметров в пользу JSON RPC.

Мы будем и дальше пробовать расширять скоуп применяемых функций Istio, в частности, для улучшения наблюдаемости и повышения безопасности в кластере. А также будем следить за развитием Istio Ambient Mesh, так как на собственном опыте убедились, что данная реализация имеет ряд преимуществ.

Бонус: AuthorizationPolicy workaround

Да, я обещал рассказать об обходном решении, реализованном для AuthorizationPolicy и сценария с path параметрами (template match). Для этого в рамках соответствующего маршрута на istio gateway потребуется объявить простановку заголовка.

# template
- match:
    - uri:
        regex: /myapp/api/v1/users/.*/documents
  name: /myapp/api/v1/users/{id}/documents
  rewrite:
    uriRegexRewrite:
        match: /myapp(/|$)(.*)
    	rewrite: /\2
  route:
    - destination:
        host: myapp.mynamespace.svc.cluster.local
        port:
          number: 80
  headers:
    request:
      set: 
        path-template: /api/v1/users/{id}/documents

После этого на уровне sidecar inbound трафика приложения можно задать следующее правило авторизации.

- from:
    - source:
        requestPrincipals:
          - '*'
  to:
    - operation:
        methods:
          - GET
  when:
    - key: request.headers[path-template]
      values:
        - /api/v1/users/{id}/documents
    - key: request.auth.claims[realm_access][roles]
      values:
        - ALLOW_USERS

Аналогичный подход применим и для схемы с sidecar gateway. В таком случае простановка заголовка будет частью конфигурации маршрута в sidecar-е вызывающего сервиса.

Важно отметить, что данное правило авторизации рекомендуется добавлять в конец списка, после правил с фильтрацией по path для назначения запроса (to), иначе указанные when условия будут проверяться для всех запросов (в данном случае GET).

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


  1. Kvento
    08.12.2023 21:56

    Спасибо. Полезная статья. Пригодится для активации rate limit.