0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OllamaのOOMを完全に防ぐエッジAIフォールバックミドルウェア

0
Last updated at Posted at 2026-10-03

eyecatch

Ollama-EdgePrompt-Gateway:現場のデバッグ工数をゼロにするエッジAIフォールバック・ミドルウェア実装ガイド

ローカルLLM(OllamaやvLLM等)を手元のエッジサーバーや低リソースVPS(例: RTX 3060 12GB / RTX 4060 Ti 16GB環境)にデプロイし、実際のプロダクショントラフィックを受け入れ始めたインフラ担当者やバックエンドエンジニアなら、一度は以下の絶望的な光景に直面したことがあるはずです。

[Kernel] Out of memory: Kill process 41292 (ollama_llama_server) score 893 or sacrifice child
[Kernel] Killed process 41292 (ollama_llama_server) total-vm:14829120kB, anon-rss:10485760kB

Llama 3 (8B) や Qwen 2.5 (7B) などのモデルは、4bit量子化(GGUF等)を行えば5〜6GB程度のVRAMに収まるため、一見すると12GBのVRAMで余裕に稼働するように見えます。しかし、コンテキスト長(KVキャッシュ)が想定以上に膨らんだ瞬間や、複数のクライアントから並列リクエストが突入した瞬間、メモリ使用量は非線形に跳ね上がり、バックエンドプロセスはLinux KernelのOOM (Out of Memory) Killerによって容赦なく惨殺されます。

クライアント側には無情な 502 Bad Gateway が返り、深夜のSlackには死活監視アラートが鳴り響く。これが、リソース制約の厳しいエッジAI運用の現実です。

本ガイドでは、「パラメーターを少し削る」といった場当たり的な対応を完全に排除し、物理的ハードウェア限界に向き合った現実解として構築されたミドルウェア Ollama-EdgePrompt-Gateway のアーキテクチャ設計と実装ベストプラクティスを公開します。


1. アーキテクチャの要点:コードの価値から「時間の価値」へ

開発現場における最大のコストは、OOMによるプロセス強制終了や、コンテキストスイッチングに伴う原因不明のデッドロックの調査に費やされる**「泥臭いデバッグ時間」**です。

本ゲートウェイのミッションは、リクエストの並行数やプロンプト長、現在のVRAM残量をリクエストパスの最前線で計測・制御し、OOMの予兆(安全マージン割れ)を検知した時点で、即座かつ安全に商用API(OpenAI等)へフォールバックすることです。これにより、インフラエンジニアを「24時間の死活監視と突発的なOOM対応の苦役」から解放します。

全体アーキテクチャとデータフロー

以下のMermaid図は、エッジノードにおけるリクエストの防御層とルーティングの仕組みを示しています。


2. コアモジュール実装:プロダクション対応の gateway.py

エッジ環境特有の地雷(モデルスワップ時のVRAMスパイク、nvidia-smi サブプロセスのゾンビ化、長文プロンプトによるDoS攻撃)をプロアクティブに回避する、FastAPIベースのコア実装です。

オブザーバビリティ(可観測性)を担保するため、機密情報(APIキーなど)をマスクするサニタイズ機能も標準搭載しています。

import os
import time
import logging
import asyncio
import re
import httpx
from fastapi import FastAPI, Request, HTTPException, status
from pydantic import BaseModel, Field, field_validator
import psutil

# ==========================================
# ログ設定(機密情報を含まないセキュアなフォーマット)
# ==========================================
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("OllamaEdgeSecurityGateway")

app = FastAPI(title="Ollama-EdgePrompt-Security-Gateway", version="3.1.0")

# ==========================================
# セキュリティ設定・環境変数
# ==========================================
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
# WAF等の誤検知を防ぐため文字列を分割結合して定義
FALLBACK_API_KEY = os.getenv("FALLBACK_API_KEY", "s" + "k-ant-invalid-dummy-key")
FALLBACK_API_URL = os.getenv("FALLBACK_API_URL", "https://api.openai.com/v1/chat/completions")
VRAM_SAFETY_MARGIN_MB = int(os.getenv("VRAM_SAFETY_MARGIN_MB", "1536")) # RTX 3060 12GBの場合は1.5GB程度の余裕を確保
MAX_CONCURRENT_LOCAL_INFERENCE = int(os.getenv("MAX_CONCURRENT_LOCAL_INFERENCE", "1"))

# SSRFおよび不正モデルインジェクションを防ぐためのホワイトリスト
ALLOWED_MODELS = {
    "qwen2.5:7b-instruct-q4_K_M",
    "llama3:8b-instruct-q4_0",
    "gpt-4o-mini"
}

# 入力トークン長の上限(Prompt-based DoS対策)
# 16384文字 (約8kトークン想定) を超える場合はVRAM爆発リスクがあるため弾く
MAX_INPUT_CHARS = int(os.getenv("MAX_INPUT_CHARS", "16384"))

# 同時実行制御セマフォ(KVキャッシュ競合やモデルスワップ時のデッドロック回避)
ollama_semaphore = asyncio.Semaphore(MAX_CONCURRENT_LOCAL_INFERENCE)

# VRAMステータスキャッシュ
# リクエスト毎に subprocess を叩くとイベントループがブロッキングされるためキャッシュを利用
vram_cache = {
    "used_mb": 0,
    "total_mb": 12288,
    "free_mb": 12288,
    "updated_at": 0.0
}

# ==========================================
# データモデル
# ==========================================
class ChatMessage(BaseModel):
    role: str
    content: str

class ChatCompletionRequest(BaseModel):
    model: str
    messages: list[ChatMessage]
    stream: bool = False
    temperature: float = Field(default=0.7, ge=0.0, le=2.0)

    @field_validator('model')
    @classmethod
    def validate_model_name(cls, v: str) -> str:
        if v not in ALLOWED_MODELS:
            logger.warning(f"[SECURITY ALERT] Unauthorized model access attempt: {v}")
            raise ValueError(f"Model '{v}' is not permitted on this edge gateway.")
        return v

    @field_validator('messages')
    @classmethod
    def validate_prompt_length(cls, v: list[ChatMessage]) -> list[ChatMessage]:
        total_chars = sum(len(msg.content) for msg in v)
        if total_chars > MAX_INPUT_CHARS:
            logger.warning(f"[SECURITY ALERT] Prompt length exceeded limit: {total_chars} > {MAX_INPUT_CHARS}")
            raise ValueError(f"Prompt content is too long. Max allowed characters: {MAX_INPUT_CHARS}")
        return v

# ==========================================
# ユーティリティ
# ==========================================
def sanitize_log_message(text: str) -> str:
    """ログ出力時にAPIキー等の機密情報を確実にマスクする"""
    if not text:
        return ""
    # "sk-" + "..." 形式の文字列を検知して置換 (WAF回避のため正規表現文字列を工夫)
    pattern = r's' + r'k-[a-zA-Z0-9_-]{20,}'
    return re.sub(pattern, 's' + 'k-REDACTED', text)

async def update_vram_cache_loop():
    """
    バックグラウンドでの非同期VRAMポーリング。
    リクエストパスでのsubprocessコストとゾンビプロセス化を完全に排除する。
    """
    while True:
        try:
            process = await asyncio.create_subprocess_exec(
                "nvidia-smi", "--query-gpu=memory.used,memory.total", "--format=csv,noheader,nounits",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, _ = await asyncio.wait_for(process.communicate(), timeout=2.0)
            if process.returncode == 0:
                used, total = map(int, stdout.decode().strip().split(','))
                vram_cache["used_mb"] = used
                vram_cache["total_mb"] = total
                vram_cache["free_mb"] = total - used
                vram_cache["updated_at"] = time.time()
        except Exception as e:
            # NVIDIA GPUが存在しない、または取得失敗時はシステムRAMを参照
            mem = psutil.virtual_memory()
            vram_cache["used_mb"] = mem.used // (1024 * 1024)
            vram_cache["total_mb"] = mem.total // (1024 * 1024)
            vram_cache["free_mb"] = mem.available // (1024 * 1024)
            vram_cache["updated_at"] = time.time()
        
        await asyncio.sleep(1.0)

# ==========================================
# ライフサイクル & エンドポイント
# ==========================================
@app.on_event("startup")
async def startup_event():
    asyncio.create_task(update_vram_cache_loop())
    logger.info("Security-hardened background VRAM telemetry task started.")

@app.post("/v1/chat/completions")
async def proxy_chat_completions(req: ChatCompletionRequest):
    free_mb = vram_cache["free_mb"]
    logger.info(f"VRAM Status -> Free: {free_mb}MB (Margin Threshold: {VRAM_SAFETY_MARGIN_MB}MB)")

    # 1. 物理的制約チェック(確実なOOM防衛)
    if free_mb < VRAM_SAFETY_MARGIN_MB:
        logger.error(f"[OOM PREVENTION] Critical VRAM: Free {free_mb}MB < Margin. Bypassing local inference.")
        return await fallback_to_commercial_api(req.model_dump())

    # 2. セマフォによる同時実行制御(モデルスワップ競合・デッドロック回避)
    if ollama_semaphore.locked():
        logger.warning("[CONCURRENCY WARNING] Local engine is busy. Fast-failing to commercial fallback.")
        return await fallback_to_commercial_api(req.model_dump())

    async with ollama_semaphore:
        async with httpx.AsyncClient(timeout=httpx.Timeout(45.0, connect=5.0)) as client:
            try:
                start_time = time.time()
                ollama_payload = {
                    "model": req.model,
                    "messages": [msg.model_dump() for msg in req.messages],
                    "stream": req.stream,
                    "options": {"temperature": req.temperature}
                }
                
                response = await client.post(f"{OLLAMA_HOST}/api/chat", json=ollama_payload)
                
                if response.status_code != 200:
                    safe_err_text = sanitize_log_message(response.text)
                    raise httpx.RequestError(f"Ollama error {response.status_code}: {safe_err_text}")
                
                elapsed = time.time() - start_time
                logger.info(f"Local inference success in {elapsed:.2f}s (Model: {req.model})")
                
                res_json = response.json()
                content = res_json.get("message", {}).get("content", "")
                
                return {
                    "id": "chatcmpl-local-secured",
                    "object": "chat.completion",
                    "created": int(time.time()),
                    "model": req.model,
                    "choices": [{
                        "index": 0,
                        "message": {"role": "assistant", "content": content},
                        "finish_reason": "stop"
                    }]
                }

            except (httpx.RequestError, httpx.TimeoutException) as e:
                safe_err = sanitize_log_message(str(e))
                logger.error(f"[FAILURE LOG] Local execution failed: {safe_err}. Triggering commercial fallback.")
                return await fallback_to_commercial_api(req.model_dump())

async def fallback_to_commercial_api(payload: dict):
    """
    ローカル崩壊時・ビジー時の商用APIフォールバック処理
    """
    async with httpx.AsyncClient(timeout=30.0) as client:
        try:
            headers = {
                "Authorization": f"Bea" + "rer {FALLBACK_API_KEY}",
                "Content-Type": "application/json"
            }
            # フォールバック時は安全・高速・安価なモデルへ強制ルーティング
            payload["model"] = "gpt-4o-mini"
            
            response = await client.post(FALLBACK_API_URL, json=payload, headers=headers)
            if response.status_code != 200:
                safe_err_text = sanitize_log_message(response.text)
                raise HTTPException(status_code=502, detail=f"Fallback API error: {safe_err_text}")
            
            logger.info("Commercial fallback completed successfully.")
            return response.json()
        except Exception as e:
            safe_err = sanitize_log_message(str(e))
            logger.critical(f"[FATAL SECURITY/INFRA] Both local and commercial fallback failed: {safe_err}")
            raise HTTPException(status_code=500, detail="All inference paths failed securely.")

@app.get("/health")
async def health_check():
    return {
        "status": "healthy",
        "vram_cache": {
            "free_mb": vram_cache["free_mb"],
            "updated_at": vram_cache["updated_at"]
        },
        "ollama_host_configured": bool(OLLAMA_HOST),
        "semaphore_locked": ollama_semaphore.locked()
    }

💡 すぐに検証環境を構築したい方向け: このアーキテクチャの完全なソースコード一式(ZIP)は Gumroad にて $0+ (無料〜) で配布しています。


3. 明日から現場で使える運用・保守のベストプラクティス

上記の実装に加え、インフラストラクチャレベルでの防御を組み合わせることで、システムの堅牢性は飛躍的に向上します。

3.1 インフラのコンテナ分離(Docker Bridge Network)

Ollamaのデフォルトポート(11434)がホストOS経由で外部へ露出すると、リバースプロキシの制御をバイパスされて直接APIを叩かれるリスクがあります。docker-compose.yml でネットワークを分離し、Gatewayからのみ通信可能な構成を徹底してください。

version: '3.8'
services:
  gateway:
    build: .
    ports:
      - "8000:8000"
    environment:
      - OLLAMA_HOST=http://ollama:11434
    networks:
      - llm_net

  ollama:
    image: ollama/ollama:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    # portsディレクティブは意図的に記述せず、ホストへの露出を防ぐ
    networks:
      - llm_net

networks:
  llm_net:
    driver: bridge

3.2 監視メトリクスの常時確認(オブザーバビリティの向上)

ログ出力だけに頼るのではなく、失敗ログやフォールバック発生回数を監視スタック(Prometheus + Grafana)にメトリクスとしてエクスポーズすることが推奨されます。
「ローカルでの推論成功率」と「フォールバック率」を可視化することで、現在のハードウェアリソースに対してモデルサイズ(パラメーター数や量子化ビット数)が適切であるかを、データ駆動で定期的に見直すことが可能になります。


4. なぜ「泥臭い失敗ログ」の開示がチームを救うのか

【実機検証で遭遇した実際のログ】

2026-10-01 03:14:22 [ERROR] OllamaEdgeGateway: [FAILURE LOG] Local execution failed: Connection aborted. 
2026-10-01 03:14:22 [ERROR] nvidia-smi: Memory used: 11840MB / 12288MB (Free: 448MB) -> OOM Killer invoked on ollama_llama_server.
2026-10-01 03:14:22 [INFO] OllamaEdgeGateway: Commercial fallback completed successfully.

コンテキスト長が限界を超えた瞬間に、突如発生するOOM。プロセスが容赦なくKILLされ、無防備なアーキテクチャであればサービス全体がダウンします。
しかし、本ミドルウェアのような「予測的防御」と「安全なフォールバック機構」を一層挟むだけで、インフラエンジニアの睡眠時間は守られ、ユーザー体験を損なうことなくシームレスなサービス提供が継続できます。

ハードウェアリソースの限界という「物理法則」に抗うのではなく、それを受け入れた上でアーキテクチャによる迂回路(フォールバック)を設計することこそが、堅牢なシステム構築の要諦です。ぜひご自身のプロジェクトのエッジ環境にも組み込んでみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?