BT

Disseminando conhecimento e inovação em desenvolvimento de software corporativo.

Contribuir

Tópicos

Escolha a região

Início Artigos Como melhorar a performance de aplicações Node.js utilizando o módulo de cluster

Como melhorar a performance de aplicações Node.js utilizando o módulo de cluster

Favoritos

O que é um cluster de Node.js e como funciona?

Um cluster, em poucas palavras, é basicamente um conjunto de recursos computacionais separados atuando de forma conjunta, agindo como se fossem um único recurso, geralmente com o objetivo de aumentar o poder de processamento de uma aplicação.

Quando os servidores de uma aplicação já estão escalados horizontalmente, com algum tipo de gerenciador de carga (load balancer) atuando para balanceá-los, basicamente já temos um cluster. Porém, quando falamos sobre a utilização do Node.js em modo cluster, na verdade falamos sobre dividir o processo principal da aplicação, criando um ou mais processos filhos (denominados workers) dentro do mesmo host e que responderão às requisições simultaneamente.

O Node.js é um sistema single-threaded. Em outras palavras, dentro do processo principal do Node.js existe apenas uma única linha de execução que parte do ponto de entrada da aplicação até o seu encerramento. Apesar desta característica, o Node.js executa tarefas assíncronas de maneira extremamente eficiente graças ao Node.js Event Loop.

Uma Aplicação Web Node.js simples funciona desta maneira:

Simple Node.js Web Application

Caso a aplicação estivesse escalada horizontalmente, o cenário seria, provavelmente, algo como:

Simple Node.js Web Application Scaled-out

É possível observar que o processo principal (main process) está sendo executado em diferentes hosts, mas o objetivo é fazer como que se possa contar com mais processos Node.js rodando em cada uma das máquinas.

É importante lembrar que uma CPU é capaz de processar apenas uma instrução por vez. Neste caso, não há motivos para termos 2 processos Node.js em execução, pois apenas um deles estaria sendo atendido pela CPU em um determinado momento.

O cenário começa a mudar quando se executa a aplicação em máquinas que possuem CPUs com mais de um núcleo, pois cada núcleo da CPU pode atender a um processo Node.js simultaneamente em um determinado momento. Uma regra geral que geralmente é utilizada ao criar um cluster de processos é a de utilizar um worker por núcleo de CPU.

A solução para executar uma aplicação em modo cluster é baseada em um módulo nativo do Node.js, que cria processos filhos a partir do principal utilizando o método child_process.fork() e estabelece uma comunicação de 2 vias entre os processos via IPC.

Em um cenário onde os hosts tivessem uma CPU com 2 núcleos (dual-core), o novo diagrama ficaria assim:

Node.js cluster workers

A dúvida que geralmente surge neste ponto é: como os requests são processados pelos workers? Quem decide quais requests serão processados por quais workers? A resposta é simples: o módulo de cluster do Node.js faz todo este trabalho. Todos os requests são recebidos pelo processo principal e o módulo de cluster distribui estas requisições, atuando como um balanceador de carga (load balancer) para os workers, desta maneira:

Node.js cluster module lb

Atualmente (Node.js 7.10.0), o módulo de cluster suporta duas estratégias de balanceamento de carga, mas é recomendada a utilização sempre da estratégia padrão (round-robin), pois a própria documentação oficial do Node.js informa que a distribuição da carga tende a apresentar-se extremamente desbalanceada quando utilizada a estratégia não-padrão.

Utilização Básica

Para ilustrar melhor o que foi descrito até aqui, vamos fazer algumas simulações na prática. O código abaixo será a base da aplicação web:

const http = require('http');

http.createServer((req, res) => {
  res.writeHead(200);
  res.end('Hello from Node.js process!\n');
}).listen(8000);

console.log('Listening on port 8000');

Por enquanto, sem surpresas:

Insira as próximas linhas no início do arquivo:

const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

Ao fazer isso, o módulo de cluster do Node.js será importado e uma variável chamada numCPUs será criada contendo o número de núcleos de CPU existentes na máquina que executará a aplicação.

A partir deste momento, uma propriedade e um método muito importantes passam a ser bastante úteis:

cluster.isMaster - esta propriedade nos informa se o processo atual é o principal ou um dos processos filhos que tenham eventualmente sido criados;

cluster.fork() - este método irá criar um novo processo filho (worker) e pode ser chamada apenas a partir do processo principal (para isso deveremos utilizar a propriedade cluster.isMaster).

Para tentar entender o que está acontecendo, adicone algumas informações interessantes ao retorno da aplicação:

res.end(`Hello from Node.js ${cluster.isMaster ? 'master' : 'child'} process!\n`);`

E o resultado é:

Com base nessa informação, é possível saber que o processo principal do Node.js ainda é o responsável por atender às requisições.

O próximo passo é simples: gerar processos filhos a partir do principal e garantir que apenas os processos filhos estejam atendendo às requisições. O seguinte código resolve nosso problema:

const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

const http = require('http');

if (cluster.isMaster) {
  console.log('Master process is running');
  // Fork workers
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end(`Hello from Node.js ${cluster.isMaster ? 'master' : 'child'} process!\n`);
  }).listen(8000);

  console.log('Listening on port 8000');
}

Observe o que acontece quando se inicia a aplicação e uma requisição é realizada:

Quatro workers foram iniciados e as requisições estão sendo atendidas por algum deles. Qual worker está atendendo aos requests? É sempre o mesmo? Adicione mais informações ao retorno para clarificar o que está acontecendo:

res.end(`Hello from Node.js - I am the worker ${cluster.worker.id}\n`);

Agora, verifique o que acontece quando fazemos múltiplas requisições:

Este é o módulo de cluster funcionando! O processo principal envia cada request para um único worker, alternando entre eles para tentar manter uma carga constante para cada um (estratégia round-robin).

Agora que a aplicação está realmente operando em modo cluster, é hora de explorar algumas coisas interessantes que podem ser feitas:

cluster.on('exit', (worker, code, signal) => {
  console.log(`Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`);
}

Se, por alguma razão, um worker parar de responder, uma mensagem passará a ser exibida. Neste caso, por que não reiniciar o worker?

cluster.on('exit', (worker, code, signal) => {
  console.log(`Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`);
  console.log('Starting a new worker');
  cluster.fork();
});

Dessa forma, um novo gerenciador de processos acabou de ser criado. O código final da aplicação ficará dessa forma:

const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

const http = require('http');

if (cluster.isMaster) {
  console.log('Master process is running');

  // Fork workers
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died with code: ${code}, and signal: ${signal}`);
    console.log('Starting a new worker');
    cluster.fork();
  });
} else {
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end(`Hello from Node.js - I am the worker ${cluster.worker.id}\n`);
  }).listen(8000);

  console.log('Listening on port 8000');
}

Utilização Avançada

Uma característica muito interessante do módulo de cluster do Node.js é o fato de ser possível estabelecer facilmente uma comunicação entre o processo principal e seus filhos (workers).

Toda a comunicação utiliza o Node.js EventEmitter, portanto o único trabalho adicional será aguardar por um evento chamado message e enviar mensagens utilizando o método process.send().

O processo principal envia uma mensagem a um worker

worker.send('message');

O processo principal espera por uma mensagem

worker.on('message', (msg) => {
  console.log(`Message received from worker process: ${msg}`);
}

No contexto do processo principal, a variável worker contém o objeto retornado pelo método cluster.fork(). Se por um acaso a referência a este objeto não for salva no momento da criação, existe uma lista chamada cluster.workers quem contém todos os objetos (referências) de todos os workers que foram criados pelo processo principal.

Um worker espera por uma mensagem

cluster.worker.on('message', (msg) => {
  console.log(`Message received from master process: ${msg}`);
}

Um worker envia uma mensagem ao processo principal

process.send('message');

Junte todas estas informações em uma simples aplicação para entender melhor como funciona:

const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  // Fork workers
  for (let i = 0; i < numCPUs; i++) {
    const worker = cluster.fork();
    worker.send(`Hello worker ${worker.id}, I am your master!`)
    worker.on('message', (msg) => {
      console.log(`Message from worker ${worker.id}: ${msg}`);
    });
  }
} else {
  cluster.worker.on('message', (msg) => {
    console.log(`Message from master received by worker ${cluster.worker.id}: ${msg}`);
  });
  process.send(`Hello master, I am the worker ${cluster.worker.id}!`)
}

E o resultado é:

Observando os resultados acima, é possível perceber um detalhe: todos os callbacks dos eventos de mensagens são executados de maneira assíncrona. Outro ponto interessante a ser observado é o fato de que as mensagens são objetos Javascript genéricos, mas é uma boa prática sempre enviar mensagens como objetos JSON e incluir informações adicionais importantes, como por exemplo qual worker está enviando a mensagem.

Importante: Se mesmo assim enviar mensagens como objetos Javascript entre processos for um dos objetivos da aplicação, não se deve enviar objetos que contém funções. É importante lembrar que funções dentro de objetos são simplesmente referências que apontam para o local da memória onde esta função está armazenada fisicamente, e cada worker tem sua própria porção de memória e sua própria instância do V8, e neste caso o local de memória onde a referência aponta simplesmente não existiria do outro lado e não seria possível visualizar nada além de um nó contendo o valor undefined.

Benchmarking

O código que será exibido nesta parte do artigo, utiliza um método que exige bastante da CPU para fazer um teste de carga com o Node.js operando com e sem o módulo de cluster. Para isto, serão necessários alguns métodos que não fazem muito sentido na prática, mas são necessários para o entendimento do exemplo em questão. A aplicação iniciará encontrando os 10.000 primeiros números primos a partir de 2, armazenará o resultado em uma lista e então será calculado o valor da tangente de cada um destes números somados com o último resultado. Para evitar qualquer tipo de caching, os resultados sempre começarão com um número decimal aleatório entre 0 e 1. Na verdade nada disso importa e o único objetivo é dar um pouco de trabalho para a CPU.

O código utilizado para fazer o teste de carga deve ser o seguinte:

const http = require('http');

function isPrime(n) {
  const max = Math.sqrt(n);
  for (let i = 2; i <= max; i++) {
    if (n % i === 0) return false;
  }
  return true;
}

function listPrimes(numPrimes) {
  const primes = [];
  for (let n = 2;  numPrimes > 0;  n++) {
    if (isPrime(n)) {
      primes.push(n);
      numPrimes = numPrimes - 1;
    }
  }
  return primes;
}

function calculate(cnt) {
  let result = Math.random(1);
  const lst = listPrimes(cnt);
  for (let n = 0; n < lst.length; n++) {
    result = Math.tan(result + lst[n]);
  }
  return result;
}

http.createServer((req, res) => {
  res.writeHead(200);
  res.end(`${calculate(10000).toString()}\n`);
}).listen(8000);

console.log('Listening on port 8000');

Existe uma grande quantidade de ferramentas disponíveis para a realização de testes de carga, dentre elas:

  • Siege | ApacheBench - Efetivos, de fácil utilização e exibem resultados bastante claros;
  • loadtest - Esta ferramenta possui algumas opções configuráveis para simular requisições em situações reais de produção e é fácil de ser integrada nos testes da própria aplicação, mas faz exatamente o mesmo que as anteriores.

O teste consiste em enviar requests repetidamente para a aplicação utilizando múltiplas threads, gerando assim uma concorrência alta, pois as requisições em situações reais ocorrem simultaneamente.

Conclusão

Ao analisar os resultados, é possível verificar que o cluster foi capaz de entregar mais que o dobro do número de requests, e isto é um ganho excepcional. Vale lembrar que o poder de processamento da máquina não está sendo modificado, mas simplesmente se está evitando uma grande quantidade de tempo ocioso de CPU. Desta forma, a aplicação está fazendo um melhor aproveitamento dos recursos disponíveis.

Numa análise mais direta: vale a pena configurar um cluster Node.js? Se o host que a aplicação será executada possui uma CPU com mais de um núcleo, na maioria dos casos sim. Existem casos específicos onde poderia haver uma degradação na performance utilizando cluster, mas isto não será tratado neste artigo. Se existe alguma dúvida, é aconselhado prosseguir com testes de carga simples como estes mostrados aqui para certificar-se do ganho de performance.

Outra informação interessante que podem ser extraídas dos resultados e que devem ser pontos de atenção: quando passamos de 50 para 100 threads, a diferença no número de transações entregues é praticamente imperceptível. Porém, é notório um aumento considerável no tempo de resposta. Isto significa que existe sempre um limite de processamento para cada host e a melhor maneira de ajustar estas configurações é efetuando testes antes de qualquer decisão final.


Sobre o autor

Stefano Baldo é Arquiteto e Desenvolvedor de Software da inGaia. Possui graduação em computação e é pós-graduado pela Fundação Getúlio Vargas. Atua com desenvolvimento de software e operações há 25 anos, já tendo trabalhado no Brasil, Estados Unidos e Espanha. Já trabalhou com uma grande quantidade de tecnologias, e atualmente tem como foco plataformas open-source como Node.js, Javascript, Python, Ruby, Docker, Elasticsearch, MongoDB, entre outras. Pode ser contatado através do e-mail stefanobaldo@gmail.com.

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

  • cluster

    by Pires Lopes da Silva,

    Seu comentário está aguardando aprovação dos moderadores. Obrigado por participar da discussão!

    Parabéns pelo post.
    Utilizei o modulo cluster nos primeiros projetos que trabalhei.
    A partir do inicio de 2016 eu passei a utilizar o pm2.
    O pm2 além de fazer o balanceamento, ele também expõe uma api com varios métodos. Esses métodos podem ser expostos via rest e dar origem a um gerenciador de processos via um client qualquer

  • Re: cluster

    by Stefano Baldo,

    Seu comentário está aguardando aprovação dos moderadores. Obrigado por participar da discussão!

    Obrigado!
    O PM2 é uma ótima solução para se criar uma camada de abstração ao implementar um cluster de processos.
    O módulo de cluster nativo evoluiu bastante desde a versão 0.6 do Node.js, quando foi lançado.
    A parte de load balancing do PM2 não possui vantagens consideráveis em relação às versões atuais do Node.js, mas o zero-downtime ao adicionar/remover ou reiniciar processos e a API são diferenciais positivos quando falamos de ambientes de produção.

HTML é permitido: a,b,br,blockquote,i,li,pre,u,ul,p

HTML é permitido: a,b,br,blockquote,i,li,pre,u,ul,p

BT