Pra começar, por que eu escolhi usar Docker?


Uma pergunta que eu faço quando pretendo começar a aprender uma nova ferramenta é: qual problema ela resolve, quais são as alternativas, e como esse problema seria resolvido sem ela?
Dessa forma, o propósito da ferramenta fica claro — assim como se realmente faz sentido usá-la.

O Docker foi amplamente adotado porque resolve uma dor universal: a padronização do ambiente em que uma aplicação roda. Sem ele, caímos na famosa frase: “na minha máquina funciona”.

No segundo post deste blog, Arquitetura e decisões do LearningSea (visão geral) eu cito brevemente a história de uma colega de trabalho que enfrentava dificuldades recorrentes com ambientes de desenvolvimento:

Algum tempo atrás, uma colega de trabalho comentou sobre dificuldades recorrentes com ambientes de desenvolvimento. Dependências que não instalavam corretamente, configurações que quebravam com frequência e inconsistências entre máquinas.
Os problemas envolviam versões de banco de dados, conflitos de portas e diferenças nas versões da linguagem.

Eu queria que meu projeto rodasse de forma independente do ambiente de desenvolvimento, com uma abordagem prática e responsabilidades bem definidas.
O Docker me proporcionou isso — vamos entender como a seguir.

Como Docker resolve esse problema


Com o Docker, a infraestrutura da aplicação passa a ser definida como código explícito. Em vez de instalar manualmente todos os softwares necessários na sua máquina, você instala apenas o Docker — e todo o restante é descrito e executado por ele: sistema base, dependências, configurações e ponto de entrada.

Uma analogia útil para entender o Docker são as máquinas virtuais — com ressalvas importantes. Assim como nelas, você tem um ambiente isolado, com seu próprio filesystem, rede interna e DNS. A diferença é que o Docker é significativamente mais leve e não virtualiza um sistema operacional completo.

O trade-off

É importante entender que o Docker não elimina a complexidade — ele a desloca. Ao adotar a ferramenta, você passa a lidar com uma nova camada, o que de fato adiciona complexidade.
Em troca, você ganha um ambiente previsível, explícito e consistente, que se comporta da mesma forma em qualquer sistema que tenha Docker.

Como é na prática?


Na prática, temos dois arquivos centrais na configuração de um ambiente com Docker: O Dockerfile e o docker-compose.yml.

Scripts de entrypoint também são comuns — eles são pontos de entrada que definem comandos executados na inicialização do container, como checagens de conexão com o banco de dados para evitar race conditions (quando um serviço depende de outro que ainda não está pronto).

Imagem & Dockerfile

Uma imagem é um conjunto de camadas read-only que define o estado de um ambiente. A partir dela, containers são criados como instâncias em execução.

O Dockerfile define como uma imagem é construída. Essa imagem descreve tudo o que a aplicação precisa para funcionar: a base do sistema (como uma imagem com Python), dependências, configurações e comandos necessários para sua execução.

Imagens prontas podem ser encontradas no repositório oficial do Docker, o Docker Hub

Exemplo:

Dockerfile

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update && apt-get install -y \
    libpq-dev \
    netcat-openbsd \
    && rm -rf /var/lib/apt/lists/*

COPY src/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY src/ .

COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
  • FROM python:3.12-slim: define a imagem base com Python sobre uma distro Linux minimalista
  • WORKDIR /app: define o diretório padrão para execução de comandos
  • RUN apt-get update && apt-get install -y ...: instala dependências do sistema necessárias para a aplicação
  • libpq-dev: são headers necessários para compilar os drivers do PostgreSQL
  • netcat-openbsd: ferramenta utilizada para checar disponibilidade de serviços (ex: porta do DB)
  • rm -rf /var/lib...: remove o cache do apt para reduzir o tamanho da imagem
  • COPY src/requirements.txt: copia a lista de dependências Python para o container
  • RUN pip install --no-cache-dir -r requirements.txt: instala as dependências sem cache
  • COPY src/ .: copia o código fonte da aplicação para dentro da imagem
  • COPY docker/entrypoint.sh /entrypoint.sh: copia o script de entrypoint
  • RUN chmod +x /entrypoint.sh: torna o script executável
  • ENTRYPOINT ["/entrypoint.sh"]: define ele como processo principal do container

Containers & Docker Compose

Um container é uma instância em execução de uma imagem, com uma camada de escrita sobre ela.

Por isso, a imagem é imutável, enquanto o container representa uma camada efêmera acima dela.
Quando um container é parado, seus processos são encerrados, e qualquer dado não persistido é perdido. A persistência é feita por meio de volumes, que serão explicados adiante.

Um pouco de contexto histórico: o Docker começou focado em containers individuais, e o Compose surgiu como uma camada acima para orquestrar múltiplos serviços — inicialmente separado, mas eventualmente integrado por se tornar parte essencial do fluxo de uso.

O Docker Compose é um subcomando responsável por inicializar e gerenciar os containers definidos no arquivo docker-compose.yml.

Uma forma prática de entender containers é como ambientes isolados em execução, com seu próprio filesystem, rede e processos. Um detalhe importante é que eles compartilham o kernel do sistema operacional do host.

Exemplo:

docker-compose.yml

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 5s
      retries: 5

  web:
    build: .
    environment:
      DB_HOST: db
      DB_NAME: app
      DB_USER: app
      DB_PASSWORD: app
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - static_files:/app/staticfiles
      - media_files:/app/media

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - static_files:/srv/static
      - media_files:/srv/media
    depends_on:
      - web

volumes:
  db_data:
  static_files:
  media_files:
  • services define os containers que serão orquestrados pelo Compose
  • db
    • image: usa a imagem oficial do PostgreSQL baseada em Alpine
    • environment: injeta variáveis de configuração no container
    • volumes: define persistência fora do ciclo de vida do container
    • healthcheck: define verificação periódica de saúde do serviço
  • web
    • build: constrói a imagem a partir do Dockerfile local
    • environment: variáveis usadas pela aplicação para conexão com o banco
    • depends_on: define dependência e ordem de inicialização entre serviços
    • condition: aguardar o healthcheck do banco antes de iniciar
    • volumes: monta volumes para persistência e compartilhamento de dados
    • static_files:/app/staticfiles: diretório onde a aplicação escreve arquivos estáticos
    • media_files:/app/media: diretório onde a aplicação armazena arquivos de mídia
  • caddy
    • image: servidor web / reverse proxy leve
    • ports: mapeia portas do host para o container (host:container)
    • volumes: monta arquivos de configuração e volumes compartilhados no container
    • static_files:/srv/static: acesso aos arquivos estáticos gerados pelo backend
    • media_files:/srv/media: acesso aos arquivos de mídia para servir diretamente
    • depends_on: define ordem de inicialização

Volumes

Os volumes são o que permitem a persistência de dados no Docker. Também são a forma padrão de compartilhar dados entre containers.
Esses volumes são montados em caminhos específicos dentro dos containers, permitindo leitura e escrita.

No fluxo acima (docker-compose.yml):

  • O container web escreve arquivos em /app/staticfiles e /app/media
  • O container caddy acessa esses mesmos dados em /srv/static e /srv/media

Ou seja, múltiplos containers podem montar o mesmo volume em caminhos diferentes, mantendo acesso ao mesmo conteúdo.

Containers não compartilham estado diretamente — volumes são o mecanismo de troca de dados entre serviços.

O backend escreve arquivos, e o proxy (Caddy) os serve diretamente (configurado no Caddyfile), evitando passar pela aplicação.

Entrypoint

O entrypoint define o comando base do container, funcionando como ponto de entrada para sua execução. Ele é sempre executado ao iniciar o container e pode ser complementado por argumentos.

Exemplo:

entrypoint.sh

#!/bin/sh

while ! nc -z $DB_HOST 5432; do
  sleep 1
done

echo "Database is up"

python manage.py migrate

exec gunicorn config.wsgi:application --bind 0.0.0.0:8000
  • #!/bin/sh: define o interpretador do script sh (shebang)
  • nc -z $DB_HOST 5432: testa a abertura de conexão TCP com o banco
  • while ! ...; do sleep 1; done: loop de espera até o serviço ficar disponível
  • python manage.py migrate: executa migrações antes de inicializar a aplicação
  • exec gunicorn ...: substitui o processo do shell pelo servidor WSGI (PID 1)
  • --bind 0.0.0.0:8000: expõe o serviço em todas as interfaces do container

O que temos até aqui?


Até aqui definimos:

  • o ambiente de aplicação de forma explícita (Dockerfile)
  • os serviços que compõem a infraestrutura (docker-compose.yml)
  • o ponto de entrada da aplicação (entrypoint.sh)

O fluxo de inicialização


O fluxo completo a partir da execução do docker compose up --build é o seguinte:

  1. O Docker constrói a imagem da aplicação com base no Dockerfile
  2. O serviço db é iniciado a partir da imagem do PostgreSQL
  3. O healthcheck do banco começa a ser executado
  4. O serviço web aguarda até que o banco esteja saudável (service_healthy) e então é iniciado
  5. O entrypoint.sh é executado como processo principal
  6. O script aguarda a disponibilidade do banco (checagem de porta)
  7. As migrações são aplicadas
  8. O Gunicorn é iniciado e passa a servir a aplicação
  9. O serviço caddy é iniciado e passa a atuar como reverse proxy

Conclusão


Com Docker, todo esse processo deixa de ser implícito e manual, e passa a ser definido de forma declarativa e reproduzível.

Esses exemplos são versões aproximadas do que utilizo no LearningSea. No entanto, várias partes foram removidas ou simplificadas para tornar o conteúdo mais didático e menos extenso.