Files
awoooi/apps/api/src/services/ollama_health_monitor.py
Your Name 9ccf230a5f
Some checks failed
CD Pipeline / tests (push) Successful in 1m24s
Code Review / ai-code-review (push) Successful in 17s
CD Pipeline / build-and-deploy (push) Successful in 3m37s
CD Pipeline / post-deploy-checks (push) Has been cancelled
fix(ollama): cooldown provider health probes
2026-05-25 12:25:32 +08:00

410 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Ollama 健康檢測模組 - P1.1a
============================
三層健康檢查:連通性 → 推理測試 → (optional) GPU 記憶體
設計原則:
- Redis 30s 快取防 health check storm
- 降級安全:快取失敗不影響功能,直接執行檢查
- 用 settings 取 host 配置(禁硬編碼 IP
- 用 httpx.AsyncClient非 requests
版本: v1.1
建立: 2026-04-25 (台北時區)
建立者: Claude Engineer-C (P1.1a)
# Created 2026-04-25 P1.1 by Claude Engineer-C
# 2026-04-25 critic-fix by Claude Engineer-C — 修 BLOCKER B3(make_cache_key 公開) + H3(timeout 45s)
"""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from enum import Enum
from urllib.parse import urlparse
import httpx
import structlog
from src.core.config import get_settings
from src.services.ollama_endpoint_circuit_breaker import (
get_ollama_endpoint_cooldown_remaining_seconds,
record_ollama_endpoint_failure,
record_ollama_endpoint_success,
)
logger = structlog.get_logger(__name__)
# =============================================================================
# 常數
# =============================================================================
REDIS_CACHE_KEY_PREFIX = "ollama_health:"
REDIS_CACHE_TTL_SECONDS = 30 # HEALTHY/SLOW/DEGRADED 快取 30s 防 check storm
# 2026-05-04 ogt: B1 修復 — OFFLINE 快取 TTL 縮短至 5s
# 根因NetworkPolicy reload/CNI 瞬態抖動導致三台同時 OFFLINE被 30s cache 放大
# OFFLINE 狀態快取越短越好,讓系統儘快重新評估並切回
REDIS_CACHE_TTL_OFFLINE_SECONDS = 5 # OFFLINE 只快取 5s儘速重試
CONNECTIVITY_TIMEOUT_SECONDS = 5.0
# 2026-04-25 critic-fix H3 by Claude Engineer-C — 45s 讓 SLOW 門檻(30s)真的能觀察到
INFERENCE_TIMEOUT_SECONDS = 45.0
# 推理延遲分級門檻(毫秒)
LATENCY_HEALTHY_THRESHOLD_MS = 10_000 # <10s → HEALTHY
LATENCY_SLOW_THRESHOLD_MS = 30_000 # 10-30s → SLOW
# >30s → DEGRADED
# =============================================================================
# 公開工具函數
# =============================================================================
def make_cache_key(url: str) -> str:
"""
由 URL 產生 Redis cache key。
取 host:port 部分netloc作為 key避免 scheme 差異干擾。
http://192.168.0.111:11434 → "ollama_health:192.168.0.111:11434"
公開函數供 OllamaFailoverManager.clear_cache() 動態組 key
確保與內部 _cache_key() 使用相同邏輯。
# 2026-04-25 critic-fix B3 by Claude Engineer-C — 避免 clear_cache 硬編碼 IP
"""
parsed = urlparse(url)
netloc = parsed.netloc or url
return f"{REDIS_CACHE_KEY_PREFIX}{netloc}"
# =============================================================================
# 資料模型
# =============================================================================
class HealthStatus(Enum):
"""Ollama 健康狀態分級"""
HEALTHY = "healthy" # <10s 推理,正常使用
SLOW = "slow" # 10-30s降級使用
DEGRADED = "degraded" # >30s 或推理超時,優先切換
OFFLINE = "offline" # 連通性失敗,必須切換
@dataclass
class HealthReport:
"""Ollama 健康檢測報告"""
status: HealthStatus
host: str = ""
latency_ms: float = 0.0
reason: str = ""
checked_at: float = field(default_factory=time.time)
from_cache: bool = False
mcp_health: dict[str, bool] = field(default_factory=dict)
def is_usable(self) -> bool:
"""是否可用(不含 OFFLINE"""
return self.status != HealthStatus.OFFLINE
def to_dict(self) -> dict:
return {
"status": self.status.value,
"host": self.host,
"latency_ms": round(self.latency_ms, 1),
"reason": self.reason,
"checked_at": self.checked_at,
"from_cache": self.from_cache,
}
# =============================================================================
# OllamaHealthMonitor
# =============================================================================
class OllamaHealthMonitor:
"""
Ollama 三層健康檢測
層 1連通性/api/tags5s timeout
層 2推理測試/api/generate35s timeout
層 3GPU 記憶體optionalvia SSH MCP
結果 Redis 快取 30s防 health check storm。
2026-04-25 Claude Engineer-C (P1.1a)
"""
def __init__(self) -> None:
self._settings = get_settings()
# -------------------------------------------------------------------------
# Public API
# -------------------------------------------------------------------------
async def check(self, host: str) -> HealthReport:
"""
執行健康檢測(含 Redis 快取)
Args:
host: Ollama 主機 URLe.g. "http://192.168.0.111:11434"
Returns:
HealthReport
"""
# 嘗試從快取取得
cached = await self._get_cached(host)
if cached is not None:
cached.from_cache = True
logger.debug("ollama_health_cache_hit", host=host, status=cached.status.value)
return cached
# 執行實際健康檢測
report = await self._run_checks(host)
report.host = host
# 寫入快取(失敗不影響功能,外部 exception 也靜默)
try:
await self._set_cached(host, report)
except Exception as e:
logger.debug("ollama_health_set_cached_outer_failed", host=host, error=str(e))
logger.info(
"ollama_health_checked",
host=host,
status=report.status.value,
latency_ms=round(report.latency_ms, 1),
reason=report.reason,
)
# 2026-04-26 P2.3 by Claude Sonnet 4.6 (tool-expert) — 更新 Prometheus health gauge
# host label 取 "111" / "188" 短標識(從 URL 解析)
try:
from src.core.metrics import OLLAMA_HEALTH_STATUS
from urllib.parse import urlparse as _urlparse
_netloc = _urlparse(host).hostname or host
# 2026-05-03 ogt: GCP 三層容災ADR-110— 通用 host label 萃取
# 私網 IP192.168.x.y→ 取最後一段("111"/"188")維持短標識
# GCP 公網 IP34.x.y.z→ 取前 8 字元避免 label 過長("34.143.1"
if _netloc.startswith("192.168."):
_host_label = _netloc.split(".")[-1]
elif "." in _netloc:
_host_label = _netloc.replace(".", "_")[:12] # e.g. "34_143_170_2"
else:
_host_label = _netloc
_is_healthy = 1 if report.status == HealthStatus.HEALTHY else 0
OLLAMA_HEALTH_STATUS.labels(host=_host_label).set(_is_healthy)
except Exception as _metric_err:
logger.debug("ollama_health_metric_error", host=host, error=str(_metric_err))
# 寫入 audit_logbest-effort
await self._write_audit_log(host, report)
return report
# -------------------------------------------------------------------------
# 三層檢查
# -------------------------------------------------------------------------
async def _run_checks(self, host: str) -> HealthReport:
"""依序執行三層檢查"""
cooldown_remaining = get_ollama_endpoint_cooldown_remaining_seconds(host)
if cooldown_remaining > 0:
return HealthReport(
status=HealthStatus.OFFLINE,
host=host,
reason=(
"recent_endpoint_failure_cooldown:"
f"{cooldown_remaining:.0f}s"
),
)
# 層 1連通性
connectivity_ok = await self._check_connectivity(host)
if not connectivity_ok:
record_ollama_endpoint_failure(host)
return HealthReport(
status=HealthStatus.OFFLINE,
host=host,
reason="連通性失敗:/api/tags 無回應",
)
# 層 2推理測試
report = await self._check_inference(host)
record_ollama_endpoint_success(host)
return report
async def _check_connectivity(self, host: str) -> bool:
"""
層 1連通性檢查
GET /api/tags5s timeout回傳 200 即通過
"""
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(CONNECTIVITY_TIMEOUT_SECONDS)
) as client:
resp = await client.get(f"{host}/api/tags")
return resp.status_code == 200
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
return False
except Exception as e:
logger.warning("ollama_connectivity_check_error", host=host, error=str(e))
return False
async def _check_inference(self, host: str) -> HealthReport:
"""
層 2推理測試
POST /api/generate35s timeout依延遲分級
"""
model = self._settings.OLLAMA_HEALTH_CHECK_MODEL
start = time.perf_counter()
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(INFERENCE_TIMEOUT_SECONDS)
) as client:
resp = await client.post(
f"{host}/api/generate",
json={
"model": model,
"prompt": "hi",
"stream": False,
"options": {"num_predict": 1},
},
)
latency_ms = (time.perf_counter() - start) * 1000
if resp.status_code != 200:
return HealthReport(
status=HealthStatus.DEGRADED,
latency_ms=latency_ms,
reason=f"推理回傳 HTTP {resp.status_code}",
)
# 分級判斷
if latency_ms < LATENCY_HEALTHY_THRESHOLD_MS:
return HealthReport(
status=HealthStatus.HEALTHY,
latency_ms=latency_ms,
)
elif latency_ms < LATENCY_SLOW_THRESHOLD_MS:
return HealthReport(
status=HealthStatus.SLOW,
latency_ms=latency_ms,
reason=f"推理延遲 {latency_ms:.0f}msslow zone",
)
else:
return HealthReport(
status=HealthStatus.DEGRADED,
latency_ms=latency_ms,
reason=f"推理延遲 {latency_ms:.0f}ms>30s",
)
except (httpx.TimeoutException, asyncio.TimeoutError):
latency_ms = (time.perf_counter() - start) * 1000
return HealthReport(
status=HealthStatus.DEGRADED,
latency_ms=latency_ms,
reason="推理超時 >35s",
)
except (httpx.ConnectError, httpx.NetworkError) as e:
# 2026-05-04 ogt: B3 修復 — connectivity 已通過,推理階段 ConnectError 改判 DEGRADED
# 原設計ConnectError → OFFLINE但 /api/tags 已成功,表示主機存活
# 根因socket 半開GCP LB 回收 idle conn或 Ollama 進程重啟,屬瞬態
# DEGRADED 不觸發 30s OFFLINE cache下次請求立刻重試
return HealthReport(
status=HealthStatus.DEGRADED,
reason=f"推理連接失敗主機可達socket 瞬斷):{e}",
)
except Exception as e:
logger.warning("ollama_inference_check_error", host=host, error=str(e))
return HealthReport(
status=HealthStatus.DEGRADED,
reason=f"推理測試例外:{e}",
)
# -------------------------------------------------------------------------
# Redis 快取
# -------------------------------------------------------------------------
def _cache_key(self, host: str) -> str:
"""生成 Redis cache key內部使用委派至 make_cache_key"""
return make_cache_key(host)
async def _get_cached(self, host: str) -> HealthReport | None:
"""從 Redis 取得快取的 HealthReport失敗返回 None"""
try:
from src.core.redis_client import get_redis
redis = get_redis()
raw = await redis.get(self._cache_key(host))
if not raw:
return None
data = json.loads(raw)
return HealthReport(
status=HealthStatus(data["status"]),
host=data.get("host", host),
latency_ms=data.get("latency_ms", 0.0),
reason=data.get("reason", ""),
checked_at=data.get("checked_at", time.time()),
)
except Exception as e:
logger.debug("ollama_health_cache_get_failed", host=host, error=str(e))
return None
async def _set_cached(self, host: str, report: HealthReport) -> None:
"""寫入 Redis 快取,失敗靜默(不影響功能)
2026-05-04 ogt: OFFLINE 結果快取 5s縮短其他狀態快取 30s
"""
try:
from src.core.redis_client import get_redis
redis = get_redis()
data = json.dumps(report.to_dict())
ttl = (
REDIS_CACHE_TTL_OFFLINE_SECONDS
if report.status == HealthStatus.OFFLINE
else REDIS_CACHE_TTL_SECONDS
)
await redis.set(self._cache_key(host), data, ex=ttl)
except Exception as e:
logger.debug("ollama_health_cache_set_failed", host=host, error=str(e))
# -------------------------------------------------------------------------
# Audit Log (structured logAuditLog 表設計用於 K8s 操作審計,不適合此場景)
# -------------------------------------------------------------------------
async def _write_audit_log(self, host: str, report: HealthReport) -> None:
"""記錄健康檢測結果structlogservice=ollama_health_monitor"""
logger.info(
"ollama_health_monitor_audit",
service="ollama_health_monitor",
host=host,
status=report.status.value,
latency_ms=round(report.latency_ms, 1),
reason=report.reason,
is_usable=report.is_usable(),
)
# =============================================================================
# Singleton
# =============================================================================
_monitor: OllamaHealthMonitor | None = None
def get_ollama_health_monitor() -> OllamaHealthMonitor:
"""取得 OllamaHealthMonitor singleton"""
global _monitor
if _monitor is None:
_monitor = OllamaHealthMonitor()
return _monitor
def reset_ollama_health_monitor() -> None:
"""重置 singleton測試用"""
global _monitor
_monitor = None