Falhas acontecem

Sempre que um serviço ou sistema chama outro, podem ocorrer falhas. Essas falhas podem resultar de vários fatores como servidores, redes, load balancers, software, sistemas operacionais ou até de erros dos operadores do sistema. Projetamos nossos sistemas para reduzir a probabilidade de falhas, mas é impossível construir sistemas que nunca falhem. Portanto, na Amazon, projetamos nossos sistemas para tolerar e reduzir a probabilidade de falhas e evitar que uma pequena porcentagem de falhas se transforme em uma interrupção completa. Para criar sistemas resilientes, empregamos três ferramentas essenciais: tempos limite, novas tentativas e retirada.

Muitos tipos de falhas se tornam aparentes, pois as solicitações demoram mais que o normal e, muitas vezes, nunca são concluídas. Quando um cliente aguarda mais do que o normal pela conclusão de uma solicitação, ele também mantém os recursos que estava usando para essa solicitação por mais tempo. Quando várias solicitações detêm os recursos por muito tempo, o servidor pode ficar sem esses recursos. Esses recursos podem incluir memória, threads, conexões, portas efêmeras ou qualquer outro item limitado. Para evitar essa situação, os clientes definem tempos limite. Tempo limite é a quantidade máxima de tempo que um cliente aguarda a conclusão de uma solicitação.

Muitas vezes, tentar a mesma solicitação novamente faz com que a solicitação tenha êxito. Isso acontece porque os tipos de sistemas que criamos não costumam falhar como uma única unidade. Em vez disso, sofrem falhas parciais ou transitórias. Uma falha parcial ocorre quando uma porcentagem das solicitações tem êxito. Uma falha transitória ocorre quando uma solicitação falha por um curto período. As novas tentativas permitem que os clientes sobrevivam a essas falhas parciais aleatórias e falhas transitórias de curta duração enviando a mesma solicitação novamente.

Nem sempre é seguro fazer uma nova tentativa. Uma nova tentativa pode aumentar a carga no sistema que está sendo chamado, se o sistema já estiver falhando porque está se aproximando de uma sobrecarga. Para evitar esse problema, implementamos nossos clientes para usar a retirada. Isso aumenta o tempo entre tentativas subsequentes, o que mantém a carga no back-end uniforme. O outro problema com as novas tentativas é que algumas chamadas remotas têm efeitos colaterais. Um tempo limite ou falha não significa necessariamente que os efeitos colaterais não tenham acontecido. Se não quiser receber os efeitos colaterais várias vezes, uma melhor prática é criar as APIs para que sejam idempotentes, o que significa que novas tentativas podem ser feitas com segurança.

Finalmente, o tráfego não chega aos serviços da Amazon a uma taxa constante. Em vez disso, a taxa de chegada de solicitações frequentemente apresenta grandes intermitências. Essas intermitências podem ser causadas pelo comportamento do cliente, pela recuperação de falhas e até mesmo por algo simples, como uma tarefa cron periódica. Se os erros forem causados pela carga, as novas tentativas poderão ser ineficazes se todos os clientes tentarem novamente ao mesmo tempo. Para evitar esse problema, usamos o jitter. Trata-se de uma quantidade aleatória de tempo antes de fazer ou tentar novamente uma solicitação para ajudar a evitar grandes intermitências diluindo a taxa de chegada.

Cada uma dessas soluções é discutida nas seções a seguir.

Tempos limite

Uma melhor prática na Amazon é definir um tempo limite em qualquer chamada remota e, geralmente, em qualquer chamada entre processos, mesmo na mesma caixa. Isso inclui um tempo limite de conexão e um tempo limite de solicitação. Muitos clientes padrão oferecem recursos robustos de tempo limite integrados.
Normalmente, o problema mais difícil é escolher um valor de tempo limite para definir. Definir um tempo limite muito alto reduz sua utilidade, porque os recursos ainda são consumidos enquanto o cliente aguarda o tempo limite. Definir o tempo limite muito baixo tem dois riscos:
 
• Maior tráfego no back-end e maior latência, porque muitas solicitações passam por uma nova tentativa.
• Pequeno aumento na latência de back-end, levando a uma interrupção completa porque todas as solicitações começam a passar por uma nova tentativa.
 
Uma melhor prática para escolher um tempo limite para chamadas em uma região da AWS é começar com as métricas de latência do serviço downstream. Portanto, na Amazon, quando fazemos um serviço chamar outro serviço, escolhemos uma taxa aceitável de falsos tempos limite (como 0,1%). Em seguida, examinamos o percentil de latência correspondente no serviço downstream (p99,9 neste exemplo). Essa abordagem funciona bem na maioria dos casos, mas existem algumas armadilhas, descritas a seguir:
 
• Essa abordagem não funciona nos casos em que os clientes têm uma latência de rede substancial, como na Internet. Nesses casos, levamos em consideração a latência razoável da rede no pior dos casos, lembrando que os clientes podem se espalhar pelo mundo.
• Essa abordagem também não funciona com serviços com limites de latência apertados, onde p99.9 é próximo a p50. Nesses casos, adicionar algum preenchimento ajuda a evitar pequenos aumentos de latência que causam altos números de tempos limite.
• Encontramos uma armadilha comum ao implementar tempos limite. O Linux SO_RCVTIMEO é poderoso, mas tem algumas desvantagens que o tornam inadequado como um tempo limite de soquete de ponta a ponta. Algumas linguagens, como Java, expõem esse controle diretamente. Outros idiomas, como o Go, fornecem mecanismos de tempo limite mais robustos.
• Também existem implementações em que o tempo limite não cobre todas as chamadas remotas, como DNS ou TLS. Em geral, preferimos usar os tempos limite incorporados em clientes bem testados. Se implementarmos nossos próprios tempos limite, prestamos muita atenção ao significado exato das opções de soquete de tempo limite e ao trabalho que está sendo feito.
 
Em um sistema em que trabalhei na Amazon, vimos um pequeno número de tempos limite conversando com uma dependência imediatamente após as implantações. O tempo limite foi definido como muito baixo, para cerca de 20 milissegundos. Fora das implantações, mesmo com esse baixo valor de tempo limite, não víamos tempos limite acontecendo regularmente. Ao investigar, descobri que o timer incluía o estabelecimento de uma nova conexão segura, que era reutilizada em solicitações subsequentes. Como o estabelecimento da conexão levou mais de 20 milissegundos, observamos um pequeno número de solicitações expirar quando um novo servidor entrou em serviço após as implantações. Em alguns casos, os pedidos tentaram novamente e foram bem-sucedidos. Inicialmente, resolvemos esse problema aumentando o valor do tempo limite no caso de uma conexão ser estabelecida. Mais tarde, melhoramos o sistema estabelecendo essas conexões quando um processo foi iniciado, mas antes de receber tráfego. Isso nos levou a resolver completamente o problema do tempo limite.

Tentativas e retirada

As novas tentativas são "egoístas". Em outras palavras, quando um cliente tenta novamente, ele passa mais tempo do servidor para obter uma chance maior de sucesso. Onde as falhas são raras ou transitórias, isso não é um problema. Isso ocorre porque o número geral de solicitações repetidas é pequeno e a compensação pelo aumento da disponibilidade aparente funciona bem. Quando falhas são causadas por sobrecarga, tentativas que aumentam a carga podem piorar significativamente as coisas. Eles podem até atrasar a recuperação mantendo a carga alta por muito tempo após a resolução do problema original. As novas tentativas são semelhantes a um medicamento poderoso - útil na dose certa, mas pode causar danos significativos quando usado em excesso. Infelizmente, em sistemas distribuídos, quase não há como coordenar todos os clientes para obter o número certo de novas tentativas.

A solução preferida que usamos na Amazon é uma retirada. Em vez de tentar novamente de forma imediata e agressiva, o cliente espera uma quantidade de tempo entre as tentativas. O padrão mais comum é uma retirada exponencial, em que o tempo de espera é aumentado exponencialmente após cada tentativa. A retirada exponencial pode levar a tempos de retirada muito longos, porque as funções exponenciais crescem rapidamente. Para evitar tentar por muito tempo, as implementações normalmente limitam a retirada a um valor máximo. Isso é chamado, previsivelmente, de retirada exponencial limitada. No entanto, isso apresenta outro problema. Agora, todos os clientes estão fazendo novas tentativas constantemente à taxa máxima. Em quase todos os casos, nossa solução é limitar o número de vezes que o cliente tenta novamente e manipular a falha resultante anteriormente na arquitetura orientada a serviços. Na maioria dos casos, o cliente desistirá da chamada de qualquer maneira, porque tem seus próprios tempos limite.

Há outros problemas com novas tentativas, descritas a seguir:

• Sistemas distribuídos geralmente têm várias camadas. Considere um sistema em que a ligação do cliente cause uma pilha de cinco chamadas de serviço. Termina com uma consulta a um banco de dados e três tentativas em cada camada. O que acontece quando o banco de dados começa a falhar nas consultas sob carga? Se cada camada tentar de forma independente, a carga no banco de dados aumentará 243 vezes, tornando improvável a recuperação. Isso ocorre porque as novas tentativas em cada camada se multiplicam: três primeiras tentativas, depois nove tentativas e assim por diante. Por outro lado, tentar novamente na camada mais alta da pilha pode desperdiçar trabalho de chamadas anteriores, o que reduz a eficiência. Em geral, para operações de plano de controle e plano de dados de baixo custo, nossa melhor prática é tentar novamente em um único ponto da pilha.
• Carga. Mesmo com uma única camada de tentativas, o tráfego ainda aumenta significativamente quando os erros são iniciados. Os disjuntores, em que as chamadas para um serviço downstream são totalmente interrompidas quando um limite de erro é excedido, são amplamente promovidas para resolver esse problema. Infelizmente, os disjuntores introduzem um comportamento modal em sistemas que pode ser difícil de testar e pode introduzir um tempo adicional à recuperação. Descobrimos que podemos mitigar esse risco limitando as tentativas localmente usando um bucket de tokens. Isso permite que todas as chamadas tentem novamente desde que existam tokens e, em seguida, tente novamente a uma taxa fixa quando os tokens estiverem esgotados. A AWS adicionou esse comportamento ao AWS SDK em 2016. Portanto, os clientes que usam o SDK têm esse comportamento de controle de utilização incorporado.
• Decidir quando tentar novamente. Em geral, nossa opinião é que APIs com efeitos colaterais não são seguras para tentar novamente, a menos que ofereçam idempotência. Isso garante que os efeitos colaterais ocorram apenas uma vez, não importa quantas vezes você tente novamente. APIs somente leitura geralmente são idempotentes, enquanto APIs de criação de recursos podem não ser. Algumas APIs, como a API RunInstances do Amazon Elastic Compute Cloud (Amazon EC2), fornecem mecanismos explícitos baseados em token para fornecer idempotência e torná-los seguros para tentar novamente. É necessário um bom design de API e cuidado ao implementar clientes, para evitar efeitos colaterais duplicados.
• Saber quais falhas valem a pena tentar novamente. O HTTP fornece uma distinção clara entre erros de cliente e servidor. Isso indica que os erros do cliente não devem ser tentados novamente com a mesma solicitação, porque eles não terão êxito mais tarde, enquanto os erros do servidor podem ter êxito nas tentativas subsequentes. Infelizmente, a consistência eventual nos sistemas desfoca significativamente essa linha. Um erro do cliente em um momento pode se tornar um sucesso no momento seguinte, à medida que o estado se propaga.

Apesar desses riscos e desafios, as tentativas são um mecanismo poderoso para fornecer alta disponibilidade diante de erros transitórios e aleatórios. É necessário julgamento para encontrar a compensação certa para cada serviço. Em nossa experiência, um bom lugar para começar é lembrar que as novas tentativas são egoístas. As novas tentativas são uma maneira de os clientes afirmarem a importância de sua solicitação e exigirem que o serviço gaste mais recursos para lidar com isso. Se um cliente é muito egoísta, pode criar problemas abrangentes.

Jitter

Quando falhas são causadas por sobrecarga ou contenção, recuar frequentemente não ajuda tanto quanto parece. Isso é devido à correlação. Se todas as chamadas com falha voltarem ao mesmo tempo, elas causam contenção ou sobrecarga novamente quando são tentadas novamente. Nossa solução é o jitter. O jitter adiciona uma certa aleatoriedade à retirada para espalhar as novas tentativas no tempo. Para obter mais informações sobre quanto jitter adicionar e as melhores maneiras de adicioná-lo, consulte Retirada exponencial e jitter.

O jitter não é apenas para novas tentativas. A experiência operacional nos ensinou que o tráfego para nossos serviços, incluindo planos de controle e dados, tende a aumentar bastante. Esses picos de tráfego podem ser muito curtos e geralmente são ocultados por métricas agregadas. Ao criar sistemas, consideramos adicionar algum jitter a todos os temporizadores, tarefas periódicas e outros trabalhos atrasados. Isso ajuda a espalhar picos de trabalho e facilita o dimensionamento dos serviços downstream para uma carga de trabalho.

Ao adicionar jitter ao trabalho agendado, não selecionamos o jitter em cada host aleatoriamente. Em vez disso, usamos um método consistente que produz o mesmo número todas as vezes no mesmo host. Dessa forma, se houver um serviço sendo sobrecarregado ou uma condição de corrida, isso acontecerá da mesma maneira em um padrão. Nós, humanos, somos bons em identificar padrões e temos mais chances de determinar a causa raiz. O uso de um método aleatório garante que, se um recurso está sendo sobrecarregado, isso só acontece - bem, aleatoriamente. Isso torna a solução de problemas muito mais difícil.

Nos sistemas em que trabalhei, como o Amazon Elastic Block Store (Amazon EBS) e o AWS Lambda, descobrimos que os clientes enviavam solicitações frequentemente em intervalos regulares, como uma vez por minuto. No entanto, quando um cliente tem vários servidores se comportando da mesma maneira, eles podem se alinhar e disparar suas solicitações ao mesmo tempo. Isso pode ocorrer nos primeiros segundos de um minuto ou nos primeiros segundos após a meia-noite em tarefas diárias. Prestando atenção à carga por segundo e trabalhando com os clientes para adicionar o jitter às cargas de trabalho periódicas deles, realizamos a mesma quantidade de trabalho com menos capacidade do servidor.

Temos menos controle sobre picos no tráfego de clientes. No entanto, mesmo para tarefas acionadas pelo cliente, é uma boa ideia adicionar o jitter onde isso não afeta a experiência do cliente.

Conclusão

Em sistemas distribuídos, falhas transitórias ou latência em interações remotas são inevitáveis. Os tempos limites impedem que os sistemas sejam suspensos excessivamente, as novas tentativas podem mascarar essas falhas e a retirada e o jitter e podem melhorar a utilização e reduzir o congestionamento nos sistemas.

Na Amazon, aprendemos que é importante ter cuidado com as novas tentativas. As novas tentativas podem amplificar a carga em um sistema dependente. Se as chamadas para um sistema estiverem atingindo o tempo limite e esse sistema estiver sobrecarregado, as novas tentativas poderão piorar a sobrecarga, em vez de melhorar. Evitamos essa amplificação tentando novamente apenas quando observamos que a dependência está íntegra. Paramos de tentar novamente quando as novas tentativas não estão ajudando a melhorar a disponibilidade.


Sobre o autor

Marc Brooker é engenheiro-chefe sênior na Amazon Web Services. Ele trabalha na AWS desde 2008 em vários serviços, como EC2, EBS e IoT. Atualmente, ele se concentra no AWS Lambda, que inclui o trabalho em escalabilidade e virtualização. Marc realmente aprecia a leitura de COEs e obras póstumas. Ele tem PhD em engenharia elétrica.

Desafios com sistemas distribuídos Como usar o descarte de carga para evitar sobrecarga Como evitar o fallback em sistemas distribuídos