0xEtern4lW0lf
Infraestrutura

Docker Compose na prática: subindo uma stack de IA local com cinco serviços

6 min de leitura
#Docker#Docker Compose#containers#healthcheck#self-hosted

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. $VAR seria substituído pelo Compose antes de o container nascer; $$VAR chega literalmente como $VAR para 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:

  • .env no .gitignore, sempre;
  • .env.example versionado, 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.