Docker Compose: Orquestrando Múltiplos Serviços Localmente

[284] Docker Compose: Orquestrando Múltiplos Serviços Localmente

Docker Compose para orquestrar múltiplos serviços localmente: a anatomia do docker-compose.yml, healthchecks e dependências reais com condition service_healthy, variáveis sensíveis em arquivo .env e os comandos do ciclo up, logs, exec e down.
DevOps

10 min de leitura

O artigo Docker Volumes e Redes: Persistência e Comunicação terminou com um conjunto de comandos docker run que configurava manualmente rede, volumes e variáveis de ambiente para cada serviço. Esse processo funciona, mas não escala. Em um projeto real com cinco, dez ou quinze serviços, gerenciar tudo manualmente se torna impossível de manter, compartilhar e reproduzir.

O Docker Compose resolve isso com elegância: toda a definição do ambiente — serviços, redes, volumes, variáveis de ambiente, dependências entre serviços — vai em um único arquivo YAML chamado docker-compose.yml. O ambiente inteiro sobe com um comando, para com outro e pode ser compartilhado via Git com qualquer membro da equipe.

Estrutura do docker-compose.yml

A anatomia básica de um arquivo Compose:

services:        # define os containers que compõem o ambiente
  nome-servico:
    image: ...   # imagem base ou
    build: ...   # caminho para o Dockerfile
    ports: ...   # mapeamento de portas
    volumes: ... # montagem de volumes
    environment: # variáveis de ambiente
    networks: ... # redes às quais pertence
    depends_on: ... # dependências de outros serviços

volumes:         # volumes gerenciados pelo Docker
  nome-volume:

networks:        # redes customizadas
  nome-rede:

O Primeiro docker-compose.yml

Convertendo o ambiente do artigo Docker Volumes e Redes: Persistência e Comunicação em um arquivo Compose:

# docker-compose.yml
services:
  postgres:
    image: postgres:16
    container_name: postgres
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: senha_segura
      POSTGRES_DB: minha_app
    volumes:
      - dados-postgres:/var/lib/postgresql/data
    networks:
      - rede-app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d minha_app"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: redis
    command: redis-server --appendonly yes
    volumes:
      - dados-redis:/data
    networks:
      - rede-app
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: api
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://app:senha_segura@postgres:5432/minha_app
      REDIS_URL: redis://redis:6379
    ports:
      - "3000:3000"
    networks:
      - rede-app
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

volumes:
  dados-postgres:
  dados-redis:

networks:
  rede-app:
    driver: bridge

Comandos Essenciais do Docker Compose

# Sobe todos os serviços em segundo plano
docker compose up -d

# Sobe e reconstrói as imagens antes de subir
docker compose up -d --build

# Acompanha os logs de todos os serviços
docker compose logs -f

# Logs de um serviço específico
docker compose logs -f api

# Lista os serviços e seus estados
docker compose ps

# Para todos os serviços
docker compose stop

# Para e remove containers, redes e volumes anônimos
docker compose down

# Para, remove containers E volumes nomeados
docker compose down -v

# Executa um comando em um serviço em execução
docker compose exec api sh

# Escala um serviço para múltiplas instâncias
docker compose up -d --scale api=3

Variáveis de Ambiente com Arquivo .env

Colocar senhas diretamente no docker-compose.yml é uma má prática — o arquivo é versionado no Git. A solução é usar um arquivo .env na mesma pasta, que o Docker Compose carrega automaticamente:

# .env
POSTGRES_USER=app
POSTGRES_PASSWORD=senha_muito_segura
POSTGRES_DB=minha_app
NODE_ENV=production
APP_PORT=3000

O docker-compose.yml passa a referenciar as variáveis:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}

  api:
    build: .
    environment:
      NODE_ENV: ${NODE_ENV}
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
    ports:
      - "${APP_PORT}:3000"

O arquivo .env deve ser adicionado ao .gitignore. Em seu lugar, versiona-se um arquivo .env.example com as chaves necessárias mas sem valores reais — servindo como documentação para novos membros da equipe.

Profiles: Ativando Serviços Opcionais

O Docker Compose suporta profiles — grupos de serviços que só sobem quando explicitamente solicitados. Útil para ferramentas de desenvolvimento que não devem subir em produção:

services:
  api:
    build: .
    ports:
      - "3000:3000"

  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: senha123

  # Só sobe quando o profile 'dev' é ativado
  adminer:
    image: adminer
    profiles: [dev]
    ports:
      - "8080:8080"

  # Só sobe quando o profile 'monitoring' é ativado
  prometheus:
    image: prom/prometheus
    profiles: [monitoring]
    ports:
      - "9090:9090"
# Sobe apenas os serviços sem profile
docker compose up -d

# Sobe incluindo o profile de desenvolvimento
docker compose --profile dev up -d

# Sobe incluindo múltiplos profiles
docker compose --profile dev --profile monitoring up -d

Override Files: Configurações por Ambiente

O Docker Compose suporta múltiplos arquivos que são mesclados automaticamente. O padrão é usar docker-compose.yml como base e docker-compose.override.yml para sobrescrições locais de desenvolvimento:

# docker-compose.yml — configuração base (versionada)
services:
  api:
    build: .
    environment:
      NODE_ENV: production
    restart: unless-stopped
# docker-compose.override.yml — sobrescrições locais (no .gitignore)
services:
  api:
    environment:
      NODE_ENV: development
    volumes:
      - ./src:/app/src     # hot reload em desenvolvimento
    command: npm run dev

O Docker Compose mescla automaticamente os dois arquivos quando docker compose up é executado. Para produção, usa-se o arquivo base explicitamente:

docker compose -f docker-compose.yml up -d

Um Ambiente de Desenvolvimento Completo

Um exemplo realista que desenvolvedores de qualquer equipe podem usar imediatamente:

# docker-compose.yml
services:
  api:
    build:
      context: .
      target: development    # usa o estágio dev do multi-stage Dockerfile
    volumes:
      - ./src:/app/src
      - ./package.json:/app/package.json
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      REDIS_URL: redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: npm run dev

  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-dev}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev123}
      POSTGRES_DB: ${POSTGRES_DB:-devdb}
    volumes:
      - dados-postgres:/var/lib/postgresql/data
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

  adminer:
    image: adminer
    profiles: [dev-tools]
    ports:
      - "8080:8080"
    depends_on:
      - postgres

volumes:
  dados-postgres:

networks:
  default:
    name: rede-dev

Com esse arquivo, qualquer desenvolvedor que clonar o repositório pode ter o ambiente completo rodando com:

cp .env.example .env
docker compose up -d

O Que Vem a Seguir

No próximo artigo serão abordadas as boas práticas para construção de imagens Docker — como reduzir tamanho, melhorar segurança e organizar Dockerfiles para ambientes de produção. É o artigo que transforma imagens funcionais em imagens prontas para o mundo real.

Referências para Aprofundamento

Documentação oficial

Leitura técnica

Prática

  • Awesome Compose — GitHub — Repositório oficial do Docker com exemplos de docker-compose.yml para dezenas de stacks tecnológicas diferentes. Excelente referência para adaptar ao projeto em uso.

Exercícios

Exercício 1

Qual a diferença entre declarar apenas depends_on: [postgres] e usar condition: service_healthy? Que falha o segundo evita?

Ver resposta

✓ Resposta: O depends_on sozinho controla apenas a ordem de inicialização: o Compose sobe o postgres antes da api. O problema é que "subiu" não significa "está pronto" — o PostgreSQL leva alguns segundos inicializando antes de aceitar conexões, e é exatamente nesse intervalo que a api sobe, tenta conectar e falha. Com condition: service_healthy, o Compose aguarda o healthcheck do serviço passar antes de iniciar quem depende dele. É a diferença entre esperar o processo existir e esperar o serviço responder — e ela costuma aparecer como aquele erro que "só acontece na primeira subida" ou "some quando eu subo de novo".

Exercício 2

Os healthchecks usam pg_isready e redis-cli ping. Por que não bastaria verificar se o processo do banco está em execução?

Ver resposta

✓ Resposta: Porque processo em execução não é o mesmo que serviço pronto. O pg_isready pergunta ao próprio PostgreSQL se ele está aceitando conexões, e o redis-cli ping espera um PONG de volta — os dois exercitam o caminho real que a aplicação vai percorrer. Verificar apenas o processo responderia "sim" durante toda a inicialização do banco, durante uma recuperação de crash e mesmo com o serviço travado sem atender ninguém. Ou seja, responderia "sim" precisamente nos momentos em que a resposta correta era "ainda não".

Exercício 3

O que docker compose down remove, e o que docker compose down -v remove a mais? Qual dos dois exige atenção?

Ver resposta

✓ Resposta: O down remove os containers, a rede criada e os volumes anônimos, mas preserva os volumes nomeados — os dados do PostgreSQL e do Redis continuam intactos, prontos para a próxima subida. O down -v remove também os volumes nomeados, apagando o banco inteiro. O perigoso é o segundo, e o risco é maior do que parece porque o -v é fácil de acrescentar por hábito quando a intenção era "limpar tudo". Em desenvolvimento isso é útil e desejado, para recomeçar de um estado limpo; em qualquer ambiente que carregue dado que importa, é uma operação sem volta.

Exercício 4

Por que colocar a senha no .env em vez de escrevê-la no docker-compose.yml? E o que ainda precisa ser feito para que o .env realmente cumpra esse papel?

Ver resposta

✓ Resposta: Porque o docker-compose.yml é versionado: a senha escrita ali entra no histórico do Git, onde permanece recuperável mesmo depois de removida do arquivo, e fica visível para todos que têm acesso ao repositório. O .env fica fora do versionamento e é carregado automaticamente pelo Compose. Mas isso só é verdade se ele estiver de fato listado no .gitignore — caso contrário o segredo apenas mudou de arquivo e continua sendo commitado, com a agravante de que agora todos acham que está protegido. O padrão consolidado é versionar um .env.example com as chaves e valores fictícios, documentando o que precisa ser preenchido, e manter o .env real apenas na máquina.

Exercício 5

Qual a diferença entre image: e build: na definição de um serviço? Por que postgres usa um e api usa o outro — e que armadilha isso cria no dia a dia?

Ver resposta

✓ Resposta: image: baixa uma imagem pronta de um registro; build: constrói a imagem localmente a partir de um Dockerfile. PostgreSQL e Redis são software de terceiros com imagens oficiais publicadas — não há nada a construir. A api é o código da própria equipe, que precisa ser empacotado, e por isso aponta context e dockerfile. A armadilha aparece no ciclo de desenvolvimento: mudanças no código da api só entram com docker compose up -d --build. Sem o --build, o Compose reaproveita a imagem já construída, e você passa a depurar um comportamento que não corresponde ao código que está no disco.

Exercício 6

O que restart: unless-stopped faz? O que aconteceria sem essa diretiva, e por que o unless-stopped é diferente de um "sempre reinicie"?

Ver resposta

✓ Resposta: Ela faz o Docker reiniciar o container automaticamente quando ele encerra — seja por erro na aplicação, seja porque a máquina foi reiniciada. Sem ela, o container morre e permanece morto: uma falha transitória às três da manhã derruba o serviço até alguém perceber e agir manualmente. A ressalva do unless-stopped é justamente o que o diferencia de um "sempre reinicie": se você parou o container deliberadamente, ele não volta sozinho quando o daemon reinicia, porque uma decisão explícita sua é respeitada. É o comportamento mínimo de auto-recuperação, e vale notar que orquestradores como o Kubernetes constroem sobre exatamente essa ideia, de forma muito mais elaborada.

Comentários

Mais em DevOps

Capstone: Pipeline Completo de CI/CD
Capstone: Pipeline Completo de CI/CD

O pipeline tratado como produto de engenharia, em que o desenvolvedor é o…

Grafana: Dashboards e Alertas que Fazem Sentido
Grafana: Dashboards e Alertas que Fazem Sentido

Dashboards que respondem perguntas em vez de acumular gráficos…

GitLab CI/CD: A Alternativa Enterprise ao GitHub Actions
GitLab CI/CD: A Alternativa Enterprise ao GitHub Actions

A plataforma que nasceu DevOps completa, e não como hospedagem de código que…