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:
# 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ão | Motivo |
|---|---|
Copiar pyproject.toml e uv.lock antes do código | A camada das dependências só é refeita quando elas mudam, e não a cada alteração de código |
uv sync --locked --no-dev | Reproduzí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 app | Rodar como root dentro do contêiner agrava qualquer vulnerabilidade |
CMD na forma de lista | O uvicorn vira o processo principal e recebe o SIGTERM do orquestrador, encerrando com elegância |
PYTHONUNBUFFERED=1 | Os logs saem na hora para a saída padrão, onde o Docker os coleta |
HEALTHCHECK | O 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:
.venv
__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
.git
.env
*.db
tests
Construir e rodar
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:
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:
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
.envpara a imagem, colocar uma senha emENVou emARG(ficam gravadas nas camadas e no histórico), usar a taglatestda imagem base em produção (a build deixa de ser reprodutível), rodar comoroot, 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.