Ollama: プロジェクトデプロイと最適化:インテリジェントカスタマーサービスシステム

デプロイと最適化はSupportBotの卒業式——開発から本番へ、機能から卓越へ。

⚠️ : デプロイ前にセキュリティチェックリストを完了すること——①API Keyなしのリクエストは401を返す;②レート制限されたリクエストは429を返す;③ポート11434は外部からアクセス不可;④SSL証明書が有効でHTTPSが強制;⑤入出力フィルタリングが動作。セキュリティ設定が「正しく見える」≠「実際に動作する」——各項目をcurlでテスト。

📋 前提条件: まず以下を習得していること

1. 学べること


2. プロジェクト背景

💡 ヒント: 本番環境変数管理のベストプラクティス——すべてのキーと機密設定は.envファイルまたはシークレットマネージャー経由で注入し、コードやDocker Composeファイルにハードコードしない。.env.gitignoreに追加;リポジトリには.env.exampleテンプレートのみ提供。

(1) デプロイ前の主要課題

AliceのSupportBotは開発完了したが、デプロイの課題に直面:

課題 リスク ソリューション
単一マシンデプロイ、冗長性なし サービス停止 Docker Composeマルチサービス
認証なし、API露出 セキュリティ脆弱性 Nginx + API Key
モニタリングの死角 障害検出が遅い Prometheus + Grafana
パフォーマンス未検証 レイテンシ目標超過 ベンチマーク + チューニング
運用ドキュメントなし 障害復旧が遅い 運用ランブック

3. Docker Compose本番設定

⚠️ 警告: 本番ではOllamaのOLLAMA_HOST=0.0.0.0はDocker内部ネットワーク内でのみ安全;ポート11434はインターネットに直接マッピングしてはならない。Nginxが唯一の外部エントリポイントであり、API Key認証とHTTPSを設定必須。

(1) 本番アーキテクチャ

100%
flowchart TD
    A[インターネット<br/>:443] --> B[Nginx<br/>SSL + 認証 + レート制限]
    B --> C[FastAPI SupportBot<br/>:8000]
    C --> D[Ollama<br/>:11434<br/>GPUアクセラレーション]
    C --> E[Chroma<br/>:8001<br/>ベクトルストア]
    F[Prometheus<br/>:9090] --> G[全サービス<br/>指標収集]
    G --> H[Grafana<br/>:3001<br/>ダッシュボード]

(2) サービス一覧

サービス イメージ ポート リソース 永続化
nginx nginx:alpine 80/443 1 vCPU、512MB 設定ファイル
supportbot カスタム 8000 2 vCPU、4GB
ollama ollama/ollama 11434 8 vCPU、16GB、1x GPU ollama_data
chroma chromadb/chroma 8001 2 vCPU、4GB chroma_data
prometheus prom/prometheus 9090 1 vCPU、2GB prometheus_data
grafana grafana/grafana 3001 1 vCPU、1GB grafana_data

(3) ▶ サンプル:本番Docker Compose

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

services:
  nginx:
    image: nginx:alpine
    container_name: supportbot-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/ssl/certs:ro
    depends_on:
      supportbot:
        condition: service_healthy
    restart: unless-stopped

  supportbot:
    build:
      context: ./app
      dockerfile: Dockerfile
    container_name: supportbot-api
    ports:
      - "8000:8000"
    environment:
      - OLLAMA_HOST=http://ollama:11434
      - CHROMA_HOST=http://chroma:8000
      - API_KEY=${SUPPORTBOT_API_KEY}
      - CHAT_MODEL=qwen2.5
      - CLASSIFIER_MODEL=llama3.2:3b
      - EMBED_MODEL=nomic-embed-text
    depends_on:
      ollama:
        condition: service_healthy
      chroma:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  ollama:
    image: ollama/ollama
    container_name: supportbot-ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    environment:
      - OLLAMA_HOST=0.0.0.0:11434
      - OLLAMA_NUM_PARALLEL=4
      - OLLAMA_KEEP_ALIVE=30m
      - OLLAMA_MAX_LOADED_MODELS=2
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:11434/api/tags"]
      interval: 30s
      timeout: 10s
      retries: 5
    restart: unless-stopped

  chroma:
    image: chromadb/chroma
    container_name: supportbot-chroma
    ports:
      - "8001:8000"
    volumes:
      - chroma_data:/chroma/chroma
    environment:
      - ANONYMIZED_TELEMETRY=FALSE
    restart: unless-stopped

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

  grafana:
    image: grafana/grafana
    container_name: supportbot-grafana
    ports:
      - "3001:3000"
    volumes:
      - grafana_data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
    depends_on: [prometheus]
    restart: unless-stopped

volumes:
  ollama_data:
  chroma_data:
  prometheus_data:
  grafana_data:
⚠️ セキュリティ警告: Grafanaのデフォルト管理者パスワードはadmin——初回ログイン後に直ちに変更必須! 本番ではGRAFANA_PASSWORD環境変数で強力なパスワードを設定、例:GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}.envファイルにランダム生成パスワード値を設定。

出力:

TEXT
Docker Compose production deployment successful, all services healthy

(4) ▶ サンプル:Nginx本番設定

NGINX
# nginx/nginx.conf
worker_processes auto;

events {
    worker_connections 1024;
}

http {
    # Rate limiting zone
    limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;

    upstream supportbot {
        server supportbot:8000;
    }

    # Redirect HTTP to HTTPS
    server {
        listen 80;
        return 301 https://$host$request_uri;
    }

    # HTTPS server
    server {
        listen 443 ssl;
        server_name ai.globalshop.example.com;

        ssl_certificate     /etc/ssl/certs/server.crt;
        ssl_certificate_key /etc/ssl/certs/server.key;
        ssl_protocols       TLSv1.2 TLSv1.3;

        # SupportBot API
        location /v1/ {
            limit_req zone=api burst=20 nodelay;

            # API Key authentication
            if ($http_x_api_key = "") {
                return 401 '{"error": "API key required"}';
            }

            proxy_pass http://supportbot;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_connect_timeout 5s;
            proxy_read_timeout 30s;
        }

        # Health check (no auth)
        location /health {
            proxy_pass http://supportbot/health;
        }
    }
}

出力:

TEXT
// Execution successful

4. パフォーマンスベンチマークと最適化

💡 ヒント: ベンチマーク前に必ず「ウォームアップ」——1〜2リクエストを送信してモデルをGPUにロード。最初のリクエストにはモデルロード時間(コールドスタート5〜30秒)が含まれる;以降のリクエストが真の推論レイテンシを反映。OLLAMA_KEEP_ALIVEがモデル常駐時間を制御。

(1) 主要パフォーマンス目標

指標 目標 テスト方法
TTFT 500ms未満 最初のトークンレイテンシ測定
P95レイテンシ 3秒未満 100リクエスト、P95を取得
スループット 20 QPS以上 同時実行テスト
自動解決率 80%以上 100の実際の質問で評価

(2) ▶ サンプル:ベンチマークスクリプト

PYTHON
# benchmark.py
import asyncio
import aiohttp
import time
import json
import statistics

import os

API_URL = "http://localhost:8000/v1/chat"
API_KEY = os.environ.get("SUPPORTBOT_API_KEY", "your-api-key-here")

TEST_QUESTIONS = [
    "What is the return policy?",
    "How long does shipping take?",
    "Combien coûte la livraison?",
    "I received a damaged item, what do I do?",
    "Compare the wireless headphones models",
    "Where is order #88765?",
    "What is the return policy?",
    "My product broke after 2 weeks, I want a refund!",
    "Do you ship to Germany?",
    "What is the warranty for electronics?",
]

async def single_request(session: aiohttp.ClientSession, question: str) -> dict:
    start = time.time()
    try:
        async with session.post(
            API_URL,
            json={"message": question},
            headers={"X-Api-Key": API_KEY},
            timeout=aiohttp.ClientTimeout(total=30)
        ) as response:
            data = await response.json()
            elapsed = time.time() - start
            return {
                "question": question[:40],
                "latency_s": round(elapsed, 2),
                "intent": data.get("intent", "?"),
                "handler": data.get("handler", "?"),
                "language": data.get("language", "?"),
                "status": "success" if response.status == 200 else "error"
            }
    except Exception as e:
        return {"question": question[:40], "latency_s": round(time.time() - start, 2),
                "status": "error", "error": str(e)}

async def run_benchmark(concurrency: int = 1, total_requests: int = 50) -> dict:
    async with aiohttp.ClientSession() as session:
        # Warm up
        await single_request(session, "Hello")

        # Run benchmark
        start = time.time()
        tasks = []
        for i in range(total_requests):
            q = TEST_QUESTIONS[i % len(TEST_QUESTIONS)]
            tasks.append(single_request(session, q))
            if len(tasks) >= concurrency:
                results = await asyncio.gather(*tasks)
                tasks = []

        if tasks:
            results += await asyncio.gather(*tasks)

        total_time = time.time() - start

    latencies = [r["latency_s"] for r in results if r["status"] == "success"]
    errors = sum(1 for r in results if r["status"] == "error")

    return {
        "concurrency": concurrency,
        "total_requests": total_requests,
        "total_time_s": round(total_time, 1),
        "errors": errors,
        "error_rate": round(errors / total_requests * 100, 1),
        "avg_latency_s": round(statistics.mean(latencies), 2) if latencies else 0,
        "p50_latency_s": round(statistics.median(latencies), 2) if latencies else 0,
        "p95_latency_s": round(sorted(latencies)[int(len(latencies) * 0.95)], 2) if latencies else 0,
        "max_latency_s": round(max(latencies), 2) if latencies else 0,
        "throughput_rps": round(total_requests / total_time, 1)
    }

if __name__ == "__main__":
    print("=== SupportBot Benchmark ===")
    for c in [1, 4, 8]:
        result = asyncio.run(run_benchmark(concurrency=c, total_requests=20))
        print(f"\nConcurrency {c}:")
        for k, v in result.items():
            print(f"  {k}: {v}")

出力:

TEXT
=== SupportBot Benchmark ===

5. セキュリティ強化

⚠️ 警告: 本番稼働前に各セキュリティ項目を個別に検証——API Keyなしのリクエストは401を返す、レート制限されたリクエストは429を返す、ポート11434は外部からアクセス不可であること。セキュリティ設定が「正しく見える」≠「実際に動作する」——curlでテスト。

(1) セキュリティチェックリスト

チェック項目 設定 ステータス
API Key認証 Nginx X-Api-Key検証
HTTPS暗号化 Nginx SSL設定
レート制限 60 req/min/IP
入力フィルタリング プロンプトインジェクション検出
出力フィルタリング 機密コンテンツフィルタリング
データサニタイズ PII自動除去
Ollama内部ネットワーク 127.0.0.1バインド

(2) ▶ サンプル:統合セキュリティミドルウェア

PYTHON
# security.py - FastAPI security middleware
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
import re
import time
from collections import defaultdict

class SecurityMiddleware:
    def __init__(self, api_key: str, rate_limit: int = 60):
        self.api_key = api_key
        self.rate_limit = rate_limit
        self.request_counts: dict = defaultdict(list)

        self.injection_patterns = [
            r"ignore\s+(previous|all)\s+instructions",
            r"you\s+are\s+now\s+DAN",
            r"show\s+(me\s+)?(your\s+)?system\s+prompt",
            r"reveal\s+(your|the)\s+(initial|system)",
        ]

        self.output_patterns = [
            r"system\s*prompt[:\s]",
            r"RETURN_POLICY_INTERNAL",
            r"INTERNAL_PRICING",
        ]

    def check_api_key(self, request: Request) -> bool:
        key = request.headers.get("X-Api-Key", "")
        return key == self.api_key

    def check_rate_limit(self, client_ip: str) -> bool:
        now = time.time()
        self.request_counts[client_ip] = [
            t for t in self.request_counts[client_ip]
            if now - t < 60
        ]
        if len(self.request_counts[client_ip]) >= self.rate_limit:
            return False
        self.request_counts[client_ip].append(now)
        return True

    def check_input(self, text: str) -> tuple[bool, str]:
        for pattern in self.injection_patterns:
            if re.search(pattern, text, re.IGNORECASE):
                return False, "Potential prompt injection detected"
        return True, ""

    def check_output(self, text: str) -> tuple[bool, str]:
        for pattern in self.output_patterns:
            if re.search(pattern, text, re.IGNORECASE):
                return False, "Sensitive content in response filtered"
        return True, ""

    def sanitize_pii(self, text: str) -> str:
        text = re.sub(r"[\w.-]+@[\w.-]+\.\w+", "[EMAIL_REDACTED]", text)
        text = re.sub(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", "[PHONE_REDACTED]", text)
        text = re.sub(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", "[CC_REDACTED]", text)
        return text

出力:

TEXT
# Function defined successfully

6. モニタリング統合

ℹ️ 情報: GPUモニタリングにはDCGM Exporter(NVIDIA公式)の使用を推奨。GPU使用率、VRAM使用量、温度、消費電力の指標を収集可能。GrafanaのGPUダッシュボードテンプレートと組み合わせれば、可視化パネルを迅速に構築し、OOMや過熱リスクを事前に検出できる。

(1) Grafanaダッシュボード指標

パネル 指標 単位 アラート
リクエストレイテンシ supportbot_request_duration_seconds P95 > 3秒
リクエスト率 supportbot_requests_total req/min < 5 req/min
エラー率 supportbot_errors_total % > 5%
GPU使用率 DCGM_FI_DEV_GPU_UTIL % > 95%
VRAM使用量 DCGM_FI_DEV_FB_USED MB > 95%
意図分布 supportbot_intent_count

(2) ▶ サンプル:Prometheus設定

YAML
# monitoring/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: "supportbot"
    static_configs:
      - targets: ["supportbot:8000"]
    metrics_path: /metrics

  - job_name: "ollama"
    static_configs:
      - targets: ["ollama:11434"]
    metrics_path: /metrics
    scheme: http

  - job_name: "node"
    static_configs:
      - targets: ["node-exporter:9100"]

rule_files:
  - "alert_rules.yml"

# alert_rules.yml
groups:
  - name: supportbot_alerts
    rules:
      - alert: HighLatency
        expr: histogram_quantile(0.95, rate(supportbot_request_duration_seconds_bucket[5m])) > 3
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "SupportBot P95 latency above 3s"

      - alert: ServiceDown
        expr: up{job="supportbot"} == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "SupportBot service is down"

      - alert: HighErrorRate
        expr: rate(supportbot_errors_total[5m]) / rate(supportbot_requests_total[5m]) > 0.05
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "SupportBot error rate above 5%"

出力:

TEXT
# Prometheus configuration loaded successfully
# Target status: 3 scrape targets all UP
# supportbot (UP) | ollama (UP) | node-exporter (UP)

7. 総合サンプル:本番稼働チェックリストと運用ランブック

💡 ヒント: 運用ランブックの価値は障害発生時に発揮される。四半期ごとに「消防訓練」を実施——意図的にサービスを停止し、ランブックに従って復旧し、ドキュメントの正確性と実運用性を検証し、古い手順を更新。

PYTHON
# ============================================
# Comprehensive: Production deployment checklist
# Final verification before going live
# ============================================

PRODUCTION_CHECKLIST = {
    "infrastructure": {
        "items": [
            "Docker Compose services all running (6/6)",
            "Ollama health check passing",
            "Chroma health check passing",
            "FastAPI health check passing",
            "Nginx SSL certificate valid (not expired)",
            "GPU acceleration verified (nvidia-smi inside container)",
        ],
        "commands": [
            "docker compose ps",
            "curl -f http://localhost:11434/api/tags",
            "curl -f http://localhost:8001/api/v1/heartbeat",
            "curl -f http://localhost:8000/health",
            "curl -f https://ai.globalshop.example.com/health",
        ]
    },
    "models": {
        "items": [
            "qwen2.5:7b pulled and verified",
            "llama3.2:3b pulled and verified",
            "nomic-embed-text pulled and verified",
            "supportbot Modelfile created and tested",
        ],
        "commands": [
            "docker exec supportbot-ollama ollama list",
            "docker exec supportbot-ollama ollama run supportbot 'Hello'",
        ]
    },
    "security": {
        "items": [
            "API Key authentication working (unauthorized request returns 401)",
            "Rate limiting working (burst above limit returns 429)",
            "Input injection filter tested",
            "Output content filter tested",
            "PII sanitization verified",
            "Ollama not accessible from internet (port 11434 blocked)",
        ]
    },
    "performance": {
        "items": [
            "Single request latency < 3s (P95)",
            "Concurrent 4 requests latency < 5s (P95)",
            "Throughput > 10 QPS",
            "RAG retrieval relevant (top-3 precision > 80%)",
            "Intent classification accuracy > 85%",
        ]
    },
    "monitoring": {
        "items": [
            "Prometheus scraping all services",
            "Grafana dashboards created and visible",
            "Alert rules configured and tested",
            "Log aggregation working",
        ]
    },
    "disaster_recovery": {
        "items": [
            "All configs in Git repository",
            "Chroma data backup scheduled (daily)",
            "Model files backed up on NFS/S3",
            "Recovery procedure documented and tested",
        ]
    }
}

def generate_runbook() -> str:
    """Generate operations runbook."""
    runbook = [
        "# SupportBot Operations Runbook\n",
        "## Service Overview",
        "- SupportBot API: https://ai.globalshop.example.com/v1/chat",
        "- Health Check: https://ai.globalshop.example.com/health",
        "- Grafana: http://localhost:3001 ⚠️ Change default admin password immediately!",
        "- Prometheus: http://localhost:9090\n",
        "## Common Operations\n",
        "### Restart a Service",
        "```bash",
        "docker compose restart supportbot  # Restart API",
        "docker compose restart ollama       # Restart Ollama",
        "```\n",
        "### Pull New Model",
        "```bash",
        "docker exec supportbot-ollama ollama pull model_name",
        "```\n",
        "### Rebuild Knowledge Base",
        "```bash",
        "docker exec supportbot-api python indexer.py /app/product_docs",
        "```\n",
        "### Check Logs",
        "```bash",
        "docker compose logs -f supportbot   # API logs",
        "docker compose logs -f ollama       # Ollama logs",
        "```\n",
        "### Emergency: Service Down",
        "1. Check: `docker compose ps`",
        "2. Restart: `docker compose restart <service>`",
        "3. Verify: `curl https://ai.globalshop.example.com/health`",
        "4. If Ollama OOM: restart with `OLLAMA_NUM_PARALLEL=2`",
        "5. Escalate: notify ops team\n",
        "## Performance Baseline",
        "| Metric | Target | Current |",
        "|:-------|:-------|:--------|",
        "| P95 Latency | < 3s | _fill after benchmark_ |",
        "| Throughput | > 10 QPS | _fill after benchmark_ |",
        "| Error Rate | < 1% | _fill after benchmark_ |",
        "| GPU Util | < 85% | _fill after benchmark_ |",
    ]
    return "\n".join(runbook)

# Generate and save
if __name__ == "__main__":
    print(generate_runbook())
    with open("RUNBOOK.md", "w") as f:
        f.write(generate_runbook())
    print("\nRunbook saved: RUNBOOK.md")

❓ よくある質問

Q デプロイ後の初回起動にどれくらいかかる?
A モデルのプルに約10〜30分(ネットワーク速度とモデルサイズによる)。以降の再起動は約30秒(モデルはVolumeにキャッシュ)。事前にモデルをプルしておくこと。
Q 本番のSSL証明書はどう取得する?
A 無料のLet's Encrypt証明書はcertbotで取得、またはCaddyで自動HTTPS。社内ネットワークでは自己署名証明書も可。
Q セキュリティ設定が実際に動作していることをどう確認する?
A 1)API Keyなしのリクエストは401を返す;2)レート制限されたリクエストは429を返す;3)ポート11434は外部からアクセス不可;4)インジェクション攻撃はブロックされる。1つずつテスト。
Q モニタリングダッシュボードの設定にどれくらいかかる?
A 基本ダッシュボードは約1時間(Node Exporterテンプレートインポート + カスタムOllamaパネル)。アラートルールは約30分。合計約2時間。
Q 本番稼働後のSupportBotの継続的最適化はどうする?
A 1)ログで低品質回答を分析;2)ナレッジベース文書を追加;3)意図分類をチューニング;4)ModelfileのMESSAGE例を拡充;5)定期的にベンチマークを再実行。
Q 以前のバージョンにロールバックするには?
A Git checkoutで設定ファイルを戻す → docker compose down → docker compose up -d。Volume内のモデルデータは影響なし。Chromaデータはバックアップから復元。

📖 まとめ


📝 練習問題

  1. 基本(難易度 ⭐):Docker Composeで最小限のSupportBotバージョンをデプロイ(Ollama + FastAPI)、/healthエンドポイントがOKを返すことを確認する。
  2. 中級(難易度 ⭐⭐):ベンチマークスクリプトを実行し、P95レイテンシ、スループット、エラー率を記録し、目標値と比較。目標未達の場合はOLLAMA_NUM_PARALLELやnum_ctxを調整して再テスト。
  3. 上級(難易度 ⭐⭐⭐):完全なSupportBot本番デプロイを完了——Nginxセキュリティ設定 + Prometheus/Grafanaモニタリング + アラートルール + 運用ランブックを含め——本番稼働チェックリストの完全検証を実行。
Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%