DevOps, CI/CD e contêineres

Docker Compose: aplicações multicontêiner, rede e volumes

Intermediário11 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

O Compose descreve uma aplicação inteira — o serviço, o banco de dados, um broker de mensagens, um proxy reverso — em um único arquivo que qualquer desenvolvedor sobe com um só comando. Este guia apresenta o formato do arquivo como ele é no Compose v2 e depois se aprofunda nas duas áreas que mais causam confusão: como os contêineres se encontram em uma rede do Compose e como os dados sobrevivem quando os contêineres não sobrevivem.

O que você vai aprender

  • Escrever um compose.yaml que descreva vários serviços com build, image, ports e environment
  • Explicar como a rede do projeto e o DNS embutido permitem que os serviços se encontrem pelo nome
  • Ordenar a inicialização corretamente com healthchecks e condições de depends_on
  • Escolher entre volumes nomeados, bind mounts e tmpfs, e fazer backup de um volume
  • Compor a configuração em camadas com arquivos env, interpolação de variáveis, perfis e arquivos de override
  • Rodar uma stack do Compose em um job de CI sem vazar estado entre builds

Uma aplicação moderna raramente é um processo só. Um serviço web precisa de um banco de dados, provavelmente de um cache, muitas vezes de um broker de mensagens, talvez de um proxy reverso e de um capturador de e-mails para desenvolvimento. Subir tudo isso na mão com docker run significa lembrar seis linhas de comando com as flags certas, na ordem certa, na rede certa — exatamente o tipo de conhecimento que vive no histórico do shell de uma única pessoa e em nenhum outro lugar.

O Compose troca tudo isso por um arquivo. O compose.yaml descreve os serviços, as redes entre eles e o armazenamento por baixo, e docker compose up coloca tudo de pé. Este guia parte do princípio de que você já está à vontade com imagens e contêineres — o guia de fundamentos do Docker cobre isso — e se concentra no formato do arquivo, no modelo de rede que surpreende quem está começando e nas decisões de armazenamento que determinam se os seus dados sobrevivem.

O arquivo e o que mudou na v2

O Compose v2 é um plugin escrito em Go, invocado como docker compose (um subcomando, sem hífen). O antigo binário em Python docker-compose foi aposentado e, com ele, dois hábitos: a chave version: de nível superior está obsoleta e gera um aviso, e o nome de arquivo preferido é compose.yaml em vez de docker-compose.yml — embora as quatro variações ainda sejam reconhecidas.

Aqui está uma stack completa para um serviço Spring Boot com PostgreSQL e Redis:

name: shop

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        JAR_FILE: target/app.jar
    image: acme/shop-api:dev
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
      SPRING_DATASOURCE_USERNAME: shop
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
      SPRING_DATA_REDIS_HOST: cache
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 3s
      retries: 6
      start_period: 40s
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./ops/initdb:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
      interval: 5s
      timeout: 3s
      retries: 10
    ports:
      - "127.0.0.1:5432:5432"

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--save", "60", "1", "--appendonly", "no"]
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

Subir, acompanhar, derrubar:

docker compose up -d --build
docker compose ps
docker compose logs -f api
docker compose exec db psql -U shop -d shop
docker compose down          # containers and networks; volumes survive
docker compose down -v       # ...and delete the named volumes too

A chave name: define o nome do projeto, que vira prefixo de cada contêiner, rede e volume criados pelo Compose. Sem ela, o Compose usa o nome do diretório — e é por isso que dois clones do mesmo repositório em pastas com nomes diferentes acabam, sem ninguém perceber, com bancos de dados separados.

Rede: a parte que pega as pessoas de surpresa

Quando o Compose sobe um projeto, ele cria uma rede bridge definida pelo usuário chamada <project>_default e conecta todos os serviços a ela. O Docker roda um servidor DNS embutido nessa rede, e cada serviço é resolvido pelo seu nome de serviço. Assim, a API acessa o PostgreSQL em db:5432 e o Redis em cache:6379, sem nenhum endereço IP no meio.

Daí vêm três consequências, e cada uma corresponde a uma pergunta muito frequente.

Dentro de um contêiner, localhost é o próprio contêiner. Uma aplicação configurada com jdbc:postgresql://localhost:5432/shop vai falhar, porque não existe PostgreSQL no namespace de rede do contêiner da API. Use o nome do serviço.

O tráfego entre contêineres ignora ports. A chave ports publica uma porta do contêiner no host, para o seu navegador e o seu cliente de banco de dados. Serviços na mesma rede conversam diretamente pela porta do contêiner, esteja ela publicada ou não. É por isso que o exemplo publica o PostgreSQL como 127.0.0.1:5432:5432 — vinculado apenas à interface de loopback, para que um notebook no Wi-Fi de um café não ofereça o banco de desenvolvimento para todo mundo no salão.

expose não publica nada. Assim como o EXPOSE em um Dockerfile, é documentação. Remova se estiver confundindo as pessoas.

Você pode definir redes adicionais para segmentar o tráfego, o que vale a pena assim que a stack tem uma borda pública e um núcleo privado:

services:
  proxy:
    image: nginx:1.27-alpine
    ports: ["80:80", "443:443"]
    networks: [edge]
    volumes:
      - ./ops/nginx.conf:/etc/nginx/conf.d/default.conf:ro

  api:
    build: .
    networks: [edge, backend]

  db:
    image: postgres:16-alpine
    networks: [backend]

networks:
  edge:
  backend:
    internal: true      # no route to the outside world

Agora o proxy não consegue acessar o banco de forma alguma, e o banco não consegue abrir conexões de saída. É uma fronteira de segurança real, imposta pela pilha de rede, e não uma convenção.

Mais duas opções aparecem com frequência. aliases dá a um serviço nomes DNS extras em uma rede, útil quando uma aplicação espera um hostname específico como kafka.internal. E network_mode: host remove totalmente o isolamento de rede, entregando ao contêiner as interfaces do host; às vezes é necessário para ferramentas de rede, quebra a descoberta de serviços e não funciona do mesmo jeito no Docker Desktop, então evite, a menos que não haja alternativa.

Ordem de inicialização e healthchecks

Na forma curta, depends_on só controla a ordem em que os contêineres são criados e iniciados. Ele não espera nada ficar pronto lá dentro, e é por isso que tantas stacks falham no primeiro up e funcionam no segundo.

A forma longa resolve isso usando o healthcheck da dependência:

depends_on:
  db:
    condition: service_healthy
  migrations:
    condition: service_completed_successfully

service_healthy espera até o HEALTHCHECK do alvo reportar que está saudável; service_completed_successfully espera um contêiner de execução única — uma migração de schema, um script de carga inicial — terminar com código zero. Um healthcheck, portanto, cumpre duas funções: mostra o estado de um serviço em execução no docker compose ps e permite que outros serviços esperem por algo que faça sentido.

Escreva healthchecks que testem a dependência de verdade, não o processo. pg_isready confirma que o PostgreSQL aceita conexões; uma verificação TCP na porta 5432 passaria enquanto o banco ainda está em recuperação. Dê aos serviços lentos um start_period generoso, durante o qual as falhas não contam para retries — caso contrário, um serviço JVM que leva 40 segundos para subir será marcado como unhealthy e reiniciado para sempre.

Mesmo com tudo isso, as aplicações devem tentar de novo a primeira conexão. A ordenação do Compose só cobre a inicialização; em tempo de execução, um contêiner de banco pode reiniciar a qualquer momento, e um serviço que morre de vez porque a dependência saiu do ar por um instante é frágil em qualquer ambiente, não só neste.

Volumes e onde os dados realmente ficam

A camada gravável de um contêiner desaparece quando o contêiner é removido. Tudo o que precisa sobreviver a um docker compose down exige um volume. O Compose oferece três tipos.

Um volume nomeado é um armazenamento que o Docker cria e gerencia em um diretório próprio. É o padrão certo para bancos de dados e qualquer outro estado: sobrevive à recriação dos contêineres, pode ser copiado para backup e movido, e as permissões ficam a cargo da imagem, e não dos IDs de usuário do seu host.

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

Um bind mount mapeia um caminho do host para dentro do contêiner. O conteúdo é exatamente o que está no seu disco, o que o torna ideal para o código-fonte que você quer editar ao vivo, arquivos de configuração e dados iniciais somente leitura:

volumes:
  - ./ops/nginx.conf:/etc/nginx/conf.d/default.conf:ro
  - ./src:/app/src

Adicione :ro a tudo o que o contêiner não tem motivo para gravar. Lembre-se de que arquivos em bind mount mantêm o dono que têm no host, então um contêiner rodando com um usuário não root pode não conseguir gravar neles — de longe a causa mais comum de erros de permissão em uma stack do Compose. Em sistemas com SELinux, acrescente :z (compartilhado) ou :Z (privado) para reetiquetar o caminho.

Uma montagem tmpfs coloca um diretório na memória do host, nunca em disco. Use para espaço temporário e para bancos de teste em que a durabilidade não importa, mas a velocidade sim:

tmpfs:
  - /tmp
  - /var/lib/postgresql/data:size=512m

Fazer backup de um volume nomeado é rodar um contêiner que o monta e grava um arquivo compactado em um lugar que você controla:

docker run --rm \
  -v shop_pgdata:/data:ro \
  -v "$PWD":/backup \
  alpine tar -czf /backup/pgdata-$(date +%F).tar.gz -C /data .

Para um banco de dados, prefira a ferramenta de dump do próprio banco — docker compose exec db pg_dump -U shop shop > shop.sql —, porque uma cópia em nível de arquivo de um diretório de dados em uso pode ficar inconsistente.

docker compose down mantém os volumes de propósito; -v os remove. Essa flag já apagou muito dado local que alguém achava descartável, então saiba qual dos dois você digita no automático.

Configuração em camadas

Fixar valores no compose.yaml o torna inútil fora de uma única máquina. O Compose oferece vários mecanismos que se combinam.

A interpolação de variáveis lê do shell e de um arquivo .env ao lado do arquivo do Compose. A sintaxe aceita valores padrão e valores obrigatórios:

environment:
  LOG_LEVEL: ${LOG_LEVEL:-info}                   # default when unset or empty
  DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
  TAG: ${TAG:-dev}

Não confunda o arquivo .env (interpolado no próprio arquivo do Compose, no momento do parsing) com env_file: em um serviço (uma lista de arquivos cujo conteúdo vira variáveis de ambiente dentro do contêiner). Eles resolvem problemas diferentes, e o pessoal pega o errado o tempo todo.

services:
  api:
    env_file:
      - ./config/common.env
      - ./config/local.env      # later files win

Os arquivos de override permitem especializar uma definição base. docker compose up mescla automaticamente o compose.yaml com o compose.override.yaml, se ele existir. Para qualquer outra coisa, informe os arquivos explicitamente:

docker compose -f compose.yaml -f compose.prod.yaml up -d

O arquivo base descreve a aplicação; o override acrescenta conveniências de desenvolvimento (código-fonte em bind mount, portas de debug, um capturador de e-mails) ou configurações de produção (limites de recursos, políticas de reinício, uma tag de imagem fixa em vez de um build).

Os perfis ligam e desligam serviços opcionais dentro de um mesmo arquivo:

services:
  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]
    ports: ["8025:8025"]
  loadtest:
    image: grafana/k6
    profiles: ["perf"]

Serviços sem a chave profiles sempre sobem; os que têm perfil só sobem quando solicitados com --profile dev ou COMPOSE_PROFILES=dev. Assim um único arquivo continua sendo a referência, em vez de surgirem compose.dev.yaml, compose.demo.yaml e por aí vai.

Por fim, as âncoras YAML e a chave extends eliminam a repetição entre serviços parecidos:

x-service-defaults: &defaults
  restart: unless-stopped
  logging:
    driver: json-file
    options: { max-size: "10m", max-file: "3" }

services:
  api:    { <<: *defaults, image: acme/shop-api:dev }
  worker: { <<: *defaults, image: acme/shop-worker:dev }

Vale copiar essas opções de logging: o driver json-file padrão não tem limite de tamanho, e um contêiner tagarela em um host que fica muito tempo no ar vai encher o disco de logs.

Compose em um job de CI

O Compose é uma ótima forma de subir as dependências dos testes de integração, desde que você trate dois problemas: builds rodando ao mesmo tempo no mesmo agente e estado que sobra de execuções anteriores.

Isole cada execução com um nome de projeto único e torne a limpeza incondicional:

export COMPOSE_PROJECT_NAME="ci-${BUILD_NUMBER}"
trap 'docker compose down -v --remove-orphans' EXIT

docker compose up -d --wait --wait-timeout 120
docker compose run --rm tests

--wait bloqueia até que todo serviço com healthcheck reporte que está saudável e retorna um código diferente de zero se algum não ficar, o que substitui os loops de sleep que antigamente entulhavam os scripts de CI. run --rm inicia um contêiner avulso a partir de uma definição de serviço, se conecta a ele e retorna o código de saída — então o resultado do job de CI é o resultado da suíte de testes.

Para uma suíte definida como serviço, --abort-on-container-exit junto com --exit-code-from consegue o mesmo para a stack inteira:

docker compose up --abort-on-container-exit --exit-code-from tests

Em CI, evite totalmente portas publicadas no host: dois builds simultâneos tentando ocupar a 8080 vão colidir. Deixe o contêiner de testes acessar o serviço pelo nome na rede do Compose. O guia de pipelines no Jenkins mostra como os relatórios resultantes são publicados.

Pense também se você precisa mesmo do Compose no próprio processo de testes — em projetos JVM, o Testcontainers sobe as mesmas imagens de forma programática a partir do código de teste, o que elimina a coordenação entre um script de shell e uma suíte de testes.

Diagnosticando uma stack que não sobe

Uma sequência curta resolve a maioria dos problemas:

  1. Validar a configuração mesclada

    docker compose config imprime o arquivo totalmente resolvido, com as variáveis substituídas e os overrides mesclados. A maioria das queixas de “minha variável está vazia” termina aqui.
  2. Olhar status e códigos de saída

    docker compose ps -a mostra quais serviços terminaram e com qual código. 137 significa que o contêiner foi morto, geralmente por falta de memória; 1 geralmente significa que o processo não conseguiu iniciar.
  3. Ler os logs do serviço com falha

    docker compose logs --tail=100 db. As imagens de banco de dados exibem mensagens claras sobre um diretório de dados inicializado com outras credenciais — sintoma clássico de um volume nomeado desatualizado.
  4. Testar a conectividade por dentro

    docker compose exec api sh -c 'getent hosts db; nc -zv db 5432' distingue um problema de DNS de um problema de listener e de um problema de configuração da aplicação.
  5. Recriar do zero

    docker compose down -v && docker compose up --build. Se isso resolver, o problema era estado desatualizado, e vale saber qual volume o guardava.

Onde as equipes erram

  • Supor que depends_on espera o serviço ficar pronto. Adicione healthchecks e a condição da forma longa.
  • Publicar todas as portas em 0.0.0.0. Vincule os bancos de desenvolvimento a 127.0.0.1 e publique só o que um navegador precisa.
  • Editar arquivos dentro de um contêiner em execução. A alteração some na recriação. Mude a imagem ou monte o arquivo com bind mount.
  • Guardar segredos no compose.yaml. O arquivo vai para o repositório. Use .env (ignorado pelo git) ou Docker secrets, e nunca reutilize credenciais de produção localmente.
  • Construir imagens implicitamente em produção. Uma stack de produção deve referenciar uma tag imutável gerada pela CI, e não um build: a partir de qualquer código que esteja no servidor.
  • Deixar latest no arquivo. Fixe postgres:16-alpine, não postgres. Um salto de versão major em um docker compose pull não vai migrar seus dados por você.
  • Nunca fazer limpeza. docker system prune e docker volume ls merecem um lugar na sua rotina; volumes órfãos de nomes de projeto antigos se acumulam em silêncio.

Principais pontos

  • O Compose v2 roda como docker compose, prefere compose.yaml e não quer mais a chave version:; defina name: para que a identidade do projeto não dependa do nome do diretório.
  • Todo serviço entra em uma rede de projeto com DNS embutido, então os contêineres se encontram por nome de serviço e porta do contêiner; ports serve apenas para acesso a partir do host, e expose não faz nada.
  • Segmente com uma segunda rede e internal: true quando quiser um banco de dados que realmente não possa ser acessado pela borda.
  • depends_on sozinho ordena a inicialização; healthchecks com condition: service_healthy ou service_completed_successfully fazem com que ele signifique “pronto” — e as aplicações ainda devem tentar de novo.
  • Volumes nomeados para dados, bind mounts para código e configuração (com :ro sempre que possível), tmpfs para espaço temporário; faça backup dos bancos com as ferramentas de dump deles.
  • Monte a configuração em camadas com interpolação via .env, env_file, arquivos de override e perfis, em vez de copiar o arquivo inteiro para cada ambiente.
  • Em CI, dê a cada execução o seu próprio nome de projeto, use --wait e run --rm, evite vincular portas do host e faça a limpeza com down -v em um trap.

Fontes e leituras complementares

Perguntas frequentes

Por que minha aplicação não consegue se conectar ao banco em localhost:5432?
Dentro de um contêiner, localhost é o namespace de rede daquele próprio contêiner, ou seja, aponta para a própria aplicação. Os outros serviços da mesma rede do Compose são acessados pelo nome do serviço e pela porta em que o processo escuta dentro do contêiner dele — db:5432 —, independentemente do que você publicou no host. Portas publicadas servem para acesso a partir do seu notebook, não para tráfego entre contêineres.
depends_on basta para o banco estar pronto antes de a aplicação subir?
Não. O depends_on simples só ordena a criação dos contêineres; o processo do banco lá dentro pode ainda estar inicializando. Adicione um healthcheck ao serviço do banco e use depends_on com a condição service_healthy para que o Compose espere a verificação passar. Mesmo assim, as aplicações devem tentar de novo a primeira conexão, porque contêineres reiniciam e dependências também somem em tempo de execução.
Qual a diferença entre um volume nomeado e um bind mount?
Um volume nomeado é um armazenamento que o Docker cria e gerencia, portável entre máquinas e fácil de copiar para backup ou de passar de um contêiner para outro. Um bind mount mapeia um caminho específico do host para dentro do contêiner, então o conteúdo e as permissões vêm do seu sistema de arquivos. Use volumes nomeados para dados, como os arquivos de um banco, e bind mounts para o código-fonte que você quer editar ao vivo durante o desenvolvimento.
Dá para usar o Compose em produção?
Para um único host com poucos serviços e tolerância a uma breve indisponibilidade durante as atualizações, sim — muitas implantações pequenas funcionam exatamente assim. O que você não tem é agendamento entre várias máquinas, rolling updates com rollback baseado na saúde dos serviços, autoscaling horizontal nem autorrecuperação em caso de falha de um nó. Quando isso passa a importar, as mesmas definições de serviço se traduzem de forma bem direta em objetos do Kubernetes.