0%
Docker Compose

Compose

Uma aplicação inteira descrita em um único arquivo.

10-15 min

Docker Compose

Uma aplicação real quase nunca é um único contêiner. É uma API, um banco de dados, um cache, às vezes um worker assíncrono e um proxy na frente. Cinco contêineres, portanto cinco docker run — com as portas certas, as variáveis certas, a rede certa, na ordem certa, e lembrando de tudo isso amanhã de manhã.

Isso se sustenta por um tempo, à base de scripts shell copiados de um projeto para outro. Depois a situação sempre se degrada da mesma maneira: o script do colega não usa a mesma versão do Postgres, ninguém mais sabe por que o worker carrega aquela opção obscura, a chegada de um novo desenvolvedor custa meio dia, e a senha do banco fica perdida no histórico de três terminais. O problema não é o número de comandos. É que não existe nenhuma descrição única e verificável do que « a aplicação » significa.

O Docker Compose responde exatamente a essa lacuna. Um arquivo YAML declara o conjunto de serviços, suas imagens, suas variáveis, seus volumes, suas dependências. Esse arquivo vive no repositório Git, ao lado do código. Clonar o projeto e digitar um único comando basta para obter a stack completa, idêntica para todos — e o git log explica por que determinada opção foi adicionada.

Uma palavra sobre a sintaxe antes de começar, porque ela é fonte de confusão. A forma moderna é docker compose, em duas palavras: é um plugin integrado ao cliente Docker. A forma antiga docker-compose, com hífen, é um binário Python separado, a « v1 », cujo desenvolvimento foi encerrado em 2023. As duas se parecem, mas a v1 ignora parte da sintaxe atual. Tudo o que segue usa docker compose.

Um primeiro arquivo, serviço por serviço

O arquivo se chama compose.yaml e fica na raiz do projeto. É o nome recomendado pela especificação Compose; docker-compose.yml continua reconhecido por compatibilidade, e você o encontrará com frequência em projetos existentes.

# compose.yaml
# Sem a chave "version:" no topo: ela é obsoleta e não tem efeito.

services:
  # ---------- A aplicação ----------
  api:
    build:
      context: .
      dockerfile: Dockerfile
    image: mon-api:dev          # nome atribuído à imagem construída
    ports:
      - "3000:3000"             # host:contêiner
    environment:
      NODE_ENV: development
      # "db" e "cache" são nomes de SERVIÇOS: eles são resolvidos
      # via DNS na rede da stack. Nenhum endereço IP para escrever.
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  # ---------- O banco de dados ----------
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      # Volume nomeado: os dados sobrevivem à remoção do contêiner
      - db-data:/var/lib/postgresql/data
    healthcheck:
      # O $$ duplo escapa a interpolação do Compose: a variável deve
      # ser resolvida DENTRO do contêiner, não pelo Compose.
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
    restart: unless-stopped

  # ---------- O cache ----------
  cache:
    image: redis:7-alpine
    command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
    volumes:
      - cache-data:/data
    restart: unless-stopped

volumes:
  db-data:
  cache-data:

Dois detalhes deste arquivo merecem ser destacados já.

O banco de dados não publica nenhuma porta: não há chave ports em db. Ainda assim, ele é perfeitamente acessível pela API no endereço db:5432, porque os dois serviços compartilham uma rede interna criada automaticamente pelo Compose. Publicar 5432:5432 exporia seu banco em todas as interfaces da máquina, o que só é necessário para conectar um cliente gráfico a partir do host.

E os serviços se referem uns aos outros pelo nome, nunca por um endereço IP. Os IPs dos contêineres mudam a cada recriação; os nomes, não. A próxima lição detalha esse mecanismo de rede.

O ciclo de comandos do dia a dia

O Compose funciona por convergência: você descreve o estado desejado, e ele calcula as ações a executar. O up não reinicia tudo, ele só mexe no que mudou.

docker compose up -d              # iniciar em segundo plano
docker compose up -d --build      # reconstruir as imagens locais antes
docker compose up -d --wait       # aguardar os healthchecks ficarem verdes
docker compose ps                 # estado da stack, com a saúde
docker compose logs -f            # logs agregados, em tempo real
docker compose logs -f --tail 100 api   # um único serviço

# Abrir um shell em um serviço que está RODANDO
docker compose exec api sh
docker compose exec db psql -U app -d appdb

# Executar um comando em um contêiner DESCARTÁVEL.
# run cria um novo contêiner, exec entra no que está rodando.
docker compose run --rm api npm run migrate

docker compose restart api

Na parada, vale distinguir com cuidado três comandos, porque um deles destrói dados:

# Parar sem remover nada. Reversível com "start".
docker compose stop

# Parar E remover contêineres e redes.
# Os VOLUMES são mantidos: seus dados sobrevivem.
docker compose down

# Idem, mais a remoção dos volumes. Seus dados são PERDIDOS.
docker compose down -v

# Remover também os contêineres órfãos de uma versão antiga do arquivo
docker compose down --remove-orphans

Por fim, o comando de diagnóstico mais rentável:

# Exibe o arquivo como o Compose o entende DE FATO: variáveis
# interpoladas, arquivos mesclados, valores padrão aplicados.
# O primeiro reflexo quando uma variável "não passa".
docker compose config

Uma palavra sobre o nome do projeto. O Compose prefixa todos os objetos que cria com um nome derivado do diretório atual: o volume db-data vira monprojet_db-data. É isso que permite rodar duas stacks independentes na mesma máquina, e também é o que explica por que renomear uma pasta aparentemente « faz perder » os dados — eles continuam lá, sob o prefixo antigo.

docker compose -p mon-projet up -d
# ou, de forma persistente, no .env: COMPOSE_PROJECT_NAME=mon-projet

image ou build: consumir ou produzir

image sozinho puxa uma imagem existente de um registry: é o caso de Postgres, Redis ou Nginx, que você nunca constrói. build constrói a partir de um Dockerfile: é o caso do seu código. Combinar as duas chaves dá um nome à imagem construída, o que a torna enviável a um registry.

services:
  api:
    build:
      context: .                        # como o "." do docker build
      dockerfile: docker/Dockerfile.api # relativo ao contexto
      target: runtime                   # estágio de um Dockerfile multi-stage
      args:                             # equivale a --build-arg
        NODE_VERSION: "22"
    image: registre.exemple.com/equipe/mon-api:dev

A armadilha de iniciante está aqui: up não reconstrói uma imagem existente. Você modifica o Dockerfile, executa up -d de novo, e nada muda. O Compose reutiliza a imagem já construída; é preciso pedir explicitamente.

docker compose build api             # reconstruir um serviço
docker compose build --no-cache api  # sem cache
docker compose up -d --build         # reconstruir e depois iniciar

As dependências: depends_on sozinho não basta

É a fonte de erro mais frequente e mais desconcertante, porque o sintoma é intermitente.

A forma curta de depends_on garante apenas uma ordem de inicialização. O Compose espera o contêiner db ser iniciado, não que o Postgres aceite conexões. Ora, o Postgres leva de dois a dez segundos para inicializar seu cluster na primeira vez. Sua API sobe nesse intervalo, falha com connection refused e encerra. Em uma máquina rápida às vezes passa: daí a impressão de um bug aleatório.

# Insuficiente: garante a ORDEM, não a DISPONIBILIDADE
services:
  api:
    depends_on:
      - db

A solução correta associa um healthcheck no serviço do qual se depende e uma condição no serviço dependente:

services:
  api:
    depends_on:
      db:
        condition: service_healthy   # aguarda o healthcheck ficar verde
      cache:
        condition: service_started   # a inicialização basta
      migrate:
        condition: service_completed_successfully  # aguarda uma saída com código 0

  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s       # frequência das verificações
      timeout: 3s        # além disso, a verificação é uma falha
      retries: 10        # falhas consecutivas antes do status "unhealthy"
      start_period: 10s  # período de carência: as falhas não são contadas

  migrate:
    build: .
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"        # um trabalho pontual, não um serviço

A terceira condição, service_completed_successfully, resolve com elegância o problema das migrations de schema: o serviço migrate executa uma vez, sai com código 0, e só então a API inicia. Chega de scripts de espera improvisados.

# A coluna STATUS exibe (healthy), (unhealthy) ou (health: starting)
docker compose ps

# Histórico detalhado das verificações, com as saídas dos comandos
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db)

Uma ressalva honesta: depends_on só atua na inicialização. Se o banco reiniciar durante a execução, sua aplicação precisa saber se reconectar. Uma lógica de reconexão com backoff progressivo no código continua indispensável; depends_on elimina o ruído na inicialização, mas não substitui a robustez.

Variáveis de ambiente: três mecanismos distintos

A confusão vem do fato de que três coisas diferentes têm quase o mesmo nome.

O arquivo .env ao lado do compose.yaml alimenta a interpolação no próprio YAML: seus valores substituem os ${...} antes que o Compose processe qualquer coisa. A chave environment define as variáveis recebidas pelo processo dentro do contêiner. A chave env_file carrega um arquivo de variáveis diretamente no contêiner, sem passar pela interpolação.

# .env  -- a excluir do Git via .gitignore
DB_PASSWORD=motdepasse-de-dev
API_PORT=3000
TAG=dev
services:
  api:
    image: mon-api:${TAG}         # interpolado ANTES da inicialização
    ports:
      - "${API_PORT}:3000"
    env_file:
      - ./config/api.env          # injetado como está no contêiner
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}   # valor padrão se ausente
      # Dois-pontos + ponto de interrogação: o Compose FALHA se a
      # variável estiver ausente ou vazia. Use para todo segredo.
      JWT_SECRET: ${JWT_SECRET:?JWT_SECRET est obligatoire}

A forma ${VAR:?message} é a melhor proteção disponível: ela transforma um segredo esquecido em um erro imediato e explícito, em vez de uma aplicação que inicia com uma string vazia e falha na autenticação três horas depois.

Duas regras de segurança para encerrar. Um .env contendo segredos reais nunca entra no Git: versione um .env.example documentado e sem valores. E em produção, os segredos não passam por variáveis legíveis no docker inspect: usa-se a seção secrets, que os monta como arquivos em /run/secrets/, de modo que a aplicação lê um caminho em vez de um valor.

Profiles: serviços opcionais na mesma stack

Uma stack de desenvolvimento acaba acumulando ferramentas de que ninguém precisa o tempo todo: uma interface de administração do banco, um servidor SMTP falso, um gerador de carga. Iniciá-las sempre desperdiça memória; colocá-las em um segundo arquivo duplica a configuração. Um serviço associado a um profile só inicia se esse profile for solicitado.

services:
  api:
    build: .
    ports:
      - "3000:3000"
    # Nenhum profile: este serviço sempre inicia

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}

  adminer:                    # administração do banco
    image: adminer:4
    profiles: ["debug"]
    ports:
      - "8080:8080"

  mailpit:                    # servidor SMTP falso, em DOIS profiles
    image: axllent/mailpit:latest
    profiles: ["debug", "mail"]
    ports:
      - "8025:8025"

  k6:                         # testes de carga, nunca por acidente
    image: grafana/k6:latest
    profiles: ["perf"]
    command: ["run", "/scripts/charge.js"]
    volumes:
      - ./perf:/scripts:ro    # :ro monta em somente leitura
docker compose up -d                                # apenas api + db
docker compose --profile debug up -d                # + adminer + mailpit
docker compose --profile debug --profile perf up -d # profiles acumulados
COMPOSE_PROFILES=debug docker compose up -d         # via variável de ambiente
docker compose --profile debug config --services    # listar sem iniciar

Sobrepor vários arquivos

O mesmo projeto muitas vezes precisa rodar em contextos diferentes: código montado e hot reload em desenvolvimento, imagem fixa e limites de recursos em produção. O Compose sobrepõe os arquivos em vez de duplicá-los. Um arquivo chamado compose.override.yaml é carregado automaticamente além do compose.yaml.

# compose.yaml -- a base comum, válida em qualquer lugar
services:
  api:
    image: registre.exemple.com/equipe/mon-api:${TAG:-latest}
    environment:
      NODE_ENV: production
    restart: unless-stopped
# compose.override.yaml -- carregado automaticamente, para desenvolvimento
services:
  api:
    build: .                   # construímos localmente
    environment:
      NODE_ENV: development
      DEBUG: "app:*"
    volumes:
      # O código do host substitui o da imagem: uma modificação
      # fica visível sem reconstruir.
      - ./src:/app/src
      # Volume anônimo: protege o node_modules da imagem de ser
      # sobrescrito pelo do host.
      - /app/node_modules
    command: ["npm", "run", "dev"]
    ports:
      - "9229:9229"            # porta do depurador do Node
# compose.prod.yaml -- carregado explicitamente
services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
# Desenvolvimento: compose.yaml + compose.override.yaml, automaticamente
docker compose up -d

# Produção: os arquivos são nomeados, o override NÃO é carregado
docker compose -f compose.yaml -f compose.prod.yaml up -d

# Sempre verifique o resultado da mesclagem antes de fazer o deploy
docker compose -f compose.yaml -f compose.prod.yaml config

É importante conhecer a regra de mesclagem: para valores simples, o último arquivo vence; para listas como ports ou volumes, as entradas são acumuladas. É uma surpresa frequente, que docker compose config revela imediatamente.

O que o Compose não faz

O Compose orquestra contêineres em um único host. Ele não distribui nada entre várias máquinas, não reinicia um serviço em outro lugar quando um servidor cai e não garante nenhum deploy sem interrupção. Isso é domínio do Kubernetes ou do Docker Swarm.

Isso não o desqualifica para produção: um único servidor gerenciado com Compose é uma arquitetura perfeitamente defensável para um serviço de tráfego moderado, e muito mais simples de operar do que um cluster. A lição 6 trata justamente desse caso.

A escala local existe, no entanto, e é útil para testar um worker:

# Três instâncias do serviço worker
docker compose up -d --scale worker=3

Ela é incompatível com a publicação de uma porta fixa: três contêineres não podem se vincular à porta 3000 do host. Remova a chave ports do serviço em questão ou deixe o Docker escolher portas livres.

Pontos-chave

  • Um compose.yaml versionado substitui uma coleção de docker run frágeis: a definição da aplicação passa a ser um documento único, revisado em code review e reproduzível por toda a equipe.
  • Use docker compose em duas palavras. docker-compose com hífen é a v1, descontinuada, que ignora parte da sintaxe atual. A chave version: no topo do arquivo é obsoleta e não tem efeito.
  • Os serviços se comunicam pelo nome graças ao DNS interno. Publique com ports apenas o que precisa ser acessível externamente: um banco de dados geralmente não faz parte disso.
  • depends_on sozinho garante apenas uma ordem de inicialização. Para esperar a disponibilidade real, adicione um healthcheck e condition: service_healthy; service_completed_successfully resolve bem o caso das migrações.
  • docker compose up não reconstrói suas imagens: lembre-se de usar --build após qualquer modificação em um Dockerfile. E docker compose down -v remove os volumes, ou seja, os dados.
  • ${VAR:?message} transforma um segredo ausente em erro imediato. Em caso de dúvida sobre uma variável, docker compose config exibe o arquivo realmente interpretado.
  • Os profiles isolam as ferramentas opcionais e os arquivos de sobrescrita adaptam a mesma stack para desenvolvimento ou produção, sem duplicar a configuração.

Dica: adquira o hábito de executar docker compose config antes de qualquer up em uma configuração modificada. A maioria das horas perdidas com o Compose se resume à diferença entre o que você acha que escreveu e o que a interpolação realmente produziu.

Comentários

Os comentários são alimentados pelo GitHub Discussions

Conecte-se com o GitHub para participar da discussão

Link copiado!