(Scalability) Escalando uma aplicação com Nginx e Docker Container
Escalar uma API REST em ASP.NET MVC com Nginx e Docker Containers, explicar porque não deu certo e a alternativa para a estratégia que falhou
Introdução
Desta vez, quero falar um pouco sobre escalabilidade (Scaling) com 4 instâncias de uma aplicação ASP.NET MVC. Para isso vou falar sobre Load Balancing, Scaling e estratégias existentes. Para demonstrar a prática utilizarei Nginx, Docker e uma aplicação API REST. Não importa a tecnologia utilizada, esse conhecimento é agnóstico a frameworks. Foi escolhido ASP.NET porque é o framework que estou aprendendo atualmente e estou construindo um projeto para dominar a tecnologia.
O que é Scaling (Escalabilidade)?
Como dito no livro Designing Data-Intensive Applications por Martin Kleppmann:
Scalability is the term we use to describe a system’s ability to cope with increased load
Então quando a carga aumenta, nós precisamos adaptar a nossa aplicação para conseguir processar a carga com a aplicação funcionando devidamente. A carga aqui é usuários acessando nossa API REST, mas não se limita a isso, em outros casos pode ser writes no banco de dados ou hit rate no cache. No caso da API REST é considerado requests per second. No meu caso hipotético, eu estou criando 4 instâncias de uma API REST pois uma só não é capaz de lidar com a quantidade de requisições.
Não vou me aprofundar muito sobre teorias de escalabilidade mas é o que pretendo estudar mais sobre logo. Para adaptar nossa aplicação, criarei uma instância exata para dividir as requisições entre essas 4 aplicações. Mas como as requisições serão divididas entre essas 4 cópias da aplicação API REST? Load Balancer é a solução para esse problema.
No mundo real cada container desse seria um virtual server da AWS ou Google Cloud, mas como isso é prática de Load Balancer, um único servidor (meu computador) é suficiente para demonstrar. Por que? Quero demonstrar como um Load Balancer funciona e seus benefícios, e não como serviços podem ser divididos entre diferentes servidores.
Load Balancer
Load Balancer é um server que pode distribuir os requests entre servidores. Isso é feito utilizando um algoritmo que é escolhido ao configurar, e dessa forma a carga (Load) é balanceado (Balanced)
Existem alguns métodos de balanceamento de carga, algoritmos que decidem qual servidor escolher. Temos o algoritmo Round Robin, utilizado também por Schedulers, em que a carga é distribuída seguindo a ordem dos servidores em um ciclo.
Um outro método é Least Connections. O load balancer vai escolher o servidor com a menor quantidade de conexões abertas, no caso o com menos carga no momento. Também há o método Generic Hash em que é provido uma hash key para decidir qual servidor irá processar a requisição.
Como soluções de Load Balancer, existem duas escolhas populares: Nginx e HAProxy. Para esse exemplo utilizarei Nginx.
Configuração Nginx como Load Balancer
O método de Round Robin com o Nginx pode ser feito assim. No Nginx, Round Robin é o padrão. No código de configuração abaixo do Nginx, eu estou definindo o grupo de servers em que o método Round Robin vai definir qual deles vai processar a requisição HTTP.
Estou utilizando api-rest-n pois esse é nome do serviço no arquivo docker-compose.yml.
Para utilizar Least Connections basta inserir a seguinte linha em upstream.
Cheque a documentação para ver como pode ser feito com os outros métodos.
Escalando nossa aplicação com Docker compose
Como estou utilizando 4 instâncias, ou réplicas, da API REST, serão 4 Docker containers rodando. O docker-compose.yml está configurado assim:
- Utilizo o extends para utilizar a mesma configuração do serviço api-rest-1 para todas as outras instâncias.
- Defino ports em api-rest-1 como sendo de 8000-8003 para que todas as instâncias da API REST vá da porta 8000 até 8003. Isso é possível graças ao extends.
Resultados
Vamos ver o primeiro resultado para a rota /api que retorna o host name do container em JSON.
Utilizando a ferramenta wrk para load testing para verificar se os requests per second aumentava com o aumento de instâncias da API REST. Utilizei o seguinte comando:
Com esse comando iria rodar o wrk na rota /api 5 vezes com 100 conexões ativas utilizando 1 CPU thread durante 30 segundos. E a cada resultado iria dar append no arquivo de log. Fiz assim para todos os casos mudando somente o número de conexões ativas e os servidores a serem balanceados no arquivo nginx.conf.
CASE 1: Leitura de RAW JSON na /api
Os resultados foram os seguintes:
- Aqui conseguimos ver que foi atingido uma média de 6300 requests per second
Agora esperamos que com 4 instâncias esse número aumente, né?
- O número de requests per second diminui de uma média de 6.3k para 4.4k
Isso me deixou surpreso. Eu esperava que o número de requests per second fosse aumentar independente. Pois se imagina que como esse computador tem 4 CPU Thread então 4 instâncias vai rodar paralelamente e assim processar mais requisições por segundo. Eu estava errado.
Isso se repete se aumentarmos para 400 conexões simultâneas. Isso me deixa em dúvida.
O que faz os requests per second diminuir? O fato que temos agora 4 processos concorrendo pelos os recursos da CPU? Isso tem alguma coisa haver com Context Switching?
Isso me deixou uma lição, Scaling não é óbvio. Não existe uma regra.
CASE 2: Leitura na rota /products com acesso ao banco de dados
Nessa rota é feita a leitura ao banco de dados com todos os produtos e retornado o JSON.
Agora iremos testar a rota com 400 conexões simultâneas com o seguinte comando:
Resultado:
- O resultado foi uma média de 1.7/1.8k requests per second.
Agora com 4 replicas/instâncias da API REST e 400 conexões simultâneas, temos:
- Uma queda de 1.7k para 1.3k requests per second.
- Em alguns momentos ocorreu Socket errors do tipo timeout. Provável que os requests foram descartados pelo a demora de ser processado.
E então porque o aumento de instâncias está fazendo a API REST aceitar menos requisições? Não foi óbvio de primeira, mas depois de um tempo descobri.
Thread Pool
ASP.NET MVC não precisa de replicas para processar as requisições paralelamente na mesma máquina. DotNet fornece uma ThreadPool, que é basicamente o que estou tentando fazer aqui, onde nós temos N recursos para processar uns dados. O recurso que estamos usando aqui é instâncias da API REST com Docker container, no DotNet é uma thread. Em que cada thread executa o código de uma request.
Quando estava tentando rodar 4 instâncias tudo que eu fazia era adicionar mais concorrentes para os recursos da máquina, já que cada instância tem threads o suficiente para executar paralelamente a nível de CPU Thread. Isso fazia com que tivesse menos recursos ainda disponíveis para a API REST e trazendo mais custos como Context Switching entre as instâncias.
Um dos Docker containers rodava e assim consumia todas as 4 CPU Thread disponíveis no meu computador. Então uma única instância utilizando bem os recursos da CPU. Colocar mais 3 instâncias só faz aumentar a concorrência entre os 4 processos da API REST rodando.
Em algum momento o Scheduler da Kernel resolveu dar vez para a API REST 2. Essa troca de contexto e execução entre processos e threads, é Context Switching.
Context Switching não é de graça e nem barato, porque tem um custo a mais para a CPU. Como pode ser visto nos resultados do load test os requests per second diminui em uma quantidade considerável. Por isso com 4 instâncias ficou mais lento, graças a essa troca (switching) de contexto (context) feita e que trouxe benefício nenhum para esse caso, só mais custo.
Para investigar isso utilizei o comando htop após iniciar o load testing com wrk. E vi que com uma instância da API REST rodando as 4 threads disponíveis da máquina estavam sendo utilizadas.
Meu erro foi não considerar a arquitetura da linguagem de programação e do framework. Se isso fosse Node.JS ou Python estaria tudo bem, porque Node.JS ou Python é single thread. Mas DotNet/C# é multithread.
Isso quer dizer que a API REST em C# não pode escalar? Não. Eu tentei fazer utilizando cada thread do meu computador como se fosse um virtual server na AWS ou Google Cloud. Como se cada instância estivesse no seu próprio virtual server. Mas a arquitetura do ASP.NET MVC e C# não permite isso.
É possível escalar uma aplicação multithread como essa. Utilize cada instância da API REST multithread em um virtual server, assim como o Nginx e o banco de dados, e comunicação entre o Nginx e as instâncias seria feito via rede utilizando a URL dos virtual servers na upstream assim como foi feito com o nome do serviço Docker.
Conclusão
Esperava um resultado e ver sobre um assunto específico, mas acabei indo para Thread Pool, Context Switching e Framework Architecture. Isso mostra o quão enganados estamos no começo de uma jornada, mas também traz ensinamentos após ela.
Aprendi muito com essa prática e me mostrou alguns conhecimentos que pensei que não tinha haver com isso mas no final acabou tendo.
- Aprendi como um load balancer funciona
- Como utilizar load testing para entender mais sobre concorrência e paralelismo com API REST
- Como Thread Pool funciona
- Como Thread Pool e Load balancer tem conceitos fundamentais muito parecidos
- Como investigar problemas de concorrência
O código dessa aplicação está no seguinte repositório, espere por novos assuntos e posts. Fiquem bem.