O que é uma espiral de morte?
Espirais de morte de nó único
Um ex-empregador meu foi contratado para criar um sistema de roteamento de e-mail para uma das maiores empresas do mundo. Eu fiz o projeto e a especificação do hardware e, em seguida, outro grupo começou a construí-lo. O projeto tinha dois resolvedores de DNS dedicados, cinco servidores para lidar com e-mails recebidos e colocá-los em fila para os verificadores de spam e vírus, hosts separados para verificação de spam e vírus e hosts separados para roteamento de e-mails enviados.
Na semana em que o sistema foi colocado em produção, recebi uma ligação: estava muito lento, o correio estava fazendo backup e as conexões estavam sendo recusadas. Comecei a fazer perguntas e descobri que o que havia sido implementado tinha sido "ligeiramente" alterado em relação ao meu projeto original para economizar algum dinheiro: eles descartaram os resolvedores de DNS dedicados e, em vez disso, executaram o BIND em cada host, e estavam executando os verificadores de vírus nos hosts que estavam lidando com os e-mails recebidos. Obviamente, eles mantiveram a mesma quantidade de RAM, e o BIND usa muita RAM para si mesmo, sem falar no verificador de vírus.
A primeira coisa que o administrador de sistemas fez quando o problema começou foi aumentar o número de conexões SMTP de entrada que os servidores aceitariam simultaneamente. Isso piorou muito o problema. Quando eu lhe disse que ele precisava diminuir o número de conexões para bem abaixo do que era, algo em torno de dez, ele argumentou comigo: é claro que aumentar o número de entregas simultâneas deveria torná-lo mais rápido, e reduzi-lo o tornaria mais lento! Minha resposta foi simples: você não pode estar no swap. O sistema estava usando tanta RAM que estava trocando para o disco, fazendo com que tudo parasse.
A redução do número de conexões SMTP simultâneas acelerou significativamente a entrega de e-mails e quase eliminou os problemas. Eles acabaram adicionando mais servidores: praticamente o mesmo número que achavam que economizariam ao compartilhar os hosts entre vários serviços.
A simultaneidade na qual um serviço opera, ou o número de solicitações que ele está atendendo ao mesmo tempo, está relacionada à taxa de solicitações recebidas e ao tempo necessário para atender a uma solicitação individual da seguinte forma:
R = C / T
em que C é a simultaneidade, R é a taxa de solicitação em solicitações por segundo e T é o tempo que o sistema leva para atender a uma solicitação, do início ao fim, em segundos. Pode parecer óbvio, olhando para essa equação, que a maneira de aumentar o número de solicitações que um sistema pode atender é aumentar a simultaneidade máxima e, de fato, isso é o que muitos administradores de sistema tentam fazer: eles veem o Apache reclamando de ter atingido o MaxClients (ou o que quer que seja), então aumentam o MaxClients. E a taxa de solicitações cai drasticamente.
O problema é que T está longe de ser uma quantidade fixa. Ela depende de todos os tipos de fatores: E/S se a solicitação chegar ao disco, memória disponível, largura de banda de rede disponível, velocidade do cliente, latência e tempo de serviço das solicitações que essa solicitação gera e muitos outros. À medida que C aumenta e um dos recursos disponíveis começa a ser totalmente utilizado ou sobrecarregado, T aumentará mais rapidamente do que C e a taxa de solicitação cairá.
Se não houver limite de simultaneidade, muitos serviços apresentam o que chamo de "efeito penhasco": a taxa de atendimento das solicitações cai drasticamente e o tempo para atender a uma única solicitação aumenta em uma ou duas ordens de magnitude. Isso pode ser causado por hosts que começam a ser trocados, algoritmos de agendamento que não são bem dimensionados ou qualquer número de efeitos sutis que não são realmente importantes de se entender.
O mais importante a ser lembrado aqui é que menos simultaneidade é frequentemente melhor e que ela sempre precisa ser limitada. Na verdade, a melhor maneira de descobrir qual deve ser sua simultaneidade máxima é medi-la. Avalie o sistema em vários níveis de simultaneidade e escolha o nível que oferece a taxa máxima e, ao mesmo tempo, atende às solicitações individuais em um período de tempo razoável quando o limite de simultaneidade é atingido.
Espirais de morte distribuídas
Outro ex-empregador meu embarcou em um projeto ambicioso para substituir os acessos ao banco de dados por serviços da Web. Eles contrataram um grupo de desenvolvedores de PHP para analisar cada parte do SQL no código do aplicativo, escrever um serviço da Web que fizesse a mesma coisa e substituir o código do aplicativo por uma chamada ao serviço da Web. O projeto foi um desastre.
Havia vários lugares em que uma única solicitação resultava em solicitações a vários bancos de dados que faziam sentido escrever como um único serviço da Web. Como as consultas individuais ao banco de dados e as consultas compostas precisavam ser chamadas, ambas foram expostas como serviços da Web. Vários dos serviços da Web chamaram outros serviços da Web hospedados no mesmo cluster. O cluster consistia em cerca de cem hosts com vários namespaces de URL diferentes em grupos de hosts potencialmente sobrepostos, com vários balanceadores de carga na frente deles executando o Pound.
Vários dos serviços da Web acabaram sendo muito mais lentos do que o código SQL e C++ existente. Com cerca de 5.000 servidores de aplicativos e apenas cerca de 100 hosts de serviços da Web, isso era um problema. Entretanto, o mais interessante e educativo foi o que aconteceu mesmo quando apenas um pequeno número de servidores de aplicativos foi apontado para os serviços da Web. A carga nos hosts de serviços da Web variava muito, com hosts individuais atingindo seu número máximo de conexões, o que fazia com que o balanceador de carga os tirasse da rotação. A carga cairia novamente no host e ele seria adicionado novamente. Esse processo progrediria até que a maior parte do cluster estivesse sobrecarregada e os servidores de aplicativos estivessem com problemas. Reiniciar nós individuais não ajudava.
Por fim, descobriu-se que a maneira mais fácil e rápida de colocar a carga no cluster sob controle era interromper o Apache em todos os nós e depois reiniciá-lo. O motivo pelo qual isso funcionou, enquanto a reinicialização de nós individuais (e, até mesmo, *finalmente* de todos os nós) não funcionou, foi porque as solicitações individuais de serviços da Web estavam gerando outras solicitações para outros nós no mesmo cluster. Se um nó individual ficasse lento, todos os outros nós acabariam tendo todos os seus clientes aguardando solicitações para esse nó, até que todo o cluster estivesse aguardando respostas de outros nós do cluster.
Eu chamo essa e outras condições relacionadas em sistemas distribuídos de espiral da morte distribuída. Distribuída porque é mais complexa e caótica do que o caso de um único nó que simplesmente fica sobrecarregado e não consegue acompanhar as solicitações recebidas.
Evitando espirais de morte
Limitar a simultaneidade perto do cliente
Um dos problemas que surgiram repetidamente nesse empregador foi a seguinte situação. A simultaneidade de um determinado serviço seria limitada dentro do próprio servidor, por exemplo, usando MaxClients no Apache ou max_connections no MySQL. Uma carga excessiva faria com que o servidor atingisse o limite de simultaneidade, e algum serviço mais próximo do cliente (o servidor de aplicativos, os front-ends da Web, os balanceadores de carga ou o Squid) retornaria erros que o usuário veria. O Squid, por exemplo, marcará um servidor "pai" como inativo se não responder por dois minutos, retornando erros HTTP 500. Os balanceadores de carga retornariam 503s. Isso frequentemente resultava em desenvolvedores culpando o serviço que retornava o erro pelo problema, ao que eu respondia: "O que está retornando o erro é o que está funcionando".
Por fim, convenci o empregador a substituir o Pound pelo Haproxy na maioria dos locais. O Haproxy permite configurar o número máximo de conexões a serem enviadas a qualquer backend, após o qual ele colocará as conexões de entrada na fila, permitindo até mesmo priorizar as conexões de entrada com base no URL que estão tentando acessar. Juntamente com seus relatórios de estatísticas, o Haproxy foi de grande valia na implantação de novos serviços da Web, porque era possível simplesmente observar quantas conexões de entrada e com que frequência eram colocadas na fila para ver se o serviço estava sobrecarregado, e a maioria dos servidores de aplicativos fazia exatamente a coisa certa quando suas conexões eram enfileiradas: eles apenas faziam o usuário esperar um pouco, deixando-o mais lento quando o sistema estava sobrecarregado, em vez de apresentar um erro que o irritaria e faria com que ele simplesmente pressionasse recarregar ou tentasse novamente a operação várias vezes, piorando o problema.
E por falar em enfileirar conexões:
Usar filas de trabalho
Dustin fala muito sobre isso. Os desenvolvedores da Web gostam de usar servidores sem estado, por isso é tradicional tentar concluir tudo relacionado a uma determinada solicitação do cliente antes de retornar um resultado para o cliente. Isso significa que todos os recursos relacionados à solicitação, por exemplo, a memória alocada pelo processo do Apache durante o processamento, talvez bloqueios mantidos no banco de dados etc., serão mantidos enquanto durar a solicitação.
Em vez disso, é melhor que você não precise fazer tudo de antemão para simplesmente aceitar as informações do cliente, colocá-las na fila e retornar uma mensagem de sucesso indicando que você assumiu a responsabilidade por elas. É assim que o e-mail funciona, por exemplo: Tradicionalmente, o SMTP retorna um código de sucesso assim que a mensagem é colocada em spool no disco, e não depois de ter sido entregue na caixa postal do usuário. Uma das vantagens de fazer as coisas dessa forma é que o enfileiramento aproveita o fato de que as gravações em fluxo contínuo são rápidas, enquanto a atualização de um banco de dados geralmente requer buscas aleatórias e, portanto, é muito mais lenta.
Evite loops em seu gráfico de chamadas
Os serviços da Web que descrevi acima teriam tido uma carga muito mais estável se as solicitações não tivessem gerado solicitações para outros nós no mesmo cluster. Em sistemas que têm loops como esse, um único nó lento (ou até mesmo um pouco mais lento) pode fazer com que todo o sistema fique de joelhos, transformando seu serviço distribuído tolerante a falhas em um serviço cuja confiabilidade diminui à medida que os nós são adicionados.
A propósito, a empresa também tinha uma solução de armazenamento em cluster muito cara que sofria exatamente dos mesmos tipos de sintomas: um único nó lento reduzia a velocidade de todo o cluster e frequentemente fazia com que tudo travasse, exigindo que todos os nós fossem reinicializados. Portanto, esse não é apenas um problema de soluções desenvolvidas internamente.
Marcar servidores como mortos ou limitar a simultaneidade de saída por servidor de destino
Isso está relacionado ao ponto anterior, pois pode atenuar ou até mesmo eliminar a propagação de falhas, mesmo que você acabe tendo loops em seu gráfico de chamadas.
Outro problema que essa mesma empresa tinha era que cada serviço da Web geralmente dependia de vários servidores MySQL. As solicitações individuais geralmente exigiam apenas um único servidor MySQL, mas se qualquer um dos servidores MySQL ficasse lento ou desaparecesse da rede, todos os hosts de serviços Web acabariam parando de responder porque, como no caso de chamadas para hosts no mesmo cluster, todas as conexões de saída em qualquer host de serviços Web acabariam sendo usadas para falar com o host lento/morto. Eles tinham um mecanismo de lista negra manual, mas uma forma de estado para um host registrar que um banco de dados havia atingido o tempo limite várias vezes, ou um limite no número de solicitações simultâneas para qualquer host de banco de dados, teria evitado que esse problema acontecesse.
Conclusão
É fácil criar um serviço distribuído que funcione perfeitamente em condições ideais, mas que falhará quando submetido às condições do mundo real. Os Death-spirals são uma das falhas mais comuns, e suas causas mais comuns podem ser evitadas seguindo algumas diretrizes simples de projeto.
Espero que essas informações sejam úteis para a criação de seus próprios serviços distribuídos! Sinta-se à vontade para me enviar suas próprias histórias de espiral da morte e, definitivamente, diga-me se eu o salvei de alguma.