Docker Compose na prática: app, banco e cache

1. Visão geral da aplicação multicontainer

Em cenários reais de desenvolvimento, raramente temos uma aplicação isolada. Uma API web típica consome um banco de dados relacional e utiliza um cache para melhorar performance. Gerenciar manualmente cada container com docker run torna-se rapidamente inviável.

Vamos construir um stack composto por:
- App: API em Node.js (Express) que expõe endpoints REST
- Banco: PostgreSQL para persistência de dados estruturados
- Cache: Redis para armazenamento temporário e sessões

O Docker Compose resolve o problema de orquestração local permitindo definir, configurar e iniciar todos os serviços com um único comando. Cada serviço roda em seu próprio container, mas compartilha uma rede interna que possibilita comunicação por nome do serviço.

2. Estrutura do projeto e Dockerfile

A organização do projeto segue boas práticas de separação de responsabilidades:

meu-projeto/
├── app/
│   ├── Dockerfile
│   ├── package.json
│   └── src/
│       ├── index.js
│       ├── database.js
│       └── cache.js
├── docker-compose.yml
├── docker-compose.override.yml
└── .env

O Dockerfile da aplicação utiliza multi-stage build para otimizar o tamanho da imagem em produção:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/index.js"]

Variáveis de ambiente como DB_HOST, DB_PASSWORD e REDIS_HOST são injetadas em tempo de execução pelo Compose, mantendo a imagem genérica e reutilizável.

3. Definindo os serviços no docker-compose.yml

O arquivo principal docker-compose.yml declara os três serviços com suas configurações específicas:

version: '3.8'

services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - DB_HOST=db
      - DB_PORT=5432
      - DB_USER=app_user
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_NAME=appdb
      - REDIS_HOST=cache
      - REDIS_PORT=6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:15-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=app_user
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app_user -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

Observe que apenas a porta da aplicação (3000) é exposta para o host. O Redis tem porta exposta para facilitar debug, mas em produção isso seria removido.

4. Redes e comunicação entre containers

O Docker Compose cria automaticamente uma rede bridge para o projeto. Cada container pode acessar os outros pelo nome do serviço definido no YAML.

A comunicação funciona da seguinte forma:
- O serviço app conecta-se ao banco usando o hostname db e porta 5432
- O cache é acessado via cache:6379
- Nenhum serviço precisa conhecer IPs ou configurações de rede manualmente

Para segurança, apenas a porta da aplicação fica acessível externamente. Banco e cache permanecem isolados na rede interna, reduzindo a superfície de ataque.

5. Volumes e persistência de dados

Cada tipo de dado recebe tratamento adequado quanto à persistência:

Volume nomeado para PostgreSQL (pgdata): Garante que os dados do banco sobrevivam a paradas e reinicializações dos containers. Mesmo com docker-compose down, os dados permanecem.

Redis sem volume persistente: O cache Redis é volátil por natureza. Se o container reiniciar, os dados em cache são perdidos, o que é aceitável e até desejável em muitos cenários.

Bind mount para desenvolvimento: No arquivo de override de desenvolvimento, adicionamos:

services:
  app:
    volumes:
      - ./app/src:/app/src

Isso permite hot reload da aplicação sem precisar reconstruir a imagem a cada alteração no código.

6. Healthchecks e dependências entre serviços

Inicialização ordenada é crítica para aplicações multicontainer. O PostgreSQL pode levar alguns segundos para ficar pronto, e a aplicação não deve tentar conectar antes disso.

Configuramos healthchecks no banco e no cache:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U app_user -d appdb"]
  interval: 5s
  timeout: 5s
  retries: 5

O depends_on com condition: service_healthy faz o Compose aguardar até que o serviço dependente esteja saudável antes de iniciar a aplicação.

Para cenários mais complexos, podemos incluir um script wait-for-it.sh no entrypoint da aplicação:

COPY wait-for-it.sh /wait-for-it.sh
RUN chmod +x /wait-for-it.sh
CMD ["/wait-for-it.sh", "db:5432", "--", "node", "src/index.js"]

7. Docker Compose para desenvolvimento vs produção

Utilizamos arquivos separados para cada ambiente:

docker-compose.yml (base): Contém a configuração comum que funciona em ambos os ambientes.

docker-compose.override.yml (desenvolvimento): Automaticamente aplicado pelo Compose quando executamos docker-compose up. Inclui bind mounts, variáveis de debug e serviços auxiliares.

docker-compose.prod.yml (produção): Usado explicitamente com -f docker-compose.yml -f docker-compose.prod.yml. Remove exposição de portas desnecessárias e adiciona políticas de restart.

Perfis permitem ativar serviços opcionais sob demanda:

services:
  adminer:
    image: adminer
    profiles: ["tools"]
    ports:
      - "8080:8080"
    depends_on:
      - db

Ativamos com: docker-compose --profile tools up

8. Comandos práticos e troubleshooting

Comandos essenciais para o dia a dia:

# Iniciar todos os serviços
docker-compose up -d

# Ver logs de todos os serviços em tempo real
docker-compose logs -f

# Ver logs de um serviço específico
docker-compose logs -f app

# Executar comando dentro de um container
docker-compose exec app sh

# Listar serviços e seus status
docker-compose ps

# Parar e remover containers, redes e volumes
docker-compose down -v

# Reconstruir imagens sem usar cache
docker-compose build --no-cache

Para resetar dados de desenvolvimento:

docker-compose down -v
docker-compose up -d

Isso remove todos os volumes, incluindo o banco de dados, e recria tudo do zero.

Inspecionar logs simultâneos ajuda a identificar problemas de comunicação entre serviços. Se a aplicação não consegue conectar ao banco, os logs mostrarão erros de conexão, indicando possível problema de healthcheck ou configuração de ambiente.

Referências