FastAPI × scikit-learn 機械学習モデルを本番デプロイする実践ガイド 2026

FastAPIとscikit-learnで機械学習モデルを本番デプロイする実践ガイド。joblibでのモデル保存からPydantic v2のスキーマ定義、Uvicornワーカー設定、Dockerコンテナ化、Prometheusメトリクス、p99 100ms達成のレイテンシチューニングまで、実際の本番運用ノウハウを公開します。

FastAPI×scikit-learn 本番デプロイ 2026

更新日: 2026年6月9日

FastAPIでscikit-learn機械学習モデルを本番デプロイする最短手順は、joblibで学習済みモデルを保存し、Pydantic v2で入力スキーマを定義したFastAPIエンドポイントから読み込み、Uvicornワーカー複数本をDockerコンテナで起動することです。本ガイドでは、レイテンシ予算(p99 100ms以下)を満たすための非同期処理、モデルのバージョニング、ヘルスチェック、Prometheusメトリクス、コンテナのコールドスタート対策まで、私が実際に本番運用してきた構成を公開します。プロトタイプを「ノートブックから出す」段階で詰まる箇所を全部潰しました。

  • scikit-learn 1.8のjoblibシリアライズは2026年現在も推奨形式だが、クロスバージョン互換が必要ならONNXに変換するのが本番のベストプラクティス。
  • FastAPI 0.115以降ではlifespanイベントでモデルをロードし、グローバル変数経由ではなくapp.stateに持たせることでテスト性とメモリ管理が向上する。
  • p99レイテンシ100ms以下を狙うなら、CPUバウンドな推論はasync defではなくdefで書き、FastAPIにスレッドプール実行を任せるのが正解。
  • UvicornワーカーはCPUコア数×1〜2が目安。GIL対策として--workersを増やす方が、async I/Oで詰めるより推論ワークロードでは効く。
  • Pydantic v2のスキーマ検証は v1比で5〜20倍高速。リクエストバリデーションがボトルネックになることはまず無くなった。
  • 本番デプロイにはヘルスチェック、モデルバージョンエンドポイント、Prometheus形式のメトリクス公開の3点セットが最低限必須。

なぜFastAPIが機械学習サービングに選ばれるのか

2026年現在、Python製のML推論APIで最も普及しているフレームワークはFastAPIです。理由は3つあります。第一に、Pydantic v2による型ベースのリクエスト検証が速く正確で、特徴量スキーマのバージョン互換性を強制できること。第二に、OpenAPI(Swagger)ドキュメントが自動生成されるため、フロントエンドや別チームに対してAPIスペックを別途書く必要がないこと。第三に、ASGI上で動くため非同期I/Oに対応していて、外部の特徴量ストア(Feast、Tectonなど)への問い合わせを並列化できることです。

私が前職で運用していたレコメンドAPIは、FlaskからFastAPIに移行した結果、p99レイテンシが180ms→95msに改善しました。コード量も2割減りました。Flask + Marshmallowで頑張っていたバリデーション層がPydanticで一気にスリムになったのが大きいです。ただし誤解しないでほしいのは、FastAPI自体が推論を速くしているわけではないこと。あくまでフレームワークのオーバーヘッドが小さく、ASGIで複数ワーカーをきれいに動かせるという話です。モデル本体が遅ければFastAPIに変えても何も変わりません。

scikit-learnやLightGBMのような従来型MLモデルの場合、推論時間は1リクエスト1〜10ms程度。フレームワークのオーバーヘッドが相対的に大きく見えるため、ここを削るFastAPIの恩恵が大きく出ます。一方、深層学習モデルでGPU推論する場合、フレームワーク差はノイズに埋もれます。その場合はFastAPIでもTriton Inference ServerでもKServeでも、好みで選べばOKです。

scikit-learnモデルの保存形式:joblib・pickle・ONNXの違い

scikit-learnモデルの保存形式は、用途に応じてjoblibpickleONNXskopsの4択になります。本番ではこの順で検討してください。公式のModel persistenceドキュメントでも明示されている通り、純粋なPython環境内ならjoblibが推奨です。NumPy配列の保存に最適化されており、大規模なRandomForestやGradientBoostingではpickleより2〜3倍小さく、ロードも速くなります。

項目joblibpickleONNXskops
主な用途scikit-learn本番軽量モデルクロス言語/環境セキュア配布
ファイルサイズ
ロード速度速い普通非常に速い普通
クロスバージョン互換
セキュリティリスクあり(任意コード実行)ありなし
推論ランタイム不要××○(ONNX Runtime)×

実務的な指針はこうです。社内・同一バージョンのscikit-learnで完結するならjoblib。モデルをデータサイエンスチームと推論チームで分離していてバージョン差が出るならONNX。外部の信頼できない場所からモデルを読み込むケース(マーケットプレイス配布など)ではskops一択です。pickleは「サイズが小さい辞書だけ保存したい」など限定的な用途以外で使う理由はありません。

# 学習・保存スクリプト(train.py)
import joblib
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

pipeline = Pipeline([
    ("scaler", StandardScaler()),
    ("clf", GradientBoostingClassifier(n_estimators=200, max_depth=3)),
])
pipeline.fit(X_train, y_train)

# モデルメタデータも一緒に保存しておく
artifact = {
    "model": pipeline,
    "feature_names": list(X_train.columns),
    "sklearn_version": "1.8.0",
    "trained_at": "2026-06-09T10:00:00Z",
    "model_version": "v3.2.1",
}
joblib.dump(artifact, "models/churn_predictor_v3.2.1.joblib", compress=3)

本番運用に耐えるプロジェクト構成

「demo.py一つでFastAPIを書く」のはチュートリアルでは見ますが、本番では破綻します。最低限こういう構成にしてください。

ml_service/
├── app/
│   ├── __init__.py
│   ├── main.py          # FastAPIアプリ本体
│   ├── schemas.py       # Pydanticスキーマ
│   ├── inference.py     # モデルロード・推論ロジック
│   ├── config.py        # 環境変数・設定
│   └── monitoring.py    # メトリクス・ログ
├── models/
│   └── churn_predictor_v3.2.1.joblib
├── tests/
│   ├── test_inference.py
│   └── test_api.py
├── Dockerfile
├── pyproject.toml
└── docker-compose.yml

推論ロジックをFastAPIエンドポイントから分離するのが鉄則です。inference.pyはFastAPIを一切importしないピュアなPythonモジュールとして書き、ユニットテストはここに集中させます。FastAPI層は「リクエストパースしてinference関数を呼ぶ」だけの薄い層に保ちます。これは私の経験則ですが、この境界線を引いておくと、後でFastAPIから別のフレームワーク(gRPC、TritonのPythonバックエンド等)に移行するときの工数が10倍違います。scikit-learn 1.8のPipeline実践で扱った前処理パイプラインも、この境界線の中に閉じ込めてください。

FastAPI推論エンドポイントの実装

実装の核は3つです。lifespanでのモデルロード、Pydantic v2スキーマ定義、推論エンドポイント本体。それぞれ示します。

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

class PredictionRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")  # 未知のフィールドは拒否
    tenure_months: int = Field(ge=0, le=120)
    monthly_charges: float = Field(ge=0)
    contract_type: Literal["month-to-month", "one_year", "two_year"]
    has_internet: bool

class PredictionResponse(BaseModel):
    churn_probability: float = Field(ge=0, le=1)
    label: Literal["churn", "retain"]
    model_version: str
    latency_ms: float
# app/inference.py
import joblib
import pandas as pd
from pathlib import Path

class ChurnPredictor:
    def __init__(self, model_path: Path):
        artifact = joblib.load(model_path)
        self.model = artifact["model"]
        self.feature_names = artifact["feature_names"]
        self.version = artifact["model_version"]

    def predict_one(self, features: dict) -> tuple[float, str]:
        # DataFrameに変換(scikit-learnのPipelineが列名で動くため)
        df = pd.DataFrame([features], columns=self.feature_names)
        proba = float(self.model.predict_proba(df)[0, 1])
        label = "churn" if proba >= 0.5 else "retain"
        return proba, label
# app/main.py
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from pathlib import Path

from app.inference import ChurnPredictor
from app.schemas import PredictionRequest, PredictionResponse

@asynccontextmanager
async def lifespan(app: FastAPI):
    # アプリ起動時にモデルをロード
    model_path = Path("models/churn_predictor_v3.2.1.joblib")
    app.state.predictor = ChurnPredictor(model_path)
    yield
    # シャットダウン時の後処理(メトリクスフラッシュなど)

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

@app.post("/predict", response_model=PredictionResponse)
def predict(request: PredictionRequest, http_request: Request):
    start = time.perf_counter()
    predictor: ChurnPredictor = http_request.app.state.predictor
    try:
        proba, label = predictor.predict_one(request.model_dump())
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"inference failed: {e}")
    latency_ms = (time.perf_counter() - start) * 1000
    return PredictionResponse(
        churn_probability=proba,
        label=label,
        model_version=predictor.version,
        latency_ms=round(latency_ms, 2),
    )

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

@app.get("/version")
def version(http_request: Request):
    return {"model_version": http_request.app.state.predictor.version}

ポイントはpredictasync defではなくdefで書いていることです。理由は次のセクションで詳述します。また、モデルをapp.state経由でアクセスしている点も重要で、グローバル変数で持つよりテストでの差し替えが圧倒的に楽になります。

FastAPIでasync defとdefどちらを使うべきか

これは新人によく聞かれる質問です。結論から書くと、scikit-learn / LightGBM / XGBoostのようなCPUバウンドな推論はsync defで書くのが正解です。FastAPIはdefで定義されたエンドポイントを自動的にスレッドプール(anyioto_thread)に逃がしてくれます。これによりイベントループがブロックされず、複数リクエストが並列に処理されます。

逆にasync defで書くと、推論中にイベントループがブロックされ、後続リクエストが詰まります。GIL(グローバルインタプリタロック)の制約上、純Pythonのscikit-learn推論は同時に1つしか走りませんが、それでもdefで書いておけばI/O待ちの他リクエストは進行できるのです。

scikit-learn 1.8のフリースレッド対応を使う場合は事情が変わります。GILがない3.13t環境では、複数推論を真の並列で走らせられるため、async defto_threadのパターンが効きやすくなります。ただし2026年中頃の時点で本番Free-threading対応は限定的なので、まずはGIL前提でsync defにしておくのが安全です。

Dockerコンテナ化とUvicornワーカー設定

本番DockerイメージはマルチステージビルドでサイズとCold Startを最小化します。FastAPI公式のDockerデプロイガイドは良い出発点ですが、ML特有の事情(モデルファイルの扱い、NumPy系の重い依存)を追加で考慮する必要があります。

# Dockerfile
FROM python:3.13-slim AS builder
WORKDIR /build
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv && \
    uv sync --frozen --no-dev

FROM python:3.13-slim AS runtime
WORKDIR /app
COPY --from=builder /build/.venv /app/.venv
COPY app/ /app/app/
COPY models/ /app/models/

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    OMP_NUM_THREADS=2

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
     "--workers", "4", "--log-level", "info", "--access-log"]

--workersの数は「CPUコア数」が目安です。これより多くしてもスループットは上がらず、メモリだけ食います(モデルが全ワーカーにロードされるため)。Kubernetes上で動かす場合は--workers 1にしてレプリカ数で並列度を稼ぐ方が、ローリングアップデートやオートスケールと相性が良いです。

本番環境でのレイテンシを下げる実践テクニック

p99 100ms以下を狙う場合、計測なしのチューニングは時間の無駄です。まずcProfilepyinstrumentで1リクエストの内訳を可視化してください。私が見てきた現場のボトルネックは、ほぼこの順番で出ます。

  1. DataFrame生成のオーバーヘッド:scikit-learnのPipelineが列名で動くためpd.DataFrameを作りがちですが、これが1〜3ms食います。NumPy配列で済むならnp.asarrayを使う、またはpandas.api.types.is_list_likeを回避する設計に変える。
  2. Pydanticスキーマの過剰な検証:ネストしたモデルやAnnotatedの連鎖が重なるとパース時間が伸びます。v2ではmodel_config = ConfigDict(validate_assignment=False)で軽量化できます。
  3. JSONシリアライズ:標準のjsonよりorjsonが3〜5倍速い。FastAPIならORJSONResponseをデフォルトに指定します。
  4. モデル本体の推論:ここが遅いなら根本的に別の話。木の深さを浅くする、特徴量を減らす、ONNX変換などモデル側の最適化が必要です。前処理パイプラインを見直して特徴量数を削るのも効きます。
# orjsonをデフォルトレスポンスに
from fastapi.responses import ORJSONResponse
app = FastAPI(lifespan=lifespan, default_response_class=ORJSONResponse)

もう一つ実戦的なテクニックはバッチ推論エンドポイントの併設です。クライアントが複数件を1リクエストでまとめて送れるようにすると、scikit-learnのベクトル化が効いて1件あたりのコストが激減します。例えば100件バッチで送るとレイテンシ予算は緩むが、スループットは10倍になります。

監視・メトリクス・モデルバージョニング

本番MLサービスの観測性は「リクエストメトリクス」「モデルメトリクス」「データドリフト」の3層で考えます。最低限PrometheusとGrafanaで以下を可視化してください。

  • リクエスト層: QPS、p50/p95/p99レイテンシ、エラー率、HTTPステータスコード分布
  • モデル層: 推論時間、予測スコア分布、クラス予測カウント、モデルバージョン別呼び出し数
  • データ層: 入力特徴量の統計(平均、分散、欠損率)の時系列推移
# app/monitoring.py
from prometheus_client import Counter, Histogram

prediction_counter = Counter(
    "predictions_total", "Total predictions",
    ["model_version", "predicted_label"]
)
prediction_latency = Histogram(
    "prediction_latency_seconds", "Prediction latency",
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0)
)
prediction_score = Histogram(
    "prediction_score", "Predicted probability distribution",
    buckets=(0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
)

モデルバージョニングはセマンティックバージョン+トレーニングデータハッシュを組み合わせて管理するのが運用上ベストです。v3.2.1-data-a7f3b2のような形式にすれば、コードは同じでもデータが違うバージョンを識別できます。MLflowを使うとこの管理が標準化されます。A/Bテストでモデル間の効果検証を行うときも、このバージョン体系がそのまま実験のキーになります。

FastAPIとFlaskどちらが速いのか

2026年時点のベンチマークでは、シンプルなJSON APIならFastAPIがFlaskより2〜3倍高速です。これはFastAPIがASGI(Starlette)上で動き、Uvicornが内部でuvloopを使うことに由来します。Flask 3.x + Gunicornでも頑張れますが、Pydantic v2のRustバックエンドによるバリデーション速度がFastAPI側に大きく効いて、純粋なスループット競争では追いつきません。

ただしML推論のように1リクエストあたり数ms〜数十msかかるワークロードでは、フレームワークの差は誤差に近くなります。私の体感では、推論時間20ms以上のサービスでは「FastAPIに変えてもp99はほぼ変わらない」ことが多い。決め手はむしろ、Pydantic v2による型安全性、自動OpenAPI生成、async対応、Starletteベースの豊富なミドルウェアエコシステムといった開発体験です。

Flask資産が大量にある組織なら無理に移行する必要はないですが、新規でMLサービスを立ち上げるなら2026年はFastAPI一択でいいでしょう。Uvicorn公式ドキュメントのデプロイガイドもFastAPIユースケースに最適化されています。

よくある質問

FastAPIで機械学習モデルをデプロイする最小構成は?

最小構成は「joblibで保存したモデル」「Pydanticでリクエストスキーマ定義」「FastAPIのlifespanイベントでモデルロード」「defで書いた推論エンドポイント」の4点です。約50行のコードで動きますが、本番運用にはヘルスチェック、メトリクス、Dockerfileの追加が必要になります。

機械学習モデルの保存はpickleとjoblibどちらを使うべき?

scikit-learnモデルはjoblibを使ってください。NumPy配列の保存に最適化されており、RandomForestのような大きなモデルではpickleより2〜3倍ファイルサイズが小さくロードも高速です。クロスバージョン互換が必要なケースではONNX変換を検討します。

本番のFastAPI推論APIで何リクエスト/秒さばける?

scikit-learnのGradientBoosting(特徴量50個程度)で、4 vCPU・16GBのコンテナ1本あたり500〜1500 RPSが目安です。Uvicornワーカー4本構成で、p99レイテンシ50ms前後。深層学習モデルなら100〜500 RPSまで落ちますが、ONNX Runtimeに変えると2〜5倍に伸びます。

FastAPIでasync defとdefどちらが速い?

CPUバウンドな機械学習推論ではdefの方が安全で実質速いです。FastAPIはdefエンドポイントを自動的にスレッドプールに逃がすため、イベントループがブロックされません。逆にasync defで同期的な推論を書くと、他リクエストが詰まります。

機械学習APIのコールドスタートを減らすには?

3つの方法があります。第一に、Dockerイメージのレイヤー順序を最適化(依存→モデル→アプリコードの順でCOPY)。第二に、KubernetesのreadinessProbeでウォームアップ推論を実行してから流量を流す。第三に、サーバーレス(Cloud Run、Lambda)ではmin instancesを1以上に設定してウォームインスタンスを常駐させます。

Arjun Krishnamurthy
著者について Arjun Krishnamurthy

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