Workshop DoWhile Rocketseat: Containerizando o ambiente

Rodrigo Botti
ReZÉnha
Published in
16 min readDec 21, 2020

Usando Docker e docker-compose

Containers de transporte coloridos empilhados em de dois arcos. Um arco passando por dentro do outro
container-storage-trade-haulage créditos a ValdasMiskinis

Introdução

Nos dias 14 e 15 de dezembro de 2020 tive a oportunidade de apresentar o workshop Containerizando o ambiente com Docker no evento DoWhile organizado pela empresa Rocketseat.

O objetivo desse artigo é ser uma versão do workshop em forma de texto.

Um pouco sobre mim

Olá pessoal, sou Rodrigo Botti, sou engenheiro backend no Zé Delivery. Sou entusiasmado por práticas de DevOps e curto automatizar o possível tanto na vida profissional quanto fora do trabalho.

O workshop

Nesse workshop vamos aprender a utilizar Docker e docker-compose para criar um ambiente de desenvolvimento containerizado.

Isso será feito com o objetivo de facilitar o desenvolvimento, testes automatizados e integração contínua de aplicações que dependem de serviços de infraestrutura externos — como bancos de dados e serviço de cache distribuído.

Vamos ver que com o uso dessas ferramentas ganharemos:

  • Não precisar instalar essas dependências em nosso ambiente local
  • Não precisar instalar essas dependências remotamente e disponibiliza-las na rede
  • Não precisar compartilhar essas dependências entre colaboradores
  • Não precisar se preocupar com o ciclo de vida dessas dependências — considerá-las efêmeras

Para aprendermos esses conceitos, utilizaremos um aplicação de exemplo. Por enquanto essa aplicação depende de acessos a elementos de infraestrutura que não temos localmente. Iremos gradativamente ver como permitir que a aplicação tenha acesso a essas dependências no ambiente de desenvolvimento local.

Etapas

O workshop será dividido nas seguintes etapas:

  1. Apresentação da aplicação exemplo: conhecer as features, familiarização com código fonte, arquitetura de software, testes unitários e de integração
  2. Adição de banco dedos MongoDB: como utilizar o docker-compose para "subir" o banco localmente — tanto para desenvolvimento local e testes automatizados
  3. Adição do serviço de cache Redis: como utilizar o docker-compose para "subir" o serviço localmente — tanto para desenvolvimento local e testes automatizados
  4. Conectando a aplicação
  5. Criação de pipeline de Continuous Integration com Github Actions: pipeline com etapa de lint e testes unitários e de integração — importante ressaltar que o foco não será em aprender sobre Github Actions mas sim como utilizar containers para facilitar o processo

Acompanhamento e pré-requisitos

A aplicação exemplo pode ser encontrada no repositório base. Todo código que vamos produzir será em cima dessa aplicação.

Disclaimer: O setup não foi testado em sistema operacional Windows então pode ser que não funcione corretamente nele. Recomendo, se possível, a utilização de alguma distribuição Linux ou Mac.

Para conseguir acompanhar o workshop será necessário ter no ambiente

  • Docker e docker-compose
  • Git
  • Conta no Github
  • Última versão de Node.JS (14+) — recomendo a utilização do NVM para instalação do Node.JS
  • Preparar sua versão do repositório: fazer o fork do repositório base; clonar localmente; entrar no diretório do repositório via terminal e executar
# caso tenha optado pelo uso do nvm
# (a versão de node vem do arquivo .nvmrc na raiz)
nvm install
nvm use
# instalar as dependências
npm install
# verificar:

# executar testes unitários e de integração
# como falta o mongodb e o redis, deve falhar com timeout de conexão
npm test
# tentar subir a aplicação localmente em modo debug
# como falta o mongodb e o redis, deve falhar por erro de conexão
npm run start:dev

Sem mais delongas, vamos ao conteúdo.

1 . A aplicação

A aplicação consiste de uma API REST em Node.JS.

Essa API é um serviço que gerencia posts de texto do tipo tweet — inspirados no Twitter — serão textos de até 140 caracteres que, pra simplificar, não serão atrelados a um autor.

Funcionalidades

A aplicação terá as seguintes funcionalidades implementadas como endpoints REST:

  • Criação de um tweet que será identificado por um uuid v4 gerado pela aplicação
  • Acrescentar like a um tweet cadastrado
  • Listar até 5 tweets com mais likes

Vamos fingir que é caro fazer esse cálculo toda vez que o endpoint é invocado e utilizaremos cache para implementar essa funcionalidade:

  • os top tweets com mais likes serão cacheados no nosso serviço de cache com expiração de 1 minuto
  • caso tenha a entrada de cache, responde com ela, caso contrário calcula so top, cria a entrada de cache com expiração de 1 minuto e responde

Observação: a escolha do MongoDB como banco de dados e de se utilizar cache na implementação da funcionalidade de mais likes foi completamente ilustrativa. Saiba decidir qual tecnologia empregar e qual técnica utilizar de acordo com os requisitos funcionais e não funcionais da aplicação. Especificamente quando se fala de cache, entenda o que pode e quanto pode ser cacheado e saiba muito bem qual deve ser a lógica de invalidação das entradas —expiração temporal é suficiente? Preciso invalidar quando ocorrer algum evento, por exemplo, criação de tweet ou like de tweet?

Arquitetura de software

A aplicação segue alguns conceitos de Clean Architecture e Domain Driven Design. A ideia aqui não é um deep dive nesses padrões, só um overview para conseguirmos nos localizar.

Resumindo, bem resumidamente:

  • Camada de application é responsável por: ser a interface de comunicação com mundo externo que nesse caso é conversar o mundo http; instanciar o use case e injetar suas dependências
  • Camada de use case é responsável por: regras de negócio; não conhece nada do mundo http nem do mundo das dependências de infraestrutura; interage com a infraestrutura através dos adapters
  • Camada de adapters é responsável por: implementar uma interface conhecida pelos use cases; recebe as conexões de infraestrutura como dependências; sabe utilizar as conexões de infraestrutura para implementar a interface
  • Camada de infrastructure é responsável por: saber se conectar e se comunicar com os elementos de infraestrutura externos; exemplos desse conceito são drivers de bancos de dados, SDK oficial da ferramenta, etc

Conectando

Legal, sabemos como nossa aplicação funciona, mas ainda precisamos definir como ela vai conseguir se comunicar com nossas dependências de infraestrutura localmente.

É aqui que veremos o docker-compose brilhar.

Quem está nessa estrada há um tempo já deve ter visto isso sendo feito de várias formas. Irei falar um pouco de algumas abordagens "tradicionais" e suas dificuldades e depois mostrar uma foto do que iremos produzir ao final desse workshop.

  • Dependência compartilhada: vamos ilustrar com bancos de dados porque é mais fácil de visualizar. Nesse caso, temos um banco de dados em algum ambiente não produtivo, devs conectam suas aplicações locais com esse banco compartilhado. Isso traz alguns problemas e desafios: primeiro o próprio banco de dados — precisa-se de alguém ou time pra tomar conta desse banco, quando atualizar, monitorar, reiniciar, decidir se tem backup, etc etc — ; a conexão — precisa-se de algum jeito de estabelecer uma conexão segura e segmentada por usuário a esse banco de dados via vpn, bastion, etc — ; por último o trabalho local de cada dev pode impactar o outro — como é compartilhado, o que um faz todos sentem, mesmo que se faça um banco lógico e user por dev, ainda tem-se o risco de se rodar processos que por exemplo consumam toda CPU, disco, memória, etc. desse único recurso compartilhado. Agora multiplique isso pra cada dependência diferente de cada time, produto, serviço da empresa.
  • Dependência local: consiste de "subir" a dependência local no próprio computador como um processo, ou seja, utilizar a máquina como servidor da dependência (banco de dados por exemplo). Essa abordagem pode não ter um encargo operacional tão grande quanto a anterior, mas sacrifica a conveniência. Para cada dependência de cada produto, serviço, projeto que você participa vai ter que saber como instalar e operar — gerenciar ciclo de vida (subir, derrubar, reiniciar), expor localmente para as aplicações.

Com o uso de containers e docker-compose veremos como conseguimos seguir a idéia da segunda abordagem de rodar tudo local mas com grande conveniência operacional e facilidade.

No final teremos uma topologia assim:

  • teremos duas redes: uma rede virtual rs_ws_env_nw e nossa máquina host localhost
  • nosso banco MongoDB será deployado localmente como um container docker, vai se expor dentro da rede virtual como mongodb:27017 e na rede host como localhost:27017
  • nosso Redis será deployado localmente como um container docker, vai se expor dentro da rede virtual como redis:6379 e na rede host como localhost:6379
  • nossa aplicação será deployada localmente em um container docker, vai se conectar com as dependências através da rede virtual, vai se expor na rede host como localhost:3000
  • nossos testes serão executados na máquina host e se comunicarão com nossas dependências através da rede host — localhost:27017 para MongoDB e localhost:6379 para Redis

Observação: estamos usando essa abordagem de ter uma rede virtual ao mesmo tempo que expomos localmente para mostrar que ambas abordagens são possíveis. Seria possível utilizar somente uma. Em minha opinião, a abordagem de expor as dependências na rede host, executar a aplicação no host — como estamos fazendo para os testes — é a mais otimizada para a experiência de desenvolvimento.

Para fazermos isso, vamos utilizar o docker-compose como ferramenta para orquestrar nossos containers docker, ou seja, gerenciar seus ciclos de vida e conectividade.

Aqui seria possível utilizar direto comandos docker no terminal, mas seria quase tão inconveniente quanto seguir a abordagem tradicional de instalar tudo localmente.

2 . Adicionando o banco de dados

Vamos começar adicionando o MongoDB localmente. Como dito anteriormente, para fazermos isso utilizaremos o docker-compose.

O docker-compose "executa" um arquivo de configuração escrito em YAML, nele definimos as dependências que queremos e suas configurações numa DSL específica e ele se encarrega de criar e executar os containers.

Por padrão, o arquivo é chamado docker-compose.yml, então vamos começar definindo esse arquivo e nosso container de MongoDB:

version: '3' # versão do docker-composeservices:    # seção onde definimos nossos containers  mongodb:              # nome que damos ao container
image: mongo:latest # imagem no dockerhub
ports: # lista de portas para expor
- "27017:27017" # porta <local>:<container>

Agora para executar, no terminal, executamos:

docker-compose up

No terminal agora veremos logs do docker fazendo o pull da imagem do mongo:latest e quando terminar de baixar e executar, teremos o log de inicialização do MongoDB!

Para verificarmos os status dos nossos containers, em outro terminal, podemos executar:

docker-compose ps

O que deve gerar algo parecido com isso, indicando que o container está de pé e está expondo a porta 27017 do container em localhost:

Como estamos expondo o banco localmente, caso você tenha instalado algum client de MongoDB, podemos conectar nele. No meu caso, vou utilizar o client de terminal mongo para conectar e executar o comando db.stats() só pra mostrar que de fato temos um MongoDB de pé:

Agora, se quisermos derrubar nossos containers, podemos executar:

docker-compose down

Após rodar esse comando, se executarmos o comando docker-compose ps novamente, veremos que nenhum container é listado.

Observação: Você deve ter notado que tivemos que utilizar um segundo terminal para executar os outros comandos porque o docker-compose "travou" nosso terminal original. Isso é facilmente resolvido rodando o docker-compose em modo "detached":

docker-compose up -d

E pronto! Subimos um MongoDB localmente com 6 linhas de arquivo yaml!

Além disso, vimos que temos poucos comandos para gerenciar o ciclo de vida de nossos containers. Isso torna muito fácil criar e destruir containers de acordo com nossa necessidade.

Estamos quase prontos pra conseguirmos rodar toda a aplicação localmente, mas ainda falta nosso cache.

3 . Adicionando o serviço de cache

Bom, agora que já adicionamos uma dependência, a próxima vai ser mais fácil, porque o processo vai ser idêntico.

Para adicionar o Redis, vamos editar o docker-compose.yml e adicioná-lo dentro da seção de services:

  redis:
image: redis:alpine # alpine pra mostrar que pode
hostname: redis
ports:
- "6379:6379"

Agora vamos executar e verificar:

docker-compose up -d  # em modo detacheddocker-compose ps

Podemos ver que ambos containers estão executando e expondo as devidas portas

Observação: Você deve ter notado que sempre executamos todos os containers que estão definidos no arquivo. Apesar de geralmente esse ser o comportamento esperado, o docker-compose nos dá a opção de executar os containers que desejarmos passando o nome deles como parâmetro para o up separados por espaço:

# docker-compose up svc1 svc2 svc3# somente redis e detached
docker-compose up -d redis

Como estamos expondo agora também o redis localmente na porta padrão, podemos nos conectar nele. No meu caso, vou utilizar o redis-cli disponível no npm e executar alguns comandos simples de listar as chaves, colocar uma chave e recuperar o valor:

E pronto! Subimos um Redis localmente e foi tão simples de fazer quanto o MongoDB.

Agora que sabemos como subir e expor nossas dependências localmente, vamos conectar nossa aplicação a elas como no desenho de topologia mostrado anteriormente.

4 . Conectando

Pelo nosso desenho, sabemos que teremos duas estratégias diferentes de conexão para os testes de integração e para rodar a aplicação localmente.

Vamos começar pela parte de testes que é a mais simples pois não envolve nenhuma mudança no nosso docker-compose-yml.

4.1 . Testes

Como já conseguimos expor nossas dependências via localhost basta configurarmos nossos testes para conectar nas mesmas.

Por sorte — na real de propósito — nossa aplicação se utiliza de variáveis de ambiente para receber configurações como essas. As variáveis de ambiente que serão expostas durante os testes ficam definidas no arquivo config/environments/test.env. Basta alterarmos esse arquivo colocando os valores corretos. Dessa forma, vamos adicionar ao arquivo as seguintes variáveis:

MONGODB_URL=mongodb://localhost:27017/test
MONGODB_DATABASE=test
MONGODB_POOL_SIZE=1
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=2

Com isso, conseguimos configurar a aplicação para se conectar em localhost nas portas especificadas. Vamos executar os testes e ver se funcionou:

# testes unitários
npm run test:unit
# testes de integração
npm run test:integration
# todos os testes de uma vez
npm test

Ao executarmos os comandos de testes, diferentemente de quando havíamos clonado a aplicação, todos passam!

Excelente! Conseguimos conectar nossos testes às nossas dependências de infraestrutura e fizemos eles passarem.

4.2 . Aplicação

Dada a topologia definida, antes de tudo, precisamos definir nossa rede virtual. O docker-compose permite que façamos isso criando uma seção de networks dentro do docker-compose.yml:

networks:        # seção para definir as redes 
rs_ws_env_nw: # nome da rede
# sem configurações (fora do escopo do workshop)

Legal, agora precisamos colocar nossas dependências dentro dessa rede:

  mongodb:
image: mongo:latest
hostname: mongodb
ports:
- "27017:27017"
networks:
- rs_ws_env_nw
redis:
image: redis:alpine
hostname: redis
ports:
- "6379:6379"
networks:
- rs_ws_env_nw

Agora precisamos definir o container da nossa aplicação. Existem múltiplos jeitos de fazer isso, a abordagem que vamos seguir é uma que facilita o desenvolvimento local: permitindo que haja hot reload da aplicação — , ou seja, alterar arquivos faz a aplicação reiniciar — sem precisar executar o build uma imagem Docker a cada mudança; permitindo o debug da aplicação:

api:
image: node:fermium-alpine
# imagem base
command: npm run start:dev
# iniciar em modo debug com reload

ports:
- "9229:9229"
# porta do inspector (debugger) do node
- "3000:3000"
# porta do servidor http

# ** aqui que a mágica acontece pra permitir o hot reload **
volumes:
- ./:/app
# montamos um volume /app dentro do container
# esse volume é uma cópia do diretório do código

working_dir: /app
# working dir = volume montado
# docker-compose up api -> inicia as dependências listadas também
depends_on:
- mongodb
- redis
restart: "on-failure"
# reinicia a aplicação se ela quebrar

networks:
- rs_ws_env_nw

Estamos quase lá. Agora precisamos fazer a aplicação se conectar às dependências através da rede virtual. Felizmente podemos utilizar variáveis de ambiente. Assim como para os testes, para ambiente de desenvolvimento, temos o arquivo de variáveis config/environments/dev.env. Vamos alterá-lo:

MONGODB_URL=mongodb://mongodb:27017/tweetdb
MONGODB_DATABASE=tweetdb
MONGODB_POOL_SIZE=1
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0

Note que diferentemente dos testes, nós utilizamos o nome do service como está definido no docker-compose.yml ao invés de localhost!

Uffa! Vamos ver se tudo funciona.

docker-compose down # só pra começar do zerodocker-compose up api

Se tudo deu certo, veremos no terminal parecido com

Legal! Parece que temos nossa aplicação de pé na porta 3000 e como a gente expos ela em localhost, estamos aptos a fazer requests localmente utilizando nosso client http favorito!

Encorajo vocês a darem uma brincada, criar uns tweets, dar uns likes, listar os top com mais likes ao mesmo tempo que verifica o banco de dados e o cache para ver que de fato tudo funciona como o esperado.

Observação:

Como dito acima, essa é uma das abordagens para ter uma aplicação rodando localmente. Nessa abordagem precisamos utilizar a técnica de copiar o volume e depende que devs tenham o ambiente configurado com a tecnologia em questão — no caso, Node.JS — o que, no caso de devs da aplicação em questão é esperado.

Mas se já precisa disso, porque executarmos dentro de um container com rede virtual e não simplesmente rodar diretamente em localhost conectando nos outros services assim como fizemos com os testes? De fato, para esse caso que mostramos aqui não é necessário e foi inteiramente só pra mostrar que era possível, no entanto, há um cenário que vejo onde pode ser útil: caso não quiséssemos expor as dependências via localhost — para evitar clash de portas com outras por exemplo, o motivo não importa— com essa abordagem isso seria possível, basta parar de expor as dependências em localhost.

Outra abordagem possível seria executar o build de uma imagem docker de nossa aplicação. Essa abordagem tem a vantagem de permitir que a máquina host não necessite de ferramentas instaladas relacionadas a linguagem/plataforma. No entanto, para dia-a-dia de desenvolvimento da aplicação em questão não faz muito sentido, deixaria tudo muito lento. Então onde isso é útil? Consigo ver dois cenários: testes de contratação onde se precisa fazer uma aplicação — o avaliador consegue rodar a aplicação muito facilmente e em qualquer sistema operacional que permita o uso do docker — e permitir que outros times consigam executar sua aplicação localmente, para testes de integração ou manuais por exemplo, sem precisar instalar toda a stack de ferramentas necessária.

5 . Pipeline de Continuous Integration

Vamos agora criar um pipeline de CI utilizando o Github Actions que é gratuito — até certo ponto — e em nuvem. O Github Actions tem uma DSL em YAML para definir pipelines de CI.

Para nosso caso, queremos um pipeline que faz o seguinte:

  • Deve executar a cada push em qualquer branch
  • Utiliza um ambiente Linux com Docker
  • Utiliza um ambiente com Node.JS versão 14
  • Instala as dependências da aplicação via npm
  • Faz check de lint
  • Executa os testes automatizados

Bom, sabemos que para os testes funcionarem, precisamos conseguir nos conectar com nossas dependências de infraestrutura. Como faremos isso? Containers é claro!

Para conseguirmos utilizar o Github Actions, precisamos definir nossos arquivos de configuração em .github/workflows. No nosso caso, vamos criar o arquivo .github/workflows/check.yml.

Veja como ficam nossas etapas do pipeline — estou omitindo o início do arquivo para nos concentrarmos somente nas partes "importantes" ao conteúdo, mas o arquivo completo está disponível no repositório base:

# Etapas do pipeline
steps
:

# ... omitindo parte de clone e versão de node ...
- run: npm ci
name: Install
- run: npm run lint
name: Lint
- run: docker-compose up -d mongodb redis # cria infra
name: Start infrastructure
- run: npm run test
name: Tests
- run: docker-compose down # derruba infra
name: Dispose infrastructure

Olha que legal, nosso pipeline executa exatamente a mesma coisa que executamos local para rodar os testes! Levantamos nossa infra utilizando o docker-compose, executamos os testes, derrubamos a infra também utilizando o docker-compose.

Agora, quando dermos push em nossos branches, o Github Actions vai executar nosso pipeline. Vamos ver como fica — na aba Actions do repositório no Github:

Ótimo! Agora temos um pipeline de Continuous Integration para garantir a qualidade de nossa aplicação e defini-lo foi tão fácil quanto copiar o que fazemos localmente!

Conclusão

Chegamos ao fim de nosso workshop que lidou com ferramentas e técnicas para utilização de containers para criar um ambiente de desenvolvimento local para aplicações que dependem de elementos de infraestrutura externos.

Vamos recapitular o que vimos e fizemos até agora:

  • Utilizar o docker-compose para orquestrar containers de dependências externas
  • Diferentes abordagens de como se conectar uma aplicação e/ou testes aos containers de dependências
  • Aplicar essas técnicas para criar um pipeline de continuous integration com testes de integração

Espero que com esse workshop vocês tenham adquirido uma nova ferramenta para seus cintos de utilidade que facilite o desenvolvimento local e execução de testes de integração.

Pós workshop

Repetindo da seção "pós workshop" do repositório base:

Código produzido

Todo código produzido durante o workshop está disponível no branch cheat-sheet do repositório base.

Lição de casa

Disclaimer: originalmente o workshop tinha sido feito para que construíssemos features da aplicação ao mesmo tempo que integrássemos com containers.
Primeiro implementaríamos as features de criação e adicionar likes e finalmente a de top com mais likes junto com seus respectivos testes.
TL;DR: originalmente, produziríamos código de aplicação no workshop também.

Caso você queira mexer um pouco na aplicação, você pode implementar os seguintes exercícios:

1 . Criar testes que estão faltando

1.1 . Unitários para os componentes da funcionalidade adicionar likes

1.2 . De integração para a funcionalidade de adicionar likes

1.3 . De integração para a funcionalidade de top tweets com mais likes

2 . No pipeline de CI, testar com check de cobertura de código

3 . Criar Makefile para agrupar tarefas utilizando make

3.1 . Task test: cria infraestrutura + executa testes com check de cobertura + derruba a infraestrutura

3.2 . Task run: cria infraestrutura + inicia a aplicação

3.3 . Modificar o pipeline de CI para utilizar a task test

Referências e comentários

  • Apesar de termos utilizado containers para tudo, o workshop em nenhum momento se propôs a explicá-los. Recomendo muito a leitura da documentação oficial para saber de fato o que são e a tecnologia por trás.
  • Além disso, a parte de networking foi explicada no modo “vamos acreditar que funciona” e foram utilizadas abordagens específicas. Recomendo a leitura também da documentação oficial, tanto para entender os detalhes quanto para entender todo o leque de opções desse assunto vasto.
  • Recomendo também a leitura do REAME do repositório base e navegar pelo código fonte
  • Tive a oportunidade de dar outro workshop no evento e criei um artigo para ele também: Programação Functional — Currying e composição de funções na prática
  • Gostaria de agradecer a minha grande amiga Thaíssa Candella por ter me convidado a participar do evento.
  • Gostaria de agradecer também o André Kanayama meu amigo e colega de Zé Delivery por ter participado dessa saga comigo e ter produzido conteúdos de altíssima qualidade: Entendendo a autenticação com JWT e Criando uma API REST escalável usando Serverless, API Gateway e DynamoDB — recomendo a todos.
  • Gostaria de agradecer também o Paulo Duarte — que também produziu workshop sensacional sobre CI/CD de aplicação python usando Github Actions — e Adrian Shiokawa por todo apoio, por assistirem os workshops e por ficarem causando com a gente durante.
  • Gostaria também de agradecer a todos do Zé Delivery que nos apoiaram torcendo por nós e dando o suporte necessário para que os workshops fossem possíveis.

Muito obrigado por ter participado do workshop!

--

--