Quando se lida com falhas num sistema de microsserviços, sempre se utilizaram mecanismos de mitigação localizados, como o corte de carga e os disjuntores, mas estes podem não ser tão eficazes como uma abordagem mais globalizada. Esses mecanismos localizados(como demonstrado em um estudo sistemático sobre o assunto publicado no SoCC 2022) são úteis para evitar que serviços individuais sejam sobrecarregados, mas não são muito eficazes para lidar com falhas complexas que envolvem interações entre serviços, que são características das falhas de microsserviços.
Uma nova forma de lidar com estas falhas complexas tem uma visão globalizada do sistema: quando surge um problema, é ativado automaticamente um plano de mitigação global que coordena as acções de mitigação nos serviços. Neste post, avaliamos o projeto de código aberto Aperture e como ele permite um plano global de mitigação de falhas para nossos serviços. Primeiro, descrevemos os tipos comuns de falhas que experimentamos no DoorDash. Em seguida, mergulhamos nos mecanismos existentes que nos ajudaram a enfrentar as falhas. Explicaremos por que os mecanismos localizados podem não ser a solução mais eficaz e argumentaremos a favor de uma abordagem de mitigação de falhas globalmente consciente. Além disso, compartilharemos nossas experiências iniciais usando o Aperture, que oferece uma abordagem global para enfrentar esses desafios.
Classes de falhas na arquitetura de microsserviços
Antes de explicarmos o que fizemos para lidar com as falhas, vamos explorar os tipos de falhas de microsserviço que as organizações enfrentam. Discutiremos quatro tipos de falhas que a DoorDash e outras empresas encontraram.
Na DoorDash, encaramos cada falha como uma oportunidade de aprendizagem e, por vezes, partilhamos as nossas ideias e lições aprendidas em publicações públicas no blogue para mostrar o nosso compromisso com a fiabilidade e a partilha de conhecimentos. Nesta secção, discutiremos alguns padrões de falha comuns que experimentámos. Cada secção é acompanhada por falhas reais retiradas das nossas publicações anteriores no blogue, que podem ser exploradas com mais pormenor.
Eis os fracassos que iremos detalhar:
- Falha em cascata: uma reação em cadeia de diferentes serviços interligados que falham
- Tempestade de tentativas: quando as tentativas exercem uma pressão adicional sobre um serviço degradado
- Espiral da morte: alguns nós falham, fazendo com que mais tráfego seja encaminhado para os nós saudáveis, levando-os a falhar também
- Falha metaestável: um termo abrangente que descreve as falhas que não se podem auto-recuperar devido à existência de um ciclo de feedback positivo
Falha em cascata
A falha em cascata refere-se ao fenómeno em que a falha de um único serviço conduz a uma reação em cadeia de falhas noutros serviços. Documentámos uma falha grave deste tipo no nosso blogue. Nesse caso, a cadeia de falhas começou com uma manutenção aparentemente inócua da base de dados, que aumentou a latência da base de dados. A latência foi então transmitida para os serviços a montante, causando erros de timeouts e esgotamento de recursos. O aumento das taxas de erro despoletou um disjuntor mal configurado, que interrompeu o tráfego entre muitos serviços não relacionados, resultando numa falha com um grande raio de ação.
A falha em cascata descreve um fenómeno geral em que a falha se espalha pelos serviços e existe uma grande variedade de formas de transmissão de uma falha para outra. A tempestade de tentativas é um modo comum de transmissão, entre outros, que abordaremos a seguir.
Tempestade de repetição
Devido à natureza pouco fiável das chamadas de procedimento remoto (RPC), os locais de chamada RPC são frequentemente equipados com tempos limite e novas tentativas para aumentar a probabilidade de sucesso de cada chamada. A repetição de um pedido é muito eficaz quando a falha é transitória. No entanto, as repetições agravam o problema quando o serviço a jusante não está disponível ou é lento, uma vez que, nesse caso, a maioria dos pedidos acaba por ser repetida várias vezes e acaba por falhar. Este cenário em que são aplicadas tentativas excessivas e ineficazes é designado por amplificação do trabalho e fará com que um serviço já degradado se degrade ainda mais. Como exemplo, este tipo de interrupção aconteceu numa fase inicial da nossa transição para os microsserviços: um aumento súbito da latência do nosso serviço de pagamento resultou no comportamento de repetição da aplicação Dasher e do seu sistema de backend, o que exacerbou a situação.
Espiral da morte
As falhas podem frequentemente espalhar-se verticalmente através de um gráfico de chamadas RPC entre serviços, mas também podem espalhar-se horizontalmente entre nós que pertencem ao mesmo serviço. Uma espiral da morte é uma falha que começa com um padrão de tráfego que faz com que um nó falhe ou se torne muito lento, de modo que o balanceador de carga encaminha novas solicitações para os nós saudáveis restantes, o que os torna mais propensos a falhar ou ficar sobrecarregados. Esta publicação do blogue descreve uma interrupção que começou com alguns pods que falharam na sonda de prontidão e, portanto, foram removidos do cluster, e os nós restantes falharam, pois não foram capazes de lidar com as cargas maciças sozinhos.
Falhas metaestáveis
Um artigo recente propõe uma nova estrutura para estudar as falhas de sistemas distribuídos, designada por "falha metaestável". Muitas das falhas que registámos pertencem a esta categoria. Este tipo de falha é caracterizado por um ciclo de feedback positivo dentro do sistema que fornece uma carga elevada sustentada devido à amplificação do trabalho, mesmo depois de o gatilho inicial (por exemplo, má implementação; uma vaga de utilizadores) ter desaparecido. A falha metaestável é especialmente má porque não se auto-recupera e os engenheiros têm de intervir para parar o ciclo de feedback positivo, o que aumenta o tempo necessário para a recuperação.
Contra-medidas locais
Todas as falhas documentadas na secção anterior são tipos de contramedidas que tentam limitar o impacto da falha localmente numa instância de um serviço, mas nenhuma destas soluções permite uma mitigação coordenada entre serviços para garantir a recuperação global do sistema. Para o demonstrar, vamos analisar cada mecanismo de atenuação existente que implementámos e, em seguida, discutir as suas limitações.
As contramedidas que iremos discutir são:
- Redução de carga: impede que os serviços degradados aceitem mais pedidos
- Disjuntor: que interrompe os pedidos de saída em caso de degradação
- Escalonamento automático: pode ajudar a lidar com cargas elevadas em picos de tráfego, mas só é útil se for configurado para ser preditivo e não reativo
De seguida, explicaremos como funcionam todas estas estratégias de tolerância a falhas e discutiremos os seus inconvenientes e desvantagens.
Desvio de carga
A redução de carga é um mecanismo de fiabilidade que rejeita os pedidos de entrada na entrada do serviço quando o número de pedidos em curso ou simultâneos excede um limite. Ao rejeitar apenas algum tráfego, maximizamos o bom rendimento do serviço, em vez de permitir que o serviço fique completamente sobrecarregado, deixando de ser capaz de fazer qualquer trabalho útil. Na DoorDash, instrumentámos cada servidor com um "limite de simultaneidade adaptável" da biblioteca concurrency-limit da Netflix. Ela funciona como um intercetor gRPC e ajusta automaticamente o número máximo de solicitações simultâneas de acordo com a mudança na latência que observa: quando a latência aumenta, a biblioteca reduz o limite de simultaneidade para dar a cada solicitação mais recursos de computação. Além disso, o separador de carga pode ser configurado para reconhecer as prioridades dos pedidos a partir do seu cabeçalho e aceitar apenas os de alta prioridade durante um período de sobrecarga.
O corte de carga pode ser eficaz para evitar que um serviço fique sobrecarregado. No entanto, uma vez que o limitador de carga é instalado a nível local, só pode lidar com falhas de serviço locais. Como vimos na secção anterior, as falhas num sistema de microsserviços resultam frequentemente de uma interação entre serviços. Por conseguinte, seria benéfico ter uma atenuação coordenada durante uma falha. Por exemplo, quando um serviço A importante a jusante se torna lento, um serviço B a montante deve começar a bloquear os pedidos antes de chegarem a A. Isto evita que a latência elevada de A se propague dentro do subgrafo, causando potencialmente uma falha em cascata.
Para além da limitação da falta de coordenação, a separação da carga também é difícil de configurar e testar. Configurar corretamente um load shedder requer testes de carga cuidadosamente orquestrados para compreender o limite de simultaneidade ideal de um serviço, o que não é uma tarefa fácil porque, no ambiente de produção, alguns pedidos são mais caros do que outros e alguns pedidos são mais importantes para o sistema do que outros. Como exemplo de um separador de carga mal configurado, tivemos uma vez um serviço cujo limite de simultaneidade inicial foi definido demasiado alto, o que resultou numa sobrecarga temporária durante o tempo de arranque do serviço. Embora o separador de carga tenha conseguido reduzir o limite, a instabilidade inicial foi má e mostrou como é importante configurar corretamente o separador de carga. No entanto, é frequente os engenheiros deixarem estes parâmetros nos seus valores predefinidos, o que muitas vezes não é o ideal para as características de cada serviço.
Disjuntor
Enquanto o load shedding é um mecanismo para rejeitar o tráfego de entrada, um circuit breaker rejeita o tráfego de saída, mas, tal como um load shedder, só tem uma visão localizada. Os disjuntores são normalmente implementados como um proxy interno que trata dos pedidos de saída para os serviços a jusante. Quando a taxa de erro do serviço a jusante excede um limiar, o disjuntor abre-se e rejeita rapidamente todos os pedidos para o serviço com problemas, sem amplificar qualquer trabalho. Após um determinado período, o disjuntor permite gradualmente a passagem de mais tráfego, acabando por regressar ao funcionamento normal. Nós da DoorDash criamos um disjuntor em nosso cliente gRPC interno.
Em situações em que o serviço a jusante está a sofrer uma falha mas tem a capacidade de recuperar se o tráfego for reduzido, um disjuntor pode ser útil. Por exemplo, durante uma espiral de morte na formação, os nós não saudáveis são substituídos por nós recém-iniciados que não estão prontos para receber tráfego, pelo que o tráfego é encaminhado para os restantes nós saudáveis, tornando-os mais susceptíveis de ficarem sobrecarregados. Um disjuntor aberto, neste caso, dá tempo e recursos adicionais para que todos os nós se tornem novamente saudáveis.
Os disjuntores têm o mesmo problema de afinação que o corte de carga: não existe uma boa forma de os autores de serviços determinarem o limiar de disparo. Muitas fontes online sobre este assunto utilizam uma "taxa de erro de 50%" como regra geral. No entanto, para alguns serviços, uma taxa de erro de 50% pode ser tolerável. Quando um serviço chamado retorna um erro, pode ser porque o próprio serviço não está saudável, ou pode ser porque um serviço mais a jusante está a ter problemas. Quando um disjuntor se abre, o serviço atrás dele fica efetivamente inacessível durante um período de tempo, o que pode ser considerado ainda menos desejável. O limiar de disparo depende do SLA do serviço e das implicações a jusante dos pedidos, que devem ser cuidadosamente considerados.
Mantenha-se informado com as actualizações semanais
Subscreva o nosso blogue de Engenharia para receber actualizações regulares sobre todos os projectos mais interessantes em que a nossa equipa está a trabalhar
Please enter a valid email address.
Obrigado por subscrever!
Escala automática
Todos os orquestradores de cluster podem ser configurados com escalonamento automático para lidar com aumentos de carga. Quando é ativado, um controlador verifica periodicamente o consumo de recursos de cada nó (por exemplo, CPU ou memória) e, quando detecta alto uso, lança novos nós para distribuir a carga de trabalho. Embora esse recurso possa parecer atraente, na DoorDash, recomendamos que as equipes não usem o dimensionamento automático reativo (que dimensiona o cluster em tempo real durante um pico de carga). Como isso é contraintuitivo, listamos a desvantagem do dimensionamento automático reativo abaixo.
- Os nós recém-lançados precisam de tempo para aquecer (preencher caches, compilar código, etc.) e apresentarão maior latência, o que reduz temporariamente a capacidade do cluster. Além disso, os novos nós executarão tarefas de arranque dispendiosas, como a abertura de ligações à base de dados e o acionamento de protocolos de adesão. Estes comportamentos são pouco frequentes, pelo que um aumento súbito dos mesmos pode conduzir a resultados inesperados.
- Durante uma interrupção que envolva uma carga elevada, adicionar mais capacidade a um serviço irá muitas vezes apenas deslocar o estrangulamento para outro local. Normalmente, não resolve o problema.
- O redimensionamento automático reativo dificulta a análise post-mortem, uma vez que a cronologia das métricas se ajusta de várias formas ao incidente, às acções que os humanos estão a tomar para o atenuar e ao redimensionador automático.
Por isso, aconselhamos as equipas a evitarem a utilização de escalonamento automático reativo, preferindo utilizar o escalonamento automático preditivo, como o cron do KEDA, que ajusta o tamanho de um cluster com base nos níveis de tráfego esperados ao longo do dia.
Todos estes mecanismos localizados são bons para lidar com diferentes tipos de falhas. No entanto, o facto de serem localizados tem as suas próprias desvantagens. Agora, vamos analisar a razão pela qual as soluções localizadas não podem ir tão longe e porque é preferível uma observação e intervenção globalizadas.
Lacunas das contramedidas existentes
Todas as técnicas de fiabilidade que utilizamos têm uma estrutura semelhante, consistindo em três componentes: medição das condições operacionais, identificação de problemas através de regras e definições, e acções a tomar quando surgem problemas. Por exemplo, no caso do corte de carga, os três componentes são:
- Medida: calcula o histórico recente de latência ou erros do serviço
- Identificar: utiliza fórmulas matemáticas e parâmetros pré-definidos para determinar se o serviço está em risco de ser sobrecarregado
- Ação: recusa o excesso de pedidos recebidos
Para o disjuntor, são os seguintes:
- Medida: avalia a taxa de erro do serviço a jusante
- Identificar: verifica se excede um limiar
- Ação: interrompe todo o tráfego de saída para esse serviço
No entanto, os mecanismos localizados existentes sofrem de deficiências semelhantes, na medida em que:
- Utilizam as métricas que são locais ao serviço para medir as condições de funcionamento; no entanto, muitas classes de interrupções envolvem uma interação entre muitos componentes, pelo que é necessário ter uma visão global do sistema para tomar boas decisões sobre como mitigar os efeitos de uma condição de sobrecarga.
- Empregam heurísticas muito gerais para determinar o estado do sistema, o que muitas vezes não é suficientemente preciso. Por exemplo, a latência por si só não pode dizer se um serviço está sobrecarregado; uma latência elevada pode ser causada por um serviço lento a jusante.
- As suas acções de correção são limitadas. Uma vez que os mecanismos são instrumentados localmente, só podem tomar medidas locais. As acções locais não são geralmente as melhores para restaurar o sistema para um estado saudável, uma vez que a verdadeira origem do problema pode estar noutro local.
Vamos discutir a forma de ultrapassar estas deficiências e tornar a atenuação mais eficaz.
Utilização de controlos globalizados: Aperture para a gestão da fiabilidade
Um projeto que vai além das contramedidas locais para implementar um controlo de carga globalizado é implementado pelo Aperture, um sistema de gestão de fiabilidade de código aberto. Ele fornece uma camada de abstração de confiabilidade que facilita o gerenciamento de confiabilidade em uma arquitetura de microsserviço distribuída. Ao contrário dos mecanismos de fiabilidade existentes que só podem reagir a anomalias locais, o Aperture oferece um sistema de gestão de carga centralizado que lhe permite coordenar muitos serviços em resposta a uma interrupção em curso.
Design do Aperture
Tal como as contramedidas existentes, o Aperture monitoriza e controla a fiabilidade do sistema com três componentes-chave.
- Observar: O Aperture recolhe métricas relacionadas com a fiabilidade de cada nó e agrega-as no Prometheus.
- Analisar: Um controlador Aperture de execução independente está constantemente a monitorizar as métricas e acompanha o desvio do SLO
- Atuar: Se houver alguma anormalidade, o controlador Aperture activará as políticas que correspondem ao padrão observado e aplicará acções em cada nó, como o corte de carga ou a limitação da taxa distribuída.
A nossa experiência com o Aperture
O Aperture é altamente configurável na forma como detecta e actua perante as anomalias do sistema. Ele recebe políticas escritas em arquivos YAML que orientam suas ações durante uma interrupção. Por exemplo, o código abaixo, retirado do documento do Aperture e simplificado, calcula a latência da média móvel exponencial (EMA). Ele obtém métricas de latência do Prometheus e dispara um alerta quando o valor calculado está acima de um limite.
circuit:
components:
- promql:
evaluation_interval: 1s
out_ports:
output:
signal_name: LATENCY
query_string:
# OMITTED
- ema:
ema_window: 1500s
in_ports:
input:
signal_name: LATENCY
out_ports:
output:
signal_name: LATENCY_EMA
warm_up_window: 10s
- decider:
in_ports:
lhs:
signal_name: LATENCY
rhs:
signal_name: LATENCY_SETPOINT
operator: gt
out_ports:
output:
signal_name: IS_OVERLOAD_SWITCH
- alerter:
alerter_config:
alert_name: overload
severity: crit
in_ports:
signal:
signal_name: IS_OVERLOAD_SWITCH
evaluation_interval: 0.5s
Quando um alerta é acionado, o Aperture executa automaticamente acções de acordo com as políticas com que está configurado. Algumas das acções que oferece atualmente incluem a limitação da taxa distribuída e a limitação da concorrência (também conhecida como load shedding). O facto de o Aperture ter uma visão e um controlo centralizados de todo o sistema abre inúmeras possibilidades para atenuar as falhas. Por exemplo, é possível configurar uma política que reduz as cargas num serviço a montante quando um serviço a jusante está sobrecarregado, permitindo que os pedidos excessivos falhem antes de chegarem ao subgrafo problemático, o que torna o sistema mais reativo e poupa custos.
Para testar a capacidade do Aperture, executámos uma implementação do Aperture e integrámo-lo num dos nossos serviços primários, tudo num ambiente de teste, e descobrimos que é um eficaz redutor de carga. À medida que aumentámos o RPS dos pedidos artificiais enviados para o serviço, observámos que a taxa de erro aumentou, mas a boa produção manteve-se estável. Em uma segunda execução, reduzimos a capacidade de computação do serviço e, dessa vez, observamos que o rendimento bom diminuiu, mas a latência aumentou apenas ligeiramente. Nos bastidores de ambas as execuções, o controlador do Aperture notou um aumento na latência e decidiu reduzir o limite de simultaneidade. Consequentemente, a integração da API no nosso código de aplicação rejeitou alguns dos pedidos recebidos, o que se reflecte num aumento da taxa de erro. O limite de simultaneidade reduzido garante que cada pedido aceite recebe recursos de computação suficientes, pelo que a latência é apenas ligeiramente afetada.
Com esta configuração simples, o Aperture actua basicamente como um limitador de carga, mas é mais configurável e fácil de utilizar do que as nossas soluções existentes. Podemos configurar o Aperture com um algoritmo sofisticado de limitação de concorrência que minimiza o impacto de carga ou latência inesperada. O Aperture também oferece um dashboard Grafana tudo-em-um usando métricas do Prometheus, que fornece uma visão rápida da saúde de nossos serviços.
Ainda não experimentamos os recursos mais avançados do Aperture, incluindo a capacidade de coordenar ações de mitigação entre serviços e a possibilidade de ter políticas de escalonamento nas quais o dimensionamento automático é acionado após uma carga sustentada. A avaliação desses recursos requer configurações mais elaboradas. Dito isto, a melhor forma de testar uma solução de fiabilidade é no ambiente de produção, onde ocorrem interrupções reais, que são sempre imprevisíveis.
Detalhes da integração do Aperture
Vale a pena aprofundar a forma como o Aperture é integrado num sistema existente. Uma implantação do Aperture consiste nos seguintes componentes:
- Controlador do ApertureEste módulo é o cérebro do sistema Aperture. Monitoriza constantemente as métricas de fiabilidade e decide quando executar um plano de mitigação. Quando um projeto é ativado, envia as acções apropriadas (por exemplo, redução de carga) para o agente Aperture.
- Agente Aperture: Cada cluster Kubernetes executa uma instância do agente Aperture, que é responsável por rastrear e garantir a saúde dos nós em execução no mesmo cluster. Quando um pedido chega a um serviço, é intercetado por um ponto de integração, que encaminha os metadados relativos para um agente Aperture. O agente Aperture regista os metadados e responde com uma decisão sobre a aceitação ou não do pedido. Essa decisão baseia-se nas informações fornecidas pelo controlador do Aperture.
- Ponto de integração: os serviços que querem beneficiar da gestão centralizada da fiabilidade podem integrar-se com o Aperture de três formas. Se os serviços são construídos em um service mesh (atualmente suportando apenas o Envoy), o Aperture pode ser implantado no service mesh diretamente sem alterar o código da aplicação. Também existem SDKs do Aperture que podem ser usados para integrar o código do aplicativo com os pontos de extremidade do Aperture. Para aplicações Java, também se pode utilizar o Java Agent para injetar automaticamente a integração do Aperture no Netty. Para ilustrar o que essa integração faz, abaixo está um trecho de código que demonstra como usar o Aperture SDK em Java.
- Prometheus & etcd: Estas são bases de dados que armazenam as métricas de fiabilidade e são consultadas pelo controlador Aperture para obter uma medida da condição de funcionamento atual.
private String handleSuperAPI(spark.Request req, spark.Response res) {
Flow flow = apertureSDK.startFlow(metadata);
if (flow.accepted()) {
res.status(202);
work(req, res);
flow.end(FlowStatus.OK);
} else {
res.status(403);
flow.end(FlowStatus.Error);
}
return "";
}
Conclusão
Os mecanismos de confiabilidade existentes são instrumentados no nível local de serviços individuais, e mostramos que mecanismos globalizados funcionam melhor para lidar com interrupções. Neste blogue, mostrámos porque é que manter um sistema de microsserviços a funcionar de forma fiável é um problema desafiante. Também apresentamos uma visão geral de nossas contramedidas atuais. Estas soluções existentes previnem eficazmente muitas interrupções, mas os engenheiros muitas vezes compreendem mal o seu funcionamento interno e não as configuram de forma optimizada. Além disso, só podem observar e agir dentro de cada serviço, o que limita a sua eficácia na mitigação de falhas num sistema distribuído.
Para testar a ideia de usar mecanismos globalizados para mitigar interrupções, investigámos o projeto de gestão de fiabilidade de código aberto Aperture. Este projeto eleva a gestão da fiabilidade a um componente primário do sistema, centralizando as responsabilidades de monitorização e controlo, em vez de as ter a cargo de serviços individuais. Ao fazer isso, o Aperture permite métodos automatizados, eficientes e económicos para lidar com interrupções. Tivemos uma experiência positiva durante o nosso teste inicial e estamos entusiasmados com o seu potencial.