NodeJS com loadbalancer por meio de NGINX e Docker Compose

Nota: Este é um post feito por um iniciante com o intuito de ajudar outros iniciantes, deixando as coisas e etapas mais claras.


Será bom você ter um entendimento básico sobre:

  • NodeJS.
  • Uso de terminal Batch ou MS-DOS (CMD).
  • Como usar uma imagem Docker de forma singular.

É necessário ter instalado em sua máquina:

  • Docker e Docker Compose.
  • Instalações podem ser encontradas na documentação oficial. (Docker Desktop já vem com o Docker Compose embutido.)

Olá leitor! Tudo bem? Alguns dias para cá, me peguei com a curiosidade para aprender sobre NGINX e o conceito de loadbalancer, porém não gostaria de instalar em minha máquina local, nem precisar virtualizar uma máquina inteira apenas para isso. Com isso me vi na obrigação de estudar acerca de Docker e Docker compose.

Alguns questionamentos:

O que é Docker?

  • Docker se trata de imagens baseadas em sistemas operacionais (Linux Alpine, Ubuntu, Debian e etc...) ou apenas uma aplicação (Node, NGINX e etc...), porém diferentemente de uma máquina virtual inteira, esta imagem vem apenas com o básico necessário para funcionar em cima da Docker Engine, que seria a camada de virtualização do Docker (Como o Hypervisor da virtualização convencional.), então digamos que você tenha uma imagem de 8MB de um Linux Alpine, coloque para rodar 2 containers desta imagem, logo quer dizer que estão sendo consumidos 16MB de armazenamento? Errado, cada container (Pode-se entender como uma "Imagem em Execução") é uma instância de uma mesma imagem, referem-se à mesma imagem, como se fosse uma POO(Programação Orientada a Objetos) de sistemas ou apps inteiros. Para entender mais, veja este artigo da hostinger.

O que é Docker Compose?

  • Quando trabalhamos com Docker, muitas vezes gostaríamos de colocar mais de 1 imagem/container para executar ao mesmo tempo, então manualmente precisamos rodar a imagem X, logo após a Y e então a Z? Não. Com o Docker Compose, podemos declarar em um arquivo .yml todos os containers que precisam ser executados em conjunto para formar uma composição (Entendeu? Docker Compose com composição, LOL).

O que é NGINX?

  • NGINX assim como o Apache, é um servidor Web OpenSource que seria a porta de entrada da ou das suas aplicações, contendo várias configurações para rotas, redirecionamentos, certificados TLS/SSL, controles de corpo de requisições e muitas outras funcionalidades.

Bom, vamos para a prática.

  • Inicialmente, crie um diretório para o projeto, eu chamei de nginx-nodeapp-docker-compose, todos os passos de criação de arquivos e diretórios a seguir serão criados dentro deste diretório.
  • Aqui está um molde da estrutura de arquivos que teremos ao final de tudo dentro deste diretório:
│   docker-compose.yml
│
├───nginx
│       nginx.conf
│
├───web1
│       Dockerfile
│       package.json
│       server.js
│
└───web2
        Dockerfile
        package.json
        server.js

1. Arquivo de configurações do NGINX.

No NGINX, por padrão, temos o arquivo /etc/nginx/conf.d/default.conf onde ficam as configurações do servidor. Em seu projeto, crie uma pasta chamada nginx, e dentro dela crie um arquivo nginx.conf com o seguinte conteúdo:

upstream loadbalancer {
  server web1:5000;
  server web2:5000;
}

server {
  listen 80;

  location / {
    return 301 /greetings;
  }

  location /greetings {
    proxy_pass http://loadbalancer;
  }
}

Desestruturando:

  • upstream → Se trata do módulo ngx_http_upstream_module, usado para definir grupos de servidores que podem ser referenciados pelo proxy_pass, utilizado geralmente para loadbalancer, ou balanceador de carga.
  • server (Dentro de upstream) → Define o servidor e sua porta de entrada (nome_do_servidor:porta). Neste exemplo estamos definindo que teremos 2 servidores para balanceamento de carga, um chamado web1 com porta de entrada sendo a 5000 e outro chamado web2 com porta de entrada sendo também a 5000.
  • server → Local onde são definidas as configurações do NGINX em si. Conteúdo englobado por chaves.
  • listen → Porta que o NGINX deve ficar "ouvindo" as requisições chegarem. Neste exemplo estamos utilizando a porta 80.
  • location → Para cada requisição, o NGINX irá escolher o melhor bloco de rota (Ou melhor, location) que poderá ser utilizada para servir a requisição, ele faz isso baseado na URI requisitada, e nas locations definidas. No exemplo utilizamos o /greetings, logo, toda requisição que vier como "http://meusite.com.br/greetings", será servida por este location.

Dentro de location, estamos fazendo os seguintes processos:

  • location / → Aqui, o return quer dizer que queremos parar o processamento na rota atual, e devolver determinado código de status ao cliente/browser, enviando ou não um cabeçalho de resposta. E podendo ser enviado um link ou rota da aplicação atual para redirecionamento quando retornado um código de status 300-399 ao cliente (Tabela de Códigos HTTP). Neste exemplo estamos informando que quando o cliente enviar uma requisição para a rota / (Ex: meusite.com.br/ ou meusite.com.br), queremos enviar de volta para ele um código de status HTTP 301 (Redirecionamento permanente) e informando que ele seja redirecionado para a rota /greetings do nosso site (Ex: meusite.com.br/greetings).
  • location /greetings → Estamos utilizando o proxy_pass explicado abaixo para usar o balanceador de carga quando vier requisições nesta rota.
  • proxy_pass → Aqui você basicamente está dizendo "Passe esta requisição para este endereço de proxy", que no nosso caso é o nosso upstream para balanceamento de carga.

2. Implementando nossas duas aplicações NodeJS.

Para implementarmos nossas duas aplicações, podemos criar um diretório chamado web1, e outro chamado web2. Dentro de cada um dos dois diretórios, criamos um arquivo chamado server.js, que irá conter nossa configuração de rota para /greetings, e inicialização do Express. Ambos arquivos terão praticamente o mesmo conteúdo, mudando apenas o retorno ao cliente, que será para diferenciarmos para qual dos dois o balanceador de carga nos levou. Com isso, pode alterar o WEB1 para WEB2 no arquivo server.js criado no diretório web2.

const express = require('express');
const app = express();

app.get('/greetings', (req, res) => {
    console.log("Request received WEB1");
    //Nesta linha o WEB1 pode ser alterado para WEB2 no diretório web2.
    res.status(200).send("Opa meu nobre, um salve pra tu aqui por meio do NGINX com Node no WEB1.");
});

app.listen(5000, () => {
    console.log('Web application is listening on port 5000');
});

3. Criando nossos arquivos package.json.

No arquivo package.json é onde ficam os metadados de nossa aplicação NodeJS, informações sobre quais dependências/pacotes ela utiliza, nome do projeto, versão atual dele, uma breve descrição, e podem ser adicionados scripts para execução. No exemplo, temos um script chamado "start", e para chamá-lo, digitamos o gerenciador de pacotes utilizado + nome do script, nesse caso como estamos utilizando o NPM para gerenciamentos dos pacote/dependências (Pode ser utilizado Yarn ou Pnpm também), digitaríamos npm start, que por sua vez executa o comando node server.js internamente, o comando node é nativo do NodeJS, e quer dizer "Olha node, executa esse arquivo server.js para mim.", assim, quem for olhar nosso código, sabe como iniciar nosso projeto. Enfim, prosseguindo, pode criar um arquivo package.json dentro do diretório web1 e outro no diretório web2 com o mesmo conteúdo, nele estamos especificando como dependência/pacote a ser utilizado apenas o ExpressJS:

{
  "name": "web",
  "version": "1.0.0",
  "description": "Running Node.js and Express.js on Docker using Docker Compose.",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.17.2"
  },
  "author": "",
  "license": "MIT"
}

4. Criando nossos Dockerfiles.

Dentro do Docker possuímos o conceito de Dockerfile, que seria um arquivo contendo todos os passos que o Docker deve seguir para executar nosso container a partir de uma imagem base. Neste caso pode criar um arquivo chamado Dockerfile dentro do diretório web1 e outro no web2, contendo o mesmo conteúdo:

FROM node:alpine

WORKDIR /usr/src/app

COPY ./package*.json ./
RUN npm install
COPY ./server.js ./

CMD ["npm","start"]

Desestruturando:

  • FROM → Todo container Docker é uma instância/referência de uma imagem, então dentro do FROM, definimos qual imagens iremos utilizar, caso o Docker não localize a imagem que informamos baixada em nosso sistema local, o próprio Docker irá baixar a imagem remotamente do Docker Hub, que é basicamente um repositório contendo diversas imagens Docker. Geralmente para aplicações seguimos o padrão nome_da_image_da_aplicacao:sistema_operacional_base, nesse caso estamos informando que queremos utilizar a imagem node que estará sendo executada "dentro" de um Linux Alpine, então no Alpine há apenas as coisas necessárias para executar o Node, sem as demais parafernalhas de um Sistema Operacional convencional.
  • WORKDIR → Informa em qual diretório dentro da imagem queremos realizar os próximos passos que serão informados, é como fazer um cd diretorio_desejado em um terminal Linux ou CMD do Windows. Nesse caso queremos trabalhar dentro do diretório /usr/src/app que se encontra dentro da imagem node.
  • COPY → Informamos que queremos copiar um arquivo ou diretório da nossa máquina local para dentro dos arquivos da imagem, utilizando o padrão "diretório_local diretório_na_imagem". Então no primeiro COPY estamos dizendo ao Docker "Por favor, pega este arquivo 'package.json' que está dentro desta mesma pasta que o Dockerfile (Por isso o ./, seria o caminho relativo para a mesma pasta que o arquivo se encontra) e envia para dentro da pasta '/usr/src/app' que se encontra dentro do container que estamos criando.", aí você pensa "Mas você colocou apenas um ./ na informação de qual diretório dentro da imagem enviar!", mas lembra do WORKDIR que utilizamos anteriormente? Todas as operações que realizamos dentro da imagem, terá como ponto inicial o diretório definido nele =]. No segundo COPY estamos pedindo ao Docker "Opa Docker, pega o arquivo server.js que se encontra no mesmo diretório deste Dockerfile e envia para o /usr/src/app da imagem node também!", mas sim, vamos falar do comando RUN que está entre os dois.
  • RUN → Indica que queremos realizar um comando de terminal dentro da imagem na qual estamos trabalhando, logo, abrir seu terminal ou CMD e digitar o comando "npm install" é a mesma coisa que estamos fazendo ao digitar RUN + Comando desejado, porém este comando será executado dentro da imagem na qual estamos trabalhando, assim com base no package.json o npm irá ler este arquivo e instalar as dependências que nele estão especificadas. Massa né?
  • CMD → Indica qual comando de terminal será executado internamente dentro do container sempre que o mesmo for iniciado, quer dizer que quando esse container terminar de ser criado, automaticamente será executado o comando npm start em seu terminal, que é o script que especificamos para iniciar nossa aplicação dentro do package.json que comentei antes, lembra?

5. O GRAND FINALE.

Hora de criarmos nosso arquivo .yml do Docker Compose, que assim como o Dockerfile, define um passo a passo de criação dos containers Docker, porém neste caso é para formar uma composição de Containers. Algumas notas: Arquivos .yml são utilizados geralmente para configurações. Irá notar que não terão chaves por exemplo para separar os blocos de execução, por conta de assim como linguagens tipo Python, ele é totalmente baseado em identação, sendo necessário dar TAB e espaço nos locais corretos. Dentro da raíz do seu projeto, pode criar um arquivo chamado docker-compose.yml com o seguinte conteúdo:

version: '3.9'
services:
  web1:
    restart: on-failure
    build: ./web1
    expose: [5000]
  web2:
    restart: on-failure
    build: ./web2
    expose: [5000]
  nginx:
    image: nginx:latest
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
    ports:
      - "80:80"
    depends_on:
      - web1
      - web2

Desestruturando:

  • services → Indicamos quais serviços (Vulgos containers) nossa composição terá. Neste exemplo teremos 1 container chamado web1, outro container chamado web2 e outro container chamado nginx.
  • restart → Definimos qual evento irá fazer com que o container seja reiniciado, neste exemplo informamos que seja reiniciado caso ocorra alguma falha.
  • build → Informamos onde o Docker pode encontrar o arquivo Dockerfile que irá definir e informar para ele quais passos devem ser seguidos para criar aquele serviço/container. Neste caso no web1 estamos informando que o arquivo Dockerfile dele se encontra dentro do diretório web1 que criamos anteriormente, não precisamos especificar o nome do arquivo por conta do Docker automaticamente buscar por Dockerfile. Mesma coisa para o web2, alterando apenas o nome do diretório.
  • ports → Dentro do Docker possuímos um conceito extremamente interessante chamado de Port Binding ou Ligação de Portas, onde quer dizer que quando alguma coisa bater na porta X da minha máquina local, quero que seja redirecionada para a porta Y do meu container Docker. Neste caso estamos dizendo que quando alguma requisição chegar pela porta 80 da minha máquina local, referenciando meu IP, quero que seja redirecionado para a porta 80 do meu NGINX, que como configuramos na etapa 1, é a mesma porta que o NGINX fica "ouvindo" as requisições.
  • expose → Assim como o ports mencionado acima, o expose também realiza manipulação de portas, porém apenas internamente do Docker, no caso estou dizendo que meu container X aceitará todas requisições feitas para ele na porta que foi exposta, porém apenas internamente no Docker, por conta de não termos vinculado esta porta com nenhuma porta da nossa máquina local, como é feito no PORTS, com isso o NGINX conseguirá acessar esses containers pela porta 5000 deles, pois ambos estão dentro do Docker, mas nosso navegador na nossa máquina local, não conseguirá.
  • image → Possui a mesma função do FROM dentro dos Dockerfiles criados anteriormente na etapa 4, porém dentro do docker-compose, por conta de não precisarmos ficar executando comando com RUN nem definir um comando de inicialização com CMD na imagem do NGINX. Podemos criar um Dockerfile também assim como no web1 e web2, porém não se faz necessário.
  • volumes → Aqui seria o mesmo que o comando COPY do Dockerfile, com a linha "./nginx/nginx.conf:/etc/nginx/conf.d/default.conf" estamos dizendo ao Docker "Pega o arquivos que se encontra em './nginx/nginx.conf' e envia ele para dentro da imagem no diretório '/etc/nginx/conf.d/' com o nome de 'default.conf' e caso já exista um arquivo com este nome, substitua ele por esse novo que estou enviando.".
  • depends_on → Informa ao Docker quais outros serviços precisam estar prontos em execução, antes do container informado ser iniciado. No exemplo deste tutorial estamos dizendo ao Docker "Inicia este container NGINX, SOMENTE após os serviços/containers web1 e web2 estarem prontos em execução, pois este depende dos demais.".

Após tudo isso, com muita dedicação, pode abrir seu terminal, navegar até o diretório do projeto (Ou abrir o terminal integrado do VSCode) e digitar o comando docker-compose up -d, isso irá fazer com que o Docker comece a criar os container solicitados e fazer os devidos passos que escrevemos anteriormente. Você deve ver uma tela com estas informações ao final da execução: Docker ao final do build

Para garantir que todos os containers estão rodando, pode executar o comando docker container ps, este comando retorna todos os container em execução, deve receber um retorno parecido com este: Docker container ps resultado

Com tudo executando, pode abrir seu navegador e pesquisar por "http://localhost/", deve receber uma tela com um dos textos informados nas aplicações NodeJS anteriormente, "Opa meu nobre, um salve pra tu aqui por meio do NGINX com Node no WEB1." ou "Opa meu nobre, um salve pra tu aqui por meio do NGINX com Node no WEB2."

Para ver o balanceador de carga em ação, fique pressionando várias vezes F5, por conta de ser somente uma máquina, acaba sendo levemente difícil alterar algo, mas em algum momento irá alternar entre WEB1 e WEB2, nos mostrando a mágica acontecendo! =] É possível que mais computadores da mesma rede acessem a aplicação, com isto basta ver qual o IP da sua máquina, e compartilhar com o colega. (╯°□°)╯︵ ┻━┻ ︵ ╯(°□° ╯)


Caso alguém enfrente algum problema ou tenha algo a complementar sobre este artigo, por favor, não deixe de compartilhar nos comentários. Vai ser um prazer ajudar ou ler mais coisas que agreguem conhecimento. E caso vejam alguma gafe no post, podem sinalizar também, afinal é errando que se aprende! Obrigado! (ღ˘⌣˘ღ)

Um dos posts mais completos do TabNews. Muito bem escrito e definitivamente útil. Obrigado por compartilhar.

Muito obrigado pelo comentário! É motivador ter este feedback. ٩(^‿^)۶

@suicideDuck22 ola, acredito que a melhor maneira de você resolver esse problema seria usando a flag --scale do docker-composer up.[1]

Por exemplo:

docker-composer up --scale web=5,nginx=1

O docker vai subir cinco instancias da aplicação web e uma instancia do container nginx.

Imagina se precisasse subir 10, 15 ou mais vezes a aplicação web.

A configuração do nginx deveria ser mudada para algo do tipo:

resolver 127.0.0.11;
set $backends web
location / {
    ....
    proxy_pass http://$backends:5000;
}

A grande mudança é na configuração de DNS do nginx que é mudada para usar o servidor de DNS embutido do docker.

[1] https://docker-docs.netlify.app/compose/reference/up/ [2] https://www.ameyalokare.com/docker/2017/09/27/nginx-dynamic-upstreams-docker.html

Perfeita colocação, desconhecia o parâmetro scale do Docker Compose. Realmente acabei criando duas aplicações por conta de ser a única forma que pensei conseguir fazer. Só sabia fazer esta duplicação utilizando os replicaSets no Kubernetes. Muito obrigado por compartilhar!

Opa, bão?

Muito obrigado pelo seu post, você explicou muito bem o conteúdo.

Irei complementar um pouco nessa parte aqui:

Quando trabalhamos com Docker, muitas vezes gostaríamos de colocar mais de 1 imagem/container para executar ao mesmo tempo, então manualmente precisamos rodar a imagem X, logo após a Y e então a Z? Não. Com o Docker Compose, podemos declarar em um arquivo .yml todos os containers que precisam ser executados em conjunto para formar uma composição (Entendeu? Docker Compose com composição, LOL).

Tudo que você disse está certo, mas a maior das ultilidades do docker compose é facilitar o network dos containers, pois container não vê container em um contexto normal. Ex:

Tenho um Banco rodando no container A e uma API rodando no container B, sem um docker compose, o container B não consegue bater na porta do container A, agora com o compose a mesma tentativa de conexão feita pelo container A funcionaria perfeitamente.

### Opa, bão e tu? Cara, muito obrigado por compartilhar, de fato eu já havia lido sobre isso em algum lugar, mas sinceramente não me lembrava. Posso realizar um EDIT realizando essa adição no post dando os créditos ao seu comentário?
# Opa to bao, foi mal pela demora asduhsaiduhsaidu, cara pode sim asduhasuidsa

Que post incrivel, parabéns!

Excelente conteúdo! Parabéns

Muito obrigado! Gratificante

Parabéns pelo post. Tudo muito bem descrito e explicado.

Obrigado! Agradecido pelo comentário!

Parabéns pela conteúdo, muito didático!

Obrigado pelo feedback!

Eu já iria pesquisar em como utilizar o nginx para conectar APIs node e balanceador de carga! caiu como uma luva! Muito grato pelo conteúdo!