Паттерны отказоустойчивости приложений в 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 стоит помнить, что:
- Circuit breaker предполагает только пассивные проверки. То есть, чтобы убедиться, что pod рабочий, Istio опять пускает на него трафик и следит, не появятся ли ошибки.
- 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 и ее экосистемы. Это удобный инструмент, а нужен он или нет — каждый должен решить для себя сам. Всё зависит от индивидуальных требований к отказоустойчивости и уровня качества сервиса.
Ключевыми элементами при формировании нужных лимитов и тайм-аутов должны быть понятно описанная схема вашей конкретной инфраструктуры и накопленная статистика мониторинга. Только в этом случае вы сможете подобрать параметры, оптимальные именно для вашей ситуации.