Como criar uma aplicação rails com docker

Neste post vamos aprender como criar uma aplicação Ruby on Rails com Docker.
Nossa ideia é separar nossa aplicação em dois containers, sendo um com a aplicação rails e o outro com o banco de dados (postgres).
Obs.: Neste post não vamos nos aprofundar muito em conceitos sobre o Docker, então assumo que você já tem algum conhecimento básico sobre.

Requisitos:
  • Docker
  • Docker compose

Passo 1:
Criar o diretório do nosso projeto, que no nosso caso eu vou chamar de "ruby_on_whales":

 mkdir ruby_on_whales

Em seguida, devemos acessar o diretório criado:

 cd ruby_on_whales

A partir daqui, sugiro abrir o diretório do projeto no VScode ou em algum editor de sua preferência.

Passo 2:
Criar o Dockerfile com o seguinte conteúdo:

 FROM ruby:3.2.2
 WORKDIR /app
 RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
 RUN apt-get update -y
 RUN apt-get install -y nodejs
 RUN npm install -g yarn

O Dockerfile é quem vai montar a imagem do Ruby (nesse caso, a versão 3.2.2). Além disso, estamos definindo "/app" como diretório padrão onde vai ficar nossa aplicação dentro do container. Em seguida instalamos o NodeJS e o Yarn.

Passo 3:
Criar o .env com o seguinte conteúdo:

POSTGRES_USER=app_user
POSTGRES_PASSWORD=123456
POSTGRES_HOST=postgres

O que estamos fazendo aqui é criar um arquivo com as variáveis de ambiente, que serão acessadas pelo container com a nossa aplicação rails e o outro container com o banco de dados (postgres). Fique a vontade para alterar o usuário e senha.

Passo 4:
Criar o arquivo docker-compose.yml com o seguinte conteúdo:

services:
  postgres:
    image: postgres:15.2
    container_name: postgres
    restart: always
    environment:
      TZ: America/Sao_Paulo
    env_file:
      - .env
    volumes:
      - database:/var/lib/postgresql/data

  web:
    build: .
    working_dir: /app
    container_name: web
    ports:
      - 3000:3000
    command: bash -c "rm -f tmp/pids/server.pid && bin/setup && rails s -b 0.0.0.0"
    volumes:
      - .:/app
      - rubygems:/usr/local/bundle
    env_file:
      - .env
    depends_on:
      postgres:
        condition: service_started

volumes:
  database:
  rubygems:

E aqui vamos por partes para entender o que está acontecendo.

Na linha 1, seguimos o padrão de um "docker-compose.yml" e escrevemos o "services:", indicando onde inicia o bloco com os containers. Como estamos lidando com um arquivo ".yml", observem a indentação para identificar melhor o início e fim de cada bloco de código:

services:

Na linha 2, determinamos que será criado um container chamado "postgres":

postgres:

Na linha 3, estamos especificando que o nome desse container será apenas "postgres". Caso contrário, o nome dele será composto por <"Nome da imagem" ou "Nome do diretório da aplicação"> + <"Nome do service" (configurado na linha 2)> + <Número sequencial de containers iguais rodando>. Ou seja, o nome seria "postgres:15.2-1" e nós queremos manter apenas o nome "postgres":

image: postgres:15.2

Na linha 4, estamos dizendo que esse container será montado a partir de uma imagem do postgres na versão 15.2:

container_name: postgres

Na linha 5, estamos configurando o container para, caso ele caia por instabilidade ou algum outro motivo, ele deve ser iniciado novamente:

restart: always

Nas linhas 6 e 7, estamos dizendo que este container utilizará por padrão o time zone de São Paulo:

environment:
  TZ: America/Sao_Paulo

Nas linhas 8 e 9, estamos informando que as variáveis de ambiente podem ser encontradas no arquivo ".env" que criamos anteriormente:

env_file:
  - .env

Esse arquivo pode ser criado com qualquer nome. Então, por exemplo: Poderíamos ter criado o "banana.env" em vez de ".env". Nesse caso poderíamos indicar o arquivo da seguinte forma:

 env_file:
   - banana.env

Nas linhas 10 e 11, estamos configurando o volume desse container. O volume se chama "database" e ele estará apontando para o diretório "/var/lib/postgresql/data" dentro do container. Dessa forma, não perderemos os dados da aplicação mesmo que esses containers sejam parados por qualquer motivo:

volumes:
  - database:/var/lib/postgresql/data

Na linha 13, começamos a configurar o segundo container, que no nosso caso se chama "web", onde ficará nossa aplicação feita com Ruby on Rails:

web:

Na linha 14, utilizamos o "build" com um "." para determinar que este container deve utilizar a imagem configurada no Dockerfile que criamos no "Passo 2". Passamos o "." como valor do "build" porque o Docker vai entender que deve procurar o Dockerfile no mesmo diretório que o docker-compose.yml está:

build: .

Poderíamos alterar ele para procurar um arquivo chamado "Dockerfile_bananinha", por exemplo. Para isso adicionaríamos o "context" e o "dockerfile" ao build. Onde o "context" deve conter o diretório e o "dockerfile" deve conter o nome do arquivo, no nosso caso "Dockerfile_bananinha". Ficaria da seguinte forma:

 build:
   context: .
   dockerfile: ./Dockerfile_bananinha

Na linha 15, utilizamos o "working_dir" para determinar que nossa aplicação dentro desse container, ficará no diretório "/app":

working_dir: /app

Na linha 16, estamos especificando que o nome desse container será apenas "web". Caso contrário, o nome dele será composto por <"Nome da imagem" ou "Nome do diretório da aplicação"> + <"Nome do service" (configurado na linha 2)> + <Número sequencial de containers iguais rodando>. Ou seja, o nome seria "ruby_on_whales_web-1" e nós queremos manter apenas o nome "web":

container_name: web

Nas linhas 17 e 18, estamos dizendo que esse container permitirá o acesso externo e interno na porta 3000. Sendo assim, como o rails por padrão roda o servidor na porta 3000, o container estará "ouvindo" nossa aplicação e disponibilizando ela também na porta 3000 no lado externo do container. Dessa forma, poderemos acessar nossa aplicação pelo nosso navegador, fora do container, pela porta 3000 (http://localhost:3000):

ports:
  - 3000:3000

Na linha 19, estamos executando alguns comandos já conhecidos por quem utiliza o Rails. Esses comandos serão executados sempre que iniciarmos este container. Os comandos tem os seguintes objetivos:
Como prevenção, gosto de manter esse comando para deletar o "server.pid" que por qualquer motivo pode travar nossa aplicação impedindo que a gente consiga rodar o servidor novamente:

rm -f tmp/pids/server.pid

O "bin/setup" vai rodar o create, migrate e seed da aplicação. Então se o banco de dados não estiver criado, é nesse momento que vamos rodar a criação do banco, tabelas e também vamos popular ele:

bin/setup

E por último vamos rodar nossa aplicação, assim como faríamos sem utilizar o docker, porém, fazendo o bind no localhost (0.0.0.0):

rails s -b 0.0.0.0

Obs.: Para concatenar os comandos utilizamos o "&&" e para rodar esse comando no terminal dentro do container colocamos tudo dentro de aspas utilizando o "bash -c", deixando ele conforme mostrado no exemplo:

command: bash -c "rm -f tmp/pids/server.pid && bin/setup && rails s -b 0.0.0.0"

Nas linhas 20, 21 e 22, estamos configurando os dois volumes desse container:

volumes:
      - .:/app
      - rubygems:/usr/local/bundle

Onde, na linha 19 informamos que tudo que estiver no mesmo diretório do "docker-compose.yml" deverá ser espelhado e aparecer igual dentro do container no diretório "/app".
Dessa forma, qualquer alteração que façamos no projeto utilizando nosso editor de código, fora do container, terá efeito instantâneo na aplicação que está rodando dentro do container.

Já na linha 20 estamos dizendo que deve ser criado um volume chamado "rubygems", e ele terá os mesmos arquivos do diretório onde ficam as gems da nossa aplicação dentro do container "/usr/local/bundle", ou seja, desde que não apaguemos este volume, não será necessário reinstalar as gems sempre que precisamos parar e iniciar nosso container, reiniciar, etc.

Nas linhas 23 e 24, estamos fazendo o mesmo que no container do postgres. Estamos dizendo que esse container deve procurar as variáveis de ambiente, quando necessário, no arquivo chamado ".env":

env_file:
  - .env

Nas linhas 25, 26 e 27, utilizamos o "depends_on" para iniciar este container apenas quando o container "postgres" já estiver iniciando. Dessa forma diminuímos o risco do container da nossa aplicação ficar pronto antes do container do banco de dados e exibir alguns erros por não encontrar ele disponível:

depends_on:
  postgres:
    condition: service_started

Nas linhas 29, 30 e 31, estamos dizendo que esses containers utilizarão esses volumes ("database" e "rubygems"):

volumes:
  database:
  rubygems:

Passo 5:
No terminal devemos acessar o diretório da nossa aplicação onde temos os 3 arquivos "Dockerfile", ".env" e "docker-compose.yml" devidamente criados e configurados. Vamos iniciar os containers e acessar o container da nossa aplicação (web). Para isso utilizaremos o seguinte comando:

docker compose run web bash

Com esse comando vamos rodar o arquivo "docker-compose.yml" e consequentemente o "Dockerfile". Perceba que, caso você ainda não tenha as imagens do Ruby 3.2.2 e Postgres 15.2 instaladas, elas serão baixadas automagicamente e instalado o que for necessário conforme configuramos. Das próximas vezes será mais rápido porque as imagens já estarão instaladas. E por último você deve está acessando o container "Web", onde vamos seguir para a instalação do Rails e a criação da nossa aplicação.

Passo 6:
Vamos agora instalar o bundler:

gem install bundler

Em seguida, vamos instalar o Rails:

gem install rails

Lembrando que dessa forma vamos instalar a versão mais recente. Podemos especificar qual versão queremos passando o "-v <número da versão>". Por exemplo, podemos instalar a versão 6 do rails, ficando da seguinte forma:

gem install rails -v 6.0

Antes de criar nossa aplicação, vamos configurar o git para utilizar a "Main" como branch padrão. Dessa forma, evitamos que o rails crie nossa aplicação utilizando a "Master" como branch padrão. (De qualquer forma é possível renomear essa branch posteriormente). O comando fica da seguinte forma:

git config --global init.defaultBranch main

Podemos utilizar o serguinte comando para verificar a branch que está configurada como padrão:

git config --global init.defaultBranch

E finalmente vamos criar nossa aplicação:

rails new . -c bootstrap -j esbuild -d postgresql -T

Caso não tenha interesse sobre as flags utilizadas ou já saiba, pode seguir para  o "Passo 7". Mas caso queira entender melhor como estamos criando nossa aplicação, segue uma breve explicação:
Especificando o uso do bootstrap como padrão:

-c bootstrap

Especificando o uso do "esbuild" em vez do padrão do Rails, que é o "importmaps":

-j esbuild

Especificando o uso do postgresql como banco de dados padrão, em vez do "sqlite" normalmente instalado pelo Rails:

-d postgresql

Especificando que não queremos instalar o "mini-test" do rails:

-T

Passo 7:
Agora vamos sair do container:

exit

E vamos alterar as permissões dos arquivos do nosso projeto, para que possamos editar, apagar e criar novos arquivos:

sudo chown -R $USER:$USER ./

Passo 8:
Vamos configurar nossa aplicação para que ela utilize o banco de dados que está no container "postgres". 
Para isso, vamos ao nosso editor de código e devemos abrir o arquivo "config/database.yml" do nosso projeto. 
Em seguida, vamos especificar que nossa aplicação deve procurar o "Host", "User" e "Password" nas variáveis de ambiente, que estão no arquivo ".env" que criamos e configuramos no "Passo 3". Ficando da seguinte forma:

host: <%= ENV["POSTGRES_HOST"] %>
user: <%= ENV["POSTGRES_USER"] %>
password: <%= ENV["POSTGRES_PASSWORD"] %>

Essa configuração deve ser adicionada no bloco "default". Esse bloco de código ficará da seguinte forma:

default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: <%= ENV["POSTGRES_HOST"] %>
  user: <%= ENV["POSTGRES_USER"] %>
  password: <%= ENV["POSTGRES_PASSWORD"] %>

Passo 9:
E finalmente, temos nossa aplicação Ruby on Rails no container "Web" e o banco de dados (postgres) no container "postgres" funcionando. Para iniciar os containers podemos rodar o comando:

docker compose up

Nossa aplicação pode ser acessada pelo endereço "localhost:3000" e qualquer alteração feita no código terá efeito dentro dos containers, sendo necessário apenas atualizar a página web, como faríamos com uma aplicação Rails sem o docker.
Podemos parar os containers utilizando o "Ctrl+C" no terminal e em seguida digitando:

docker compose down

Passo 10:
Esse passo não é obrigatório. Nossa aplicação já está funcionando com o docker, porém, vou mostrar como podemos criar alguns atalhos para substituir os comandos do docker utilizando o Makefile.
Primeiro vamos criar um arquivo no diretório raiz do nosso projeto, chamado "Makefile". Em seguida vamos escrever os seguintes comandos nele:

CONTAINER_NAME ?= web

up:
  docker compose up

down:
  docker compose down

shell:
  @docker exec -it $(CONTAINER_NAME) \
  sh -c "/bin/bash || /bin/sh"

clean:
  @docker compose down
  @docker system prune --volumes --force
  @docker network prune
  @rm -rf tmp/* || sudo rm -rf tmp/*
  @mkdir -p tmp/pids && touch tmp/pids/.keep

Agora vamos entender o que está acontecendo no nosso Makefile.

Na linha 1, nós estamos criando uma variável chamada "CONTAINER_NAME" e estamos armazenando o nome do container da nossa aplicação, conforme configuramos no passo 4. Será útil a seguir.

Nas linhas 3 e 4, nós estamos criando o comando "up", que executará o comando "docker compose up". Pode ser executado da seguinte forma:

make up

Nas linhas 6 e 7, nós estamos criando o comando "down", que executará o comando "docker compose down". Pode ser executado da seguinte forma:

make down

Nas linhas 9, 10 e 11, nós estamos criando o comando "shell", que executará o comando "docker exec -it web /bin/bash". Observem que estamos utilizando a variável criada na linha 1. Pode ser executado da seguinte forma:

make shell

Nas linha 13, 14, 15, 16, 17 e 18, estamos criando o comando "clean", que deve ser utilizado com um pouco mais de atenção, já que ele vai parar e apagar todos os containers, volumes e redes do nosso docker. Então pode afetar outras aplicações. Além disso, está removendo o diretório "tmp" da nossa aplicação e recriando o "tmp/pids". Recomendo utilizar com o sudo, já que provavelmente vai precisar de algumas permissões de admin para executar, ficando dessa forma:

sudo make clean

Comandos básicos para utilizar o docker:
Decidi disponibilizar aqui alguns comandos básicos para melhorar a experiência de quem está tendo o primeiro contato com o docker. Seguem abaixo:

Listar os containers que estão rodando:

docker ps

Listar todos os containers (iniciados ou parados):

docker container ls -a

Parar um container específico:

docker container stop <container id>

Iniciar todos os containers:

docker compose up

Parar todos os containers:

docker compose down

Forçar a parada de todos os containers:

docker kill $(docker ps -q)

Remover um container específico:

docker rm <container id>

Remover todos os containers:

docker container prune

Acessar um container:

docker exec -it <container id> bash

Inspecionar um container:

docker inspect <container id>

Sair de um container:

exit

Listar os volumes:

docker volume ls

Inspecionar um volume:

docker volume inspect <volume name>

Remover um volume específico:

docker volume rm <volume name>

Remover todos os volumes:

docker volume prune

Listar todas as redes:

docker network ls

Inspecionar uma rede:

docker network inspect <network id>

Remover uma rede:

docker network rm <network id>

Remover todas as redes:

docker network prune

Remover todos os containers, volume e rede:

docker system prune

Conclusão:
Meu objetivo com o esse post é conseguir ajudar as pessoas que, assim como eu um dia, tem dificuldade de aprender sobre o uso básico e prático do Docker com aplicações Ruby on Rails. Porém, pode ser adaptado para criar aplicações Python ou qualquer outra linguagem/framework "dockerizadas".
Gostaria de enfatizar que aqui eu tento trazer o conteúdo de forma mais palatável, porém, sugiro fortemente pesquisar na documentação oficial do docker.

Referências:
Documentação do Docker: https://docs.docker.com/
Imagem do Ruby (Docker Hub): https://hub.docker.com/_/ruby
Imagem do Postgres (Docker Hub): https://hub.docker.com/_/postgres