feat(api): ADR-030 Phase 2 診斷資料收集強化

實作智能自動修復系統的資料收集層:

1. k8s_diagnostics.py - K8s 診斷服務
   - Pod Events/Logs/ResourceUsage 收集
   - CrashLoopBackOff/OOM/ImagePull 偵測
   - 非同步並行收集 + 錯誤容忍

2. diagnosis_aggregator.py - 診斷聚合器
   - 整合 K8s + SignOz + Expert Rules
   - DiagnosisContext 提供結構化 LLM Prompt
   - DiagnosisSignal 信號分析

3. decision_manager.py - 決策引擎整合
   - Step 2.5 加入診斷收集
   - 傳遞 diagnosis_context 給 LLM

4. openclaw.py - LLM Prompt 增強
   - 整合 K8s/SignOz 深度診斷上下文
   - 支援 diagnosis_signals 摘要

ADR-030 架構: 診斷先行,根因分析,非盲目重啟

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-26 21:55:50 +08:00
parent bb6151cf44
commit 60e9538889
4 changed files with 1301 additions and 3 deletions

View File

@@ -0,0 +1,654 @@
"""
K8s Diagnostics Service - Phase 2 資料收集強化
==============================================
ADR-030: 智能自動修復系統
提供 K8s 診斷資料收集:
- Pod Events (kubectl get events)
- Pod Logs (kubectl logs)
- Resource Usage (kubectl top)
設計原則:
- 非同步執行,不阻塞主流程
- 錯誤容忍,單一失敗不影響整體
- 結果快取,避免重複查詢
版本: v1.0
建立: 2026-03-27 (台北時區)
"""
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from typing import Any
import structlog
from src.core.config import settings
logger = structlog.get_logger(__name__)
# Lazy import kubernetes_asyncio to avoid import errors when not installed
_k8s_client = None
_k8s_config_loaded = False
async def _get_k8s_client():
"""Lazy load kubernetes client"""
global _k8s_client, _k8s_config_loaded
if _k8s_client is not None:
return _k8s_client
try:
from kubernetes_asyncio import client, config
if not _k8s_config_loaded:
try:
# 優先使用 in-cluster 配置
config.load_incluster_config()
logger.info("k8s_diagnostics_incluster_config")
except config.ConfigException:
# Fallback 到 kubeconfig
await config.load_kube_config(config_file=settings.KUBECONFIG_PATH)
logger.info("k8s_diagnostics_kubeconfig", path=settings.KUBECONFIG_PATH)
_k8s_config_loaded = True
_k8s_client = client
return _k8s_client
except Exception as e:
logger.error("k8s_diagnostics_init_failed", error=str(e))
return None
# =============================================================================
# Data Models
# =============================================================================
class EventType(str, Enum):
"""K8s Event 類型"""
NORMAL = "Normal"
WARNING = "Warning"
@dataclass
class K8sEvent:
"""K8s Event 資料"""
type: EventType
reason: str
message: str
count: int
first_timestamp: datetime | None
last_timestamp: datetime | None
source_component: str
involved_object: str
def to_dict(self) -> dict[str, Any]:
return {
"type": self.type.value,
"reason": self.reason,
"message": self.message,
"count": self.count,
"first_timestamp": self.first_timestamp.isoformat() if self.first_timestamp else None,
"last_timestamp": self.last_timestamp.isoformat() if self.last_timestamp else None,
"source_component": self.source_component,
"involved_object": self.involved_object,
}
def is_warning(self) -> bool:
return self.type == EventType.WARNING
def is_recent(self, minutes: int = 30) -> bool:
"""檢查是否為最近的事件"""
if not self.last_timestamp:
return False
age = datetime.now(UTC) - self.last_timestamp
return age.total_seconds() < minutes * 60
@dataclass
class ResourceUsage:
"""資源使用量"""
cpu_millicores: int # 毫核 (1000m = 1 core)
memory_bytes: int # Bytes
cpu_limit_millicores: int | None = None
memory_limit_bytes: int | None = None
@property
def cpu_percent(self) -> float | None:
"""CPU 使用率 (相對於 limit)"""
if self.cpu_limit_millicores:
return (self.cpu_millicores / self.cpu_limit_millicores) * 100
return None
@property
def memory_percent(self) -> float | None:
"""Memory 使用率 (相對於 limit)"""
if self.memory_limit_bytes:
return (self.memory_bytes / self.memory_limit_bytes) * 100
return None
@property
def memory_mb(self) -> float:
"""Memory in MB"""
return self.memory_bytes / (1024 * 1024)
def to_dict(self) -> dict[str, Any]:
return {
"cpu_millicores": self.cpu_millicores,
"cpu_percent": round(self.cpu_percent, 1) if self.cpu_percent else None,
"memory_bytes": self.memory_bytes,
"memory_mb": round(self.memory_mb, 1),
"memory_percent": round(self.memory_percent, 1) if self.memory_percent else None,
}
def is_cpu_high(self, threshold: float = 80.0) -> bool:
"""CPU 使用率是否過高"""
return self.cpu_percent is not None and self.cpu_percent > threshold
def is_memory_high(self, threshold: float = 80.0) -> bool:
"""Memory 使用率是否過高"""
return self.memory_percent is not None and self.memory_percent > threshold
@dataclass
class PodStatus:
"""Pod 狀態詳情"""
name: str
namespace: str
phase: str # Pending, Running, Succeeded, Failed, Unknown
ready: bool
restart_count: int
container_statuses: list[dict[str, Any]] = field(default_factory=list)
conditions: list[dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"namespace": self.namespace,
"phase": self.phase,
"ready": self.ready,
"restart_count": self.restart_count,
"container_statuses": self.container_statuses,
"conditions": self.conditions,
}
def is_healthy(self) -> bool:
return self.phase == "Running" and self.ready
def is_crash_loop(self) -> bool:
"""檢查是否處於 CrashLoopBackOff"""
for cs in self.container_statuses:
waiting = cs.get("state", {}).get("waiting", {})
if waiting.get("reason") == "CrashLoopBackOff":
return True
return False
def is_image_pull_error(self) -> bool:
"""檢查是否為 Image Pull 錯誤"""
for cs in self.container_statuses:
waiting = cs.get("state", {}).get("waiting", {})
reason = waiting.get("reason", "")
if reason in ("ImagePullBackOff", "ErrImagePull", "ErrImageNeverPull"):
return True
return False
@dataclass
class K8sDiagnostics:
"""K8s 診斷資料彙總"""
pod_name: str
namespace: str
collected_at: datetime = field(default_factory=lambda: datetime.now(UTC))
# 診斷資料
pod_status: PodStatus | None = None
events: list[K8sEvent] = field(default_factory=list)
logs: str = ""
previous_logs: str = ""
resource_usage: ResourceUsage | None = None
# 錯誤記錄
errors: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"pod_name": self.pod_name,
"namespace": self.namespace,
"collected_at": self.collected_at.isoformat(),
"pod_status": self.pod_status.to_dict() if self.pod_status else None,
"events": [e.to_dict() for e in self.events],
"logs_length": len(self.logs),
"previous_logs_length": len(self.previous_logs),
"resource_usage": self.resource_usage.to_dict() if self.resource_usage else None,
"errors": self.errors,
}
@property
def warning_events(self) -> list[K8sEvent]:
"""取得警告類型的事件"""
return [e for e in self.events if e.is_warning()]
@property
def recent_events(self) -> list[K8sEvent]:
"""取得最近 30 分鐘的事件"""
return [e for e in self.events if e.is_recent(30)]
def get_diagnosis_summary(self) -> str:
"""產生診斷摘要"""
lines = []
if self.pod_status:
lines.append(f"Pod Phase: {self.pod_status.phase}")
lines.append(f"Ready: {self.pod_status.ready}")
lines.append(f"Restart Count: {self.pod_status.restart_count}")
if self.pod_status.is_crash_loop():
lines.append("WARNING: CrashLoopBackOff detected!")
if self.pod_status.is_image_pull_error():
lines.append("WARNING: Image Pull Error detected!")
if self.resource_usage:
if self.resource_usage.is_cpu_high():
lines.append(f"WARNING: High CPU usage ({self.resource_usage.cpu_percent:.1f}%)")
if self.resource_usage.is_memory_high():
lines.append(f"WARNING: High Memory usage ({self.resource_usage.memory_percent:.1f}%)")
warning_count = len(self.warning_events)
if warning_count > 0:
lines.append(f"Warning Events: {warning_count}")
for e in self.warning_events[:3]: # 最多顯示 3 個
lines.append(f" - {e.reason}: {e.message[:100]}")
if self.errors:
lines.append(f"Collection Errors: {len(self.errors)}")
return "\n".join(lines) if lines else "No issues detected"
# =============================================================================
# K8s Diagnostics Service
# =============================================================================
class K8sDiagnosticsService:
"""
K8s 診斷資料收集服務
功能:
- 取得 Pod Events
- 取得 Pod Logs (current + previous)
- 取得 Resource Usage (via metrics-server)
- 取得 Pod Status
設計:
- 非同步並行收集
- 單一失敗不影響整體
- 結果包含錯誤資訊
"""
def __init__(self, default_namespace: str = "awoooi-prod"):
self.default_namespace = default_namespace
async def collect_diagnostics(
self,
pod_name: str,
namespace: str | None = None,
include_logs: bool = True,
include_previous_logs: bool = True,
log_tail_lines: int = 100,
) -> K8sDiagnostics:
"""
收集完整的 K8s 診斷資料
Args:
pod_name: Pod 名稱 (可以是部分名稱,會自動匹配)
namespace: Namespace (預設 awoooi-prod)
include_logs: 是否包含日誌
include_previous_logs: 是否包含上一次容器的日誌
log_tail_lines: 日誌行數
Returns:
K8sDiagnostics 包含所有收集到的資料
"""
ns = namespace or self.default_namespace
diagnostics = K8sDiagnostics(pod_name=pod_name, namespace=ns)
client = await _get_k8s_client()
if not client:
diagnostics.errors.append("K8s client initialization failed")
return diagnostics
# 先找到實際的 Pod 名稱 (支援部分匹配)
actual_pod_name = await self._find_pod(client, pod_name, ns)
if not actual_pod_name:
diagnostics.errors.append(f"Pod not found: {pod_name}")
return diagnostics
diagnostics.pod_name = actual_pod_name
# 並行收集所有資料
import asyncio
tasks = [
self._get_pod_status(client, actual_pod_name, ns),
self._get_pod_events(client, actual_pod_name, ns),
]
if include_logs:
tasks.append(self._get_pod_logs(client, actual_pod_name, ns, log_tail_lines, previous=False))
if include_previous_logs:
tasks.append(self._get_pod_logs(client, actual_pod_name, ns, log_tail_lines, previous=True))
# 資源使用量需要 metrics-server
tasks.append(self._get_resource_usage(client, actual_pod_name, ns))
results = await asyncio.gather(*tasks, return_exceptions=True)
# 處理結果
idx = 0
# Pod Status
if isinstance(results[idx], Exception):
diagnostics.errors.append(f"Pod status: {results[idx]}")
else:
diagnostics.pod_status = results[idx]
idx += 1
# Events
if isinstance(results[idx], Exception):
diagnostics.errors.append(f"Events: {results[idx]}")
else:
diagnostics.events = results[idx] or []
idx += 1
# Logs
if include_logs:
if isinstance(results[idx], Exception):
diagnostics.errors.append(f"Logs: {results[idx]}")
else:
diagnostics.logs = results[idx] or ""
idx += 1
if include_previous_logs:
if isinstance(results[idx], Exception):
# Previous logs 失敗很常見 (沒有 previous container)
pass
else:
diagnostics.previous_logs = results[idx] or ""
idx += 1
# Resource Usage
if isinstance(results[idx], Exception):
diagnostics.errors.append(f"Resource usage: {results[idx]}")
else:
diagnostics.resource_usage = results[idx]
logger.info(
"k8s_diagnostics_collected",
pod_name=actual_pod_name,
namespace=ns,
has_status=diagnostics.pod_status is not None,
events_count=len(diagnostics.events),
logs_length=len(diagnostics.logs),
has_resource_usage=diagnostics.resource_usage is not None,
errors_count=len(diagnostics.errors),
)
return diagnostics
async def _find_pod(
self,
client,
pod_name: str,
namespace: str,
) -> str | None:
"""找到實際的 Pod 名稱 (支援部分匹配)"""
try:
v1 = client.CoreV1Api()
# 先嘗試精確匹配
try:
await v1.read_namespaced_pod(name=pod_name, namespace=namespace)
return pod_name
except client.exceptions.ApiException as e:
if e.status != 404:
raise
# 部分匹配 (用於 Deployment Pod 名稱)
pods = await v1.list_namespaced_pod(namespace=namespace)
for pod in pods.items:
if pod_name in pod.metadata.name:
return pod.metadata.name
return None
except Exception as e:
logger.warning("k8s_find_pod_failed", pod_name=pod_name, error=str(e))
return None
async def _get_pod_status(
self,
client,
pod_name: str,
namespace: str,
) -> PodStatus | None:
"""取得 Pod 狀態"""
try:
v1 = client.CoreV1Api()
pod = await v1.read_namespaced_pod(name=pod_name, namespace=namespace)
# 計算 restart count
restart_count = 0
container_statuses = []
if pod.status.container_statuses:
for cs in pod.status.container_statuses:
restart_count += cs.restart_count or 0
container_statuses.append({
"name": cs.name,
"ready": cs.ready,
"restart_count": cs.restart_count,
"state": {
"running": cs.state.running is not None,
"waiting": {
"reason": cs.state.waiting.reason if cs.state.waiting else None,
"message": cs.state.waiting.message if cs.state.waiting else None,
} if cs.state.waiting else None,
"terminated": {
"reason": cs.state.terminated.reason if cs.state.terminated else None,
"exit_code": cs.state.terminated.exit_code if cs.state.terminated else None,
} if cs.state.terminated else None,
},
})
# Ready 條件
ready = False
conditions = []
if pod.status.conditions:
for c in pod.status.conditions:
conditions.append({
"type": c.type,
"status": c.status,
"reason": c.reason,
"message": c.message,
})
if c.type == "Ready" and c.status == "True":
ready = True
return PodStatus(
name=pod_name,
namespace=namespace,
phase=pod.status.phase,
ready=ready,
restart_count=restart_count,
container_statuses=container_statuses,
conditions=conditions,
)
except Exception as e:
logger.warning("k8s_get_pod_status_failed", pod_name=pod_name, error=str(e))
raise
async def _get_pod_events(
self,
client,
pod_name: str,
namespace: str,
limit: int = 20,
) -> list[K8sEvent]:
"""取得 Pod 相關 Events"""
try:
v1 = client.CoreV1Api()
# 取得該 namespace 的所有 events然後過濾
field_selector = f"involvedObject.name={pod_name}"
events = await v1.list_namespaced_event(
namespace=namespace,
field_selector=field_selector,
limit=limit,
)
result = []
for e in events.items:
result.append(K8sEvent(
type=EventType(e.type) if e.type else EventType.NORMAL,
reason=e.reason or "",
message=e.message or "",
count=e.count or 1,
first_timestamp=e.first_timestamp.replace(tzinfo=UTC) if e.first_timestamp else None,
last_timestamp=e.last_timestamp.replace(tzinfo=UTC) if e.last_timestamp else None,
source_component=e.source.component if e.source else "",
involved_object=f"{e.involved_object.kind}/{e.involved_object.name}" if e.involved_object else "",
))
# 按最後時間排序
result.sort(key=lambda x: x.last_timestamp or datetime.min.replace(tzinfo=UTC), reverse=True)
return result
except Exception as e:
logger.warning("k8s_get_pod_events_failed", pod_name=pod_name, error=str(e))
raise
async def _get_pod_logs(
self,
client,
pod_name: str,
namespace: str,
tail_lines: int = 100,
previous: bool = False,
) -> str:
"""取得 Pod 日誌"""
try:
v1 = client.CoreV1Api()
logs = await v1.read_namespaced_pod_log(
name=pod_name,
namespace=namespace,
tail_lines=tail_lines,
previous=previous,
)
return logs or ""
except Exception as e:
# Previous logs 失敗很常見
if not previous:
logger.warning("k8s_get_pod_logs_failed", pod_name=pod_name, error=str(e))
raise
async def _get_resource_usage(
self,
client,
pod_name: str,
namespace: str,
) -> ResourceUsage | None:
"""取得資源使用量 (需要 metrics-server)"""
try:
# 使用 CustomObjectsApi 查詢 metrics
custom_api = client.CustomObjectsApi()
metrics = await custom_api.get_namespaced_custom_object(
group="metrics.k8s.io",
version="v1beta1",
namespace=namespace,
plural="pods",
name=pod_name,
)
# 解析 metrics
total_cpu = 0
total_memory = 0
for container in metrics.get("containers", []):
usage = container.get("usage", {})
cpu = usage.get("cpu", "0")
memory = usage.get("memory", "0")
# 解析 CPU (可能是 "100m" 或 "1")
if cpu.endswith("n"):
total_cpu += int(cpu[:-1]) // 1000000 # nano to milli
elif cpu.endswith("m"):
total_cpu += int(cpu[:-1])
else:
total_cpu += int(float(cpu) * 1000)
# 解析 Memory (可能是 "100Mi", "1Gi", "1000000Ki")
if memory.endswith("Ki"):
total_memory += int(memory[:-2]) * 1024
elif memory.endswith("Mi"):
total_memory += int(memory[:-2]) * 1024 * 1024
elif memory.endswith("Gi"):
total_memory += int(memory[:-2]) * 1024 * 1024 * 1024
else:
total_memory += int(memory)
# 取得 limits (from pod spec)
v1 = client.CoreV1Api()
pod = await v1.read_namespaced_pod(name=pod_name, namespace=namespace)
cpu_limit = None
memory_limit = None
for container in pod.spec.containers:
if container.resources and container.resources.limits:
limits = container.resources.limits
if "cpu" in limits:
cpu_str = limits["cpu"]
if cpu_str.endswith("m"):
cpu_limit = (cpu_limit or 0) + int(cpu_str[:-1])
else:
cpu_limit = (cpu_limit or 0) + int(float(cpu_str) * 1000)
if "memory" in limits:
mem_str = limits["memory"]
if mem_str.endswith("Mi"):
memory_limit = (memory_limit or 0) + int(mem_str[:-2]) * 1024 * 1024
elif mem_str.endswith("Gi"):
memory_limit = (memory_limit or 0) + int(mem_str[:-2]) * 1024 * 1024 * 1024
return ResourceUsage(
cpu_millicores=total_cpu,
memory_bytes=total_memory,
cpu_limit_millicores=cpu_limit,
memory_limit_bytes=memory_limit,
)
except Exception as e:
logger.warning("k8s_get_resource_usage_failed", pod_name=pod_name, error=str(e))
raise
# =============================================================================
# Singleton
# =============================================================================
_diagnostics_service: K8sDiagnosticsService | None = None
def get_k8s_diagnostics_service() -> K8sDiagnosticsService:
"""取得 K8s 診斷服務 singleton"""
global _diagnostics_service
if _diagnostics_service is None:
_diagnostics_service = K8sDiagnosticsService()
return _diagnostics_service