Паттерны отказоустойчивости приложений в Kubernetes. Часть 2.

Паттерны отказоустойчивости приложений в Kubernetes. Часть 2.


Retry/Timeouts на Service mesh

Объяснения этого вопроса будут производиться на примере Istio — одного из самых популярных Service mesh. Часто наши клиенты находятся внутри кластера (допустим, у нас микросервисная архитектура), и у нас совершенно нет желания ставить Ingress и каким-то образом их через него публиковать. Для решения задач подобного рода служит Service mesh.

Мы можем поставить в кластер Service mesh, который делает примерно следующее. В Service mesh есть control plane, который собирает информацию со всего кластера о состоянии подов, сервисов и неймспейсов. Также он получает некоторое количество дополнительных настроек, специализированных для Service mesh.

Далее control plane агрегирует эти настройки и рассылает по так называемым Sidecar proxy-контейнерам, которые находятся в каждом pod'е. «Sidecar» в переводе означает коляску мотоцикла, что хорошо отражает вспомогательную функцию этой сущности. Sidecar proxy-контейнеры, как правило, попадают туда автоматически через Mutation webhook при создании pod`а.

Итак, Service mesh разложила config по нашим Sidecar proxy-контейнерам. Клиентское приложение хочет сделать запрос к сервису, но его по дороге перехватывает Sidecar proxy в его поде.

Далее Sidecar сам попытается доставить (проксировать) запрос сервиса.

Неудачная попытка доставки
Удачная попытка доставки

Нюансы конфигурации Retry/Timeouts в Istio

Retry и Timeout в Istio мы можем настроить в custom resource, который называется Virtual Service.

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: microservice-route
spec:
  hosts:
  - "*"
  http:
  - match:
    - method:
        regex: "GET|HEAD|TRACE"
    route:
      - destination:
          host: microservice.prod.svc.cluster.local
    retries:
      attempts: 3
      perTryTimeout: 2s
      retryOn: connect-failure,refused-stream,gateway-error,503
    timeout: 5s

 Пример конфигурации

Для начала обратите внимание, что Istio по умолчанию выполняет Retry для всех запросов. То есть если вы не хотите выполнять Retry для неидемпотентных запросов, то вам нужно написать для этого дополнительный фильтр.

Далее мы конфигурируем сами Retry:

attempts — максимальное количество попыток;

perTryTimeout — тайм-аут на попытку (в отличие от Ingress мы настраиваем Timeout именно на одну попытку-Retry, а не на общее время, в течение которого можно делать повторы);

retryOn — список ошибок, в случае которых запрос будет повторён (аналогично Ingress Nginx).

Кроме того, в Istio есть отдельные настройки Timeout. Их стоит использовать, когда Retry не настроены, потому что опция retryOn их переопределяет.

Circuit breaker

Circuit breaker-паттерн проектирования пришёл к нам из инженерии и работает аналогично аварийному автоматическому выключателю в вашем электрическом щитке. В процессе работы экземпляр приложения может испытывать проблемы, которые влияют на обработку запросов от клиентов. Это могут быть внутренние проблемы приложения, проблемы с внешними зависимостями, проблемы с сетью, просто высокая нагрузка или тяжёлый запрос. В этом случае разумно не добивать его новыми запросами или повторами запросов, но убрать с него нагрузку и дать время на восстановление. Возможно, за время без трафика приложение сможет справиться с пришедшей нагрузкой, инфраструктура восстановится или к инстансу придёт Watchdog и приведёт его в чувство.

Circuit breaker мы можем настроить в Istio при помощи custom resource DestinationRule:

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: microservice-destinationrule
spec:
  host: microservice.prod.svc.cluster.local
  trafficPolicy:
    connectionPool:
      tcp:
        connectTimeout: 100ms
    outlierDetection:
      consecutive5xxErrors: 7
      interval: 10s
      baseEjectionTime: 3m
      maxEjectionPercent: 100
      minHealthPercent: 50

Пример конфигурации

Здесь мы можем настроить:

Consecutive5xxErrors — общее количество ошибок, после которого исключаем pod из маршрутизации трафика, причём здесь считаются как ошибки протокола, так и тайм-ауты. Это могут быть тайм-ауты, настроенные как в VirtualService (как в примере выше), так и настроенные здесь же, в секции connectionPool;

interval — период, за который мы считаем ошибки. В данном конкретном случае после семи ошибок за последние 10 секунд pod исключается из маршрутизации трафика;

baseEjectionTime — время, на которое мы исключаем pod из маршрутизации;

maxEjectionPercent — это максимальное количество подов нашего сервиса, которые мы можем отключить от трафика;

minHealthPercent — это минимальное число здоровых ready pod, после которых Circuit breaker начинает работать.

При настройке Circuit breaker на Istio стоит помнить, что:

  1. Circuit breaker предполагает только пассивные проверки. То есть, чтобы убедиться, что pod рабочий, Istio опять пускает на него трафик и следит, не появятся ли ошибки.
  2. outlierDetection срабатывает на клиенте, и счётчик ошибок действует в пределе одного конкретного Sidecar proxy-контейнера. То есть если у вас несколько pod’ов клиентов, то Circuit breaker может для них включаться по-разному.

Rate limits

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

Как правило, если всё нормально, то мы рассчитываем производительность нашей инфраструктуры заранее, так как приблизительно представляем себе средние нагрузки. Исходя из этих расчётов закладываются серверные мощности, каналы связи и другие параметры. Тем не менее рано или поздно к вам постучится риск падения сервиса из-за перегрузки. Это могут быть внезапный habr-эффект, ошибка в реализации клиента, приведшая к флуду API-запросов, или просто несколько миллионов запросов от неизвестных ботов, которые решили поинтересоваться вашими уязвимостями.

Согласно паттерну Rate limit мы пропускаем к приложению ровно то количество запросов, которые система может обработать, а остальные отбрасываем. Это ведёт к ухудшению качества сервиса, но в данном случае оно оправданно.

Rate limits на Ingress

В экосистеме Kubernetes есть несколько возможностей настройки Rate limits. Прежде всего мы можем их настроить на Ingress. Ingress-контроллер — это единая точка входа для клиентского трафика, и на нём это делать логичнее и проще всего. Но для обеспечения отказоустойчивости количество экземпляров Ingress у нас обычно больше одного.

Это нужно помнить при настройке лимитов.

Например, в Ingress Nginx есть два типа Rate limits: это локальные Rate limits и глобальные Rate limits.

Локальные Rate limits

Локальные Rate limits действуют в пределах одного экземпляра Ingress. Для того чтобы их выставить, мы можем через аннотации сделать специальные настройки.

 apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-configuration-example
  annotations:
    nginx.ingress.kubernetes.io/limit-connections: 100
    nginx.ingress.kubernetes.io/limit-rpm: 200
    nginx.ingress.kubernetes.io/limit-rps: 10
    nginx.ingress.kubernetes.io/limit-burst-multiplier: 2
    nginx.ingress.kubernetes.io/proxy-buffering: "on"
    nginx.ingress.kubernetes.io/limit-whitelist: 8.8.8.8
spec:
  ingressClassName: nginx
  rules:
  - host: custom.configuration.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: http-svc
            port: 8080

Пример конфигурации

Прежде всего мы можем настроить сами лимиты:

nginx.ingress.kubernetes.io/limit-connections — лимит на соединение;

nginx.ingress.kubernetes.io/limit-rps — лимит на количество запросов в секунду;

nginx.ingress.kubernetes.io/limit-rpm — лимит на количество запросов в минуту.

Причём они срабатывают именно в таком порядке: connections → rpm → rps.

Часто наши Ingress-контроллеры, в свою очередь, находятся за load balancer. Это могут быть облачный IP load balancer (например, Amazon Elastic load balancer или haproxy в OpenStack) или HTTP-based load balancer.

Для того чтобы мы могли применять лимиты к клиентам, мы должны знать их адрес. Для этого в случае IP load balancer мы должны включить поддержку proxy protocol, то есть указать use-proxy-protocol: «true» в глобальной ConfigMap Ingress-контроллера. В случае с HTTP-based load balancer мы должны согласовать хедеры, в которых будем передавать адрес клиента. По умолчанию это X-Forwarded-For, но мы можем его переопределить при помощи настройки use-forwarded-headers.

Дополнительно мы можем настроить так называемые Burst-лимиты. Очень часто нагрузка от клиента неравномерная, например, большую часть времени он медленно тащит какой-то контент, и его это совершенно не задевает, к примеру, во время фоновой синхронизации данных приложения, пока телефон лежит в кармане. Но когда телефон оказался в руке и вам прилетает резкий всплеск запросов, клиент ожидает, что они будут обработаны немедленно и без задержек. Если мы просто отбросим запросы, выбивающиеся за лимит, то можем, во-первых, огорчить клиента, а во-вторых, недогрузить инфраструктуру.

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

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

Limit burst в Ingress-Nginx можно настроить при помощи аннотации:

nginx.ingress.kubernetes.io/limit-burst-multiplier.

Также мы можем настроить ширину полосы пропускания для каждого отдельного соединения при помощи аннотаций:

nginx.ingress.kubernetes.io/limit-rate — количество килобайт в секунду, разрешённое для отправки в это соединение;

nginx.ingress.kubernetes.io/limit-rate-after — начальное количество килобайт, после которого дальнейшая передача ответа на данное соединение будет ограничена по скорости, но limit-rate и limit-rate-after работают, только если у нас включены и настроены proxy-буфера;

nginx.ingress.kubernetes.io/proxy-buffering: «on».

Кроме того, мы можем задать Whitelist, то есть лист IP-адресов, к которым никогда не будем применять Rate limit при помощи аннотации nginx.ingress.kubernetes.io/limit-whitelist.

И последний момент: при отказе в отработке запроса при истечении лимитов Nginx по умолчанию отдаёт ошибку 503 Service Unavailable. Это не совсем корректно. В спецификации HTTP протокола есть более подходящий код ответа: HTTP 429 Too Many Requests. Изменить код ответа можно при помощи настройки limit-req-status-code в глобальной configmap.

Глобальные Rate limits

Настраивая локальные Rate limits, мы должны помнить, что с изменением количества экземпляров Ingress, через которые идёт трафик, мы меняем не только полосу пропускания, но изменяем и лимиты. Для того чтобы это преодолеть, у нас существуют Global Rate limits.

apiVersion: v1
kind: ConfigMap
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
data:
  global-rate-limit-memcached-host: memcached.nginx-ingress.svc.cluster.local
  global-rate-limit-memcached-port: 11211
  global-rate-limit-memcached-connect-timeout: 20ms

В Ingress Nginx они реализованы при помощи библиотеки lua-resty-global-throttle и сервиса memcached. Для того чтобы начать с ними работать, мы должны настроить memcached: указать его параметры в глобальном ConfigMap Ingress-контроллера и настроить аннотациями параметры глобальных лимитов.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-configuration-example
  annotations:
    nginx.ingress.kubernetes.io/global-rate-limit: 100
    nginx.ingress.kubernetes.io/global-rate-limit-window: 5s
    nginx.ingress.kubernetes.io/global-rate-limit-key: "${remote_addr}-${http_x_api_client}"
    nginx.ingress.kubernetes.io/global-rate-limit-ignored-cidrs: "8.8.8.8"
spec:
  ingressClassName: nginx
  rules:
  - host: custom.configuration.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: http-svc
            port: 8080

Пример конфигурации

В этих параметрах мы можем настроить:

nginx.ingress.kubernetes.io/global-rate-limit — количество запросов;

nginx.ingress.kubernetes.io/global-rate-limit-window — временной период, за который готовы их принять.

Кроме того, мы можем настроить Whitelist при помощи nginx.ingress. kubernetes.io/global-rate-limit-ignored-cidrs.

Rate limits на Service mesh

Настраивая Rate limits на Service mesh, мы должны помнить, что proxy, которая принимает решение о наложении Rate limits, может находиться в любом pod`е: на сервере, на клиенте, везде, в любой щели.

Первая возможность для настройки Rate limits в Istio — это секция connection pool для DestinationRule.

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: microservice-destinationrule
  namespace: prod
spec:
  host: microservice
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
      http:
        http2MaxRequests: 200
        http1MaxPendingRequests: 5

Пример конфигурации

Разработчики Istio считают это частью Circuit breaker, но я думаю, что это не совсем корректно. В рамках лимитов на connection pool мы можем настроить лимиты на количество активных TCP-соединений, максимальное количество HTTP-запросов в обработке и максимальное количество запросов в ожидании — такой специфический аналог Burst limit. Это правило применяется на клиенте, и оно локальное внутри конкретного Sidecar proxy. То есть, настроив лимит в 100 соединений при двух клиентах, мы соответственно получаем 200 запросов на сервисе.

Envoy Rate limits

В Istio мы также можем настроить так называемые Envoy Rate limits. Они тоже бывают локальными и глобальными, и для их настройки используется custom resource EnvoyFilter. На мой взгляд, это функционал, который очень похож на Configuration Snippets в Ingress-контроллере. То есть разработчики Istio не стали заморачиваться с созданием каких-то абстракций для Rate limits, а отдали нам конфиг Envoy и сказали: «Вот, пожалуйста». Документацию по этим Rate limits, наверное, лучше искать в документации Envoy.

Локальные Rate limits

При настройке локальных Rate limits в Istio мы должны помнить, что лимиты мы можем применять к TCP и HTTP, на источнике и на назначении трафика, для входящего и исходящего трафиков. Например:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: httpbin-ratelimit
  namespace: istio-system
spec:
  workloadSelector:
    labels:
      app: httpbin
      version: v1
  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: http_local_rate_limiter
              token_bucket:
                max_tokens: 50
                tokens_per_fill: 10
                fill_interval: 120s
              filter_enabled:
                runtime_key: local_rate_limit_enabled
                default_value:
                  numerator: 100
                  denominator: HUNDRED
              filter_enforced:
                runtime_key: local_rate_limit_enforced
                default_value:
                  numerator: 100
                  denominator: HUNDRED
              response_headers_to_add:
                - append_action: APPEND_IF_EXISTS_OR_ADD
                  header:
                    key: x-rate-limited
                    value: TOO_MANY_REQUESTS
              status:
                code: BadRequest

Пример конфигурации

Здесь мы видим, что custom resource EnvoyFilter состоит из двух частей. Первая описывает, куда применяется патч конфигурации envoy, вторая — сам патч. В первой части примера мы видим, что патч применяется к HTTP-протоколу (applyTo: HTTP_FILTER) к входящим (context: SIDECAR_INBOUND) для подов приложения, имеющим метку, описанную в workloadSelector.

В патче мы видим непосредственно описание Rate limits. Они сделаны по принципу token bucket. То есть у нас есть bucket, у которого есть заданная ёмкость токенов (max_tokens — это могут быть http-запросы в обработке либо tcp-соединения) и правила его заполнения: tokens_per_fill — количество новых токенов; fill_interval — временной промежуток, в который мы можем принять описанное количество токенов. Но если bucket у нас заполнен, то мы не можем принять ничего.

Глобальные Rate limits

Для того чтобы начать с ними работать, мы должны установить специальный сервис для работы с Rate limits, куда Envoy будут ходить по gRPC. Чаще всего это должен быть Redis, который хранит информацию по соединениям.

Здесь мы можем настроить domain, к которому будем применять лимиты, и сами лимиты: временной промежуток unit и requests_per_unit — количество запросов, которые мы готовы в него принять (что-то достаточно близкое к глобальным лимитам Ingress Nginx).

apiVersion: v1
kind: ConfigMap
metadata:
  name: ratelimit-config
data:
  config.yaml: |
    domain: productpage-ratelimit
    descriptors:
      - key: PATH
        value: "/productpage"
        rate_limit:
          unit: minute
          requests_per_unit: 1
      - key: PATH
        rate_limit:
          unit: minute
          requests_per_unit: 100

Применяются глобальные лимиты также при помощи custom resource EnvoyFilter:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: filter-ratelimit
  namespace: istio-system
spec:
  workloadSelector:
    # select by label in the same namespace
    labels:
      istio: ingressgateway
  configPatches:
    # The Envoy config you want to modify
    - 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
        # Adds the Envoy Rate Limit Filter in HTTP filter chain.
        value:
          name: envoy.filters.http.ratelimit
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
            # domain can be anything! Match it to the ratelimter service config
            domain: productpage-ratelimit
            failure_mode_deny: true
            timeout: 10s
            rate_limit_service:
              grpc_service:
                envoy_grpc:
                  cluster_name: outbound|8081||ratelimit.default.svc.cluster.local
                  authority: ratelimit.default.svc.cluster.local
              transport_api_version: V3

Здесь в секции patch мы можем видеть описание domain, для которого мы хотим ввести лимиты и описание grpc service, в котором они хранятся.

При настройке глобальных Rate limits стоит понимать, что это распределённые Rate limits, и они небесплатные с точки зрения нагрузки. Запрос в Redis занимает время и увеличивает время на обработку трафика нашими прокси. Это тяжёлый механизм, поэтому разработчики, что Ingress, что Istio, рекомендуют использовать локальные и глобальные Rate limits вместе, чтобы оптимизировать использование ресурсов.

Короткие выводы

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

Мы рассказали про применение в этих целях платформы Kubernetes и ее экосистемы. Это удобный инструмент, а нужен он или нет — каждый должен решить для себя сам. Всё зависит от индивидуальных требований к отказоустойчивости и уровня качества сервиса.

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

Report Page