Deploy de Modelos Scikit-learn com FastAPI: Guia Completo 2026

Como servir modelos scikit-learn em produção com FastAPI: serialização com joblib, endpoint validado por Pydantic, lifespan, Gunicorn + Uvicorn e Docker.

Deploy Scikit-learn + FastAPI 2026

Atualizado: 04 de Julho de 2026

Para fazer deploy de modelos scikit-learn com FastAPI, serialize o modelo treinado com joblib, crie uma aplicação FastAPI com um endpoint /predict validado por Pydantic, e sirva com Uvicorn (ou Gunicorn + workers Uvicorn em produção). Essa combinação entrega tempos de resposta de dezenas de milissegundos, valida entradas automaticamente e gera documentação OpenAPI sem esforço extra. Hoje é o caminho padrão para tirar modelos do Jupyter e colocar em produção com custo por predição controlado.

  • joblib é preferível a pickle para modelos scikit-learn: compressão melhor para arrays NumPy grandes e menor risco de ClassNotFoundError entre versões.
  • Pydantic v2 (padrão do FastAPI 0.115+) valida payloads JSON em Rust, reduzindo overhead de deserialização em 5 a 17 vezes.
  • Use lifespan para carregar o modelo uma única vez na inicialização, nunca dentro do handler da rota.
  • Em produção, sirva com gunicorn -k uvicorn.workers.UvicornWorker -w N onde N ≈ 2×CPU + 1 para cargas mistas I/O + CPU.
  • Sempre exponha /health e /ready para orquestradores (Kubernetes, ECS) distinguirem "processo vivo" de "modelo carregado".
  • Latência p99 abaixo de 50 ms é factível para modelos tabulares até ~1M parâmetros num container 2 vCPU.

Como serializar seu modelo scikit-learn

A primeira decisão de qualquer deploy é como persistir o modelo treinado. No universo scikit-learn você tem três opções realistas em 2026: joblib, pickle puro, e ONNX. Na maior parte dos projetos que já operei em produção, joblib é o padrão sensato. Ele compacta arrays NumPy grandes de forma muito mais eficiente que o pickle padrão e evita boa parte das dores de cabeça com __reduce__ em classes customizadas. A documentação oficial de persistência do scikit-learn lista as três alternativas e recomenda joblib para uso interno controlado.

Uma armadilha que já me queimou numa call de madrugada: nunca serialize um modelo numa versão do scikit-learn e desserialize em outra major. O pickle carrega classes por caminho de módulo, e refatorações internas do sklearn quebram a deserialização silenciosamente. O modelo até "carrega", mas o comportamento das predições muda. Fixe a versão do sklearn no requirements.txt com == e replique-a no ambiente de serving. Honestamente, esse é o tipo de bug que não aparece no CI e só surge quando alguém atualiza uma dependência transitiva.

# train.py — treina um pipeline e persiste com joblib
import joblib
from sklearn.datasets import fetch_openml
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder

X, y = fetch_openml("credit-g", version=1, as_frame=True, return_X_y=True)

numeric_cols = X.select_dtypes("number").columns.tolist()
categorical_cols = X.select_dtypes("category").columns.tolist()

pre = ColumnTransformer([
    ("num", StandardScaler(), numeric_cols),
    ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_cols),
])

pipe = Pipeline([
    ("pre", pre),
    ("clf", GradientBoostingClassifier(n_estimators=200, random_state=42)),
])
pipe.fit(X, y)

joblib.dump(pipe, "model.joblib", compress=3)
print("Modelo salvo em model.joblib")

O parâmetro compress=3 costuma ser o ponto doce: reduz o arquivo em 60 a 80% para modelos com muitos coeficientes float, com custo desprezível na hora de carregar. Já para modelos maiores (mais de 500 MB descomprimido), avalie exportar para sklearn-onnx. O runtime ONNX serve em C++ puro e derruba latência p99 pela metade em modelos árvore-baseados.

Estrutura de projeto recomendada

Uma estrutura de pastas limpa evita o clássico "funciona no notebook, quebra no container". A minha convenção padrão para serviços de inferência scikit-learn tem sido esta há uns dois anos, e sobreviveu a três migrações de plataforma:

ml-service/
├── app/
│   ├── __init__.py
│   ├── main.py         # aplicação FastAPI
│   ├── schemas.py      # modelos Pydantic (request/response)
│   ├── predictor.py    # carregamento do modelo + inferência
│   └── config.py       # settings via pydantic-settings
├── artifacts/
│   └── model.joblib    # NÃO commitar, puxar do object storage
├── tests/
│   ├── test_endpoints.py
│   └── test_predictor.py
├── Dockerfile
├── pyproject.toml
└── requirements.lock

Note três coisas: (1) o artefato do modelo mora numa pasta separada e é baixado do S3/GCS no build ou no boot do container, nunca versionado em git; (2) há um módulo predictor.py isolado, porque quero poder testá-lo sem subir o servidor HTTP; (3) uso requirements.lock gerado por uv pip compile ou pip-tools, com hashes, porque em serving toda dependência flutuante é uma bomba-relógio.

Criando o endpoint /predict com Pydantic

Aqui é onde o FastAPI brilha: você declara o formato de entrada como uma classe Pydantic e ganha validação, coerção de tipos e documentação OpenAPI sem uma linha extra. Desde o Pydantic v2 (usado por padrão a partir do FastAPI 0.100+), a validação é implementada em Rust. Na prática, vejo overhead de 0.3 a 0.8 ms por request contra 3 a 5 ms na v1 para payloads médios.

# app/schemas.py
from pydantic import BaseModel, Field
from typing import Literal

class CreditRequest(BaseModel):
    duration: int = Field(ge=1, le=120, description="Duração do empréstimo em meses")
    credit_amount: float = Field(gt=0)
    age: int = Field(ge=18, le=100)
    employment: Literal["unemployed", "<1", "1<=X<4", "4<=X<7", ">=7"]
    housing: Literal["own", "rent", "for free"]

    model_config = {"json_schema_extra": {"examples": [{
        "duration": 24, "credit_amount": 5000.0, "age": 35,
        "employment": "1<=X<4", "housing": "own",
    }]}}

class CreditResponse(BaseModel):
    prediction: Literal["good", "bad"]
    probability: float = Field(ge=0.0, le=1.0)
    model_version: str

Repare em três detalhes que evitam bugs de produção. Primeiro, Field(ge=..., le=...) impõe limites sanos: sem eles, alguém vai mandar age=-1 e o modelo vai retornar algo sem sentido. Segundo, Literal[...] em vez de str força o cliente a mandar exatamente as categorias que o OneHotEncoder viu no treino. E terceiro, examples gera payloads clicáveis na página /docs, o que reduz muito o atrito com o time de backend consumindo o serviço.

Carregando o modelo com lifespan

O erro mais comum que vejo em código de tutorial: fazer joblib.load dentro da função da rota. Isso re-carrega o modelo do disco em cada request e leva a latências absurdas. A forma correta é usar o lifespan context manager do FastAPI, introduzido para substituir os deprecados @app.on_event.

# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
import joblib
import pandas as pd
from app.schemas import CreditRequest, CreditResponse

MODEL_STATE = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Executado UMA vez ao iniciar o processo worker
    MODEL_STATE["pipe"] = joblib.load("artifacts/model.joblib")
    MODEL_STATE["version"] = "credit-gbc-1.4.2"
    yield
    # Cleanup ao desligar
    MODEL_STATE.clear()

app = FastAPI(title="Credit Scoring API", lifespan=lifespan)

@app.post("/predict", response_model=CreditResponse)
def predict(payload: CreditRequest) -> CreditResponse:
    pipe = MODEL_STATE.get("pipe")
    if pipe is None:
        raise HTTPException(status_code=503, detail="Modelo não carregado")

    df = pd.DataFrame([payload.model_dump()])
    proba = pipe.predict_proba(df)[0]
    idx = int(proba.argmax())
    label = pipe.classes_[idx]

    return CreditResponse(
        prediction=label,
        probability=float(proba[idx]),
        model_version=MODEL_STATE["version"],
    )

@app.get("/health")
def health():
    return {"status": "alive"}

@app.get("/ready")
def ready():
    return {"status": "ready" if "pipe" in MODEL_STATE else "loading"}

Note que /predict é def, não async def. Inferência scikit-learn é CPU-bound e síncrona, e declarar como async bloquearia o event loop e derrubaria o throughput. O FastAPI é esperto o suficiente para rodar funções síncronas num threadpool automaticamente. Escrevi mais sobre esse padrão em nosso guia de análise de dados com DuckDB e Python, onde a mesma regra vale para queries analíticas.

Servidor de produção: Uvicorn vs Gunicorn

Em desenvolvimento, uvicorn app.main:app --reload resolve. Em produção, a resposta padrão que dou é: use Uvicorn gerenciado por Gunicorn com UvicornWorker. Gunicorn traz supervisão de processos (reinicia workers travados, gerencia sinais), enquanto Uvicorn cuida do protocolo ASGI.

AspectoUvicorn standaloneGunicorn + UvicornWorker
Reinício de worker travadoNãoSim (timeout configurável)
Zero-downtime reloadLimitadoSim (SIGHUP)
Multi-workerSim (via --workers)Sim (recomendado)
Overhead de memóriaMenor+15 a 30 MB por worker
Recomendado paraDev, Kubernetes single-processVMs, ECS, cargas mistas

O comando que uso em 90% dos deploys em contêineres:

gunicorn app.main:app \
    -k uvicorn.workers.UvicornWorker \
    -w 4 \
    --bind 0.0.0.0:8000 \
    --timeout 30 \
    --graceful-timeout 15 \
    --access-logfile - \
    --error-logfile -

Sobre o número de workers: para modelos sklearn (CPU-bound puro), 2×CPU_cores + 1 é o ponto de partida. Se o handler faz I/O antes da inferência (buscar features num Redis, por exemplo), suba para 4×CPU_cores. Sempre meça. Use hey ou vegeta em ambiente igual ao de prod, não no seu MacBook.

Empacotando com Docker

Um Dockerfile enxuto para o serviço acima, com imagem final em torno de 180 MB usando python:3.12-slim, com build de camadas separadas para maximizar cache:

# Dockerfile
FROM python:3.12-slim AS base

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

WORKDIR /srv

# 1) Dependências (camada estável)
COPY requirements.lock ./
RUN pip install --require-hashes -r requirements.lock

# 2) Artefato do modelo (puxado do S3 no build)
ARG MODEL_URI=s3://ml-artifacts/credit/1.4.2/model.joblib
COPY scripts/download_model.sh ./
RUN ./download_model.sh "$MODEL_URI" ./artifacts/model.joblib

# 3) Código da aplicação (muda com frequência)
COPY app ./app

EXPOSE 8000
USER 1000:1000

CMD ["gunicorn", "app.main:app", \
     "-k", "uvicorn.workers.UvicornWorker", \
     "-w", "2", "--bind", "0.0.0.0:8000", \
     "--timeout", "30"]

Duas decisões importantes: o modelo entra na imagem no build, não em runtime. Isso evita cold starts de mais de 20 segundos e falhas por rede intermitente. A contrapartida é que cada nova versão do modelo é uma nova imagem. Combine com Argo CD ou Flux para promoção controlada. O outro ponto é o USER 1000:1000: rodar como não-root é requisito básico de qualquer PodSecurityPolicy razoável, e várias plataformas serverless (Cloud Run, App Runner) rejeitam containers rodando como root.

Observabilidade: health checks, logs e métricas

Um serviço de ML sem observabilidade é uma dívida técnica esperando para explodir num plantão. O mínimo que exijo antes de mandar para produção:

  • Dois endpoints de saúde separados: /health responde 200 se o processo está vivo (usado pelo livenessProbe); /ready responde 200 apenas quando o modelo foi carregado (usado pelo readinessProbe). Misturar os dois causa loops de reinício durante o boot.
  • Logs estruturados em JSON com request_id, latência em ms, versão do modelo e classe prevista. Nunca as features do usuário sem consentimento. Uso structlog em cima do logging padrão.
  • Métricas Prometheus via prometheus-fastapi-instrumentator: latência por rota, contagem por status, e um Histogram customizado para model_probability. Se a distribuição de probabilidades muda de forma abrupta, é sinal precoce de data drift.
# Adicionando métricas em app/main.py
from prometheus_fastapi_instrumentator import Instrumentator

Instrumentator().instrument(app).expose(app, endpoint="/metrics")

Para uma análise mais profunda de anomalias e drift em dados de entrada, cruze com nosso guia de detecção de outliers com pandas e scikit-learn. Os mesmos algoritmos que você usou para limpar o treino servem como monitor de entrada em serving.

Erros comuns em produção

Uma lista curta dos tropeços que já vi (ou cometi) mais de uma vez em serviços FastAPI + sklearn rodando com tráfego real:

1. Skew de versão entre treino e serving

Numpy 1.x salva arrays num formato incompatível com numpy 2.x em alguns casos de dtype=object. Fixe versões idênticas nos dois ambientes e adicione um teste que carrega o modelo e faz uma predição num sample fixo durante o CI. Peguei esse bug numa migração de imagem base do CI e passei um sábado inteiro rastreando o culpado.

2. DataFrame com colunas fora de ordem

Se você treinou com X[['a', 'b', 'c']] e o request chega com {'c':..., 'a':..., 'b':...}, o pd.DataFrame([payload.model_dump()]) preserva a ordem do dict, que é a ordem dos campos do Pydantic. Mesmo assim, prefira definir as colunas explicitamente antes do predict: df = df[pipe.feature_names_in_.tolist()].

3. Cold start em serverless

Se está deployando em Cloud Run ou Lambda, o carregamento de joblib.load de um modelo de 200 MB pode levar 3 a 6 segundos. Aumente minInstances ou pré-aqueça com um cron. Alternativa mais radical: exportar para ONNX e usar onnxruntime, que reduz o tempo de load em cerca de 5×.

4. Vazamento de memória por acumulação de DataFrames

Rodar predict_proba num único DataFrame por request está OK. O problema aparece quando você agrega para batch: se acumular DataFrames em memória sem limite, o worker cresce até o OOMKiller matar. Configure --max-requests 1000 --max-requests-jitter 100 no Gunicorn para reciclar workers periodicamente. Se estiver começando com pandas, veja também nosso guia de migração de pandas para Polars. Polars tem controle de memória bem mais previsível para batches grandes.

Perguntas Frequentes

FastAPI é bom para machine learning em produção?

Sim. FastAPI é hoje a escolha padrão para serving de modelos scikit-learn e PyTorch em Python, graças à validação Pydantic, geração automática de OpenAPI e integração natural com Uvicorn/ASGI. Para latências abaixo de 10 ms com modelos grandes, considere runtimes especializados como Triton ou BentoML por cima do FastAPI.

Qual a diferença entre joblib e pickle para salvar modelos?

joblib.dump foi desenhado para lidar com arrays NumPy grandes de forma eficiente. Usa compressão dedicada por buffer e é 2 a 5× mais rápido que pickle padrão em modelos scikit-learn típicos. Use joblib para modelos sklearn; use pickle apenas para objetos Python simples.

Preciso usar async def no endpoint /predict?

Não. Inferência scikit-learn é CPU-bound e síncrona; declarar como async def bloqueia o event loop e reduz o throughput. O FastAPI executa funções def síncronas num threadpool automaticamente. Use async def apenas quando o handler realmente aguarda I/O (chamadas HTTP, Redis, banco).

Como versionar modelos servidos em uma API FastAPI?

A abordagem mais confiável é embutir a versão do modelo (semver ou hash git-lfs) no artefato e retorná-la em cada resposta. Para servir múltiplas versões simultaneamente, use rotas como /v1/predict e /v2/predict ou deploys paralelos com pesos de tráfego controlados pelo ingress (Istio, ALB).

Qual latência esperar de um modelo scikit-learn em FastAPI?

Para modelos tabulares (Random Forest, Gradient Boosting) com até ~1M parâmetros, num container de 2 vCPU, é realista atingir p50 de 5 a 15 ms e p99 abaixo de 50 ms. Redes neurais grandes ou features com pré-processamento pesado sobem para centenas de ms. Nesse caso, considere exportar para ONNX ou usar aceleração especializada.

Arjun Krishnamurthy
Sobre o Autor Arjun Krishnamurthy

ML engineer focused on getting models out of notebooks and into production. Has war stories about every serving framework.