Containerizar uma API Python parece simples — até você precisar de logs estruturados, healthcheck, graceful shutdown e multi-stage build para uma imagem final < 100MB. Esta aula reproduz o template que usamos no lab.

O Dockerfile final (que vamos construir)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# ===== Stage 1: build =====
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# ===== Stage 2: runtime =====
FROM python:3.12-slim
WORKDIR /app

# Non-root user
RUN useradd -m -u 1000 -s /bin/bash app

# Copy installed deps from builder
COPY --from=builder /root/.local /home/app/.local
ENV PATH=/home/app/.local/bin:$PATH

# Copy app code
COPY --chown=app:app . .
USER app

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1

CMD ["gunicorn", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", "--access-logfile", "-", \
"main:app"]

Resultado: imagem final de ~92MB.

Passo 1 — Multi-stage cuts your image in half

O truque do multi-stage é instalar dependências em um stage descartável (builder), e copiar apenas o resultado para o stage final. Sem isso, sua imagem carrega gcc, headers do Python, cache do pip — tudo desnecessário em runtime.

Approach Tamanho final
python:3.12 + pip install 980 MB
python:3.12-slim + pip install 280 MB
python:3.12-slim + multi-stage 92 MB
python:3.12-alpine + multi-stage 68 MB ⚠️

Alpine é menor, mas dá problemas com libs que dependem de glibc (numpy, scipy, pandas). Para APIs simples, vale. Para data-science, fique no slim.

Passo 2 — Por que gunicorn + uvicorn workers?

FastAPI é ASGI (async). Uvicorn é o servidor ASGI de referência. Mas em produção:

  • Uvicorn sozinho: processo único. Se ele crashar, sua API morre.
  • Gunicorn como process manager: fork de N workers, restart automático, graceful shutdown.
  • Workers do tipo UvicornWorker: gunicorn gerencia, uvicorn executa o async.

A combinação dá robustez de process manager + performance async. É o padrão recomendado pela própria documentação do FastAPI.

Passo 3 — Healthcheck que faz sentido

1
2
3
4
5
6
7
8
9
@app.get("/health")
async def health():
# 1. Processo está respondendo? Sim (a função executou)
# 2. Conexão com banco está ok?
try:
await db.execute("SELECT 1")
except Exception:
return Response(status_code=503)
return {"status": "ok"}

Esse /health é usado tanto pelo HEALTHCHECK do Dockerfile quanto pelo liveness/readiness do Kubernetes. Não retorne sempre 200: o healthcheck precisa falhar se o banco cair, senão o orchestrator nunca remove o pod doente do load balancer.

Passo 4 — Logs estruturados

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import logging, json

class JSONFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"ts": self.formatTime(record),
"level": record.levelname,
"msg": record.getMessage(),
"module": record.module,
})

handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])

Logs JSON são indispensáveis para qualquer ferramenta de observability (CloudWatch, Datadog, Grafana Loki) extrair campos.

Passo 5 — Graceful shutdown

1
2
3
4
5
6
7
8
9
10
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app):
# startup
yield
# shutdown: close connections, flush logs
await db.close()

app = FastAPI(lifespan=lifespan)

Sem isso, ao escalar para baixo, conexões DB ficam abertas no servidor, requests em vôo são abortadas.

Resultado

Imagem final pronta para:

  • ECS Fargate
  • Cloud Run
  • Kubernetes
  • Docker Compose em produção (com Caddy/Nginx na frente)

E roda em qualquer plataforma sem mudança.