- Blog
- Balanceamento de carga
Balanceamento de carga
Criando um balanceador com JavaScript e entendendo de forma prática pra que são utilizados
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á nossolocalhost
;balancer
: a porta do nosso balanceador de carga, vamos configurá-lo para porta3000
;servers
: um array com as portas dos nossos servidores que irão de3001
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çãohttp.createServer
(responsável por criar o servidor a partir de uma função qualquer) que receberá como parâmetro o retorno da funçãocreateListener
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!