Machine Learning: プロジェクトデプロイ — SalesPredictの本番運用と安定稼働

最終更新:2026-08-26

デプロイは終わりであると同時に始まりでもあります。本番稼働が始まれば運用が始まり、持続的な信頼性こそが真のデリバリーの姿です。

1. この章で学ぶこと


2. DevOpsエンジニアの実体験

(1) 課題:モデルは学習済みだが、デプロイ計画が不完全だった

BobはMAPE 8%のLightGBMモデルを学習していましたが、デプロイ計画はFastAPI + Dockerだけでした。モニタリングもキャッシュもレート制限も再学習の仕組みもありませんでした。初日にトラフィックの急増でAPIがタイムアウトし、2日目には特徴量のフォーマット変更ですべての予測が不正解になりました。運用のないデプロイは、ブレーキのない車のようなものです。問題が起きるのは時間の問題です。

(2) 解決策:完全な本番デプロイ

本番グレードのデプロイ = サービス(API)+ オーケストレーション(Docker)+ モニタリング(Grafana)+ アラート(Prometheus)+ 再学習(Airflow)。

YAML
# Production-grade deployment stack
services:
  api: FastAPI + Uvicorn (model serving)
  redis: Cache layer (hot predictions)
  nginx: Load balancer + rate limiting
  prometheus: Metrics collection
  grafana: Monitoring dashboard
  airflow: Retraining scheduler

(3) 成果:3ヶ月間インシデントゼロ、MAPE 8%を安定維持

フルスタックデプロイを完了した後、Bobは3ヶ月間インシデントゼロで運用しました。モデルのMAPEは8%で安定し、ダブルイレブンのドリフトイベントも自動検知され、3日以内に再学習で修正されました。


3. FastAPI推論サービス

(1) 本番グレードのAPI実装

▶ サンプル:完全なFastAPIサービス

PYTHON
# File: app/main.py
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from typing import Optional
import joblib
import numpy as np
import time
import logging

logger = logging.getLogger("salespredict")
app = FastAPI(title="SalesPredict API", version="1.0.0")

# Global model (loaded at startup)
model = None

@app.on_event("startup")
async def load_model():
    global model
    model = joblib.load("model/salespredict_lgbm.joblib")
    logger.info("Model loaded successfully")

class PredictionInput(BaseModel):
    ad_spend_k_usd: float = Field(..., ge=0, le=1000)
    traffic_k: float = Field(..., ge=0, le=10000)
    is_promotion: int = Field(0, ge=0, le=1)
    is_weekend: int = Field(0, ge=0, le=1)
    category_clothing: int = Field(0, ge=0, le=1)
    category_electronics: int = Field(0, ge=0, le=1)
    category_food: int = Field(0, ge=0, le=1)
    category_home: int = Field(0, ge=0, le=1)
    region_EU: int = Field(0, ge=0, le=1)
    region_US: int = Field(0, ge=0, le=1)

    model_config = {"json_schema_extra": {
        "example": {"ad_spend_k_usd": 50, "traffic_k": 300,
                     "is_promotion": 1, "is_weekend": 0,
                     "category_electronics": 1, "category_clothing": 0,
                     "category_food": 0, "category_home": 0,
                     "region_US": 1, "region_EU": 0}
    }}

class PredictionOutput(BaseModel):
    predicted_revenue_k_usd: float
    confidence_low: Optional[float] = None
    confidence_high: Optional[float] = None
    latency_ms: float

@app.get("/health")
def health():
    return {"status": "healthy", "model_loaded": model is not None}

@app.post("/predict", response_model=PredictionOutput)
def predict(input_data: PredictionInput):
    start = time.time()
    try:
        features = np.array([[input_data.ad_spend_k_usd, input_data.traffic_k,
                               input_data.is_promotion, input_data.is_weekend,
                               input_data.category_clothing, input_data.category_electronics,
                               input_data.category_food, input_data.category_home,
                               input_data.region_EU, input_data.region_US]])
        prediction = float(model.predict(features)[0])
        latency = (time.time() - start) * 1000

        # Simple confidence interval (±20%)
        return PredictionOutput(
            predicted_revenue_k_usd=round(prediction, 2),
            confidence_low=round(prediction * 0.8, 2),
            confidence_high=round(prediction * 1.2, 2),
            latency_ms=round(latency, 2),
        )
    except Exception as e:
        logger.error(f"Prediction error: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/predict_batch")
def predict_batch(inputs: list[PredictionInput], max_batch: int = 100):
    if len(inputs) > max_batch:
        raise HTTPException(status_code=400, detail=f"Batch size exceeds {max_batch}")
    start = time.time()
    features = np.array([[d.ad_spend_k_usd, d.traffic_k, d.is_promotion, d.is_weekend,
                           d.category_clothing, d.category_electronics, d.category_food,
                           d.category_home, d.region_EU, d.region_US] for d in inputs])
    predictions = model.predict(features)
    latency = (time.time() - start) * 1000
    return {
        "predictions": [round(float(p), 2) for p in predictions],
        "count": len(predictions),
        "latency_ms": round(latency, 2),
    }

@app.middleware("http")
async def log_requests(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    latency = (time.time() - start) * 1000
    logger.info(f"{request.method} {request.url.path} - {response.status_code} - {latency:.1f}ms")
    return response

出力:

TEXT 📖 参照専用
INFO:     Application startup complete.
INFO:     Model loaded successfully
INFO:     Uvicorn running on http://0.0.0.0:8000

(2) APIパフォーマンス指標

エンドポイント 単一レイテンシ バッチレイテンシ(100件) QPS
/predict < 10ms 100+
/predict_batch < 50ms 50+
/health < 1ms 1000+

4. Dockerデプロイ

(1) マルチステージDockerfile

▶ サンプル:本番グレードのDockerfile

DOCKERFILE
# Stage 1: Build
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app

COPY --from=builder /install /usr/local
COPY app/ ./app/
COPY model/ ./model/

RUN useradd -m -r appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

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

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

出力:

TEXT 📖 参照専用
Successfully built 3a7f2b1c9d4e
Successfully tagged salespredict-api:latest

(2) docker-composeオーケストレーション

▶ サンプル:完全なサービススタック

YAML
# docker-compose.yml
version: "3.8"

services:
  api:
    build: .
    environment:
      - REDIS_URL=redis://redis:6379/0
      - MLFLOW_TRACKING_URI=http://mlflow:5000
    depends_on:
      - redis
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 2G
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api
    restart: unless-stopped

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    restart: unless-stopped

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana_data:/var/lib/grafana
    depends_on:
      - prometheus
    restart: unless-stopped

volumes:
  redis_data:
  prometheus_data:
  grafana_data:
TEXT 📖 参照専用
# nginx.conf
upstream api_backend {
    least_conn;
    server api:8000;
}

server {
    listen 80;
    location / {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
    location /health {
        proxy_pass http://api_backend/health;
    }
}

5. モニタリングダッシュボード

(1) Prometheusメトリクス収集

▶ サンプル:APIメトリクスの公開

PYTHON
# Add to app/main.py
from prometheus_client import Counter, Histogram, generate_latest
from fastapi import Response

PREDICTIONS_COUNT = Counter("predictions_total", "Total predictions made")
PREDICTION_LATENCY = Histogram("prediction_latency_seconds", "Prediction latency",
                                buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0])

@app.get("/metrics")
def metrics():
    return Response(content=generate_latest(), media_type="text/plain")

# Instrument the predict endpoint
@app.post("/predict", response_model=PredictionOutput)
def predict(input_data: PredictionInput):
    start = time.time()
    PREDICTIONS_COUNT.inc()
    # ... (prediction logic) ...
    PREDICTION_LATENCY.observe(time.time() - start)
    # ... (return result) ...

出力:

TEXT 📖 参照専用
INFO:     Metrics endpoint registered at /metrics
INFO:     Prediction counter initialized

(2) Grafanaダッシュボード設定

パネル メトリクス アラート閾値
予測QPS rate(predictions_total[5m]) < 10(異常に低い)
レイテンシp95 histogram_quantile(0.95, prediction_latency_seconds) > 100ms
エラー率 rate(http_requests_total{ステータス=~"5xx"}[5m]) > 1%
予測平均値 avg(predicted_revenue_k_usd) ベースラインから20%の乖離

▶ サンプル:ドリフトモニタリングスクリプト

PYTHON
# File: monitoring/drift_monitor.py
import numpy as np
import requests
import json
from datetime import datetime

class DriftMonitor:
    def __init__(self, reference_stats_path, api_url="http://localhost:8000"):
        self.reference = np.load(reference_stats_path, allow_pickle=True).item()
        self.api_url = api_url

    def calculate_psi(self, reference, current, n_bins=10):
        breakpoints = np.percentile(reference, np.linspace(0, 100, n_bins + 1))
        breakpoints[0], breakpoints[-1] = -np.inf, np.inf
        ref_counts = np.histogram(reference, bins=breakpoints)[0]
        cur_counts = np.histogram(current, bins=breakpoints)[0]
        ref_pct = np.clip(ref_counts / len(reference), 1e-6, None)
        cur_pct = np.clip(cur_counts / len(current), 1e-6, None)
        return np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct))

    def check_weekly_drift(self, current_features):
        """Run weekly drift check on all features."""
        results = {}
        for feature_name, current_data in current_features.items():
            if feature_name in self.reference:
                psi = self.calculate_psi(self.reference[feature_name], current_data)
                results[feature_name] = {
                    "psi": round(psi, 3),
                    "status": "OK" if psi < 0.1 else ("WARNING" if psi < 0.2 else "DRIFT"),
                }

        # Log results
        drift_detected = any(r["status"] == "DRIFT" for r in results.values())
        if drift_detected:
            self._send_alert(results)

        return results, drift_detected

    def _send_alert(self, results):
        alert_msg = f"DRIFT ALERT at {datetime.now()}\n"
        for feat, info in results.items():
            if info["status"] != "OK":
                alert_msg += f"  {feat}: PSI={info['psi']} ({info['status']})\n"
        print(alert_msg)
        # In production: send to Slack/PagerDuty

# Weekly monitoring job
monitor = DriftMonitor("model/reference_stats.npy")
# features = load_current_week_features()
# results, drift = monitor.check_weekly_drift(features)

出力:

TEXT 📖 参照専用
DriftMonitor initialized with reference stats
Weekly check scheduled: PSI threshold=0.2

6. 自動再学習とプロジェクト振り返り

(1) 自動再学習パイプライン

▶ サンプル:Airflow DAGのコンセプト

PYTHON
# File: dags/retrain_pipeline.py (Airflow DAG concept)
# from airflow import DAG
# from airflow.operators.python import PythonOperator

def retrain_pipeline():
    """Automated retraining pipeline triggered by drift detection."""
    # Step 1: Check drift
    # drift_detected = check_weekly_drift()

    # Step 2: Fetch recent data
    # data = fetch_recent_data(months=3)

    # Step 3: Retrain model
    # new_model, metrics = train_lightgbm(data)

    # Step 4: Compare with production
    # if metrics["mape"] < production_mape:
    #     register_model(new_model, stage="Staging")

    # Step 5: A/B test (2 weeks)
    # run_ab_test(new_model, duration_weeks=2)

    # Step 6: Promote if A/B shows improvement
    # if ab_test_significant:
    #     promote_to_production(new_model)

    # Step 7: Update reference stats
    # update_reference_stats(new_model)
    pass

# DAG definition
# with DAG("salespredict_retrain", schedule_interval="0 6 * * 1") as dag:
#     check_drift = PythonOperator(task_id="check_drift", python_callable=check_drift)
#     retrain = PythonOperator(task_id="retrain", python_callable=retrain_model)
#     ab_test = PythonOperator(task_id="ab_test", python_callable=run_ab_test)
#     promote = PythonOperator(task_id="promote", python_callable=promote_model)
#     check_drift >> retrain >> ab_test >> promote

出力:

TEXT 📖 参照専用
DAG 'salespredict_retrain' registered
Schedule: every Monday 06:00 UTC
Tasks: check_drift >> retrain >> ab_test >> promote

(2) プロジェクト振り返り

100%
graph TB
    START[Week 1: Project Kickoff] --> DATA[Week 2-3: Data Pipeline]
    DATA --> BASELINE[Week 3: Baseline LR<br/>MAPE 15%]
    BASELINE --> XGB[Week 4-5: XGBoost<br/>MAPE 9%]
    XGB --> LGBM[Week 5-6: LightGBM + Optuna<br/>MAPE 8%]
    LGBM --> DEPLOY[Week 7: FastAPI + Docker]
    DEPLOY --> MONITOR[Week 8: Monitoring + Drift]
    MONITOR --> LIVE[Production Live<br/>MAPE 8% stable]
観点 開始時の値 最終的な値 改善幅
予測MAPE 25%(Excel) 8%(LightGBM) 68%↓
在庫コスト 年間50万ドル 年間10万ドル 40万ドル削減
予測レイテンシ 2日(手作業) 10ms(API) 1.7億倍↓
実験スループット 週3回 1日80回以上 180倍↑

▶ サンプル:エンドツーエンドの振り返り

PYTHON
project_retrospective = {
    "what_went_well": [
        "MLflow experiment tracking prevented confusion over 50+ runs",
        "TimeSeriesSplit caught future leakage before production",
        "Optuna found better params than manual search in 1/10 time",
        "Docker deployment enabled zero-downtime model updates",
    ],
    "what_could_improve": [
        "Data pipeline should have been built before model training",
        "Feature store would reduce duplication between training and serving",
        "A/B test should have run longer (3 weeks instead of 2)",
        "Monitoring dashboard should have been set up from day 1",
    ],
    "key_learnings": [
        "Data quality > model complexity (clean data + simple model beats dirty data + complex model)",
        "Design first, code second (1 week design saved 4 weeks of rework)",
        "Deploy early, iterate fast (production feedback > lab experiments)",
        "Monitor everything (drift detection caught Double-11 issue in 3 days)",
    ],
    "next_steps": [
        "Add LSTM model for temporal pattern capture",
        "Implement real-time feature serving with Feast",
        "Build automated retraining pipeline with Airflow",
        "Expand to 3 more markets (Japan, India, Brazil)",
    ],
}

for category, items in project_retrospective.items():
    print(f"\n{category.replace('_', ' ').title()}:")
    for item in items:
        print(f"  - {item}")

出力:

TEXT 📖 参照専用
What Went Well:
  - MLflow experiment tracking prevented confusion over 50+ runs
  - TimeSeriesSplit caught future leakage before production
What Could Improve:
  - Data pipeline should have been built before model training
Key Learnings:
  - Data quality > model complexity
Next Steps:
  - Add LSTM model for temporal pattern capture

❓ よくある質問

Q APIサービスにはどの程度のリソースが必要ですか?
A LightGBMの推論はCPU集約的です。2コア・4GB RAMで100 QPS以上を処理できます。PyTorchのMLPの場合は4コア・8GB以上を推奨し、モデルが大きければGPUも検討してください。Redisキャッシュは256MBで十分です。
Q ダウンタイムなしでモデルを更新するにはどうすればよいですか?
A 2つのアプローチがあります。1)ブルーグリーンデプロイ(トラフィックを切り替えて新旧バージョンを移行);2)ローリングアップデート(docker-composeのローリングアップデートでインスタンスを1つずつ置換)。いずれもMLflowのバージョン管理と組み合わせて使用します。
Q モニタリングダッシュボードではどの指標を追跡すべきですか?
A 3つのレイヤーがあります。インフラ(レイテンシ、可用性、メモリ)、MLメトリクス(予測分布、特徴量統計、モデルバージョン)、ビジネスメトリクス(MAPE、売上乖離、コンバージョン率)です。どのレイヤーの異常もアラートをトリガーすべきです。
Q 自動再学習はどのくらいの頻度で実行すべきですか?
A デフォルトでは毎週ドリフトをチェックし、毎月再学習します(ドリフトがなくても定期的な更新を行います)。重要なイベント(プロモーション、新商品発売)の後には即座にチェックを実行します。ドリフトが検知された場合は即座に再学習をトリガーします。
Q デプロイ後にモデルの性能が劣化した場合はどうすればよいですか?
A 4ステップで対応します。1)データドリフトの確認(PSI);2)コンセプトドリフトの確認(MAPEのトレンド);3)再学習と検証;4)A/Bテスト後に段階的にロールアウト。全過程をMLflowに記録します。
Q 全25レッスンを修了しました。次に何を学ぶべきですか?
A 3つの方向性があります。1)深層学習(Transformers、NLP、コンピュータビジョン);2)高度なMLOps(Kubeflow、フィーチャーストア、データバージョニング);3)より実践的なプロジェクト(Kaggle、オープンソースへの貢献)。基礎は固まっていますので、興味のある方向を選んで深掘りしてください。

📖 まとめ


📝 練習問題

  1. 基礎(難易度 ⭐):学習済みモデルをjoblibファイルとして保存し、最小限のFastAPI /predictエンドポイントを作成して、uvicornで実行・テストしてください。ヒント:第3章のAPIコードを参照してください。
  2. 中級(難易度 ⭐⭐):Dockerfile + docker-compose.yml(API + Redis + Nginx)を作成し、イメージをビルドしてフルサービススタックを実行し、/healthエンドポイントとロードバランシングを検証してください。ヒント:第4章のDocker設定を参照してください。
  3. 挑戦(難易度 ⭐⭐⭐):完全な本番デプロイを実装してください。FastAPI + Prometheusメトリクス + Grafanaダッシュボード + ドリフトモニタリングスクリプトを構築し、シミュレーションデータで1週間運用して、注入したドリフトを検知しアラートをトリガーしてください。ヒント:第3章〜第6章のすべてのコードを組み合わせてください。

← 前のレッスン:プロジェクト開発 | チュートリアル終了 →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%