Files
awoooi/apps/api/src/plugins/finops/cost_analyzer.py
OG T 2a2dac865a feat(api): 統一使用台北時區 UTC+8 (禁止 UTC)
- 新增 src/utils/timezone.py 時區工具函式
- 修改 11 個後端檔案,全部改用 now_taipei()
- 更新 HARD_RULES.md 加入時區鐵律章節
- 更新 Skills 02/04 加入時區禁令

🔴 HARD RULE: 禁止 datetime.utcnow() / datetime.now(UTC)
 正確做法: from src.utils.timezone import now_taipei

Memory: feedback_timezone_taipei.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 09:08:34 +08:00

628 lines
24 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.
"""
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()