Pular para o conteúdo

    Capítulo 60, Backend

    Docker para Python

    Um contêiner empacota a aplicação com tudo de que ela precisa, de modo que ela rode igual no seu notebook, no CI e no servidor. Escrever um bom Dockerfile é uma questão de ordem das camadas e de menos privilégio.

    Os arquivos deste capítulo estão em exemplos/api_pedidos/.

    O que é uma imagem

    Uma imagem é uma pilha de camadas somente-leitura, e cada instrução do Dockerfile cria uma. Um contêiner é uma imagem em execução. O Docker guarda cada camada em cache e só a refaz se a instrução ou os arquivos que ela usa mudaram. A consequência prática governa todo o desenho: o que muda raramente vai primeiro, e o que muda sempre (o seu código) vai por último.

    O Dockerfile da API de pedidos

    Ele tem dois estágios. No primeiro, o uv instala as dependências a partir do uv.lock (as mesmas versões que você testou). No segundo, só o resultado vai para a imagem final: sem o uv, sem cache, sem ferramentas de compilação:

    exemplos/api_pedidos/Dockerfile
    # Estágio 1: instala as dependências com o uv
    FROM python:3.13-slim AS construcao
    COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
    ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
    WORKDIR /app
    
    # Dependências primeiro: esta camada só é refeita quando o uv.lock muda
    COPY pyproject.toml uv.lock README.md ./
    RUN --mount=type=cache,target=/root/.cache/uv \
        uv sync --locked --no-install-project --no-dev
    
    COPY src ./src
    COPY migrations ./migrations
    COPY alembic.ini ./
    RUN --mount=type=cache,target=/root/.cache/uv \
        uv sync --locked --no-dev
    
    # Estágio 2: imagem final, sem o uv e sem ferramentas de construção
    FROM python:3.13-slim
    RUN useradd --create-home --uid 10001 app
    WORKDIR /app
    COPY --from=construcao --chown=app:app /app /app
    ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
    USER app
    EXPOSE 8000
    HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
      CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/saude').status == 200 else 1)"
    CMD ["uvicorn", "api_pedidos.app:criar_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
    

    Cada decisão tem um motivo:

    DecisãoMotivo
    Copiar pyproject.toml e uv.lock antes do códigoA camada das dependências só é refeita quando elas mudam, e não a cada alteração de código
    uv sync --locked --no-devReproduzível (falha se o lock estiver desatualizado) e sem pytest, mypy ou ruff na imagem de produção
    Dois estágios (multi-stage)A imagem final é menor e tem menos superfície de ataque
    useradd e USER appRodar como root dentro do contêiner agrava qualquer vulnerabilidade
    CMD na forma de listaO uvicorn vira o processo principal e recebe o SIGTERM do orquestrador, encerrando com elegância
    PYTHONUNBUFFERED=1Os logs saem na hora para a saída padrão, onde o Docker os coleta
    HEALTHCHECKO orquestrador sabe quando a aplicação está de fato respondendo

    O que fica de fora da imagem

    O .dockerignore mantém lixo e segredos fora do contexto de construção. Sem ele, o COPY poderia levar o seu .env e o seu .venv para dentro da imagem:

    exemplos/api_pedidos/.dockerignore
    .venv
    __pycache__
    .pytest_cache
    .mypy_cache
    .ruff_cache
    .git
    .env
    *.db
    tests
    

    Construir e rodar

    Terminal
    docker build -t api-pedidos .
    docker run --rm -p 8000:8000 -e API_AMBIENTE=dev api-pedidos
    

    A configuração entra por variáveis de ambiente (-e), exatamente como o capítulo 58 ensinou. A imagem é a mesma em todos os ambientes.

    Docker Compose: a aplicação com o banco

    Uma API real precisa do banco. O Compose descreve os serviços, as variáveis e a ordem em que sobem. Aqui, o PostgreSQL só é considerado pronto quando o seu healthcheck passa, a migração roda uma vez e termina, e só então a API sobe:

    exemplos/api_pedidos/compose.yaml
    services:
      banco:
        image: postgres:16
        environment:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: pedidos
        volumes:
          - dados_pg:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U app -d pedidos"]
          interval: 5s
          timeout: 3s
          retries: 10
    
      migracao:
        build: .
        command: ["alembic", "upgrade", "head"]
        environment:
          API_AMBIENTE: prod
          API_DATABASE_URL: postgresql+psycopg://app:app@banco:5432/pedidos
        depends_on:
          banco:
            condition: service_healthy
    
      api:
        build: .
        ports:
          - "8000:8000"
        environment:
          API_AMBIENTE: prod
          API_DATABASE_URL: postgresql+psycopg://app:app@banco:5432/pedidos
        depends_on:
          banco:
            condition: service_healthy
          migracao:
            condition: service_completed_successfully
    
    volumes:
      dados_pg:
    
    Terminal
    docker compose up --build
    docker compose logs -f api
    docker compose down -v
    

    O depends_on com service_healthy e service_completed_successfully resolve o problema clássico de "a API subiu antes do banco". O down -v apaga também o volume do banco, e sem o -v os dados sobrevivem entre execuções.

    O que nunca fazer em um Dockerfile

    Copiar o .env para a imagem, colocar uma senha em ENV ou em ARG (ficam gravadas nas camadas e no histórico), usar a tag latest da imagem base em produção (a build deixa de ser reprodutível), rodar como root, ou instalar as ferramentas de teste na imagem final. E um contêiner roda um processo principal: se você precisa de dois, são dois serviços.

    Migração é um passo do deploy, não da partida da API

    Se a API aplicasse as migrações ao subir, duas réplicas subindo juntas tentariam migrar ao mesmo tempo. Por isso a migração é um serviço separado, que roda uma vez antes de qualquer réplica da API. Em Kubernetes, esse papel é de um Job.