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:
Validar a configuração mesclada
docker compose configimprime 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.Olhar status e códigos de saída
docker compose ps -amostra 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.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.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.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_onespera 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 a127.0.0.1e 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
latestno arquivo. Fixepostgres:16-alpine, nãopostgres. Um salto de versão major em umdocker compose pullnão vai migrar seus dados por você. - Nunca fazer limpeza.
docker system pruneedocker volume lsmerecem 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, preferecompose.yamle não quer mais a chaveversion:; definaname: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;
portsserve apenas para acesso a partir do host, eexposenão faz nada. - Segmente com uma segunda rede e
internal: truequando quiser um banco de dados que realmente não possa ser acessado pela borda. depends_onsozinho ordena a inicialização; healthchecks comcondition: service_healthyouservice_completed_successfullyfazem 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
:rosempre 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
--waiterun --rm, evite vincular portas do host e faça a limpeza comdown -vem 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.