Dockerizando uma aplicação Ruby on Rails

Este vai ser um tutorial bem mão na massa, então não vamos nos aprofundar nas ferramentas do Docker e Docker Compose em si, mas sim em como utilizá-las para criar um ambiente de desenvolvimento focado em aplicações Ruby on Rails. Para isso você vai precisar instalar o Docker e o…

Este vai ser um tutorial bem mão na massa, então não vamos nos aprofundar nas ferramentas do Docker e Docker Compose em si, mas sim em como utilizá-las para criar um ambiente de desenvolvimento focado em aplicações Ruby on Rails. Para isso você vai precisar instalar o Docker e o Docker Compose de acordo com o seu sistema operacional. Eu Recomendo que você também siga o pós-instalação do Docker para configurar algumas permissões, caso contrário você irá precisar executar os comandos com o sudo todas as vezes.

O vídeo What is a Container Image?, disponível no YouTube, traz uma breve explicação sobre alguns “objetos” Docker tais como imagens, contêineres, instruções e comandos em um Dockerfile, etc. O guia Get started da página oficial do Docker também é uma excelente maneira de obter uma visão geral e também reforçar alguns fundamentos importantes, então sugiro fortemente dar uma olhada nesse material se o Docker é algo completamente novo para você.

Um novo projeto

Vamos começar executando um contêiner gerado a partir da imagem do Ruby 3.2 para criar o esqueleto da nossa aplicação. Você pode conferir todas as versões de imagens disponíveis na página oficial da linguagem no Docker Hub. Em seguida iremos “automatizar” a execução de certos comandos e instruções através de um arquivo chamado Dockerfile para nos ajudar a não precisar escrever comandos gigantescos através do terminal.

Primeiro vamos criar um diretório com o nome que queremos dar a aplicação (aqui vou chamar de docker_rails) e, a partir do terminal, navegamos para dentro dele para executar alguns comandos do Docker.

$ mkdir docker_rails && cd docker_rails

# volume para persistir dados entre as reinicializações do contêiner
$ docker volume create docker_rails_bundle_cache

# iniciamos a execução do contêiner
$ docker run --rm -it --name ruby_container --mount type=bind,source="$(pwd)",target=/usr/src/app --mount type=volume,source=docker_rails_bundle_cache,target=/usr/local/bundle -w /usr/src/app ruby:3.2-alpine ash
  • run: comando responsável por iniciar um contêiner a partir da imagem especificada
  • rm: indica que o contêiner será automaticamente removido após a sua execução ser encerrada
  • it: permite anexar o nosso terminal ao terminal dentro do contêiner para executar comandos nele
  • name: indica o nome que daremos ao contêiner em execução, dessa forma podemos manipulá-lo com mais facilidade
  • mount: utilizado para descrever volumes e fazer o mapeamento entre os diretórios da nossa máquina local e os diretórios dentro do contêiner. Também permite armazenar dados que não se perderão quando a execução do contêiner for encerrada. É preciso também especificar as seguintes opções:
    • type: tipo do volume. bind geralmente é utilizado para fazer o mapeamento e volume para persistir dados que fazem sentido existirem apenas dentro do contêiner, como por exemplo as gems que serão instaladas com o comando bundle install
    • source: fonte do volume, podendo ser um diretório ou um volume criado previamente
    • target: diretório do contêiner que servirá como alvo do volume
  • w: working directory, é o diretório onde nós entraremos logo que o nosso terminal for anexado ao contêiner
  • ruby:3.2-alpine: noma da imagem (será baixada do docker hub caso ainda não exista na máquina local)
  • ash: equivalente ao bash para o Alpine Linux

Caso você ainda não tenha a imagem do Ruby 3.2 na sua máquina local, ela será baixada e logo em seguida o nosso terminal será anexado ao terminal do próprio contêiner, dessa forma conseguiremos rodar comandos para instalar a Gem do Rails e começar o setup da nossa aplicação.

# executando em ruby_container

# note que estamos no diretório que indicamos através da flag -w
$ pwd

# para checar as informações do ambiente
$ gem environment

# instalando as dependências necessárias do Rails
$ apk --update add build-base git musl-dev nodejs npm postgresql-dev tzdata

# hora de instalar o rails
$ gem install rails

# verificando a instalação
$ rails --version

Agora poderemos fazer a instalação limpa de uma nova aplicação Rails. Ainda no terminal anexado ao contêiner, vamos criar um novo projeto Rails. Aqui você já pode criar à sua maneira. Na aplicação que vamos usar como exemplo, optamos pelo Vite ao invés do asset pipeline comum do Rails e Postgresql como banco de dados.

# executando em ruby_container

# d  para especificar o banco de dados que pretendemos utilizar
# B  ignora a execução do comando bundle install
# T  ignora o gerador de arquivos de teste padrão do Rails (usaremos o RSpec)
# A  ignora o setup do asset pipeline
# J  ignora os arquivos javascripts gerados por padrão
$ rails new . -d postgresql -BTAJ

Você verá que todos os arquivos gerados pelo Rails para a nossa aplicação irão aparecer no diretório que criamos anteriormente graças ao bind mount que definimos ao executar o contêiner. Nesse momento já podemos alterar o nosso Gemfile a vontade para remover/incluir as gems que queremos usar. Para este exemplo, o Gemfile que utilizamos é semelhante ao seguinte:

source "https://rubygems.org"

ruby "3.2.2"

gem "rails", "~> 7.1.1"
gem "pg", "~> 1.1"
gem "puma", ">= 5.0"
gem "hiredis"
gem "redis"
gem "sidekiq"
gem "vite_rails"
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "bootsnap",    require: false

group :development, :test do
  gem "debug", platforms: %i[ mri windows ]
  gem "rspec-rails"
  gem "rubocop",                    require: false
  gem "rubocop-performance",        require: false
  gem "rubocop-rails", "~> 2.19.0", require: false
  gem "rubocop-rspec",              require: false
  gem "standard",                   require: false
  gem "standard-rails",             require: false
end

group :development do
  gem "web-console"
  gem "rack-mini-profiler"
end

Vamos continuar com o setup da nossa aplicação:

# executando em ruby_container

# instala as depedências da nossa aplicação
$ bundle install

# configura o Vite para uso com o app Rails
$ bundle exec vite install

# podemos sair do terminal anexado
# graças a flag --rm que passamos para o comando docker run
# o contêiner terá sua execução encerrada automaticamente
$ exit

Criando nosso Dockerfile

O Dockerfile é o arquivo que o Docker utilizará para criar a imagem base da nossa aplicação. Agora que já temos o esqueleto do projeto, ele vai nos ajudar a evitar executar comandos gigantescos do Docker sempre que precisarmos alterar algo dentro da imagem da nossa aplicação.

Para manter as coisas organizadas, vamos criar um diretório chamado docker na raiz do projeto e dentro dele criaremos um arquivo chamado Dockerfile.dev e outro arquivo chamado docker-entrypoint.sh que será o entrypoint da nossa imagem.

$ mkdir docker
$ touch docker/Dockerfile.dev docker/docker-entrypoint.sh

Vamos começar editando o arquivo docker/docker-entrypoint.sh. Ele será executado sempre que o nosso contêiner for iniciado.

#!/bin/sh
set -e

# Remove algum server.pid pré-existente para Rails
rm -f /usr/src/app/tmp/pids/server.pid

# Então executa o processo principal do contêiner (definido como CMD no Dockerfile).
exec "$@"

Agora vamos editar o arquivo docker/Dockerfile.dev com o seguinte conteúdo:

# Especificamos a imagem base e definimos uma variável de ambiente
# que ficará disponível dentro do contêiner gerado a partir desta imagem
FROM ruby:3.2-alpine AS builder
ENV BUNDLE_PATH="/usr/local/bundle"

# Instalamos os pacotes necessários para rodar a aplicação
# apk add é a forma que instalamos pacotes no Alpine Linux
RUN apk add --update --no-cache \
    build-base \
    git \
    musl-dev \
    nodejs \
    npm \
    postgresql-client \
    postgresql-dev \
    tmux \
    tzdata

# Essa instrução vai baixar o Overmind direto do Github e copia-lo para dentro
# do diretório /usr/local/bin. O Overmind é uma execelente alternativa ao Foreman
# pois nos permite usar comandos como o byebug ou binding.pry dentro da nossa
# aplicação sem maiores problemas. Vale a pena dar uma conferida nele:
#   https://github.com/DarthSim/overmind
RUN wget -O /tmp/overmind.gz \
    https://github.com/DarthSim/overmind/releases/download/v2.4.0/overmind-v2.4.0-linux-amd64.gz \
    && gzip -d /tmp/overmind.gz \
    && cp /tmp/overmind /usr/local/bin

# Define o diretório padrão de tal forma que todos os comandos a seguir
# irão ser executados a partir dele.
WORKDIR /usr/src/app

# Instrui os arquivos que deverão ser copiados da nossa máquina para
# dentro da imagem.
COPY ["Gemfile", "Gemfile.lock", "package.json", "package-lock.json", "./"]

# Aqui garantimos que a imagem vai ter todas as dependências necessárias
# para a nossa aplicação Rails
RUN bundle check || bundle install --jobs $(nproc) --retry 2
RUN npm install

# E aqui copiamos todo o código que está na nossa máquina para dentro da imagem.
COPY . .
COPY ["./docker/docker-entrypoint.sh", "/usr/local/bin"]

# Vamos tornar o overmind e docker-entrypoint.sh executáveis
RUN chmod +x /usr/local/bin/overmind /usr/local/bin/docker-entrypoint.sh

# Definimos o entrypoint da nossa imagem
ENTRYPOINT ["docker-entrypoint.sh"]

# Indicamos as portas que queremos disponibilizar para acesso fora do contêiner
# além de definir o comando padrão que será executado caso não seja passado
# nenhum quando iniciarmos a execução do contêiner
EXPOSE 3000 3036 3037
CMD ["bin/rails", "server", "--port", "3000", "--binding", "0.0.0.0"]

Gerenciando serviços com o Docker Compose

Agora que temos o nosso Dockerfile devidamente escrito, vamos configurar a nossa aplicação para usar o Compose. Ele é uma ferramenta para definir e executar aplicações Docker a partir de vários contêineres. Com o Compose, você usa um arquivo YAML para configurar os serviços da sua aplicação e, com um único comando, você é capaz de criar e iniciar todos os serviços da sua configuração.

Vamos configurar três serviços: app, postgres e redis. Começamos criando o arquivo docker-compose.yml na raiz do nosso projeto com o seguinte conteúdo:

services:
  app:
    build:
      context: ./
      dockerfile: ./docker/Dockerfile.dev
    image: docker_rails
    container_name: docker_rails_app
		# substitui o comando padrão declarado pelo CMD do Dockerfile
    command: ash -c "rm -f tmp/pids/server.pid && overmind start"
    ports:
      - '3000:3000'
      - '3036:3036'
      - '3037:3037'
    working_dir: /usr/src/app
    volumes:
      - .:/usr/src/app
      - docker_rails_bundle_cache:/usr/local/bundle:delegated
    env_file: .env
		# indicamos que app depende de outros dois contêineres
    depends_on:
      - postgres
      - redis
    stdin_open: true

  postgres:
    image: postgres:alpine
    container_name: postgres_db
    restart: on-failure
    volumes:
      - docker_rails_postgres_data:/var/lib/postgresql/data:delegated
    env_file: .env
    # variáveis que terão seu valor alterado no momento em que
    # o serviço iniciar. Note que DATABASE_USER e DATABASE_PASSWORD
    # são variáveis definidas dentro do arquivo .env
    environment:
      POSTGRES_USER: ${DATABASE_USER}
      POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
    mem_limit: 512m

  redis:
    image: redis:alpine
    container_name: redis_server
    command: redis-server
    restart: on-failure
    ports:
      - '6379:6379'
    volumes:
      - 'docker_rails_redis_data:/data:delegated'
    mem_limit: 128m

# aqui precisamos listar todos os volumes utilizados individualmente
volumes:
  docker_rails_bundle_cache:
  docker_rails_postgres_data:
  docker_rails_redis_data:

Considerações sobre algumas configurações:

  • image nome da imagem base para o serviço. No caso de app, a imagem será construída a partir do Dockerfile.dev que especificamos anteriormente e será nomeada com docker_rails enquanto os demais serviços utilizarão imagens “prontas” que serão baixadas do dockerhub. Nós não precisamos definir um Dockerfile nem para o Postgres nem para o Redis.
  • container_name nome que será dado ao contêiner com o serviço em execução ports permite que façamos o mapeamento entre as portas disponíveis no serviço executando dentro do container Docker de tal forma que conseguimos acessá-las a partir da nossa máquina local.
  • volumes como descrito bem no início quando ainda estávamos criando o esqueleto da nossa aplicação, nos permite mapear diretórios inteiros entre a nossa máquina local e o contêiner Docker em execução além de poder ser utilizado para persistir.
  • env_file usado para indicar o caminho de um arquivo de configuração contendo variáveis de ambiente para serem utilizadas pelo serviço. Você pode utilizar a configuração environment em conjunto para definir ou alterar valores de outras variáveis.
  • ports usada para expor/mapear as portas do contêiner.

A referência completa para a configuração de serviços você encontra em https://docs.docker.com/compose/compose-file/05-services/

Agora que temos o nosso docker-compose.yml devidamente estruturado, vamos criar um arquivo chamado .env dentro da raiz do nosso projeto contendo as seguintes declarações de variáveis de ambiente:

RAILS_ENV="development"
# postegres
DATABASE_USER="postgres"
DATABASE_PASSWORD="secret"
DATABASE_HOST="postgres"
DATABASE_PORT="5432"
# redis
REDIS_URL="redis://redis:6379/1"
REDIS_HOST="redis"
# sidekiq
SIDEKIQ_USERNAME="admin"
SIDEKIQ_PASSWORD="admin"
# overmind env
OVERMIND_TITLE="DockerRails"
OVERMIND_PROCFILE="Procfile.dev"
OVERMIND_SOCKET="0.0.0.0:3004"
OVERMIND_NETWORK="tcp"

Todas essas variáveis serão passadas para os nossos serviços já que definimos a opção env_file com o caminho apontando para .env. Vale ressaltar que os valores das variáveis DATABASE_HOST e REDIS_HOST correspondem aos nomes dos serviços definidos no docker-compose.yml para o Postgres e Redis respectivamente. Você deve lembrar de atualizar tais valores caso decida mudar o nome dos serviços mais tarde.

Conectando ao banco de dados

Vamos agora alterar o conteúdo do arquivo config/database.yml para configurar a conexão com o Postgres utilizando as variáveis de ambiente que definimos:

default: &default
  adapter: postgresql
  encoding: unicode
  host: <%= ENV["DATABASE_HOST"] %>
  port: <%= ENV["DATABASE_PORT"] %>
  username: <%= ENV["DATABASE_USER"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

# ...

Configurando o Sidekiq

O Sidekiq vai automaticamente utilizar as variáveis de ambiente REDIS_URL e REDIS_HOST para se conectar ao Redis. Vamos configurar apenas uma autenticação básica para termos acesso ao monitor do Sidekiq Web. Vamos criar o arquivo sidekiq.rb dentro de config/initializers contendo o seguinte código:

require 'sidekiq/web'

Sidekiq::Web.use Rack::Auth::Basic do |username, password|
  ActiveSupport::SecurityUtils.secure_compare(
    ::Digest::SHA256.hexdigest(username),
    ::Digest::SHA256.hexdigest(ENV["SIDEKIQ_USERNAME"])
  ) & ActiveSupport::SecurityUtils.secure_compare(
    ::Digest::SHA256.hexdigest(password),
    ::Digest::SHA256.hexdigest(ENV["SIDEKIQ_PASSWORD"])
  )
end

Note que para a autenticação ser bem sucedida você deve entrar com o login e senha correspondendo aos valores definidos no arquivo .env para SIDEKIQ_USERNAME e SIDEKIQ_PASSWORD respectivamente. Basta agora adicionar a seguinte linha de código dentro do arquivo config/routes.rb para acessar o Sidekiq Web a partir da aplicação:

Rails.application.routes.draw do
	# acessamos via localhost:3000/sidekiq
  mount Sidekiq::Web => '/sidekiq'
	
	# ...
end

Finalizando os ajustes

Anteriormente enquanto estávamos criando a nossa aplicação, executamos o comando bundle exec vite install para usar o Vite com o Rails. Esse comando cria para nós 3 arquivos: Procfile.dev, vite.config.ts e config/vite.json. Nós precisamos fazer ainda alguns ajustes para não termos qualquer problema entre o Vite e o Docker. Vamos começar editando o arquivo config/vite.json para indicar o host nos ambientes “development” e “test”:

{
  "all": {
    "sourceCodeDir": "app/frontend",
    "watchAdditionalPaths": []
  },
  "development": {
    "autoBuild": true,
    "host": "0.0.0.0",
    "publicOutputDir": "vite-dev",
    "port": 3036
  },
  "test": {
    "autoBuild": true,
    "host": "0.0.0.0",
    "publicOutputDir": "vite-test",
    "port": 3037
  }
}

Em seguida vamos editar o arquivo vite.config.ts na raiz do projeto e adicionar a configuração server para podermos aproveitar os benefícios do hot module replacement do Vite:

// ...

server: {
  host: true,
  strictPort: true,
  hmr: { host: 'localhost', protocol: 'ws' },
},

Por último, vamos editar o arquivo Procfile.dev para executar também o Sidekiq. O Overmind vai ler o seu conteúdo para iniciar os processos descritos nele.

vite: bin/vite dev
web: bin/rails server --port 3000 --binding 0.0.0.0
worker: bundle exec sidekiq

Executando a aplicação

Com todos esses ajustes feitos até agora, já está mais do que na hora de colocarmos tudo isso para funcionar, não é mesmo? Vamos gerar a imagem da nossa aplicação e deixar que o Docker Compose faça o resto para nós!

# certifque-se de que o seu terminal está na raiz do projeto

# vamos gerar a imagem da nossa aplicação
$ docker-compose build

# (opcional) caso queira listar todas as imagens presentes
$ docker images

# finalmente, vamos rodar a aplicação
# usamos -d para executar em modo detached, dessa forma ficamos com o terminal
# livre para executar mais comandos
$ docker-compose up -d

# vamos dar uma olhada nos nossos queridos contêineres 
$ docker-compose ps
# Se tudo tiver ocorrido como o esperado, a coluna state terá todos
# os valores definidos para "up"

Acesse o endereço http://localhost:3000/ e perceba que a aplicação está de fato funcionando, só que não! O Rails retornará um erro apontando que o banco de dados da aplicação ainda não foi criado. Essa é uma ótima oportunidade para mostrar como nós podemos executar comandos dentro de algum contêiner sem precisar entrar num terminal anexado.

Mais uma vez, o Docker Compose vai facilitar a nossa vida. Podemos executar comandos em contêineres que estão em execução com o comando docker-compose exec. Passamos para ele o nome do serviço no qual iremos rodar o comando e logo em seguida passamos o comando em si:

# note que app é o nome do serviço descrito no docker-compose.yml
$ docker-compose exec app bin/rails db:create

Agora sim! Ao visitar http://localhost:3000/ você deve conseguir visualizar as boas vindas do Rails. Navegue também para http://localhost:3000/sidekiq e entre com as mesmas credenciais definidas no arquivo .env para acessar o monitor do Sidekiq.

Quando quiser parar a execução da aplicação basta rodar o comando docker-compose down e todos os serviços serão pausados. Basta executar docker-compose up novamente para rodar a aplicação.

Mantenha a imagem atualizada!

Sempre que você fizer modificações no Dockerfile ou até mesmo no Gemfile do projeto, lembre-se de reconstruir a imagem docker com o comando docker-compose build para garantir que ela irá conter todas as dependências necessárias para executar a aplicação.

Referências

0 Comentário