Docker Compose na prática: subindo uma stack de IA local com cinco serviços
Estou construindo o Konhecia, um agente de suporte com RAG corporativo que roda
inteiramente na própria infraestrutura — sem chamar API externa de LLM. Antes de
escrever uma linha de lógica, o problema era outro: são quatro serviços de
infraestrutura que precisam subir na ordem certa, se enxergar pela rede e
sobreviver a um reboot. Este post é a base de Docker Compose que eu usaria para
explicar isso para alguém que nunca passou de docker run.
O que o Compose resolve
docker run sobe um container. Um sistema real tem vários, e eles precisam de
três coisas que o docker run só dá na unha: rede compartilhada, ordem de
inicialização e estado que sobrevive ao container.
O Compose descreve tudo isso num único docker-compose.yml declarativo. Você não
diz como subir — diz qual é o estado desejado, e o Compose reconcilia.
docker compose up -d --build # sobe tudo, em background, reconstruindo o que mudou
docker compose logs -f # acompanha os logs de todos os serviços juntos
docker compose down # derruba os containers (mantém os volumes)
Anatomia de um serviço
Cada entrada em services: vira um container. Os campos que realmente importam:
services:
postgres:
image: postgres:16-alpine # imagem pronta, com tag FIXA
restart: unless-stopped # volta sozinho após crash ou reboot
environment:
POSTGRES_USER: ${POSTGRES_USER} # interpolado do .env
ports:
- "5432:5432" # host:container
volumes:
- postgres_data:/var/lib/postgresql/data
Três detalhes que separam um compose de brinquedo de um compose sério:
Fixe a tag da imagem. postgres:16-alpine, qdrant/qdrant:v1.19.1,
ollama/ollama:0.33.3. Nunca :latest. Com latest, a stack que funcionou hoje
pode quebrar amanhã sem que uma linha do seu código mude — e você vai perder uma
tarde descobrindo isso.
Volume nomeado ≠ bind mount. postgres_data:/var/lib/... é um volume
gerenciado pelo Docker: os dados vivem fora do ciclo de vida do container, então
docker compose down e um novo up preservam o banco. Já ./:/code é um bind
mount — mapeia uma pasta do host, ideal para desenvolvimento com hot-reload.
restart: unless-stopped. O container volta após crash ou reboot da máquina,
mas respeita um docker compose stop explícito. É quase sempre o que você quer.
Rede: o serviço vira hostname
Isso é o que mais confunde quem está começando. O Compose cria uma rede própria para o projeto, e o nome do serviço vira o hostname dentro dela.
Por isso, na configuração da aplicação, o host do Postgres é postgres e não
localhost:
DATABASE_URL=postgresql+asyncpg://rag_user:senha@postgres:5432/rag_support
QDRANT_HOST=qdrant
OLLAMA_BASE_URL=http://ollama:11434
E note: essa comunicação não precisa de ports:. A seção ports só existe
para expor o serviço ao host, para você conseguir abrir o Swagger no navegador
ou dar um psql de fora. Serviço falando com serviço usa a rede interna direto.
O corolário de segurança é importante: todo ports: é uma porta a mais na sua
superfície de ataque. Num ambiente compartilhado, "5432:5432" publica o
Postgres na interface da máquina. O correto ali é publicar só em loopback
("127.0.0.1:5432:5432") ou simplesmente remover a linha — se nada de fora
precisa falar com o banco, nada de fora deveria alcançá-lo.
Ordem de inicialização: depends_on não basta
Aqui mora o erro clássico. Isto não faz o que parece:
app:
depends_on:
- postgres
depends_on sozinho garante apenas que o container do Postgres foi iniciado —
não que o Postgres lá dentro já esteja aceitando conexões. O processo leva
alguns segundos a mais, e sua aplicação sobe, tenta conectar e morre.
A forma correta combina healthcheck com condition: service_healthy:
postgres:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
app:
depends_on:
postgres:
condition: service_healthy
Agora o app só é criado depois que o pg_isready retornar sucesso. O
healthcheck é um comando que roda dentro do container em intervalos: saiu
com 0, está saudável.
O caso em que não dá para fazer healthcheck
Nem sempre é possível. A imagem oficial do Qdrant é distroless — não tem shell,
não tem curl, não tem wget. Não existe comando para o test: rodar.
A saída não é inventar um sleep 10. É mover a resiliência para o cliente:
quem conecta implementa retry com backoff na inicialização. Isso é mais robusto de
qualquer forma, porque cobre também o caso do serviço cair depois que tudo
subiu — cenário que healthcheck nenhum resolve para você.
Vale registrar isso como comentário no próprio YAML. Sem a nota, o próximo a ler o arquivo (você, daqui a três meses) vai achar que foi esquecimento.
Container one-shot: trabalho de inicialização
O servidor de LLM precisa dos modelos baixados antes de servir qualquer coisa. São alguns gigabytes. Isso não pertence nem ao Dockerfile (incharia a imagem) nem à aplicação (não é responsabilidade dela).
O padrão é um container efêmero de inicialização: usa a mesma imagem, roda uma
tarefa, termina com exit 0 e some.
ollama-init:
image: ollama/ollama:0.33.3
depends_on:
ollama:
condition: service_healthy
entrypoint:
- /bin/sh
- -c
- |
set -e
ollama pull "$$OLLAMA_LLM_MODEL"
ollama pull "$$OLLAMA_EMBEDDING_MODEL"
volumes:
- ollama_models:/root/.ollama # MESMO volume do servidor
Dois detalhes:
$$escapa a interpolação do Compose.$VARseria substituído pelo Compose antes de o container nascer;$$VARchega literalmente como$VARpara o shell de dentro. Esquecer isso rende uma string vazia e um erro confuso.- O volume compartilhado é o ponto todo. O init escreve os modelos em
ollama_models, o servidor lê do mesmo volume. Sem isso, o download iria para o sistema de arquivos efêmero do init e evaporaria junto com ele.
GPU opcional, degradação graciosa
Reservar uma GPU é declarativo:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
Requer o NVIDIA Container Toolkit no host. O detalhe que economiza suporte: com o Ollama, se a GPU não estiver disponível, ele cai para CPU sozinho — mais lento, porém funcional. Ninguém precisa editar o YAML para rodar o projeto num notebook sem placa. Sempre que der, prefira a dependência opcional que degrada a dependência obrigatória que quebra.
Segredos: .env fora do Git
O Compose lê um .env na raiz automaticamente e interpola ${VARIÁVEL}. A regra
é chata e inegociável:
.envno.gitignore, sempre;.env.exampleversionado, com as chaves e comentários mas sem valores reais;- valores placeholder que gritam, do tipo
troque-esta-chave-antes-de-usar.
Um .env.example bem comentado é a melhor documentação de configuração que um
projeto pode ter, porque é a única que não desatualiza sem ser notada.
O resultado
Cinco serviços — Postgres, Qdrant, servidor de LLM, o init dos modelos e a API — sobem com um comando, na ordem certa, e alguém que clonou o repositório há trinta segundos tem o ambiente inteiro de pé:
cp .env.example .env
docker compose up -d --build
Esse é o teste real de um compose bem escrito: um clone e um comando. Se o README precisa de sete passos manuais antes disso, o problema não está no README.
No próximo post da série, a camada de cima: a API em FastAPI que conversa com esses serviços — injeção de dependência, autenticação e resposta em streaming.