Pontos Principais
-
As falhas em cascata envolvem algum tipo de mecanismo de feedback. Nos sistemas de software distribuído, geralmente envolvem um ciclo de feedback em que algum evento causa uma redução na capacidade, um aumento na latência ou um pico de erros. Então a resposta dos outros componentes do sistema piora o problema original.
-
Muitas vezes, é muito difícil dimensionar uma falha em cascata adicionando mais capacidade ao serviço: Novas instâncias saudáveis são atingidas com excesso de carga instantaneamente e ficam saturadas, fazendo com que a capacidade dos serviços não seja suficiente para lidar com o problema de carga.
-
Às vezes, a única solução é desligar o serviço por inteiro para recuperar e, em seguida, reintroduzir a carga.
-
O potencial de falhas em cascata é inerente a muitos, senão à maioria, dos sistemas distribuídos. Se ainda não vimos este problema em nosso sistema, isso não significa que estamos imune, nós podemos estar operando confortavelmente dentro dos limites do sistema. Não há garantia de que isso seja verdade amanhã ou na próxima semana.
O que é uma falha em cascata?
Falhas em cascata são falhas que envolvem algum tipo de mecanismo de feedback, em outras palavras, que possuem ciclos viciosos em ação.
A interrupção que derrubou o Amazon DynamoDB no US-East-1 em 20 de setembro de 2015 por mais de quatro horas é um exemplo clássico de falha em cascata. Havia dois subsistemas envolvidos: Servidores de armazenamento e um serviço de metadados. Os servidores de armazenamento solicitam suas atribuições de partição de dados ao serviço de metadados, que é replicado nos datacenters. No momento em que o incidente ocorreu, o tempo médio para recuperar as atribuições de partição havia aumentado significativamente, devido à introdução de um novo tipo de índice (Índices Secundários Globais ou GSIs - Global Secondary Indexes), mas a capacidade do serviço de metadados não havia sido aumentada, nem os prazos configurados para a operação de solicitação de atribuição da partição de dados. Qualquer solicitação que não teve êxito dentro desse prazo foi considerada como tendo falhado e o cliente tentaria a requisição novamente.
Figura 1: Serviços envolvidos na interrupção do DynamoDB em setembro de 2015
O incidente foi desencadeado por um problema de rede transitório, que fez com que alguns servidores de armazenamento não recebessem suas atribuições de partição.
Esses servidores de armazenamento se retiraram do serviço e continuaram a repetir seus pedidos de designações de partição. Os servidores de metadados ficaram sobrecarregados com a carga dessas solicitações e, portanto, tiveram uma resposta mais lenta, o que fez com que mais solicitações enviadas a eles expirassem e fossem tentadas novamente. Essas tentativas aumentaram ainda mais a carga no serviço. O serviço de metadados estava tão sobrecarregado que os operadores tiveram que desligar o firewall dos servidores de armazenamento para adicionar capacidade extra. Isso significava efetivamente colocar todo o serviço DynamoDB offline no US-East-1.
Por que as falhas em cascata são tão ruins?
O maior problema das falhas em cascata é que elas podem derrubar todo o sistema, derrubando instâncias do serviço uma a uma, até que todo o serviço de balanceamento de carga perca a integridade.
O segundo problema é que essas falhas são de um tipo excepcionalmente difícil de ser recuperada. Normalmente começam com uma pequena perturbação, como um problema de rede transitório, um pequeno aumento na carga ou a falha de algumas instâncias. Ao invés de se recuperar voltando ao estado normal com o tempo, o sistema começa a piorar. Um sistema com falha em cascata não se recupera automaticamente. Só será restaurado através da intervenção humana.
O terceiro problema é que, se as condições corretas existirem no sistema, falhas em cascata podem ocorrer sem aviso. Infelizmente, é difícil evitar as pré-condições básicas para falhas em cascata: É simplesmente uma falha total. Se a falha de um componente puder causar novas tentativas ou mudar a carga para outras partes do sistema, as condições básicas da falha em cascata estarão lá. Mas nem tudo está perdido: Existem padrões que podemos aplicar que nos ajudam a defender os sistemas contra as falhas em cascata.
Ciclos de feedback: Como falhas em cascata derrubam nossos sistemas
As falhas em cascata nos sistemas de software distribuídos geralmente envolvem um ciclo de feedback onde algum evento causa uma redução na capacidade, um aumento na latência ou um pico de erros, então, a resposta dos outros componentes do sistema piora o problema original.
O diagrama de loop causal (CLD - Causal Loop Diagram) é uma boa ferramenta para entender esses incidentes. Abaixo está um CLD para o incidente do DynamoDB.
Figura 2: Diagrama de loop causal para interrupção do DynamoDB em setembro de 2015
Os CLDs são uma ferramenta da System Dynamics, uma abordagem para modelar sistemas complexos inventados por Jay Forrester do MIT. Cada seta mostra como duas quantidades no sistema interagem. Um '+' ao lado da seta significa que um aumento na primeira quantidade tenderá a aumentar a segunda quantidade e um '-' significa que há uma relação inversa. Portanto, o aumento da capacidade do serviço, ou seja, o número de instâncias que o servem, reduzirá a carga por instância. Adicionar um novo tipo de índice ou novas tentativas de solicitações com falha tenderá a aumentá-lo.
Onde temos um ciclo no diagrama, podemos observar os sinais e ver se o ciclo está equilibrado, uma mistura de '+' e '-'. No diagrama apresentado, temos todos os sinais de '+' no ciclo, o que significa que não está equilibrado. Na System Dynamics, isso é chamado de "ciclo de reforço" (daí o 'R' no centro, com a seta ao redor).
Ter um ciclo de reforço no sistema não significa que estará constantemente sobrecarregado. Se a capacidade for suficiente para atender à demanda, ele funcionará bem. No entanto, isso significa que, nas circunstâncias certas, uma redução na capacidade, um pico na carga ou qualquer outra coisa que empurre a latência ou os timeouts acima de um limite crítico, uma falha em cascata pode ocorrer, como aconteceu com o DynamoDB.
Uma chave de realização . Existe um ciclo muito semelhante para a maioria dos serviços replicados com clientes que tentam uma nova chamada após uma falha. Este é um padrão muito comum. Mais adiante neste artigo, examinaremos alguns padrões que ajudam a impedir que esse ciclo se transforme em um cenário de falha em cascata.
Vejamos outro exemplo de falha em cascata: o Parsely's Kafkapocalyspe. Os sistemas envolvidos aqui são diferentes, mas o padrão é semelhante. Devido ao lançamento, a Parsely aumentou a carga dos sistemas, incluindo o cluster Kafka. Sem o conhecimento deles, eles estavam próximos dos limites de rede nos nós EC2, nos quais estavam executando os brokers Kafka. Em algum momento, um broker atingiu seu limite de rede e ficou indisponível. A carga aumentou em outros brokers, pois os clientes falharam e, rapidamente, todos os brokers estavam inoperantes.
Como no cenário anterior da AWS, nós vemos na interrupção da Parsely a rapidez com que um sistema pode deixar de ser estável e previsível para um estado muito não-linear e disfuncional após a violação de um limite e como a recuperação não ocorre até a intervenção dos operadores.
Recuperando-se de uma falha em cascata
Muitas vezes, é muito difícil dimensionar uma falha em cascata adicionando mais capacidade ao seu serviço: Novas instâncias saudáveis são atingidas com excesso de carga instantaneamente e ficam saturadas, portanto não conseguiremos chegar a um ponto em que tenhamos capacidade de veiculação suficiente para lidar com o problema de carga.
Muitos sistemas de balanceamento de carga usam uma verificação de integridade para enviar solicitações apenas para instâncias íntegras, embora seja necessário desativar esse comportamento durante um incidente para evitar concentrar toda a carga em instâncias totalmente novas à medida que são apresentadas. O mesmo se aplica a qualquer tipo de serviço de orquestração ou gerenciamento que mata instâncias nos servidores que falham nas verificações de integridade (como o próprio kubernetes "Liveness, Readiness and Startup Probes"). Eles removerão instâncias sobrecarregadas, contribuindo para o problema de capacidade.
Às vezes, a única solução é reduzir o serviço inteiro para recuperar e, em seguida, reintroduzir a carga. Vimos isso na interrupção do DynamoDB. O Spotify teve uma interrupção em 2013, onde eles também tiveram que desligar o serviço impactado para se recuperar. Isso é verdade quando o serviço sobrecarregado não impõe nenhum limite no número de solicitações em fila ou atuais requisições.
Seis anti-padrões de falha em cascata
Anti-padrão 1: Aceitando números ilimitados de solicitações recebidas
Qualquer pessoa que tenha feito muitos testes comparativos provavelmente notou que instâncias individuais de um serviço geralmente atingem um pico na taxa de transferência, então, se a carga aumentar ainda mais, veremos uma queda na taxa de transferência e um aumento na latência. Essa mudança ocorre porque parte do trabalho de qualquer serviço não é paralelizável (há uma boa explicação sobre a matemática na palestra do Barão Schwartz 'Aproximando-se do limite de carga de trabalho inaceitável'). Em um estado de falha em cascata, as instâncias de serviço individuais podem terminar com várias solicitações na fila ou várias threads simultâneas tentando executar, que o serviço pode ficar totalmente sem resposta e não se recuperar automaticamente (geralmente, necessita de uma reinicialização). Duo experimentou condições como essa durante uma interrupção em 2018: "Determinamos que a limitação era ineficaz devido à maneira como nosso aplicativo enfileirava as solicitações enquanto aguardava uma conexão com o banco de dados. Nesse caso, essas solicitações enfileiradas foram criadas de maneira que o banco de dados não pôde se recuperar, pois tentou processar esse grande atraso de solicitações, mesmo depois que o tráfego diminuiu e os limites estavam em vigor".
É por isso que definir um limite de carga em cada instância de serviço é tão importante. A utilização de carga (Loadshedding) em um balanceador de carga que funciona, mas também devemos definir limites nos serviços, para termos uma boa defesa. Os mecanismos para implementar um limite para solicitações simultâneas variam, dependendo da linguagem de programação e da estrutura do servidor que estamos usando, mas podem ser tão simples quanto um semáforo. A ferramenta de limites de concorrência da Netflix é um exemplo baseado em Java.
A falha de solicitação antecipada quando um servidor está muito carregado também é boa opção para os clientes. É melhor obter uma falha rápida e tentar novamente em uma instância diferente, ou exibir um erro ou uma experiência degradada, do que aguardar até que o prazo final da solicitação termine (se não houver um prazo final definido). Permitir que isso aconteça pode levar à lentidão, que se espalha por toda uma arquitetura do microservice, e às vezes pode ser complicado encontrar o serviço que está causando o problema, quando cada serviço é interrompido.
Anti-padrão 2: O Comportamento perigoso de um nova tentativa do cliente
Nem sempre temos controle sobre o comportamento do cliente, mas se controlamos nossos clientes, a moderação dos padrões de solicitação do cliente pode ser uma ferramenta muito útil. No nível mais básico, os clientes devem limitar o número de vezes que podem tentar novamente uma solicitação com falha, dentro de um curto período de tempo. Em um sistema em que os clientes tentam várias vezes repetidamente, qualquer pico menor de erros pode causar uma enxurrada de solicitações repetidas, criando algo parecido a um ataque DDOS. A Square experimentou isso em março de 2017, quando sua instância Redis ficou indisponível por causa de um trecho de código que tentaria uma requisição repetitivamente até 500 vezes.
Aqui está uma amostra do código Golang para esse loop de repetição simples:
const MAX_RETRIES = 500
for i := 0; i < MAX_RETRIES; i++ {
_, err := doServerRequest()
if err == nil {
break
}
}
Quando os engenheiros da Square lançaram uma correção para reduzir o número de novas tentativas, o ciclo de feedback terminou imediatamente e o serviço começou a funcionar normalmente.
Os clientes devem usar um Exponential Backoff crescente entre as tentativas de repetição. Também é uma boa prática adicionar um pouco de ruído aleatório ou jitter. Isso 'barra' uma onda de novas tentativas ao longo do tempo, para que um serviço que fica temporariamente travado por alguns milissegundos não seja atingido com o dobro de sua carga normal quando todos os clientes tentarem simultaneamente. O número de tentativas e quanto tempo de espera é específico do aplicativo. As solicitações voltadas ao usuário devem falhar rapidamente ou retornar um resultado de falha de algum tipo, enquanto o processamento em lote ou assíncrono pode esperar muito mais tempo.
Aqui está um exemplo de código Golang para um loop de repetição com Exponential backoff e jitter:
const MAX_RETRIES = 5
const JITTER_RANGE_MSEC = 200
steps_msec := []int{100, 500, 1000, 5000, 15000}
rand.Seed(time.Now().UTC().UnixNano())
for i := 0; i < MAX_RETRIES; i++ {
_, err := doServerRequest()
if err == nil {
break
}
time.Sleep(time.Duration(steps_msec[i] + rand.Intn(JITTER_RANGE_MSEC)) *
time.Millisecond)
}
As práticas modernas recomendadas vão um passo além do Exponential Backoff e jitter. O padrão de design da aplicação de um Circuit Breaker agrupa as chamadas para um serviço externo e rastreia o sucesso e a falha dessas chamadas ao longo do tempo. Uma sequência de chamadas com falha 'irá disparar' o circuit breaker, o que significa que não serão feitas mais chamadas para o serviço externo com falha e os clientes que tentarem fazer essas chamadas receberão imediatamente um erro. Periodicamente, o circuit breaker analisará o serviço externo, permitindo uma solicitação. Se a solicitação sondada for bem-sucedida, o circuit breaker será redefinido e começará novamente a fazer chamadas para o serviço externo.
Os circuit breakers são poderosos porque podem compartilhar o estado em todas as solicitações de um cliente para o mesmo backend, enquanto o Exponential Backoff é específico para uma única solicitação. Os circuit breakers reduzem a carga em um serviço de backend em dificuldades mais do que qualquer outra abordagem. Aqui está uma implementação de circuit breaker para a Golang. O Hystrix da Netflix inclui um circuit breakers em Java.
Anti-padrão 3: Travando com Informações Ruins - a 'Query of Death'
Essa "query of death" é qualquer solicitação ao sistema que pode causar uma falha. Um cliente pode enviar uma query of death, travar uma instância do serviço e continuar tentando novamente, desativando as demais. A redução na capacidade pode reduzir potencialmente todo o serviço à medida que as instâncias restantes ficam sobrecarregadas com a carga de trabalho.
Esse tipo de cenário pode ser o resultado de um ataque ao serviço, mas pode não ser malicioso, apenas por azar. É por isso que é uma prática recomendada nunca retornar ou travar com entradas inesperadas. Um programa deve retornar inesperadamente apenas se o estado interno parecer incorreto e não for seguro continuar o processo.
O teste de Fuzz é uma prática de teste automatizada que pode ajudar a detectar programas que travam com entradas mal formatadas. O teste de fuzz é importante, mais ainda, para qualquer serviço exposto a entradas não confiáveis, o que significa qualquer coisa fora da nossa empresa.
Anti-padrão 4: Failover baseado em proximidade e efeito dominó
O que nossos sistemas fazem se um datacenter inteiro ou uma zona de disponibilidade cair? Se a resposta for "failover para o mais próximo", nossos sistemas podem causar uma falha em cascata.
Figura 3: Mapa dos locais do datacenter
Se perdermos um dos nossos datacenters da costa leste dos EUA na topologia mostrada acima, o outro datacenter da região receberá aproximadamente o dobro da carga assim que os usuários fizerem o failover. Se o datacenter restante da costa leste dos EUA não puder gerenciar a carga e também falhar, é provável que a carga vá principalmente para os datacenters da costa oeste (é mais barato do que enviar todo o tráfego para a Europa, normalmente). Se falharem, nossos datacenters restantes provavelmente cairão como dominós. Nosso plano de failover, que visa melhorar a confiabilidade do nosso sistema, impossibilitou todo o serviço.
Sistemas geograficamente equilibrados como esse precisam executar uma das duas coisas: Certifique-se de que a carga falhe de maneira a não sobrecarregar os demais datacenters, ou mantenha muita capacidade em todos os lugares.
Os sistemas baseados em IP Anycast (como a maioria dos serviços DNS e muitas CDNs) geralmente super provisionam, especificamente porque o anycast, que serve um único IP a partir de vários pontos da Internet, não controla o tráfego de entrada.
Esse nível de super provisionamento para falhas pode ser muito caro. Para muitos sistemas, usar uma maneira de direcionar o carregamento para datacenters com capacidade disponível faz mais sentido. Isso geralmente é feito usando o balanceamento de carga DNS (por exemplo, a distribuição inteligente de tráfego do NS1).
Anti-padrão 5: Trabalho solicitado por falha
Às vezes, os serviços funcionam quando ocorre uma falha. Considere um sistema hipotético de armazenamento de dados distribuídos que divide os dados em blocos. Queremos um número mínimo de réplicas de cada bloco e verificamos regularmente se temos o número certo de cópias. Se não o fizermos, começaremos a fazer novas cópias. Aqui está um trecho de um pseudocódigo:
replicaChecker()
while true {
for each block in filesystem.GetAllBlocks() {
if block.replicasHeartbeatedOK() < minReplicas {
block.StartCopyNewReplica()
}
}
}
Figura 4: Replicação de blocos de dados após uma falha
Essa abordagem provavelmente funcionará bem se perdermos um bloco ou um servidor dentre vários. Mas e se perdermos uma proporção substancial dos servidores? Um rack inteiro? A capacidade de veiculação do sistema será reduzida e os servidores restantes ficarão ocupados replicando novamente os dados. Não estabelecemos limites para a quantidade de replicação que vamos fazer por vez. Aqui está um diagrama de loop causal mostrando o loop de feedback.
Figura 5: Diagrama de loop causal mostrando o loop de feedback no sistema
A maneira usual de contornar isso é atrasar a replicação (porque a falha geralmente é incerta) e limitar o número de processos de replicação em andamento com algo como o algoritmo de token bucket. O diagrama de loop causal abaixo mostra como isso muda o sistema: Ainda temos um loop de feedback, mas agora existe um loop interno equilibrado que impede que o sistema caia no ciclo de feedback.
Figura 6: Diagrama de loop causal mostrando o limite de taxa na replicação
Anti-padrão 6: Longos tempos de inicialização
Às vezes, os serviços são projetados para fazer vários processos na inicialização, talvez seja porque estão lendo e armazenando em cache vários dados. É melhor evitar esse padrão por dois motivos. Primeiro, dificulta qualquer forma de dimensionamento automático: Quando detectamos um aumento na carga e iniciamos o serviço com uma inicialização lenta, podemos criar vários problemas. Segundo, se as instâncias do serviço falharem por algum motivo (falta de memória ou uma query of death causar uma falha), levaremos muito tempo para voltar à nossa capacidade normal. Ambas as condições podem facilmente levar a sobrecarga no nosso serviço.
Reduzindo Riscos de Falha em Cascata
O potencial de falhas em cascata é inerente a muitos, senão à maioria, dos sistemas distribuídos. Se ainda não vimos um em nosso sistema, isso não significa que estamos imunes a eles. Podemos estar operando confortavelmente dentro dos limites do nosso sistema. Não há garantia de que isso seja verdade no futuro, tanto próximo como distante.
Listamos vários antipadrões para evitar se desejamos reduzir o risco de ocorrer uma falha em cascata. Nenhum serviço pode suportar um pico arbitrário de carga. Ninguém deseja que o serviço retorne erros, mas, às vezes, é um mal necessário, quando a alternativa é ver todo o serviço ficar parado, tentando lidar com todas as solicitações recebidas.
Leituras Adicionais
- 'Addressing Cascading Failures', de Mike Ulrich, pelo Site Reliability Engineering: Como o Google executa sistemas de produção.
- Capítulo 'Stability Patterns' em Release It! De Michael T Nygard.
- Capítulo "Handling Overload", de Alejandro Forero Cuervo pelo Site Reliability Engineering: como o Google executa sistemas de produção.
Sobre a Autora
Laura Nolan é engenheira sênior da Slack Technologies em Dublin. Sua formação é em engenharia de confiabilidade de sites (SRE), engenharia de software, sistemas distribuídos e ciência da computação. Ela escreveu o capítulo 'Managing Critical State' no livro da O'Reilly 'Site Reliability Engineering', além de contribuir no livro mais recente sobre o assunto 'Buscando SRE'. Ela é membro do comitê USENIX SREcon.