Introdução
Quando entrei neste projeto, o maior desafio era a alcançar um determinado nivel de escalabilidade para nos prepararmos para uma Proof of Concept que seriamos submetidos. A aplicação, construída em Ruby on Rails, processava consultas médicas transcrevendo-as em anamneses estruturadas via OpenAI. No entanto, ao rodarmos um teste de carga, percebemos que o sistema não aguentava mais de 20k requisições simultâneas.
Diante disso, precisei entender os gargalos e otimizar ao máximo a aplicação para fazer um uso mais eficiente de nossa infraestutura custo benefício, rodando Sidekiq, Puma e Redis.
Este artigo detalha como aumentamos a capacidade para suportar 500k requisições simultâneas, aproveitando 100% dos recursos disponíveis, sem precisar de usar de recursos para melhorar a infraestrutura.
O Diagnóstico: Identificando os Gargalos
Bom, antes de mais nada, pra quem ja vivenciou a experiencia de passar por uma POC sendo um desenvolvedor, sabe do que estou falando. É uma montanha russa! A todo momento alterações e novas features são solicitadas, mas nessa em específico, tínhamos uma grande barreira, a famigerada Escalabilidade.
Como ja foi citado, nossa aplicação que até o momento era apenas um MVP, estava enfrentando grandes problemas quanto a esse quesito, e seriamos massivamentes testados especificamente nesse nele, foi ai que por em prática conceitos básicos de Sistemas Operacionais fizeram com que nossa aplicação alcancasse 500k de requisições sem gargalar nossa infraestrutura.
Para entender os problemas da aplicação, criamos um teste de carga usando Locust, simulando o fluxo real do usuário no sistema. Nosso objetivo era medir a resistência da aplicação sob um grande volume de requisições simultâneas.
Pra quem ainda não conhece o Locust para testes de sobrecarga, vale conferir! É extremamente friendly e a curva de aprendizado para utilização é realmente muito rapida!
Segue link: https://locust.io/
Resultados do teste com Locust:
- Inicialmente, com um número moderado de requisições, o sistema respondia bem.
- À medida que a carga aumentava, os tempos de resposta começaram a crescer exponencialmente.
- Ao atingir 20k requisições simultâneas, o servidor não conseguia mais lidar com a demanda, resultando em timeouts, erros de conexão resetada e, por fim, uma falha completa da aplicação.
- A imagem anexada ilustra bem os principais erros encontrados durante o teste, como timeouts, erros de conexão fechada remotamente e falhas SSL, indicando que a aplicação não estava conseguindo manter conexões abertas ou responder dentro do tempo esperado.
- O efeito cascata desses problemas gerava uma sobrecarga ainda maior no sistema, tornando a aplicação completamente indisponível.
- Esse diagnóstico confirmou que nossa arquitetura não estava preparada para lidar com um volume massivo de acessos simultâneos, exigindo ajustes para melhorar o gerenciamento de conexões, fila de processamento e paralelismo.
Foi aí que entender um conceito básico de S.O salvou nossas vidas
Diante do cenário caótico que enfrentávamos, percebemos que a solução não estava apenas em otimizar código ou adicionar mais servidores indiscriminadamente. Precisávamos repensar como nossa aplicação lidava com múltiplas requisições simultâneas. Foi aí que os conceitos de concorrência, paralelismo, processos e threads se tornaram nossas armas principais para escalar sem comprometer a infraestrutura.
Concorrência: O Jogo de Equilibrar as Execuções
A concorrência é um dos conceitos fundamentais de Sistemas Operacionais. Ela permite que diferentes partes de um programa compartilhem recursos e se revezem na execução, gerenciando a carga de maneira mais eficiente.
Para ilustrar, imagine que você está em um restaurante pequeno e há um único garçom anotando pedidos, levando pratos e cobrando os clientes. O garçom não consegue fazer todas as tarefas ao mesmo tempo, então ele precisa se organizar:
- Ele anota um pedido e pausa essa tarefa para levar um prato pronto até a mesa.
- Depois de entregar o prato, ele volta para continuar anotando o pedido de outro cliente.
Isso é concorrência – as tarefas não acontecem simultaneamente, mas sim de forma intercalada, aproveitando os momentos em que o garçom pode realizar uma nova ação sem ficar parado esperando outra tarefa terminar.
Agora, imagine que o garçom começa a ficar sobrecarregado porque precisa lidar com muitos pedidos ao mesmo tempo. O que acontece?
- O atendimento começa a ficar lento.
- Os pedidos começam a acumular na cozinha.
- Alguns clientes vão embora sem serem atendidos (timeouts na API ).
E foi exatamente isso que aconteceu com a nossa aplicação. Nossa API estava lidando com as requisições de forma sequencial, sem tirar proveito da concorrência real. O resultado? Um efeito cascata de travamentos e falhas de conexão.
Concorrência vs. Paralelismo: O Ponto de Virada
Agora, vamos imaginar que o restaurante cresce e decide contratar mais garçons.
Se os garçons continuarem compartilhando a mesma bandeja para levar pratos, eles ainda precisarão esperar uns pelos outros. Isso ainda é concorrência.
Mas se cada garçom tiver sua própria bandeja e puder atender clientes simultaneamente, agora estamos falando de paralelismo real!
Na nossa aplicação, o grande erro era tratar processos bloqueantes como concorrentes, quando, na verdade, eles poderiam ser paralelos.
O que isso significa na prática?
- Algumas tarefas estavam competindo pelo mesmo recurso, quando poderiam ser distribuídas de forma independente.
- Nossa API estava funcionando como um único garçom, lidando com várias requisições de maneira intercalada, mas sem processá-las ao mesmo tempo.
A solução? Transformar processos concorrentes em paralelos, eliminando bloqueios desnecessários!
Processos e Threads: Quem Faz o Trabalho?
Agora que entendemos que precisávamos de concorrência, como implementá-la na prática? Para isso, precisamos entender dois conceitos-chave: processos e threads.
O que são processos?
Um processo é uma instância independente de um programa em execução. Ele tem seu próprio espaço de memória e recursos exclusivos. No contexto de um servidor web, cada processo pode lidar com uma ou mais requisições, mas como cada processo consome uma quantidade significativa de memória, escalá-los diretamente pode ser caro e ineficiente.
O que são threads?
As threads são unidades menores de execução dentro de um processo. Diferente dos processos, threads compartilham o mesmo espaço de memória, tornando sua criação e gerenciamento mais leve. Isso permite que uma aplicação possa lidar com múltiplas tarefas ao mesmo tempo sem precisar iniciar novos processos do zero.
Podemos pensar nos processos como cozinhas separadas em um restaurante e nas threads como os chefs dentro de cada cozinha. Se cada cozinha trabalha de forma isolada, a comunicação pode ser lenta. Mas se dentro de cada cozinha há vários chefs compartilhando ingredientes e espaço, os pedidos são preparados de forma mais eficiente.
Ajustando a Concorrrência com Base na Quantidade de Núcleos do Servidor
Para garantir que o sistema aproveitasse 100% da capacidade disponível sem sobrecarregar os recursos, utilizamos um cálculo baseado na quantidade de núcleos da CPU.
Cada núcleo pode processar um número limitado de tarefas simultaneamente. Para definir quantos processos e threads usar, utilizamos a seguinte fórmula padrão:
Fórmula para Processos e Threads:
Número de processos = Número de núcleos físicos
Número de threads por processo = 2 a 4 threads por núcleo
Isso significa que, se o servidor possui 8 núcleos físicos, podemos configurar:
8 processos Puma (1 por núcleo)
Entre 16 e 32 threads no total (2 a 4 por núcleo)
Por que esse cálculo funciona?
Se usarmos menos processos do que núcleos, não aproveitamos toda a CPU.
Se usarmos muito mais threads do que o recomendado, corremos o risco de aumentar a latência e criar contenção de recursos.
Como configurar isso no Puma?
No arquivo config/puma.rb, podemos definir a configuração dinamicamente:
workers ENV.fetch("WEB_CONCURRENCY") { 8 } # Número de processos baseado nos núcleos
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 16 } # Threads por processo
threads threads_count, threads_count
preload_app!
Ponto importante!
- Teste diferentes configurações para encontrar o ponto ideal, pois a performance pode variar dependendo da carga de trabalho e do banco de dados.
- Utilize ferramentas como htop ou top no Linux para monitorar o uso da CPU e ajustar conforme necessário.
Ajustando o NGINX para Lidar com Alto Volume de Requisições
Com o Puma e Sidekiq otimizados, ainda enfrentamos um problema: o NGINX não estava configurado para lidar com um alto número de conexões simultâneas. Como ele atua como um proxy reverso, precisávamos ajustá-lo para permitir mais conexões e evitar erros de 502 Bad Gateway e timeouts.
Principais ajustes que fizemos:
- Aumentamos o limite de conexões simultâneas No arquivo /etc/nginx/nginx.conf, ajustamos os valores para permitir mais conexões concorrentes:
worker_processes auto; # Define automaticamente a quantidade de processos com base nos núcleos da CPU
worker_connections 8192; # Define quantas conexões cada worker pode lidar simultaneamente
multi_accept on; # Permite aceitar múltiplas conexões ao mesmo tempo
O que isso faz?
- worker_processes auto → Define um número de processos igual ao número de núcleos da CPU.
- worker_connections 8192 → Permite que cada processo gerencie até 8192 conexões simultâneas.
- multi_accept on → Permite que o NGINX aceite várias conexões ao mesmo tempo, melhorando a latência.
Aumentamos o tempo limite das requisições
Com muitas requisições passando pelo NGINX, algumas podiam demorar mais tempo para serem processadas, especialmente as que envolviam a transcrição de consultas médicas via OpenAI. Ajustamos os timeouts para evitar encerramentos prematuros:
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
send_timeout 60s;
- O que isso faz? Evita que conexões sejam fechadas prematuramente durante requisições mais demoradas.
Ajustamos o buffer de resposta
Como algumas respostas da API podiam ser grandes, aumentamos o buffer para evitar truncamento ou erros de payload.
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
- O que isso faz? Impede que respostas grandes sejam cortadas e melhora a eficiência da comunicação entre NGINX e Puma.
Como aplicamos isso para resolver nosso problema?
Nosso servidor original estava lidando com cada requisição de forma bloqueante, ou seja, cada requisição ocupava um recurso até ser completamente processada, sem permitir que outras requisições fossem tratadas enquanto isso. Esse comportamento causava um efeito cascata de timeouts e falhas, derrubando o sistema sob alta carga.
Para escalar corretamente e garantir 500k requisições simultâneas, aplicamos quatro estratégias principais:
Ajustamos a Concorrência com Base nos Núcleos do Servidor
Antes de qualquer mudança, entendemos que a chave para escalar nossa aplicação era aproveitar melhor os recursos do servidor, evitando desperdício de CPU e memória.
Como fizemos isso?
Calculamos o número ideal de processos e threads com base nos núcleos físicos do servidor
Definimos 1 processo Puma por núcleo e 2 a 4 threads por núcleo para garantir um melhor aproveitamento sem sobrecarregar o sistema
Monitoramos o desempenho com ferramentas como htop e top para ajustar os valores de acordo com o perfil da aplicação
Adotamos o servidor Puma com múltiplas threads
O Puma é um servidor web otimizado para concorrência baseada em threads. Diferente de servidores tradicionais que criam um novo processo para cada requisição, ele mantém um número fixo de processos e cria múltiplas threads dentro de cada um para atender às requisições simultaneamente. Isso permitiu que nossa aplicação Rails processasse muito mais requisições sem consumir memória excessivamente.
Implementamos filas de background com Sidekiq e Redis
Algumas operações, como a transcrição de consultas médicas via OpenAI, eram naturalmente demoradas e não precisavam ser processadas imediatamente na resposta da requisição. Utilizando o Sidekiq, conseguimos delegar essas tarefas para workers, que as executavam em segundo plano sem bloquear as requisições principais.
Ajustamos a concorrência da base de dados
O banco de dados também era um gargalo crítico. Otimizar conexões e garantir que as queries fossem eficientes foi essencial para evitar travamentos causados pelo excesso de conexões concorrentes.
O Resultado
Após essas mudanças, rodamos novamente os testes de carga com o Locust. O impacto foi impressionante:
- Conseguimos aumentar a capacidade de 20k para 500k requisições simultâneas, aproveitando melhor a infraestrutura existente.
- A latência média das requisições caiu significativamente, pois o servidor conseguia processar várias ao mesmo tempo sem sobrecarregar os recursos.
- A estabilidade da aplicação foi garantida, mesmo sob cargas intensas.
- Ao final, conseguimos alcançar a escalabilidade necessária sem precisar gastar com novos servidores, apenas utilizando conceitos básicos de concorrência, processos e threads a nosso favor.
E foi dessa forma que conseguimos sair de 20k de Requisiçõs a 500k+ com uma infra de custo benefício!!!
Deixo um agradecimento a toda equipe que esteve durante todo esse processo também!
Top comments (0)