Gabriel / Ramos

⟨ pintor de pixel ⟩

  • Blog
  • Balanceamento de carga

Balanceamento de carga

Criando um balanceador com JavaScript e entendendo de forma prática pra que são utilizados

11 min. de leitura

Foto por Gabriel Ramos

Balanceamento de carga (ou load balancing) é um conceito muito aplicado para distribuir carga entre diversos servidores.

Vamos começar imaginando um fluxo de acesso em um aplicação que só é executada em um único servidor, bem simples. Teríamos algo mais ou menos assim:

Cliente -----> Servidor

Por enquanto, nada de surpreendente até aqui... Mas vamos imaginar que a carga de nossa aplicação cresceu em um determinado momento e agora só esse antigo servidor não é mais o suficiente para suportar a carga de requisições necessárias.

Você vai precisar escalar sua infraestrutura e você pode fazer isso de duas maneiras, basicamente:

  • Verticalmente: onde você insere mais memória/CPU nos seus servidores;
  • Horizontalmente: onde você insere mais máquinas para lidar com suas requisições.

No caso da escala horizontal, onde você acrescenta novos servidores para responder às requisições de usuários, é necessário uma maneira automatizada de distribuir essas requisições entre os servidores de forma uniforme. É aí que um balanceador de carga entra em jogo!

Um balanceador de carga, basicamente, será um servidor nesse "meio de campo" responsável por rotear a sua requisição para um dos servidores envolvidos.

Com isso, sua infraestrutura será algo como:

                              / ---> Servidor 1
Cliente -----> Balanceador --------> Servidor 2
                              \ ---> Servidor 3

Para entender melhor como isso funciona de forma prática, vamos construir um exemplo usando Node, onde utilizaremos 3 servidores para responder uma requisição simples e um servidor para balancear a carga entre eles.

Construindo um balanceador de carga

Nosso balanceador de carga será uma implementação simples de um servidor na porta 3000 que irá balancear a carga entre 3 servidores nas portas 3001, 3002 e 3003.

Arquivo de configuração

Vamos começar com um arquivo de configuração, para que possamos manter registrado quantos servidores iremos utilizar e em quais portas eles funcionarão. Vamos criar o arquivo config.js.

Ele deverá possuir um objeto com:

  • host: será nosso localhost;
  • balancer: a porta do nosso balanceador de carga, vamos configurá-lo para porta 3000;
  • servers: um array com as portas dos nossos servidores que irão de 3001 até 3003.
// arquivo config.js
const config = {
  host: 'localhost',
  balancer: 3000,
  servers: [
    3001,
    3002,
    3003
  ]
};

module.exports = config;

Criação dos servidores

Feito isso, vamos criar um arquivo servers.js que será responsável por criar os processos dos nossos servidores. Para criar nossos servidores, vamos utilizar o módulo http nativo do node. Vamos aproveitar e importar nossas configurações do arquivo config.js também:

// arquivo servers.js
// importamos o módulo http nativo
const http = require('http');
// importamos das configurações
//  o host e os servers, renomeando para ports
const { host, servers: ports } = require('./config');

Agora vamos criar uma função que simplesmente irá retornar: Respondido pelo servidor da porta ${port} para nós. Essa função deverá ser executada para cada um dos servidores, então vamos utilizar uma técnica chamada currying para criar uma função que irá retornar uma outra função. Faremos isso para conseguir armazenar o valor com a porta do servidor dentro de um determinado escopo.

Essa última função, por sua vez, irá receber os objetos de req e res, bem comum em ambientes de requisição e resposta em APIs (e popularmente utilizado por frameworks como Express).

Vamos chamar essa função de createListener:

const http = require('http');
const { host, servers: ports } = require('./config');

// criamos a função createListener que recebe a porta
// e retorna uma outra função com req/res
const createListener = port => (req, res) => {
  // retornamos uma mensagem como resposta
  // ao executar essa função
  res.end(`Respondido pelo servidor da porta ${port}`);
};

Agora, podemos fazer um map no nosso array ports para configurar cada um dos servidores. Para cada valor de ports, vamos retornar um objeto com algumas configurações, sendo elas:

  • própria porta do servidor;
  • um valor de listener que será o resultado da execução da função http.createServer (responsável por criar o servidor a partir de uma função qualquer) que receberá como parâmetro o retorno da função createListener que acabamos de criar.
const http = require('http');
const { host, servers: ports } = require('./config');

const createListener = port => (req, res) => {
  res.end(`Respondido pelo servidor da porta ${port}`);
};

// realizamos um map nas portas
const servers = ports.map(port => ({
  // retornamos a própria porta
  port,
  // retornamos um listener
  // executando a função createListener
  listener: http.createServer(createListener(port))
}));

Com isso, agora temos um array servers onde cada item é um objeto com sua respectiva porta e uma função que será utilizada para criar nosso servidor e escutar nessa determinada porta.

Por último, podemos rodar um laço como forEach nesse array, para cada um dos servidores, utilizar a função .listen a partir da chave listener que criamos para que os servidores comecem a "escutar" suas respectivas portas. Fora isso, essa função também precisará do host como segundo parâmetro e pode receber um callback como terceiro, para executar um log, por exemplo:

const http = require('http');
const { host, servers: ports } = require('./config');

const createListener = port => (req, res) => {
  res.end(`Respondido pelo servidor da porta ${port}`);
};

const servers = ports.map(port => ({
  port,
  listener: http.createServer(createListener(port))
}));

// realizamos um loop no array de servers
servers.forEach(server => {
  // para cada server
  // executamos listener.listen
  // fornecendo a porta, o host e um callback
  server.listener.listen(server.port, host, () => {
    // que apenas executa um log no terminal
    console.log(`Servidor rodando na porta ${server.port}`);
  });
});

Com isso, basta executar no seu terminal node ./servers que você poderá ver o seguinte resultado:

Servidor rodando na porta 3001
Servidor rodando na porta 3002
Servidor rodando na porta 3003

Caso queira ver o resultado, utilize seu navegador e acesse localhost em uma dessas portas que você verá a resposta que criamos anteriormente.

Perfeito, agora vamos para nosso balanceador de carga.

Criando o balanceador

Para iniciar nosso balanceador, vamos também importar o módulo http nativo do Node e também as configurações de host e balance do nosso arquivo de configuração. Vamos fazer tudo isso em um novo arquivo balancer.js:

// arquivo balancer.js
// importamos o módulo http
const http = require('http');
// importamos os valores de host
// e balancer, renomeando também para port
const { host, balancer: port } = require('./config');

Nosso balanceador será bem mais simples, vamos apenas criá-lo utilizando a função createServer do módulo http igual fizemos anteriormente. Após isso, vamos também atribuir sua porta, host e uma função de callback para exibir algo no console:

const http = require('http');
const { host, balancer: port } = require('./config');

// executamos a função createServer
const balancer = http.createServer();
// configuramos o balancer para ouvir em sua porta
// com o determinado host
balancer.listen(port, host, () => {
  // e executando um log diferente
  console.log(`Load balancer rodando na porta ${port}`);
});

Agora iremos para a cereja do bolo: precisaremos adaptar o balanceador para que cada requisição seja respondida por um servidor diferente. Podemos fazer isso atribuindo uma função aos eventos disparados quando qualquer requisição chega ao servidor. Podemos atribuir funções a esses eventos através do método .on:

const http = require('http');
const { host, balancer: port } = require('./config');

const balancer = http.createServer();
balancer.listen(port, host, () => {
  console.log(`Load balancer rodando na porta ${port}`);
});

// atribuímos uma função
// que será disparada toda vez que uma requisição
// chegar ao balanceador de carga
balancer.on('request', (req, res) => {
});

Com isso, trabalharemos essa função para que uma nova requisição seja feita aos servidores que devem responder corretamente. Vamos começar criando um objeto request de configuração da requisição que nosso load balancer irá fazer aos servidores finais:

const http = require('http');
const { host, balancer: port } = require('./config');

const balancer = http.createServer();
balancer.listen(port, host, () => {
  console.log(`Load balancer rodando na porta ${port}`);
});

balancer.on('request', (req, res) => {
  // criamos uma variável request
  // para armazenar os dados da requisição
  // que iremos propagar ao servidor final
  const request = {
    // contendo seu host
    host,
    // passamos adiante os valores de
    // path, método e cabeçalhos recebidos
    path: req.url,
    method: req.method,
    headers: req.headers,
  };
});

Com essa configuração pronta, vamos utilizar o método http.request para criar um objeto com essa nova requisição. Ele receberá a configuração que acabamos de criar e um callback que utilizará pipe para conectar a resposta do servidor final à resposta do balanceador:

const http = require('http');
const { host, balancer: port } = require('./config');

const balancer = http.createServer();
balancer.listen(port, host, () => {
  console.log(`Load balancer rodando na porta ${port}`);
});

balancer.on('request', (req, res) => {
  const request = {
    host,
    path: req.url,
    method: req.method,
    headers: req.headers,
  };
  // criamos uma variável connector
  // e executamos http.request
  // fornecendo o objeto request como parâmetro
  // e uma função de callback que irá conectar através do método pipe
  // a resposta dessa nova requisição à resposta do balanceador
  const connector = http.request(request, (resp) => resp.pipe(res));
});

Com isso, só precisamos executar essa requisição em conjunto com a requisição recebida no balanceador. Podemos fazer isso também através do método .pipe:

const http = require('http');
const { host, balancer: port } = require('./config');

const balancer = http.createServer();
balancer.listen(port, host, () => {
  console.log(`Load balancer rodando na porta ${port}`);
});

balancer.on('request', (req, res) => {
  const request = {
    host,
    path: req.url,
    method: req.method,
    headers: req.headers,
  };

  const connector = http.request(request, (resp) => resp.pipe(res));
  // realizamos a conexão do nosso connector
  // e a requisição recebida através do método pipe
  req.pipe(connector);
});

Agora, só falta mais um detalhe para que possamos balancear a carga entre os servidores corretamente: precisamos disparar as requisições, cada um para um dos servidores em suas respectivas portas, certo?

Vamos lá!

Resolvendo as portas do servidores dinamicamente

Vamos criar uma função getServerPort lá no arquivo config.js que criamos no início:

// arquivo config.js
const config = {
  host: 'localhost',
  balancer: 3000,
  servers: [
    3001,
    3002,
    3003
  ],
  // função getServerPort
  getServerPort() {
  }
}

module.exports = config;

Utilizaremos ela para "descobrir" qual a porta do servidor que irá receber as requisições. Vamos importá-la no nosso arquivo balancer.js e também adicionar a porta ao objeto request que criamos anteriormente:

const http = require('http');

// importamos a função getServerPort
const { host, balancer: port, getServerPort } = require('./config');

const balancer = http.createServer();
balancer.listen(port, host, () => {
  console.log(`Load balancer rodando na porta ${port}`);
});


balancer.on('request', (req, res) => {
  // criamos uma variável port
  const port = getServerPort();
  const request = {
    host,
    // fornecemos essa mesma variável na requisição
    port,
    path: req.url,
    method: req.method,
    headers: req.headers
  };

  const connector = http.request(request, (resp) => resp.pipe(res));

  req.pipe(connector);
});

Agora só precisamos fazer nossa lógica na função getServerPort para que ela retorne as portas dos servidores corretamente. Por exemplo:

  • em sua primeira execução, deverá retornar 3001;
  • em sua segunda execução, deverá retornar 3002;
  • em sua terceira execução, deverá retornar 3003.

E esse fluxo se repete o tempo inteiro, sempre retornando um dos valores das 3 portas disponíveis. Como nossas portas estão em um array servers, podemos trabalhar com seus índices, variando de 0 até 2.

Com isso em mente, uma possível implementação da nossa função getServerPort pode ser a seguinte:

const config = {
  host: 'localhost',
  balancer: 3000,
  servers: [
    3001,
    3002,
    3003
  ],
  getServerPort() {
    // verificamos se é possível incrementar + 1
    // no índice das portas em `server`
    const canIncreaseServerPort = config.currentServerPort < config.servers.length - 1;
    // se possível, incrementamos + 1, caso contrário, voltamos à porta inicial
    const nextPort = canIncreaseServerPort ? config.currentServerPort + 1 : 0;
    // armazenamos essa configuração na variável config.currentServerPort
    config.currentServerPort = nextPort;

    // retornamos a porta correta através de config.servers
    return config.servers[nextPort];
  }
}

module.exports = config;

Note que a variável currentServerPort é criada pela própria função getServerPort e ela não está presente no objeto config antes. Isso é apenas um "pulo do gato" para poder iniciar nosso balanceamento da porta 0, utilizando voluntariamente algumas limitações da própria linguagem JavaScript.

Em outras palavras, currentServerPort inicia como undefined, o que faz com que a comparação config.currentServerPort < config.servers.length - 1 sejá falsa e o ternário da segunda linha da função retorne 0 em sua primeira execução.

Resumo do código

Caso precise relembrar o código todo feito até agora, abaixo estão os 3 arquivos config.js, servers.js e balancer.js:

config.js

const config = {
  host: 'localhost',
  balancer: 3000,
  servers: [
    3001,
    3002,
    3003
  ],
  getServerPort() {
    const canIncreaseServerPort = config.currentServerPort < config.servers.length - 1;
    const nextPort = canIncreaseServerPort ? config.currentServerPort + 1 : 0;
    config.currentServerPort = nextPort;

    return config.servers[nextPort];
  }
}

module.exports = config;

servers.js

const http = require('http');
const { host, servers: ports } = require('./config');

const createListener = port => (req, res) => {
  res.end(`Respondido pelo servidor da porta ${port}`);
};

const servers = ports.map(port => ({
  listener: http.createServer(createListener(port)),
  port
}));

servers.forEach(server => {
  server.listener.listen(server.port, host, () => {
    console.log(`Servidor rodando na porta ${server.port}`);
  });
});

balancer.js

const http = require('http');

const { host, balancer: port, getServerPort } = require('./config');

const balancer = http.createServer();
balancer.listen(port, host, () => {
  console.log(`Load balancer rodando na porta ${port}`);
});


balancer.on('request', (req, res) => {
  const port = getServerPort();
  const request = {
    host,
    port,
    path: req.url,
    method: req.method,
    headers: req.headers
  };

  const connector = http.request(request, (resp) => resp.pipe(res));

  req.pipe(connector);
});

Testando tudo junto

Para validar nossa implementação ao final de todo o desenvolvimento, basta abrir duas abas em seu terminal e executar node ./servers em uma e node ./balancer na outra.

Após isso, basta fazer uma requisição ao servidor que está balanceando toda a carga através do seu navegador acessando localhost:3000 ou utilizando alguma ferramenta como curl, no terminal.

Caso repita essa requisição variás vezes, você verá que cada um dos servidores irá responder em seu respectivo momento, por exemplo, ao executar 3 vezes, é possível ver:

curl -X GET localhost:3000
Respondido pelo servidor da porta 3001
curl -X GET localhost:3000
Respondido pelo servidor da porta 3002
curl -X GET localhost:3000
Respondido pelo servidor da porta 3003

Nossa implementação é simples, mas importante!

Claro que nossa implementação foi bem ingênua, todos os servidores rodam em um mesmo processo e em uma mesma máquina. Mesmo assim, serviu para que o conceito de balanceamento de carga ficasse claro de uma maneira um pouco mais prática e "colocando a mão na massa".

Existem diversas ferramentas e decisões técnicas muito mais confiáveis pra realizar essa tarefa do que a que fizemos aqui, pode ter certeza, aplicando diversos algoritmos para balancear cargas de forma mais confiável e com tecnologias e implementações muito mais robustas.

Mesmo sendo básico, nosso balanceador de carga funciona bem para seu propósito e para que possamos entender como um load balancer vai funcionar no fim dia sem complicar muito as coisas.

Espero que tenha gostado!


Compartilhe