Docker Compose para Desenvolvimento: Hot Reload e Ambientes Reproduzíveis
O Docker Compose é uma ferramenta fundamental para qualquer desenvolvedor que trabalhe com microserviços ou aplicações containerizadas. Ele permite definir e executar múltiplos containers de forma orquestrada através de um único arquivo YAML. Neste artigo, vamos além da configuração básica e mergulharemos em estratégias práticas para implementar hot reload durante o desenvolvimento e criar ambientes que funcionam identicamente em qualquer máquina.
A principal vantagem de usar Docker Compose em desenvolvimento é eliminar o famoso "funciona na minha máquina" — você garante que todos os desenvolvedores trabalhem com as mesmas versões de dependências, banco de dados e serviços. Além disso, o hot reload permite que você veja mudanças em tempo real sem reiniciar containers, acelerando significativamente o ciclo de desenvolvimento.
Fundamentos do Docker Compose e Volumes
O que é Docker Compose e por que importa
Docker Compose funciona através de um arquivo docker-compose.yml que descreve todos os serviços da sua aplicação em um formato declarativo. Cada serviço é essencialmente um container, mas em vez de gerenciar comandos docker run complexos, você define tudo uma vez e executa docker-compose up. A grande diferença em relação ao Docker tradicional é que o Compose gerencia o networking entre containers automaticamente, permitindo que eles se comuniquem apenas pelo nome do serviço.
Para desenvolvimento específico, o conceito crucial é o de volumes. Volumes permitem compartilhar diretórios entre seu sistema de arquivos local e o container. Diferentemente das imagens Docker, que são imutáveis, volumes persistem alterações. Isso é fundamental para hot reload — quando você edita um arquivo no seu editor local, a mudança aparece instantaneamente dentro do container.
Configurando volumes para sincronização em tempo real
Existem três tipos de volumes em Docker: volumes nomeados (gerenciados pelo Docker), bind mounts (apontam para caminhos específicos) e tmpfs (em memória). Para desenvolvimento, usaremos bind mounts porque queremos que os arquivos do projeto local sejam refletidos dentro do container.
version: '3.9'
services:
app:
build: .
container_name: minha_app
volumes:
- .:/app
- /app/node_modules
ports:
- "3000:3000"
environment:
- NODE_ENV=development
Neste exemplo, .:/app mapeia o diretório atual do seu computador para /app dentro do container. A segunda linha /app/node_modules é importante: ela cria um volume anônimo para node_modules, impedindo que o diretório local sobrescreva as dependências instaladas no container. Isso é fundamental porque as dependências do seu sistema operacional podem ser incompatíveis com as do container.
Implementando Hot Reload em Diferentes Linguagens
Node.js com Nodemon
Para aplicações Node.js, o Nodemon é a escolha padrão. Ele monitora mudanças nos arquivos e reinicia automaticamente o processo Node.js. Configurar isso no Docker é simples, mas requer atenção aos detalhes.
Primeiro, instale o nodemon como dependência de desenvolvimento:
npm install --save-dev nodemon
Crie um arquivo nodemon.json:
{
"watch": ["src"],
"ext": "js,json",
"ignore": ["node_modules"],
"exec": "node",
"delay": 500
}
Agora configure o docker-compose.yml:
version: '3.9'
services:
app:
build: .
container_name: node_app_dev
volumes:
- .:/app
- /app/node_modules
working_dir: /app
ports:
- "3000:3000"
environment:
- NODE_ENV=development
command: nodemon src/index.js
stdin_open: true
tty: true
O Dockerfile correspondente seria:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
Quando você executa docker-compose up, o Nodemon dentro do container monitora o diretório /app (que está sincronizado com seu código local). Sempre que um arquivo é alterado, o Node.js é automaticamente reiniciado. Os parâmetros stdin_open: true e tty: true garantem que você possa ver o output e até mesmo enviar sinais de interrupção (Ctrl+C) para o container.
Python com Watchdog
Para Python, a abordagem é similar, mas usaremos Watchdog ou simplesmente recarregamento de módulo com Flask/Django. Para uma API Flask:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["flask", "run", "--host=0.0.0.0"]
version: '3.9'
services:
app:
build: .
container_name: python_app_dev
volumes:
- .:/app
ports:
- "5000:5000"
environment:
- FLASK_APP=main.py
- FLASK_ENV=development
command: flask run --host=0.0.0.0
Flask em modo desenvolvimento já recarrega automaticamente quando detecta mudanças. O parâmetro FLASK_ENV=development ativa esse comportamento. Para mais controle, você pode usar:
RUN pip install --no-cache-dir -r requirements.txt watchdog[watchmedo]
E alterar o comando para:
command: watchmedo auto-restart -d /app -p '*.py' -- python main.py
Ambientes Reproduzíveis: Beyond the Basics
Arquitetura Multi-Container com Banco de Dados
O verdadeiro poder do Docker Compose em desenvolvimento aparece quando você precisa orchestrar múltiplos serviços. Considere uma aplicação Node.js com PostgreSQL e Redis:
version: '3.9'
services:
api:
build: .
container_name: api_dev
volumes:
- .:/app
- /app/node_modules
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
- db
- cache
command: nodemon src/index.js
db:
image: postgres:15-alpine
container_name: postgres_dev
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=myapp
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
cache:
image: redis:7-alpine
container_name: redis_dev
ports:
- "6379:6379"
volumes:
postgres_data:
Nesta configuração, a comunicação entre serviços acontece pelo nome: db e cache são nomes de host válidos dentro da rede Docker criada pelo Compose. A variável DATABASE_URL aponta para db:5432 em vez de localhost:5432 porque estamos dentro da rede Docker, não do seu sistema operacional.
O arquivo init.sql é particularmente útil — ele é executado automaticamente quando o container PostgreSQL inicia pela primeira vez, criando tabelas e inserindo dados de teste. Isso garante que cada desenvolvedor tenha a mesma estrutura de banco de dados.
Versionamento de Dependências e Reprodutibilidade
Uma causa comum de inconsistência entre ambientes é variações de versão. O Docker resolve isso através de imagens, mas você deve ser explícito:
services:
db:
image: postgres:15.2-alpine # Versão específica, não 15-alpine
# ...
cache:
image: redis:7.0.11-alpine # Sempre especifique versão exata
# ...
Para sua aplicação, use lock files. Em Node.js, isso é package-lock.json. Em Python, requirements.txt com versões fixas ou poetry.lock:
# requirements.txt
Flask==2.3.2
SQLAlchemy==2.0.19
psycopg2-binary==2.9.7
redis==5.0.0
# docker-compose.yml
api:
build:
context: .
dockerfile: Dockerfile
# Você pode adicionar args se necessário
Commit esses arquivos no Git. Assim, quando um novo desenvolvedor clona o repositório e executa docker-compose up, ele obtém exatamente as mesmas versões que você estava usando.
Configuração Avançada e Otimizações
Usando .env para Variáveis de Ambiente
Embora seja tentador hardcoding valores no docker-compose.yml, o certo é usar variáveis de ambiente. Crie um arquivo .env na raiz do projeto:
DATABASE_URL=postgresql://postgres:devpassword@db:5432/myapp
REDIS_URL=redis://cache:6379
API_PORT=3000
DEBUG=true
Docker Compose carrega automaticamente esse arquivo. No seu docker-compose.yml:
services:
api:
ports:
- "${API_PORT}:3000"
environment:
- DATABASE_URL=${DATABASE_URL}
- DEBUG=${DEBUG}
Para produção, use um .env.production diferente (não commit no Git) ou variáveis de ambiente do seu orquestrador (Kubernetes, AWS ECS, etc.).
Estratégias de Networking e Comunicação
Docker Compose cria uma rede padrão onde todos os serviços podem se comunicar. Para casos mais complexos, você pode criar redes explícitas:
version: '3.9'
networks:
backend:
driver: bridge
frontend:
driver: bridge
services:
api:
networks:
- backend
# ...
db:
networks:
- backend
# ...
web:
networks:
- frontend
# ...
Isso é útil quando você quer isolar certos serviços. Por exemplo, um container de teste nunca precisa acessar o banco de dados diretamente — apenas a API faz isso.
Health Checks para Startup Order
Um problema comum é que o depends_on apenas garante que o container inicie, não que ele esteja pronto para receber requisições. Use health checks:
services:
db:
image: postgres:15-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# ...
api:
depends_on:
db:
condition: service_healthy
# ...
Agora o api só inicia quando o banco estiver respondendo a requisições.
Fluxo de Trabalho Prático
Iniciando e Desenvolvendo
Para começar o desenvolvimento:
docker-compose up
Adicione -d para rodar em background:
docker-compose up -d
Para visualizar logs:
docker-compose logs -f api
O -f mantém o output em tempo real, como tail -f.
Quando você edita um arquivo no seu editor local, a mudança é refletida instantaneamente no container. Se estiver usando Nodemon ou similar, o serviço será automaticamente reiniciado.
Entrando no Container para Debug
Às vezes você precisa executar comandos dentro do container:
docker-compose exec api sh
# Agora você está dentro do container
npm test
npm run build
Para Python:
docker-compose exec app python manage.py shell
Destruindo e Limpando
Quando terminar:
docker-compose down
Isso para os containers mas preserva volumes nomeados. Para remover tudo, incluindo volumes:
docker-compose down -v
Conclusão
Aprendemos que Docker Compose é muito mais do que uma ferramenta para produção — é um multiplicador de produtividade em desenvolvimento. Os três pontos principais que você deve levar adiante:
-
Volumes com bind mounts criam a ilusão de desenvolvimento local: O hot reload com Nodemon, Flask ou equivalentes transforma o Docker de um obstáculo em um diferencial, garantindo que você desenvolva na mesma plataforma que rodará em produção.
-
Ambientes reproduzíveis eliminam surpresas: Versionar imagens específicas, usar lock files, e manter
docker-compose.ymlno Git garante que "funciona no meu computador" se converta em "funciona em qualquer lugar". Um novo desenvolvedor fazgit clone,docker-compose up, e já está desenvolvendo. -
Multi-container orchestration simplifica arquiteturas complexas: Em vez de gerenciar PostgreSQL, Redis e sua API separadamente, o Compose centraliza tudo em um arquivo declarativo, com networking e health checks automáticos, economizando horas de setup.