""" FinOps Cost Analyzer - 閒置資源掃描與成本換算 Phase 3.3: 商業變現能力 - Day-1 ROI 核心功能: 1. Orphaned PVCs (孤兒儲存卷) - 沒有被任何 Pod 掛載 2. Zombie Pods (殭屍容器) - CPU 使用率連續 7 天 < 1% 3. Over-provisioned Nodes (過度配置節點) - Request 高但 Usage 低 輸出格式: - total_wasted_usd: 每月浪費金額 - recommended_actions: OpenClaw 可執行的建議清單 """ import logging from dataclasses import dataclass, field from datetime import timedelta from src.utils.timezone import now_taipei from enum import Enum from typing import Literal logger = logging.getLogger(__name__) # ==================== Types ==================== class ResourceType(str, Enum): """資源類型""" PVC = "pvc" # PersistentVolumeClaim POD = "pod" # Pod NODE = "node" # Node DEPLOYMENT = "deployment" # Deployment SERVICE = "service" # Service class WasteReason(str, Enum): """浪費原因""" ORPHANED = "orphaned" # 孤兒資源 (無連結) ZOMBIE = "zombie" # 殭屍 (幾乎無活動) OVER_PROVISIONED = "over_provisioned" # 過度配置 IDLE = "idle" # 閒置 @dataclass class WastedResource: """浪費的資源""" resource_type: ResourceType name: str namespace: str reason: WasteReason details: str monthly_cost_usd: float created_at: datetime last_used_at: datetime | None = None # 資源規格 spec: dict = field(default_factory=dict) def to_dict(self) -> dict: return { "resourceType": self.resource_type.value, "name": self.name, "namespace": self.namespace, "reason": self.reason.value, "details": self.details, "monthlyCostUsd": round(self.monthly_cost_usd, 2), "createdAt": self.created_at.isoformat(), "lastUsedAt": self.last_used_at.isoformat() if self.last_used_at else None, "spec": self.spec, } class SavingsType(str, Enum): """節省類型 - 區分真實省錢 vs 釋放資源""" REALIZABLE = "realizable" # 真實省錢 (例如刪除 PVC → AWS 帳單立刻減少) FREED = "freed" # 釋放資源 (例如刪除 Pod → 除非 Node 縮容否則不省錢) @dataclass class RecommendedAction: """建議的優化動作 (OpenClaw 可執行)""" action_id: str action_type: Literal["delete", "scale_down", "resize", "migrate"] resource_type: ResourceType resource_name: str namespace: str description: str estimated_savings_usd: float risk_level: Literal["low", "medium", "high", "critical"] command_hint: str # 給 OpenClaw 的執行提示 savings_type: SavingsType = SavingsType.REALIZABLE # 節省類型 def to_dict(self) -> dict: return { "actionId": self.action_id, "actionType": self.action_type, "resourceType": self.resource_type.value, "resourceName": self.resource_name, "namespace": self.namespace, "description": self.description, "estimatedSavingsUsd": round(self.estimated_savings_usd, 2), "riskLevel": self.risk_level, "commandHint": self.command_hint, "savingsType": self.savings_type.value, } @dataclass class CostReport: """成本報告 (OpenClaw 整合用)""" scan_id: str scanned_at: datetime cluster_name: str # 核心指標 total_wasted_usd: float total_resources_scanned: int wasted_resources_count: int # 詳細資料 wasted_resources: list[WastedResource] recommended_actions: list[RecommendedAction] # 分類統計 waste_by_type: dict[str, float] waste_by_namespace: dict[str, float] def to_dict(self) -> dict: """輸出 OpenClaw 可讀取的 JSON 格式""" return { "scanId": self.scan_id, "scannedAt": self.scanned_at.isoformat(), "clusterName": self.cluster_name, # OpenClaw 核心關注 "totalWastedUsd": round(self.total_wasted_usd, 2), "totalResourcesScanned": self.total_resources_scanned, "wastedResourcesCount": self.wasted_resources_count, # 詳細資料 "wastedResources": [r.to_dict() for r in self.wasted_resources], "recommendedActions": [a.to_dict() for a in self.recommended_actions], # 統計 "wasteByType": {k: round(v, 2) for k, v in self.waste_by_type.items()}, "wasteByNamespace": {k: round(v, 2) for k, v in self.waste_by_namespace.items()}, # 摘要 (給 AI 的自然語言描述) "summary": self._generate_summary(), } def _generate_summary(self) -> str: """產生 AI 可讀的摘要""" if self.total_wasted_usd < 10: return f"Cluster {self.cluster_name} is well-optimized. Only ${self.total_wasted_usd:.2f}/month potential savings." top_waste = max(self.waste_by_type.items(), key=lambda x: x[1]) if self.waste_by_type else ("none", 0) return ( f"Cluster {self.cluster_name} has ${self.total_wasted_usd:.2f}/month in wasted resources. " f"Found {self.wasted_resources_count} idle resources. " f"Biggest waste: {top_waste[0]} (${top_waste[1]:.2f}/month). " f"{len(self.recommended_actions)} optimization actions available." ) # ==================== Pricing Configuration ==================== @dataclass class PricingConfig: """ 費率配置 (可依雲端供應商調整) 預設值基於 AWS 美東區域 (us-east-1) """ # 儲存 (per GB/month) storage_gp3_per_gb: float = 0.08 # EBS gp3 storage_gp2_per_gb: float = 0.10 # EBS gp2 storage_io1_per_gb: float = 0.125 # EBS io1 storage_standard_per_gb: float = 0.05 # Standard HDD # 運算 (per vCPU/month, 假設 on-demand) compute_per_vcpu: float = 30.0 # ~$0.04/hr * 720hr compute_per_gb_ram: float = 4.0 # ~$0.005/hr/GB * 720hr # 網路 load_balancer_per_month: float = 18.0 # ALB/NLB 固定費 nat_gateway_per_month: float = 32.0 # NAT Gateway # ╔════════════════════════════════════════════════════════════════╗ # ║ SAFETY_BUFFER: 縮容安全係數 ║ # ║ 避免建議縮到剛好 actual usage,造成 OOM/CPU throttling ║ # ║ 公式: wasted = requested - (actual × 1.2) ║ # ╚════════════════════════════════════════════════════════════════╝ safety_buffer: float = 1.2 def get_storage_price(self, storage_class: str) -> float: """依 StorageClass 取得費率""" mapping = { "gp3": self.storage_gp3_per_gb, "gp2": self.storage_gp2_per_gb, "io1": self.storage_io1_per_gb, "standard": self.storage_standard_per_gb, } return mapping.get(storage_class.lower(), self.storage_gp3_per_gb) # 預設費率 DEFAULT_PRICING = PricingConfig() # ==================== Idle Resource Scanner ==================== class IdleResourceScanner: """ 閒置資源掃描器 偵測並量化 K8s 叢集中的浪費資源, 轉換為美金金額,供 OpenClaw 決策 """ def __init__(self, pricing: PricingConfig | None = None): self.pricing = pricing or DEFAULT_PRICING self._scan_counter = 0 async def full_scan(self, cluster_name: str = "default") -> CostReport: """ 執行完整掃描 Returns: CostReport 包含所有浪費資源與建議動作 """ self._scan_counter += 1 scan_id = f"scan-{self._scan_counter:04d}-{now_taipei().strftime('%Y%m%d%H%M%S')}" logger.info(f"[FinOps] Starting full scan: {scan_id}") # 執行各類掃描 orphaned_pvcs = await self._scan_orphaned_pvcs() zombie_pods = await self._scan_zombie_pods() over_provisioned = await self._scan_over_provisioned_nodes() # 合併所有浪費資源 all_wasted = orphaned_pvcs + zombie_pods + over_provisioned # 產生建議動作 actions = self._generate_recommendations(all_wasted) # 計算統計 total_wasted = sum(r.monthly_cost_usd for r in all_wasted) waste_by_type = self._group_by_type(all_wasted) waste_by_ns = self._group_by_namespace(all_wasted) report = CostReport( scan_id=scan_id, scanned_at=now_taipei(), cluster_name=cluster_name, total_wasted_usd=total_wasted, total_resources_scanned=self._get_mock_total_resources(), wasted_resources_count=len(all_wasted), wasted_resources=all_wasted, recommended_actions=actions, waste_by_type=waste_by_type, waste_by_namespace=waste_by_ns, ) logger.info( f"[FinOps] Scan complete: {scan_id} - " f"${total_wasted:.2f}/month wasted, {len(actions)} actions" ) return report # ==================== Orphaned PVCs ==================== async def _scan_orphaned_pvcs(self) -> list[WastedResource]: """ 掃描孤兒 PVC 孤兒 PVC = 已建立但沒有被任何 Pod 掛載的 PersistentVolumeClaim 常見原因: Pod 刪除後忘記清理 PVC """ # Phase 3: Mock 資料 (實際連接 K8s API 待 Phase 4) mock_orphans = [ { "name": "data-postgres-backup-old", "namespace": "database", "size_gb": 500, "storage_class": "gp3", "created": now_taipei() - timedelta(days=90), "last_used": now_taipei() - timedelta(days=60), }, { "name": "logs-elasticsearch-2023", "namespace": "logging", "size_gb": 200, "storage_class": "gp2", "created": now_taipei() - timedelta(days=180), "last_used": now_taipei() - timedelta(days=120), }, { "name": "cache-redis-temp", "namespace": "default", "size_gb": 50, "storage_class": "gp3", "created": now_taipei() - timedelta(days=30), "last_used": None, }, ] results = [] for pvc in mock_orphans: price_per_gb = self.pricing.get_storage_price(pvc["storage_class"]) monthly_cost = pvc["size_gb"] * price_per_gb results.append(WastedResource( resource_type=ResourceType.PVC, name=pvc["name"], namespace=pvc["namespace"], reason=WasteReason.ORPHANED, details=f"PVC not mounted by any Pod. Size: {pvc['size_gb']}GB ({pvc['storage_class']})", monthly_cost_usd=monthly_cost, created_at=pvc["created"], last_used_at=pvc["last_used"], spec={ "sizeGb": pvc["size_gb"], "storageClass": pvc["storage_class"], }, )) logger.info(f"[FinOps] Found {len(results)} orphaned PVCs") return results # ==================== Zombie Pods ==================== async def _scan_zombie_pods(self) -> list[WastedResource]: """ 掃描殭屍 Pod 殭屍 Pod = CPU 使用率連續 7 天 < 1% 的 Pod 常見原因: 被遺忘的測試 Pod、已下線但未刪除的服務 """ mock_zombies = [ { "name": "legacy-api-5d7b8c9f6-abc12", "namespace": "legacy", "cpu_request": 2.0, # vCPU "mem_request_gb": 4.0, "avg_cpu_percent": 0.3, "created": now_taipei() - timedelta(days=120), "last_active": now_taipei() - timedelta(days=45), }, { "name": "test-worker-batch-xyz99", "namespace": "testing", "cpu_request": 1.0, "mem_request_gb": 2.0, "avg_cpu_percent": 0.1, "created": now_taipei() - timedelta(days=60), "last_active": now_taipei() - timedelta(days=30), }, { "name": "debug-shell-admin", "namespace": "default", "cpu_request": 0.5, "mem_request_gb": 1.0, "avg_cpu_percent": 0.0, "created": now_taipei() - timedelta(days=14), "last_active": now_taipei() - timedelta(days=10), }, ] results = [] for pod in mock_zombies: # 計算成本: CPU + Memory cpu_cost = pod["cpu_request"] * self.pricing.compute_per_vcpu mem_cost = pod["mem_request_gb"] * self.pricing.compute_per_gb_ram monthly_cost = cpu_cost + mem_cost results.append(WastedResource( resource_type=ResourceType.POD, name=pod["name"], namespace=pod["namespace"], reason=WasteReason.ZOMBIE, details=( f"CPU usage < 1% for 7+ days. " f"Avg: {pod['avg_cpu_percent']:.1f}%. " f"Resources: {pod['cpu_request']} vCPU, {pod['mem_request_gb']}GB RAM" ), monthly_cost_usd=monthly_cost, created_at=pod["created"], last_used_at=pod["last_active"], spec={ "cpuRequest": pod["cpu_request"], "memoryGb": pod["mem_request_gb"], "avgCpuPercent": pod["avg_cpu_percent"], }, )) logger.info(f"[FinOps] Found {len(results)} zombie Pods") return results # ==================== Over-provisioned Nodes ==================== async def _scan_over_provisioned_nodes(self) -> list[WastedResource]: """ 掃描過度配置節點 過度配置 = Request 很高但實際 Usage 很低 例如: Request 8 vCPU 但只用 1 vCPU """ mock_nodes = [ { "name": "worker-large-01", "namespace": "kube-system", "total_cpu": 16.0, "total_mem_gb": 64.0, "requested_cpu": 12.0, "requested_mem_gb": 48.0, "actual_cpu": 2.0, "actual_mem_gb": 8.0, "created": now_taipei() - timedelta(days=200), }, { "name": "worker-gpu-unused", "namespace": "kube-system", "total_cpu": 8.0, "total_mem_gb": 32.0, "requested_cpu": 4.0, "requested_mem_gb": 16.0, "actual_cpu": 0.5, "actual_mem_gb": 2.0, "created": now_taipei() - timedelta(days=90), }, ] results = [] for node in mock_nodes: # ╔════════════════════════════════════════════════════════════════╗ # ║ 安全緩衝計算: wasted = requested - (actual × SAFETY_BUFFER) ║ # ║ 避免縮容建議導致 OOM / CPU throttling ║ # ╚════════════════════════════════════════════════════════════════╝ buffered_cpu = node["actual_cpu"] * self.pricing.safety_buffer buffered_mem = node["actual_mem_gb"] * self.pricing.safety_buffer wasted_cpu = node["requested_cpu"] - buffered_cpu wasted_mem = node["requested_mem_gb"] - buffered_mem if wasted_cpu < 1 and wasted_mem < 4: continue # 浪費不夠顯著 (含安全緩衝後) cpu_waste_cost = wasted_cpu * self.pricing.compute_per_vcpu mem_waste_cost = wasted_mem * self.pricing.compute_per_gb_ram monthly_cost = cpu_waste_cost + mem_waste_cost utilization = node["actual_cpu"] / node["requested_cpu"] * 100 results.append(WastedResource( resource_type=ResourceType.NODE, name=node["name"], namespace=node["namespace"], reason=WasteReason.OVER_PROVISIONED, details=( f"Utilization: {utilization:.0f}%. " f"Requested: {node['requested_cpu']} vCPU, {node['requested_mem_gb']}GB. " f"Actual: {node['actual_cpu']} vCPU, {node['actual_mem_gb']}GB" ), monthly_cost_usd=monthly_cost, created_at=node["created"], last_used_at=now_taipei(), spec={ "totalCpu": node["total_cpu"], "totalMemoryGb": node["total_mem_gb"], "requestedCpu": node["requested_cpu"], "requestedMemoryGb": node["requested_mem_gb"], "actualCpu": node["actual_cpu"], "actualMemoryGb": node["actual_mem_gb"], "utilizationPercent": utilization, }, )) logger.info(f"[FinOps] Found {len(results)} over-provisioned resources") return results # ==================== Recommendations ==================== def _generate_recommendations( self, wasted: list[WastedResource], ) -> list[RecommendedAction]: """ 產生優化建議 (OpenClaw 可執行) """ actions = [] action_counter = 0 for resource in wasted: action_counter += 1 action_id = f"action-{action_counter:03d}" if resource.resource_type == ResourceType.PVC: # ✅ REALIZABLE: 刪除 PVC → AWS 帳單立刻減少 actions.append(RecommendedAction( action_id=action_id, action_type="delete", resource_type=resource.resource_type, resource_name=resource.name, namespace=resource.namespace, description=f"Delete orphaned PVC '{resource.name}' - not mounted by any Pod", estimated_savings_usd=resource.monthly_cost_usd, risk_level="low", command_hint=f"kubectl delete pvc {resource.name} -n {resource.namespace}", savings_type=SavingsType.REALIZABLE, )) elif resource.resource_type == ResourceType.POD: # ⚠️ FREED: 刪除 Pod 只是釋放資源,除非 Node 縮容否則不省錢 risk = "medium" if resource.monthly_cost_usd > 50 else "low" actions.append(RecommendedAction( action_id=action_id, action_type="delete", resource_type=resource.resource_type, resource_name=resource.name, namespace=resource.namespace, description=f"Delete zombie Pod '{resource.name}' - CPU < 1% for 7+ days", estimated_savings_usd=resource.monthly_cost_usd, risk_level=risk, command_hint=f"kubectl delete pod {resource.name} -n {resource.namespace}", savings_type=SavingsType.FREED, )) elif resource.resource_type == ResourceType.NODE: # ✅ REALIZABLE: Node 縮容/刪除 → AWS 帳單減少 actions.append(RecommendedAction( action_id=action_id, action_type="resize", resource_type=resource.resource_type, resource_name=resource.name, namespace=resource.namespace, description=( f"Resize node '{resource.name}' - " f"utilization only {resource.spec.get('utilizationPercent', 0):.0f}%" ), estimated_savings_usd=resource.monthly_cost_usd, risk_level="high", command_hint=f"# Consider migrating workloads and downsizing {resource.name}", savings_type=SavingsType.REALIZABLE, )) # 按節省金額排序 (最大節省優先) actions.sort(key=lambda a: a.estimated_savings_usd, reverse=True) return actions # ==================== Utilities ==================== def _group_by_type(self, resources: list[WastedResource]) -> dict[str, float]: """依類型分組統計""" result: dict[str, float] = {} for r in resources: key = r.resource_type.value result[key] = result.get(key, 0) + r.monthly_cost_usd return result def _group_by_namespace(self, resources: list[WastedResource]) -> dict[str, float]: """依 Namespace 分組統計""" result: dict[str, float] = {} for r in resources: result[r.namespace] = result.get(r.namespace, 0) + r.monthly_cost_usd return result def _get_mock_total_resources(self) -> int: """Mock: 總掃描資源數""" return 150 # 假設叢集有 150 個資源 def calculate_monthly_savings(self, report: CostReport) -> dict: """ 計算月度節省摘要 ╔════════════════════════════════════════════════════════════════╗ ║ 嚴格區分真實省錢 vs 釋放資源 ║ ║ - realizableSavingsUsd: 刪除後 AWS 帳單立刻減少 ║ ║ - freedResourcesUsd: 釋放 Pod/Container,需要 Node 縮容才省錢 ║ ╚════════════════════════════════════════════════════════════════╝ Returns: OpenClaw 可直接使用的 JSON 格式 """ realizable = sum( a.estimated_savings_usd for a in report.recommended_actions if a.savings_type == SavingsType.REALIZABLE ) freed = sum( a.estimated_savings_usd for a in report.recommended_actions if a.savings_type == SavingsType.FREED ) return { "totalWastedUsd": round(report.total_wasted_usd, 2), # ⚠️ 嚴格區分 "realizableSavingsUsd": round(realizable, 2), # 真實省錢 "freedResourcesUsd": round(freed, 2), # 釋放資源 (需縮容才省錢) "potentialSavingsUsd": round(realizable + freed, 2), # 總計 (參考用) "actionCount": len(report.recommended_actions), "topActions": [ { "action": a.description, "savings": round(a.estimated_savings_usd, 2), "risk": a.risk_level, "savingsType": a.savings_type.value, } for a in report.recommended_actions[:5] # Top 5 ], "annualProjection": round(realizable * 12, 2), # 年度預估僅計真實省錢 "annualProjectionWithFreed": round((realizable + freed) * 12, 2), } # 全域實例 idle_scanner = IdleResourceScanner()