Sobrevivendo ao Caos no Kubernetes com Istio Service Mesh e EKS

Share:
 

Na sua casa você pode usar o que você quiser, aqui hoje vamos usar Istio. Sem tempo pra chorar irmão...


O objetivo desse post é apresentar mecanismos de resiliência que podemos agregar em nosso Workflow para sobreviver em cenários de caos e desastres utilizando Istio e EKS. A ideia é estabelecer uma linha de pensamento progressiva apresentando cenários de desastre de aplicações, dependencias e infraestrutura e como corrigílos utilizando as ferramentas apresentadas. 

Para este post foi criado um laboratório simulando um workflow sincrono onde temos 4 microserviços que se comunicam entre si, simulando um sistema distribuido de pedidos, onde todos são estimulados por requisições HTTP. 


Premissas Iniciais 

  • O ambiente roda em um EKS utilizando a versão 1.20 do Kubernetes em 3 zonas de disponibilidade
  • Vamos Trabalhar com a premissa de um SLO de 99,99
  • O ambiente já possui Istio default com Gateways e VirtualServices Vanilla configurados pra todos os serviços
  • O objetivo é aumentar a resiliência diretamente no Istio, por isso nenhuma aplicação tem fluxo de circuit breaker ou retry pragmaticamente implementados.
  • Vamos utilizar o Kiali, Grafana para visualizar as métricas
  • Vamos utilizar o K6 para injetar carga no workload
  • Vamos utilizar o chaos-mesh para injetar falhas de plataforma do Kubernetes
  • Vamos utilizar o gin-chaos-monkey para injetar falhas a nível de aplicação
  • O objetivo não é avaliar arquitetura de solução, e sim focar nas ferramentas apresentadas como opções

Fluxo sincrono do teste 




Parte 1: Resiliência em falhas de aplicação  

O objetivo é coletar as métricas de disponibilidade do fluxo sincrono com qualquer componente podendo falhar a qualquer momento, primeiro teste tem o objetivo de injetar uma carga de 60s, simulando 20 VUS (Virtual Users) e ver como esses erros se comportam em cascata até chegar no cliente final. 

Cenários 1.1 - Arquitetura inicial 

Vamos rodar o teste de carga do k6 no ambiente para ver como vamos nos sair sem nenhum tipo de mecanismo de resiliência: 


k6 run --vus 20 --duration 60s k6/loadtest.js




Podemos ver que o chaos-monkey da aplicação cumpriu seu papel, injetando falhas aleatórias em todas as dependencias da malha de serviços, ofendendo drasticamente nosso SLO de disponibilidade pra 88.10 %, estourando nosso Error Budget para esse periodo fake. 


Podemos ver também que todas as aplicações da malha, em algum momento apresentaram falhas aleatórias no runtime conforme o esperado, falhando drasticamente em cascata. 




Sumário do teste 1.1:

  • Tempo do teste: 60s
  • Total de requisições: 13905
  • Requests por segundo: 228.35/s
  • Taxa de erros a partir do client: 11.91%
  • Taxa real de sucesso do  serviço principal orders-api: 88.10%
  • Taxa de sucesso dos consumidores do orders-api: 88.10% 
  • SLO Cumprido: Não 


Cenário 1.2 - Retry para Falhas HTTP

O objetivo do cenário é implementar a lógica de retry para falhas HTTP nos virtualservices das aplicações. Vamos adicionar as opções de retries no virtual services, com as opções 5xx,gateway-error,connect-failure. Podendo ocorrer até 3 tentativas de retry com um timeout de 500ms. 


As opções de retentativas são, de acordo com a documentação 

  • 5xx: Ocorrerá uma nova tentativa se o servidor upstream responder com qualquer código de resposta 5xx 
  • gateway-error: Uma politica parecida com o 5xx, porém voltadas a falhas especificas de gateway como 502, 503, or 504 no geral. Nesse caso, é redundante, porém fica de exemplo. 
  • connect-failure: Será realizada mais uma tentativa em caso de falha de conexão por parte do upstream ou em casos de timeout. 



Vamos rodar novamente os testes simulando 20 usuários por 60 segundos com os 3 retries aplicados. 


 


Conseguimos uma melhoria de mais de 6% de disponibilidade entre o que o serviço degradado respondeu com o que o cliente recebeu, utilizando apenas 3 tentativas de retry entre todos os serviços.

Sumário do teste 1.2:

  • Tempo do teste: 60s
  • Total de requisições: 17873
  • Requests por segundo: 293.17/s
  • Taxa de erros a partir do client: 0.07%
  • Taxa real de sucesso do serviço principal orders-api: 93.08 %
  • Taxa de sucesso dos consumidores do orders-api: 99.25% 
  • SLO Cumprido: Não 


Cenário 1.3 - Ajustando a quantidade de retries para suprir o cenário


Para fechar o ciclo, aumentei o numero de retries de 3 para 5 e repeti os testes nos mesmos cenários:




Neste cenário conseguimos suprir os 93% de erros que vieram dos upstreams da nossa malha de serviço por meio de falhas intermitente nas aplicações garantindo 100% de disponibilidade para o cliente. Neste cenário de componentes intermitentes falhando aleatoriamente por diversas causas, estaríamos com um grande saving de erros poupados para o cliente final.  

 Sumário do teste 1.3:

  • Tempo do teste: 60s
  • Total de requisições: 18646
  • Requests por segundo: 308.79/s
  • Taxa de erros a partir do client: 0.00%
  • Taxa real de sucesso do serviço principal orders-api: 93.09 %
  • Taxa de sucesso dos consumidores do orders-api: 100.00%
  • SLO Cumprido: Sim


Parte 2: Resiliência em falhas de infraestrutura   

Para executar os testes de infraestrutura vamos utilizar o Chaos Mesh como utilitário para injetar falhas no nos componentes do nosso fluxo, e desligar o chaos-monkey do runtime das aplicações. A partir desde ponto, não injetaremos mais falhas intencionais a partir da aplicação para testarmos puramente falhas a nível da plataforma. O objetivo é analisar novamente como o nosso fluxo sincrono se comporta perdendo unidades computacionais bruscamente em diversos cenários, e como nosso fluxo de melhoria continua nos retries podem nos ajudar e agregar ainda mais valor como plataforma. 


Cenário 2.1 - Injetando falhas de healthcheck nos componentes do workload 


Neste primeiro cenário, vamos injetar o mesmo volume de requisições, e no meio deles vamos aplicar o cenário de pod-failure no nosso fluxo.  Em todas as aplicações, vamos aplicar um teste de 30s onde vamos perder 90% dos nossos pods repentinamente por falha de healthcheck. Esse é um teste bem agressivo, e tem o intuito de verificar como o que fizemos até agora, agrega de valor nesse cenário. 


kubectl apply -f chaos-mesh/01-pod-failture/

kubectl apply -f chaos-mesh/01-pod-failture
podchaos.chaos-mesh.org/cc-pod-failure created
podchaos.chaos-mesh.org/clients-pod-failure created
podchaos.chaos-mesh.org/orders-pod-failure created
podchaos.chaos-mesh.org/payment-pod-failure created

❯ kubectl get pods -n orders
NAME READY STATUS RESTARTS AGE
orders-api-fb5c94987-225zp 0/2 Running 2 100s
orders-api-fb5c94987-8rpjb 0/2 Running 2 14m
orders-api-fb5c94987-bmnqm 0/2 Running 2 85s
orders-api-fb5c94987-d9c4f 0/2 Running 2 14m
orders-api-fb5c94987-gd745 0/2 Running 2 14m
orders-api-fb5c94987-htbcn 2/2 Running 0 100s
orders-api-fb5c94987-rzqkg 0/2 Running 2 100s
orders-api-fb5c94987-st8l2 0/2 Running 2 85s
❯ kubectl get pods -n cc
NAME READY STATUS RESTARTS AGE
cc-api-548bb458-78p77 2/2 Running 0 14m
cc-api-548bb458-nkjmj 0/2 Running 2 14m
cc-api-548bb458-sgfrb 0/2 Running 2 14m
❯ kubectl get pods -n payment
NAME READY STATUS RESTARTS AGE
payment-api-d466c7f59-6q86t 0/2 Running 4 115s
payment-api-d466c7f59-pv6wd 0/2 Running 4 14m
payment-api-d466c7f59-q9bsv 0/2 Running 4 14m
payment-api-d466c7f59-zbsvs 2/2 Running 0 14m
❯ kubectl get pods -n clients
NAME READY STATUS RESTARTS AGE
clients-api-5c8d89b4d-8nvsd 2/2 Running 0 14m
clients-api-5c8d89b4d-wd8rq 0/2 Running 4 14m
clients-api-5c8d89b4d-xm4ln 0/2 Running 4 14m


Vamos analisar os resultados: 



Neste teste,  90% de todos os pods do nosso workflow pararam de responder no healthcheck repentinamente por 30s. Mesmo com nosso cenário de retries entre os virtualservices, ainda tivemos 1.22% de erros  retornados ao cliente.  Conseguimos um saving de quase 5% de erros, mas ainda assim ferimos nosso SLO de 99,99%. 

Sumário do Teste 2.1:

  • Tempo do teste: 60s
  • Total de requisições: 14340
  • Requests por segundo: 233.75/s
  • Taxa de erros a partir do client: 1.22%
  • Taxa real de sucesso do serviço principal orders-api: 93.97 %
  • Taxa de sucesso dos consumidores do orders-api: 98.69%
  • SLO Cumprido: Não 

Cenário 2.2 - Adicionando retry por conexões perdidas / abortadas 

No caso anterior, com a perca repentina de 90% dos recursos computacionais da malha, mesmo com as politicas de retentativas, tivemos um grande saving de disponibilidade mas ainda assim não batemos a meta de SLO de disponibilidade. Então vamos adicionar algumas outras politicas de retentativa que podem prever esses cenários em cascata. Vamos adicionar as opções 5xx,gateway-error,connect-failure,refused-stream,reset,unavailable,cancelled no nosso retryOn. A ideia é evitar os erros de perda de conexões abertas durante uma queda brusca de pods. 


As opções de retentativas são, de acordo com a documentação para HTTP: 

  • reset: Será feita uma tentativa de retry em caso de disconnect/reset/read timeout vindo do upstream
Caso você esteja utilizando algum backend gRPC, tomei a liberdade de adicionar as outras opções no exemplo, caso seu backend seja exclusivamente HTTP, as mesmas não serão necessárias, mas fica como estudo: 
  • resource-exhausted: retentativa gRPC em caso de headers contendo o termo "resource-exhausted"
  • unavailable: retentativa em gRPC em caso de headers contendo o  termo "unavailable"
  • cancelled: retentativa em gRPC em caso de headers contendo o  termo "cancelled"
Executar novamente os testes para avaliar o quanto de melhoria temos colocando as retentativas por disconnect/reset/timeout adicionais



Neste teste tivemos um saving significativo de disponibilidade, tendo um numero de 0.03% contabilizados no cliente. Poupando apenas 5 erros de 15633 requisições. Bastante coisa. Da pra melhorar? Da. 

Sumário do teste 2.2:

  • Tempo do teste: 60s
  • Total de requisições: 15633
  • Requests por segundo: 258.4/s
  • Taxa de erros a partir do client: 0.03%
  • Taxa real de sucesso do serviço principal orders-api: 92.81 %
  • Taxa de sucesso dos consumidores do orders-api: 99.99 %
  • SLO Cumprido: Não 

Cenário 2.2 - Adicionando circuit breakers nos upstreams


Para a cereja do bolo pro assunto de resiliência em service-mesh, nesse caso o istio, são os circuit breakers. É um conceito muito legal que não é tão fácil de compreender como os retry. Circuit breakers nos ajudam a sinalizar pros clientes que um determinado serviço está fora, poupando esforço para consumi-lo e junto com as retentativas estar sempre validando se os mesmos estão de volta "a ativa" ou não. Isso vai nos ajudar a "não tentar" mais requisições nos hosts que atenderem aos requisitos de circuito quebrado. Além de poder limitar a quantidade de requisições ativas que nosso backend consegue atender, para evitar uma degradação maior ou gerar uma falha não prevista. Para isso vamos adicionar um recurso chamado DestinationRule em todas as aplicações da malha de serviço. 

 

As coisas mais importantes desse novo objeto são consecutive5xxErrorsbaseEjectionTime

Os consecutive5xxErrors é o numero de erros que um upstream pode retornar para que o mesmo seja considerado com o circuito aberto. 

Já o baseEjectionTime é o tempo que o host ficará com o circuito aberto antes de retornar para a lista de upstreams. 

Peguei essas duas imagens deste post excelente a respeito de circuit-breaking do Istio mais conceitual, e de como ele funciona em conjunto com os retries que já implementamos. 







A partir do momento que o baseline de erros de um upstream ativa a quebra de circuito, o upstream fica inativa na lista pelo período recomendado pelo Pool Ejection, e com as considerações de retry, podemos iterar na lista até encontrar um host saudável para aquela requisição em específico. 

Seguindo essa lógica, vamos aos testes: 




Neste teste finalmente conseguimos atingir os 100% de disponibilidade com falha temporária e repentina de 90% do healthcheck das aplicações da malha. No No Kiali, podemos ver que o circuit breaker foi implementado em todas as pontas do workflow. 




Sumário do teste 2.3:

  • Tempo do teste: 60s
  • Total de requisições: 20557
  • Requests por segundo: 342/s
  • Taxa de erros a partir do client: 0.00%
  • Taxa real de sucesso do serviço principal orders-api: 100 %
  • Taxa de sucesso dos consumidores do orders-api: 100 %
  • SLO Cumprido: SIM 


Cenário 2.3 - Morte instantânea de 90% dos pods 


Vamos avaliar um outro cenário, parecido mas não igual. No cenário anterior validamos a action pod-failure, que injeta uma falha de healthcheck nos pods mas não os mata definitivamente. Nesta vamos executar a action pod-kill, onde 90% dos pods vão sofrer um force terminate. 

Vamos iniciar o teste de carga e no meio dela vamos injetar a falha no workload. 

apiVersion: chaos-mesh.org/v1alpha1
kind: PodChaos
metadata:
name: orders-pod-kill
namespace: orders
spec:
action: pod-kill
mode: fixed-percent
value: "90"
duration: "30s"
selector:
namespaces:
- orders
labelSelectors:
"app": "orders-api"

kubectl apply -f chaos-mesh/02-pod-kill

podchaos.chaos-mesh.org/cc-pod-kill created
podchaos.chaos-mesh.org/clients-pod-kill created
podchaos.chaos-mesh.org/orders-pod-kill created
podchaos.chaos-mesh.org/payment-pod-kill created

❯ kubectl get pods -n payment

NAME READY STATUS RESTARTS AGE
payment-api-645c7958cd-c25nf 2/2 Running 0 2m40s
payment-api-645c7958cd-clbpg 1/2 Running 0 6s
payment-api-645c7958cd-h6fgh 0/2 Running 0 6s
payment-api-645c7958cd-lt5bp 0/2 PodInitializing 0 6s
payment-api-645c7958cd-s2gzx 0/2 Running 0 6s
payment-api-645c7958cd-v6c8w 0/2 Running 0 6s

❯ kubectl get pods -n orders

NAME READY STATUS RESTARTS AGE
orders-api-86b4c65f9b-6wdg5 1/2 Running 0 18s
orders-api-86b4c65f9b-pvqv4 1/2 Running 0 18s
orders-api-86b4c65f9b-wbkt2 2/2 Running 0 4m13s

❯ kubectl get pods -n cc

NAME READY STATUS RESTARTS AGE
cc-api-58b558fc8f-6dqlh 2/2 Running 0 15m
cc-api-58b558fc8f-7zz8t 1/2 Running 0 30s
cc-api-58b558fc8f-wnjcs 1/2 Running 0 30s

❯ kubectl get pods -n clients

NAME READY STATUS RESTARTS AGE
clients-api-59b5cf8bc-46cws 1/2 Running 0 47s
clients-api-59b5cf8bc-4rkvp 1/2 Running 0 47s
clients-api-59b5cf8bc-hdngf 1/2 Running 0 47s
clients-api-59b5cf8bc-txcb4 2/2 Running 0 16m
clients-api-59b5cf8bc-vb8lh 1/2 Running 0 47s




Desta vez passamos de primeira no teste de uma queda brusca de pods com carga quente. Os retries com circuit breaker dos pods agiram muito rapido evitando uma quantidade significativa de retries, aumentando até o reposnse e tput. 

Sumário do teste 2.3:

  • Tempo do teste: 60s
  • Total de requisições: 24948
  • Requests por segundo: 415,56/s
  • Taxa de erros a partir do client: 0.00%
  • Taxa real de sucesso do serviço principal orders-api: 100 %
  • Taxa de sucesso dos consumidores do orders-api: 100 %
  • SLO Cumprido: SIM 

Cenário 2.4 - Morte de uma zona de disponibilidade da AWS 

Neste laboratório estamos rodando o EKS com 3 AZ's na região de us-east-1, sendo us-east-1a, us-east-1b, us-east-1c rodando com 2 EC2 em cada uma delas. 


Antes de mais nada, vamos utilizar o recurso do PodAffinityPodAntiAffinity para criar uma sugestão de regra para o scheduler: "divida-se igualmente entre os hosts utilizando a referencia a label failure-domain.beta.kubernetes.io/zone", na qual é preenchida nos nodes do EKS com a zona de disponibilidade que aquele node está rodando, o que irá acarretar em garantir um Multi-AZ do workload.

❯ kubectl describe node ip-10-0-89-102.ec2.internal
Name: ip-10-0-89-102.ec2.internal
Roles: <none>
Labels: beta.kubernetes.io/arch=amd64
beta.kubernetes.io/instance-type=t3.large
beta.kubernetes.io/os=linux
eks.amazonaws.com/capacityType=ON_DEMAND
eks.amazonaws.com/nodegroup=eks-cluster-node-group
eks.amazonaws.com/nodegroup-image=ami-0ee7f482baec5230f
failure-domain.beta.kubernetes.io/region=us-east-1
failure-domain.beta.kubernetes.io/zone=us-east-1c
ingress/ready=true
kubernetes.io/arch=amd64
kubernetes.io/hostname=ip-10-0-89-102.ec2.internal
kubernetes.io/os=linux
node.kubernetes.io/instance-type=t3.large
topology.kubernetes.io/region=us-east-1
topology.kubernetes.io/zone=us-east-1c <----------- AQUI 
Annotations: node.alpha.kubernetes.io/ttl: 0
volumes.kubernetes.io/controller-managed-attach-detach: true

Então, em todos os nossos arquivos de deployment vamos adicionar as notações de affinity

 

❯ kubectl get pods -n orders -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
orders-api-56f8bf5b7c-7wbz4 2/2 Running 0 2m10s 10.0.54.38 ip-10-0-58-137.ec2.internal <none> <none>
orders-api-56f8bf5b7c-pcqtd 2/2 Running 0 2m10s 10.0.89.56 ip-10-0-81-235.ec2.internal <none> <none>
orders-api-56f8bf5b7c-zlwgd 2/2 Running 0 2m10s 10.0.78.253 ip-10-0-76-14.ec2.internal <none> <none>

Levando os IP's dos pods para o painel, podemos ver se a sugestão está funcionando entre as 3 zonas de disponibilidade.



 
Este teste não será tão inteligente. Vou selecionar todos os nodes da zona us-east-1a e dar um halt via SSM enquanto nosso teste roda.






Sumário dos testes 2.4:

  • Tempo do teste: 60s
  • Total de requisições: 23136
  • Requests por segundo: 385.11/s
  • Taxa de erros a partir do client: 0.00%
  • Taxa real de sucesso do serviço principal orders-api: 100 %
  • Taxa de sucesso dos consumidores do orders-api: 100 %
  • SLO Cumprido: SIM 

Considerações importantes


  • Mecanismo de resiliência é igual itaipava no cooler do seu tio em dia de churrasco na piscina, sempre cabe mais, no fim todo mundo vai acabar bebendo, e sempre vai faltar.

  • A resiliência a nível de plataforma é uma parte da composição da resiliência de uma aplicação, não a solução completa pra ela
  • O fluxo de retry deve ser implementado somente se as aplicações atrás delas tiverem mecanismos de idempotência para evitar duplicidades, principalmente, falando em HTTP, de requests não idempotentes como POST por exemplo.
     
  • Os retry e circuit breaker dos  meshs em geral não devem ser tratados como mecanismo de resiliência principal da solução

  • Não substitui a resiliência a nível de código / aplicação

  • Os circuit breakers e retentativas devem ser implementados a nível de código independente da plataforma suportar isso

  • A busca por circuit breakers pragmáticos tende a prioridade em caso de downtime total de uma dependência, principalmente para buscar fluxos alternativos como fallback, não apenas para serem usados para "dar erro mais rápido". Pense em "posso ter um SQS como fallback para o meu kafka?", "tenho um sistema de apoio para enfileirar as mensagens que estão dando falha"?, "eu posso reprocessar tudo que falhou quando minhas dependências voltarem?" antes de qualquer coisa, beleza? 






    Um comentário: