Por alguns anos, trabalhei na equipe Service Frameworks na Amazon. Nossa equipe criou ferramentas que ajudaram os proprietários dos serviços da AWS, como Amazon Route 53 e Elastic Load Balancing, a criarem os serviços deles mais rapidamente, e os clientes de serviços os chamam mais facilmente. Outras equipes da Amazon forneceram aos proprietários de serviços funcionalidades como medição, autenticação, monitoramento, geração de bibliotecas de clientes e geração de documentação. Em vez de cada equipe de serviço ter que integrar esses recursos manualmente, a equipe Service Frameworks fez essa integração uma vez e expôs a funcionalidade a cada serviço por meio da configuração.

Um desafio que enfrentamos foi determinar como fornecer padrões sensíveis, especialmente para recursos relacionados à performance ou à disponibilidade. Por exemplo, não foi possível definir um tempo limite padrão do cliente com facilidade, porque nossa estrutura não fazia ideia de quais poderiam ser as características de latência de uma chamada de API. Isso não teria sido mais fácil para os proprietários ou clientes de serviços descobrirem por si mesmos, por isso continuamos tentando e obtivemos algumas informações úteis ao longo do caminho.

Uma pergunta comum com que nos debatíamos era determinar o número padrão de conexões que o servidor permitiria abrir para os clientes ao mesmo tempo. Essa configuração foi projetada para evitar que um servidor assuma trabalho demais e fique sobrecarregado. Mais especificamente, queríamos definir as configurações do máximo de conexões para o servidor proporcionalmente ao máximo de conexões para o load balancer. Isso foi antes dos dias do Elastic Load Balancing, então os load balancers de hardware eram amplamente utilizados.

Decidimos ajudar os proprietários de serviços e os clientes de serviços da Amazon a descobrir o valor ideal do máximo de conexões a serem configuradas no load balancer e o valor correspondente a ser definido nas estruturas que fornecemos. Decidimos que, se pudéssemos descobrir como usar o julgamento humano para fazer uma escolha, então poderíamos escrever um software para emular esse julgamento.

Determinar o valor ideal acabou sendo muito desafiador. Quando o máximo de conexões era configurado para um valor muito baixo, o load balancer poderia reduzir o aumento no número de solicitações, mesmo quando o serviço tinha capacidade suficiente. Quando o máximo de conexões era alto demais, os servidores ficavam lentos e sem resposta. Quando o máximo de conexões era definido da maneira certa para uma carga de trabalho, a carga de trabalho se deslocava ou a performance da dependência mudava. Em seguida, os valores estariam errados novamente, resultando em interrupções ou sobrecargas desnecessárias.

No final, descobrimos que o conceito de máximo de conexões era muito impreciso para fornecer a resposta completa ao quebra-cabeça. Neste artigo, descreveremos outras abordagens, como descarte de carga, que consideramos terem funcionado bem.

A anatomia da sobrecarga

Na Amazon, evitamos a sobrecarga projetando nossos sistemas para dimensionar proativamente, antes que eles encontrem situações de sobrecarga. No entanto, os sistemas de proteção envolvem proteção em camadas. Isso começa com a escalabilidade automática, mas também inclui mecanismos para eliminar o excesso de carga normalmente, a capacidade de monitorar esses mecanismos e, mais importante, os testes contínuos.
 
Quando testamos nossos serviços com carga, descobrimos que a latência de um servidor com baixa utilização é menor do que a latência dele com alta utilização. Sob carga pesada, a contenção de threads, a alternância de contexto, a coleta de lixo e a contenção de E/S se tornam mais pronunciadas. Eventualmente, os serviços atingem um ponto de inflexão em que a performance deles começa a se degradar ainda mais rapidamente.
 
A teoria por trás dessa observação é conhecida como Lei de escalabilidade universal, que é uma derivação da Lei de Amdahl. Essa teoria especifica que, embora o throughput de um sistema possa ser aumentada usando a paralelização, ela é limitada em última instância pelo throughput dos pontos de serialização (ou seja, pelas tarefas que não podem ser paralelizadas).
 
Infelizmente, não apenas o throughput é limitado pelos recursos de um sistema, mas geralmente diminui quando o sistema está sobrecarregado. Quando um sistema recebe mais trabalho do que os recursos dele suportam, ele fica lento. Os computadores assumem o trabalho mesmo quando estão sobrecarregados, mas gastam quantidades crescentes do tempo deles alternando o contexto e ficam muito lentos para serem úteis.
 
Em um sistema distribuído em que um cliente está se comunicando com um servidor, o cliente geralmente fica impaciente e deixa de esperar que o servidor responda após algum tempo. Essa duração é conhecida como tempo limite. Quando um servidor fica tão sobrecarregado que a latência dele excede o tempo limite do cliente, as solicitações começam a falhar. O gráfico a seguir mostra como o tempo de resposta do servidor aumenta à medida que o throughput oferecido (em transações por segundo) aumenta e o tempo de resposta atinge um ponto de inflexão eventualmente em que as coisas se deterioram rapidamente.

No gráfico anterior, quando o tempo de resposta excede o tempo limite do cliente, fica claro que as coisas estão ruins, mas o gráfico não mostra o quão ruim estão. Para ilustrar isso, podemos plotar a disponibilidade percebida pelo cliente juntamente com a latência. Em vez de usar uma medida genérica do tempo de resposta, podemos mudar para o uso do tempo médio de resposta. O tempo médio de resposta significa que 50% das solicitações foram mais rápidas que o valor médio. Se a latência mediana do serviço for igual ao tempo limite do cliente, metade das solicitações expirará, portanto a disponibilidade será de 50%. É aqui que um aumento de latência transforma um problema de latência em um problema de disponibilidade. Veja um gráfico disso acontecendo:

Infelizmente, este gráfico é difícil de interpretar. Uma maneira mais simples de descrever o problema da disponibilidade é distinguir goodput de throughput. Throughput é o número total de solicitações por segundo que estão sendo enviadas ao servidor. Goodput é o subconjunto do throughput que é tratado sem erros e com latência baixa o suficiente para que o cliente faça uso da resposta.

Ciclos de feedback positivos

A parte insidiosa de uma situação de sobrecarga é como ela se amplifica em um ciclo de comentários. Quando um cliente atinge o tempo limite, já é suficientemente ruim que ele tenha um erro. O pior é que todo o progresso que o servidor fez até agora nessa solicitação foi desperdiçado. E a última coisa que um sistema deve fazer em uma situação de sobrecarga, onde a capacidade é restrita, é desperdiçar trabalho.

Para piorar ainda mais, os clientes costumam repetir a solicitação. Isso multiplica a carga oferecida no sistema. E se houver um gráfico de chamadas suficientemente profundo em uma arquitetura orientada a serviços (ou seja, um cliente chama um serviço, que chama outros serviços, que chamam outros serviços), e se cada camada executar várias tentativas, uma sobrecarga na camada inferior causará novas tentativas em cascata que amplificarão a carga oferecida exponencialmente.

Quando esses fatores são combinados, uma sobrecarga cria seu próprio ciclo de comentários que resulta em sobrecarga como um estado estacionário.

Impedindo que o trabalho seja desperdiçado

Superficialmente, o descarte de carga é simples. Quando um servidor aborda a sobrecarga, ele deve começar a rejeitar solicitações em excesso para poder se concentrar nas solicitações que decide liberar. O objetivo do descarte de carga é manter a latência baixa para as solicitações que o servidor decide aceitar, para que o serviço responda antes que o cliente atinja o tempo limite. Com essa abordagem, o servidor mantém alta disponibilidade para as solicitações que aceita e apenas a disponibilidade do tráfego em excesso é afetada.

Manter a latência sob controle descartando o excesso de carga torna o sistema mais disponível. Mas os benefícios dessa abordagem são difíceis de visualizar no gráfico anterior. A linha de disponibilidade geral ainda desce, o que parece ruim. A chave é que as solicitações que o servidor decidiu aceitar permanecem disponíveis porque foram atendidas rapidamente.
O descarte de carga permite que um servidor mantenha sua boa produtividade e conclua o máximo de solicitações possível, mesmo com o aumento de throughput oferecido. No entanto, o ato de descartar a carga não é gratuito, portanto, eventualmente, o servidor é vítima da lei de Amdahl e o goodput cai.

Testes

Quando converso com outros engenheiros sobre descarte de carga, gosto de ressaltar que, se eles não testaram o serviço até o ponto em que ele falha e muito além do ponto em que ele falha, eles devem assumir que o serviço falhará da maneira menos desejável possível. Na Amazon, passamos muito tempo testando nossos serviços. A geração de gráficos como os apresentados anteriormente neste artigo nos ajuda a basear a performance da sobrecarga e rastrear nossas ações ao longo do tempo à medida que fazemos alterações em nossos serviços.

Existem vários tipos de testes de carga. Alguns testes de carga garantem que uma frota dimensione automaticamente conforme a carga aumenta, enquanto outros usam um tamanho fixo de frota. Se, em um teste de sobrecarga, a disponibilidade de um serviço diminui rapidamente para zero à medida que o throughput aumenta, é um bom sinal de que o serviço precisa de mecanismos adicionais de descarte de carga. O resultado ideal do teste de carga é que o goodput se estabilize quando o serviço estiver próximo de ser totalmente utilizado e permaneça estável, mesmo quando mais throughput for aplicado.

Ferramentas como o Chaos Monkey ajudam a executar testes de engenharia do caos nos serviços. Por exemplo, eles podem sobrecarregar a CPU ou introduzir perda de pacotes para simular condições que ocorrem durante uma sobrecarga. Outra técnica de teste que usamos é fazer um teste de geração de carga ou canário existente, impulsionar a carga sustentada (em vez de aumentar a carga) em direção a um ambiente de teste, mas começar a remover servidores desse ambiente de teste. Isso aumenta o throughput oferecido por instância e se torna é possível testá-lo. Essa técnica de aumentar artificialmente a carga diminuindo o tamanho da frota é útil para testar um serviço isoladamente, mas não substitui completamente os testes de carga total. Um teste de carga completo, de ponta a ponta, também aumentará a carga para as dependências desse serviço, o que pode descobrir outros gargalos.

Durante os testes, medimos a disponibilidade e a latência percebidas pelo cliente, além da disponibilidade e latência do servidor. Quando a disponibilidade do lado do cliente começa a diminuir, levamos a carga muito além desse ponto. Se o descarte de carga estiver funcionando, o goodput permanecerá estável, mesmo que o throughput oferecido aumente muito além dos recursos em escala do serviço.

O teste de sobrecarga é crucial antes de explorar os mecanismos para evitar sobrecarga. Cada mecanismo introduz complexidade. Por exemplo, considere todas as opções de configuração nas estruturas de serviço que mencionei no início do artigo e como os padrões eram difíceis de corrigir. Cada mecanismo para evitar sobrecarga adiciona proteções diferentes e tem eficácia limitada. Por meio de testes, uma equipe pode detectar os gargalos do sistema e determinar a combinação de proteções necessárias para lidar com a sobrecarga.

Visibilidade

Na Amazon, independentemente de quais técnicas usamos para proteger nossos serviços contra sobrecarga, pensamos cuidadosamente sobre as métricas e a visibilidade necessárias quando essas contramedidas de sobrecarga entram em vigor.

Quando a proteção contra redução da carga rejeita uma solicitação, essa rejeição reduz a disponibilidade de um serviço. Quando o serviço erra e rejeita uma solicitação, embora tenha capacidade (por exemplo, quando o número máximo de conexões é definido muito baixo), gera um falso positivo. Nós nos esforçamos para manter a taxa de falsos positivos de um serviço em zero. Se uma equipe descobrir que a taxa de falsos positivos de seu serviço é diferente de zero regularmente, o serviço é ajustado com muita sensibilidade ou os hosts individuais estão sendo sobrecarregados constante e legitimamente, e pode haver um problema de escalabilidade ou balanceamento de carga. Em casos como esse, podemos ter algum ajuste na performance do aplicativo, ou podemos mudar para tipos de instância maiores, para que eles possam lidar com desequilíbrios de carga com mais facilidade.

Em termos de visibilidade, quando o descarte de carga rejeita solicitações, garantimos que temos a instrumentação adequada para saber quem era o cliente, qual operação ele estava chamando e qualquer outra informação que nos ajude a ajustar nossas medidas de proteção. Também usamos alarmes para detectar se as contramedidas estão rejeitando qualquer volume significativo de tráfego. Quando há uma redução da carga, nossa prioridade é aumentar a capacidade e resolver o gargalo atual.

Há outra consideração sutil, mas importante, sobre a visibilidade no descarte de carga. Concluímos que é importante não poluir as métricas de latência de nossos serviços com falha na latência de solicitações. Afinal, a latência de descarte de carga de uma solicitação deve ser extremamente baixa em comparação com outras solicitações. Por exemplo, se um serviço estiver descartando sua carga em 60% do tráfego, a latência média do serviço pode parecer bastante surpreendente, mesmo que a latência de solicitações bem-sucedidas esteja terrível, porque não está sendo reportada com precisão, como resultado de solicitações com falha rápida.

Efeitos do descarte de carga na falha da escalabilidade automática e da zona de disponibilidade

Se configurado incorretamente, o descarte de carga pode desativar a escalabilidade automática reativa. Considere o exemplo a seguir: um serviço está configurado para a escalabilidade reativa com base em CPU e também tem o descarte de carga configurado para rejeitar solicitações em um destino de CPU semelhante. Nesse caso, o sistema de descarte de carga reduzirá o número de solicitações para manter a carga da CPU baixa e a escalabilidade reativa nunca receberá ou obterá um sinal atrasado para iniciar novas instâncias.

Também tomamos o cuidado de considerar a lógica de descarte de carga quando definimos limites de escalabilidade automática para lidar com falhas da zona de disponibilidade. Os serviços são dimensionados para um ponto em que a capacidade de uma zona de disponibilidade poderá ficar indisponível, preservando nossas metas de latência. As equipes da Amazon costumam analisar métricas de sistema como CPU para aproximar o quão perto um serviço está de atingir seu limite de capacidade. No entanto, com o descarte de carga, uma frota pode estar muito mais próxima do ponto em que as solicitações seriam rejeitadas do que as métricas do sistema indicam, e pode não ter o excesso de capacidade provisionada para lidar com uma falha na zona de disponibilidade. Com o descarte de carga, precisamos ter a certeza de testar nossos serviços quanto à interrupção para entender a capacidade e a margem disponível da nossa frota a qualquer momento.

De fato, podemos usar o descarte de carga para economizar custos, modelando o tráfego fora do pico e não crítico. Por exemplo, se uma frota processa o tráfego do site da amazon.com, ela pode decidir que não vale a pena escalar o tráfego do rastreador de pesquisa para obter redundância total da zona de disponibilidade. No entanto, temos muito cuidado com essa abordagem. Nem todas as solicitações custam o mesmo, e provar que um serviço deve fornecer redundância da zona de disponibilidade para tráfego humano e descartar o tráfego excessivo de rastreadores ao mesmo tempo exige um design cuidadoso, testes contínuos e adesão da empresa. E se os clientes de um serviço não souberem que um serviço está configurado dessa maneira, seu comportamento durante uma falha na zona de disponibilidade pode parecer uma queda crítica maciça na disponibilidade, em vez de um descarte não crítico da carga. Por esse motivo, em uma arquitetura orientada a serviços, tentamos fazer esse tipo de modelagem o mais cedo possível (como no serviço que recebe a solicitação inicial do cliente) em vez de tentar tomar decisões de priorização global em toda a pilha.

Mecanismos de descarte de carga

Ao discutir descarte de carga e cenários imprevisíveis, também é importante manter o foco nas muitas condições previsíveis que levam à redução da carga. Na Amazon, os serviços mantêm capacidade em excesso suficiente para lidar com falhas da zona de disponibilidade sem precisar adicionar mais capacidade. Eles usam o controle de utilização para garantir a equidade entre os clientes.

No entanto, apesar dessas proteções e práticas operacionais, um serviço tem uma certa quantidade de capacidade a qualquer momento e, portanto, pode ficar sobrecarregado por vários motivos. Esses motivos incluem picos inesperados de tráfego, perda repentina de capacidade da frota (de implantações incorretas ou não), clientes deixando de fazer solicitações baratas (como leituras em cache) para fazer solicitações caras (como falhas ou gravações no cache). Quando um serviço fica sobrecarregado, ele precisa concluir as solicitações que recebeu, ou seja, os serviços precisam se proteger contra a redução da carga. No restante desta seção, discutiremos algumas das considerações e técnicas que usamos ao longo dos anos para gerenciar a sobrecarga.

Entendendo o custo de descartar solicitações

Garantimos o teste de carga de nossos serviços muito além do ponto em que os platôs da boa produção. Um dos principais motivos dessa abordagem é garantir que, ao rejeitarmos solicitações durante o descarte de carga, o custo de descartar a solicitação seja o menor possível. Vimos que é fácil perder uma declaração de log acidental ou uma configuração de soquete, o que pode deixar a solicitação muito mais cara do que o necessário.

Em casos raros, descartar rapidamente uma solicitação pode ser mais caro do que mantê-la. Nesses casos, diminuímos a velocidade das solicitações rejeitadas para corresponder (no mínimo) à latência das respostas bem-sucedidas. No entanto, é importante fazer isso quando o custo da retenção de solicitações for o mais baixo possível. Por exemplo, quando elas não estão vinculando uma thread de aplicativo.

Como priorizar solicitações

No entanto, é importante fazer isso quando o custo de reter solicitações for o mais baixo possível. Por exemplo, quando eles não estão vinculando um segmento de aplicativo. A solicitação mais importante que um servidor receberá é uma solicitação de ping de um load balancer. Se o servidor não responder às solicitações de ping a tempo, o load balancer deixará de enviar novas solicitações para esse servidor por um período e o servidor ficará ocioso. E, em um cenário de redução da carga, a última coisa que queremos fazer é reduzir o tamanho de nossas frotas. Além das solicitações de ping, as opções de priorização de solicitação variam de serviço para serviço.

Considere um serviço da web que fornece dados para renderizar a amazon.com. É provável que uma chamada de serviço que ofereça suporte à renderização de página da web para um rastreador de índice de pesquisa seja menos crítica do que uma solicitação originada por um ser humano. As solicitações do rastreador são importantes para veicular, mas, idealmente, elas podem ser alteradas para um horário fora do pico. No entanto, em um ambiente complexo como o amazon.com em que um grande número de serviços coopera, se os serviços usarem heurísticas conflitantes de priorização, a disponibilidade em todo o sistema poderá ser afetada e o trabalho poderá ser desperdiçado.

A priorização e o controle de utilização podem ser usados juntos para evitar limites estritos do controle de utilização, enquanto ainda protegem um serviço contra sobrecarga. Na Amazon, nos casos em que permitimos que os clientes ultrapassem seus limites de aceleração configurados, as solicitações em excesso desses clientes podem ter prioridade menor do que as solicitações dentro da cota de outros clientes. Dedicamos muito tempo focando em algoritmos de posicionamento para minimizar a probabilidade de indisponibilidade da capacidade de intermitência, mas, considerando as compensações, favorecemos a carga de trabalho provisionada previsível sobre a carga de trabalho imprevisível.

Como ficar de olho no relógio

Se um serviço estiver no meio da veiculação de uma solicitação e perceber que o tempo limite do cliente expirou, ele poderá pular o restante do trabalho e falhar na solicitação nesse momento. Caso contrário, o servidor continuará trabalhando na solicitação e sua resposta tardia é como uma árvore caindo na floresta. Da perspectiva do servidor, ele retornou uma resposta bem-sucedida. Mas da perspectiva do cliente que atingiu o tempo limite, foi um erro.

Uma maneira de evitar esse trabalho desperdiçado é que os clientes incluam dicas de tempo limite em cada solicitação, o que informa ao servidor quanto tempo está disposto a esperar. O servidor pode avaliar essas dicas e descartar solicitações condenadas a baixo custo.

Essa dica de tempo limite pode ser expressa como um tempo absoluto ou como uma duração. Infelizmente, servidores em sistemas distribuídos são notoriamente ruins em concordar com a hora exata. O Amazon Time Sync Service compensa sincronizando os relógios das instâncias do Amazon Elastic Compute Cloud (Amazon EC2) com uma frota de relógios atômicos e controlados por satélite redundantes em cada região da AWS. Relógios bem sincronizados também são importantes na Amazon para fins de registro em log. A comparação de dois arquivos de log em servidores que possuem relógios fora de sincronia torna a solução de problemas ainda mais difícil do que é no início.

A outra maneira de "ficar de olho no relógio" é medir a duração em uma única máquina. Os servidores são bons em medir durações decorridas localmente, porque não precisam obter consenso com outros servidores. Infelizmente, expressar tempos limite em termos de durações também tem seus problemas. Por um lado, o cronômetro usado deve ser monotônico e não retroceder quando o servidor sincronizar com o NTP (Network Time Protocol). Um problema muito mais difícil é que, para medir uma duração, o servidor precisa saber quando iniciar um cronômetro. Em alguns cenários de sobrecarga extrema, grandes volumes de solicitações podem se enfileirar nos buffers TCP (Transmission Control Protocol). Portanto, quando o servidor lê as solicitações de seus buffers, o cliente já excedeu o tempo limite.

Sempre que os sistemas da Amazon expressam sugestões de tempo limite do cliente, tentamos aplicá-los transitivamente. Em locais onde uma arquitetura orientada a serviços inclui vários saltos, propagamos o prazo de "tempo restante" entre cada salto, para que um serviço a jusante no final de uma cadeia de chamadas possa saber quanto tempo leva para que sua resposta seja útil.

Depois que um servidor conhece o prazo do cliente, há a questão de onde aplicar o prazo na implementação do serviço. Se um serviço tiver uma fila de solicitações, usamos essa oportunidade para avaliar o tempo limite após remover a fila de cada solicitação. Mas isso ainda é bastante complicado, porque não sabemos quanto tempo a solicitação provavelmente levará. Alguns sistemas mantêm uma estimativa de quanto tempo as solicitações de API estão demorando e descartam as solicitações antecipadamente se o prazo relatado pelo cliente exceder uma estimativa de latência. No entanto, as coisas raramente são tão simples. Por exemplo, os acertos no cache são mais rápidos do que os acidentados, e o estimador não sabe se é um acerto ou um erro na frente. Ou os recursos de back-end do serviço podem ser particionados e apenas algumas partições podem ser lentas. Há muitas oportunidades para a esperteza, mas também é possível que a esperteza dê um tiro pela culatra em uma situação imprevisível.

Em nossa experiência, aplicar o tempo limite do cliente no servidor ainda é melhor que a alternativa, apesar das complexidades e desvantagens. Em vez de as solicitações se acumularem e o servidor possivelmente trabalhar em solicitações que não importam mais para ninguém, achamos útil aplicar um "tempo de vida útil por solicitação" e descartar solicitações condenadas.

Concluindo o que foi iniciado

Não queremos que nenhum trabalho útil seja desperdiçado, especialmente em sobrecarga. Jogar fora o trabalho cria um ciclo de comentários positivo que aumenta a sobrecarga, pois os clientes costumam repetir uma solicitação se um serviço não responder a tempo. Quando isso acontece, uma solicitação que consome recursos se transforma em muitas solicitações que consomem recursos, multiplicando a carga no serviço. Quando os clientes atingem o tempo limite e tentam novamente, geralmente param de aguardar uma resposta em sua primeira conexão enquanto fazem uma nova solicitação em uma conexão separada. Se o servidor concluir a primeira solicitação e as respostas, o cliente poderá não estar ouvindo, porque agora está aguardando uma resposta da solicitação repetida.

Esse problema de trabalho desperdiçado é o motivo pelo qual tentamos projetar serviços para executar trabalhos vinculados. Nos locais em que expomos uma API que pode retornar um grande conjunto de dados (ou realmente qualquer lista), expomos como uma API que suporta paginação. Essas APIs retornam resultados parciais e um token que o cliente pode usar para solicitar mais dados. Achamos mais fácil estimar a carga adicional em um serviço quando o servidor lida com uma solicitação que tem um limite superior à quantidade de memória, CPU e largura de banda da rede. É muito difícil executar o controle de admissão quando um servidor não tem ideia do que será necessário para processar uma solicitação.

Uma oportunidade mais sutil de priorizar solicitações é sobre como os clientes usam as APIs de um serviço. Por exemplo, digamos que um serviço tenha duas APIs: start() e end(). Para concluir seu trabalho, os clientes precisam poder chamar as duas APIs. Nesse caso, o serviço deve priorizar solicitações end() em vez de solicitações start(). Se priorizasse start(), os clientes não poderiam concluir o trabalho iniciado, resultando em reduções da carga.

A paginação é outro lugar para observar o trabalho desperdiçado. Se um cliente precisar fazer várias solicitações sequenciais para paginar os resultados de um serviço e perceber uma falha após a página N-1 e jogar fora os resultados, estará desperdiçando chamadas de serviço N-2 e quaisquer tentativas realizadas ao longo do caminho. Isso sugere que, como solicitações end(), as solicitações de primeira página devem ser priorizadas atrás das solicitações de paginação de páginas subsequentes. Ele também enfatiza por que projetamos serviços para executar trabalhos limitados e não paginamos infinitamente por meio de um serviço que eles chamam durante uma operação síncrona.

Cuidado com as filas

Também é útil analisar a duração da solicitação ao gerenciar filas internas. Muitas arquiteturas de serviço modernas usam filas na memória para conectar conjuntos de threads para processar solicitações durante vários estágios do trabalho. É provável que uma estrutura de serviço da web com um executor tenha uma fila configurada à frente dela. Com qualquer serviço baseado em TCP, o sistema operacional mantém um buffer para cada soquete e esses buffers podem conter um grande volume de solicitações reprimidas.

Quando retiramos o trabalho das filas, usamos essa oportunidade para examinar quanto tempo o trabalho permaneceu na fila. No mínimo, tentamos registrar essa duração em nossas métricas de serviço. Além de limitar o tamanho das filas, descobrimos que é extremamente importante colocar um limite superior na quantidade de tempo que uma solicitação recebida fica em uma fila e a descartamos se ela for muito antiga. Isso libera o servidor para trabalhar em solicitações mais recentes que têm maior chance de êxito. Como uma versão extrema dessa abordagem, procuramos maneiras de usar uma fila do tipo último a entrar, primeiro a sair (LIFO: last in, first out), se o protocolo aceitar. (O pipelining HTTP/1.1 de solicitações em uma determinada conexão TCP não oferece suporte a filas LIFO, mas o HTTP/2 geralmente sim.)

Os load balancers também podem enfileirar solicitações ou conexões de entrada quando os serviços estiverem sobrecarregados, usando um recurso chamado filas de pico. Essas filas podem levar a redução da carga, porque quando um servidor finalmente recebe uma solicitação, não faz ideia de quanto tempo a solicitação estava na fila. Um padrão geralmente seguro é usar uma configuração de transbordo, que falha rapidamente em vez de enfileirar solicitações em excesso. Na Amazon, esse aprendizado foi incorporado à próxima geração do serviço Elastic Load Balancing (ELB). O Classic Load Balancer usava uma fila de pico, mas o Application Load Balancer rejeita o excesso de tráfego. Independentemente da configuração, as equipes da Amazon monitoram as métricas relevantes do load balancer, como a profundidade da fila de pico ou a contagem de transbordos, dos serviços delas.

Em nossa experiência, a importância de observar as filas não pode ser exagerada. Muitas vezes, fico surpreso ao encontrar filas na memória em que não pensei intuitivamente em procurá-las, em sistemas e bibliotecas das quais dependo. Quando estou pesquisando sistemas, acho útil supor que há filas em algum lugar que ainda não conheço. Obviamente, o teste de sobrecarga fornece informações mais úteis do que a digitação no código, desde que eu possa apresentar os casos de teste realistas corretos.

Protegendo contra sobrecarga nas camadas inferiores

Os serviços são compostos de várias camadas (de load balancers, a sistemas operacionais com recursos netfilter e iptables, a estruturas de serviço e o código) e cada camada fornece alguma capacidade de proteger o serviço.

Proxies HTTP como o NGINX geralmente oferecem suporte a um recurso de máximo de conexões (max_conns) para limitar o número de solicitações ou conexões ativas que serão transmitidas ao servidor de back-end. Esse pode ser um mecanismo útil, mas aprendemos a usá-lo como último recurso, em vez da opção de proteção padrão. Com proxies, é difícil priorizar tráfego importante e o rastreamento de contagem de solicitações brutas em operação às vezes fornece informações imprecisas sobre um serviço estar realmente sobrecarregado ou não.

No começo deste artigo, descrevi um desafio do meu tempo na equipe Service Frameworks. Estávamos tentando fornecer às equipes da Amazon um padrão recomendado para o máximo de conexões a serem configuradas em seus load balancers. No final, sugerimos que as equipes definissem o máximo de conexões para o load balancer e o proxy alto, e permitissem que o servidor implementasse algoritmos de descarte de carga mais precisos com informações locais. No entanto, também era importante que o valor máximo de conexões não excedesse o número de threads do listener, processos do listener ou descritores de arquivo em um servidor, para que o servidor tivesse os recursos para lidar com solicitações críticas de verificação de integridade do load balancer.

Os recursos do sistema operacional para limitar o uso de recursos do servidor são poderosos e podem ser úteis em emergências. E, como sabemos que a sobrecarga pode acontecer, nos preparamos para isso, usando os runbooks corretos com comandos específicos prontos. O utilitário iptables pode colocar um limite superior no número de conexões que o servidor aceitará e pode rejeitar o excesso de conexões muito mais barato do que qualquer processo do servidor. Também pode ser configurado com controles mais sofisticados, como permitir novas conexões a uma taxa limitada ou até mesmo permitir uma taxa ou contagem limitada de conexões por endereço IP de origem. Os filtros IP de origem são poderosos, mas não se aplicam aos load balancers tradicionais. No entanto, um ELB Network Load Balancer preserva o IP de origem do chamador, mesmo na camada do sistema operacional, através da virtualização de rede, fazendo com que regras de tabelas de ip, como os filtros de IP de origem, funcionem conforme o esperado.

Protegendo em camadas

Em alguns casos, um servidor fica sem recursos para até rejeitar solicitações sem diminuir a velocidade. Com essa realidade em mente, analisamos todos os saltos entre um servidor e seus clientes para ver como eles podem cooperar e ajudar a eliminar o excesso de carga. Por exemplo, vários produtos da AWS incluem opções de descarte de carga por padrão. Quando enfrentamos um serviço com o Amazon API Gateway, podemos configurar uma taxa máxima de solicitações que qualquer API aceitará. Quando nossos serviços são oferecidos pelo API Gateway, pelo Application Load Balancer ou pelo Amazon CloudFront, podemos configurar o AWS WAF para eliminar o tráfego em excesso em várias dimensões.

A visibilidade cria uma tensão difícil. A rejeição antecipada é importante porque é o local mais barato para reduzir o excesso de tráfego, mas tem um custo para a visibilidade. É por isso que protegemos em camadas: para permitir que um servidor assuma mais do que possa trabalhar e elimine o excesso, e registre informações suficientes para saber qual tráfego está caindo. Como há muito tráfego que um servidor pode perder, contamos com a camada à frente dele para protegê-lo de volumes extremos de tráfego.

Pensando em sobrecarga de maneira diferente

Neste artigo, discutimos como a necessidade de descartar a carga surge do fato de que os sistemas se tornam mais lentos à medida que recebem um trabalho mais simultâneo, à medida que forças como limites de recursos e contenção entram em ação. O ciclo de comentários de sobrecarga é impulsionado pela latência, que causa trabalho desnecessário, amplificação da taxa de solicitação e ainda mais sobrecarga. Essa força, impulsionada pela Lei de escalabilidade universal e pela Lei de Amdahl, é importante para permitir o descarte do excesso de carga e a manutenção de uma performance consistente e previsível diante da sobrecarga. O foco na performance previsível e consistente é um princípio de design importante que serve de base para a criação dos serviços da Amazon.

Por exemplo, o Amazon DynamoDB é um serviço de banco de dados que oferece performance e disponibilidade previsíveis em escala. Mesmo se uma carga de trabalho passe por uma sobrecarga e ultrapasse os recursos de provisionamento configurados, o DynamoDB mantém a latência previsível de goodput para aquela carga de trabalho. Fatores como o Auto Scaling do DynamoDB, a capacidade de adaptação e sob demanda reagem rapidamente para aumentar as taxas de produção e adaptar-se a um aumento na carga de trabalho. Durante esse período, o goodput permanece estável, mantendo um serviço nas camadas acima do DynamoDB com performance previsível e melhorando a estabilidade de todo o sistema.

O AWS Lambda fornece um exemplo ainda mais amplo do foco na performance previsível. Quando usamos o Lambda para implementar um serviço, cada chamada de API é executada em seu próprio ambiente de execução, com quantidades consistentes de recursos de computação alocados a ele, e esse ambiente de execução funciona apenas com uma solicitação por vez. Isso difere de um paradigma baseado em servidor, em que um determinado servidor trabalha em várias APIs.

O isolamento de cada chamada da API para seus próprios recursos independentes (computação, memória, disco, rede) contornará a lei da Amdahl de alguma maneira, porque os recursos de uma chamada da API não estarão em conflito com os recursos de outra chamada da API. Portanto, se o throughput exceder o goodput, ele permanecerá estável em vez de cair como ocorre em um ambiente mais tradicional baseado em servidor. Isso não é uma panaceia, pois as dependências podem ficar mais lentas e fazer com que a concorrência suba. No entanto, nesse cenário, pelo menos os tipos de contenção de recursos no host que discutimos neste artigo não se aplicarão.

Esse isolamento de recursos é um benefício um tanto sutil, mas importante, dos ambientes modernos de computação sem servidor, como o AWS Fargate, o Amazon Elastic Container Service (Amazon ECS) e o AWS Lambda. Na Amazon, descobrimos que é necessário muito trabalho para implementar o descarte de carga, desde o ajuste de grupos de threads até a escolha da configuração perfeita para o máximo de conexões do load balancer. Os padrões sensíveis para esses tipos de configurações são difíceis ou impossíveis de encontrar, porque dependem das características operacionais exclusivas de cada sistema. Esses ambientes de computação mais recentes e sem servidor fornecem isolamento de recursos de nível inferior e expõem botões de nível superior, como controles de simultaneidade e controle de utilização, para proteção contra sobrecarga. De certa forma, em vez de buscar o valor padrão perfeito da configuração, podemos contornar completamente essa configuração e protegê-la de categorias de sobrecarga sem nenhuma configuração.

Leituras complementares

Lei de escalabilidade universal
Lei de Amdahl
Arquiteturas orientadas por eventos em etapas (SEDA)
Lei de Little (descreve a simultaneidade em um sistema e como determinar a capacidade dos sistemas distribuídos)
Telling Stories About Little’s Law, Blog do Marc
Elastic Load Balancing Deep Dive and Best Practices, apresentação no re:Invent 2016 (descreve a evolução do Elastic Load Balancing para parar o enfileiramento de solicitações em excesso)
• Burgess, Thinking in Promises: Designing Systems for Cooperation, O’Reilly Media, 2015



Sobre o autor

David Yanacek é o engenheiro-chefe sênior responsável pelo AWS Lambda. David trabalha como desenvolvedor de software na Amazon desde 2006, e anteriormente trabalhou no Amazon DynamoDB e no AWS IoT, além de em estruturas de trabalho internas de web service e sistemas de automação de operações de frota. Uma das atividades preferidas de David é a análise de logs e o exame de métricas operacionais para descobrir maneiras de tornar a execução de sistemas mais eficientes com o passar do tempo.

Tempos limite, novas tentativas e retirada com jitter Como implementar verificações de integridade Como instrumentar sistemas distribuídos para visibilidade operacional