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 especificadarm
: indica que o contêiner será automaticamente removido após a sua execução ser encerradait
: permite anexar o nosso terminal ao terminal dentro do contêiner para executar comandos nelename
: indica o nome que daremos ao contêiner em execução, dessa forma podemos manipulá-lo com mais facilidademount
: 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 evolume
para persistir dados que fazem sentido existirem apenas dentro do contêiner, como por exemplo as gems que serão instaladas com o comandobundle install
source
: fonte do volume, podendo ser um diretório ou um volume criado previamentetarget
: 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êinerruby: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
Obs.: A tag alpine
é utilizada para indicar que a nossa imagem é baseada na versão “Dockerizada” do Alpine Linux, uma distribuição Linux conhecida por ser excepcionalmente leve e segura e as imagens derivadas tendem a ter um tamanho significativamente reduzido em comparação às imagens tradicionais.
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
Obs.: Caso tenha problemas com permissão para editar os arquivos, abra uma nova aba do seu terminal e navegue até o diretório da aplicação (certifique-se de que irá rodar no terminal da sua própria máquina e não no terminal anexado ao contêiner), então execute o seguinte comando: sudo chown -R $USER:$USER .
Isso sempre é necessário para nos dar permissão aos arquivos gerados a partir do contêiner em execução.
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çãoenvironment
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.