""" 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