From bf06737eed297930aba49737918a5e7ea050f2b5 Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 29 Mar 2026 15:48:09 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20ADR-038/039=20+=20LOGBOOK=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-038: OpenClaw 併發治理架構 - ADR-039: 全域自動修復熔斷 - LOGBOOK: 今日進度記錄 Co-Authored-By: Claude Opus 4.5 --- docs/LOGBOOK.md | 31 ++- ...ADR-038-openclaw-concurrency-governance.md | 248 ++++++++++++++++++ .../ADR-039-global-autorepair-governance.md | 245 +++++++++++++++++ 3 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 docs/adr/ADR-038-openclaw-concurrency-governance.md create mode 100644 docs/adr/ADR-039-global-autorepair-governance.md diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 4953a9ab..cf0eae4d 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -5,11 +5,11 @@ --- -## 📍 當前狀態 (2026-03-29 14:10 台北) +## 📍 當前狀態 (2026-03-29 15:45 台北) | 項目 | 狀態 | |------|------| -| **當前 Phase** | ✅ **Phase 21 Wave A-D 全部完成** + 📋 **Phase 22 戰略規劃完成** | +| **當前 Phase** | ✅ **Phase 21 Wave A-D 全部完成** + ✅ **ADR-037 監控增強** | | **Day** | Day 12 | | **K3s 版本** | v1.34.5+k3s1 (mon + mon1) | | **叢集健康** | ✅ **所有 Pod 正常運行** | @@ -29,6 +29,33 @@ | **模組化合規** | ✅ **100% 通過** | | **⚠️ Wave 1 待執行** | 🔴 **8 個 P0 安全網代碼修復(XCLAIM + Circuit Breaker + Semaphore 等)** | +## 🔧 ADR-037 監控增強部署 (2026-03-29 15:45 台北) + +### 完成項目 + +| 類型 | 說明 | 狀態 | +|------|------|------| +| 🔴 Runner 修復 | 停用衝突 `awoooi-110-2` service + 清理 `_diag` | ✅ | +| 📊 Database Exporters | PostgreSQL (9187) + Redis (9121) @ 192.168.0.188 | ✅ | +| 📈 Prometheus 整合 | Database Exporters 加入 scrape config | ✅ | +| 🔧 API Lint 修復 | 36 個 Ruff lint 錯誤全部修復 | ✅ | +| 📊 NVIDIA Dashboard | `nvidia-nemotron.json` 匯入 Grafana (18 panels) | ✅ | +| 📋 首席架構師審查 | **194/200 (97%) OUTSTANDING** | ✅ | + +### 關鍵指標 + +``` +Prometheus Targets: +- postgres: health=up (192.168.0.188:9187) +- redis: health=up (192.168.0.188:9121) + +Grafana Dashboard: +- URL: http://192.168.0.188:3002/d/nvidia-nemotron +- Panels: 18 (Circuit Breaker, Latency P50/P95/P99, Anomaly Frequency) +``` + +--- + ## 📋 Phase 22: 全維度盤點暨戰略規劃 (2026-03-29 完成) ### 完成項目 diff --git a/docs/adr/ADR-038-openclaw-concurrency-governance.md b/docs/adr/ADR-038-openclaw-concurrency-governance.md new file mode 100644 index 00000000..3c5cf60a --- /dev/null +++ b/docs/adr/ADR-038-openclaw-concurrency-governance.md @@ -0,0 +1,248 @@ +# ADR-038: OpenClaw 推理引擎併發治理架構 + +**狀態**: 已批准 +**日期**: 2026-03-29 14:05 (台北時間) +**決策者**: 統帥 + Antigravity (首席架構師) +**觸發事件**: 沙盤推演發現 Thundering Herd 可導致 Ollama OOM 崩潰 + +--- + +## 問題陳述 + +### 場景:網路閃斷後的告警雪崩 + +``` +.188 網路閃斷 3 分鐘 → 恢復後: + Alertmanager 積壓 N 個告警同時倒入 AWOOOI Webhook + Sentry 積壓 N 個錯誤同時倒入 AWOOOI Webhook + Signal Worker 拉起 N 個 asyncio.Task 同時呼叫 OpenClaw + Ollama/GPU 收到 N 個並發 LLM 推理請求 + → VRAM/RAM OOM → Ollama 進程崩潰 + → Circuit Breaker 觸發,但系統大腦已死 +``` + +**核心問題**:Circuit Breaker 只防「失敗」,不防「超載」。 + +--- + +## 決策:Semaphore + Circuit Breaker 雙層保護 + +### 架構設計 + +``` +告警事件 + │ + ▼ +Signal Worker (asyncio.Task) + │ + ▼ +┌─────────────────────────────────────────┐ +│ Layer 1: Circuit Breaker │ +│ - 5 次連續失敗 → OPEN(60 秒冷卻) │ +│ - OPEN 狀態:立即返回 None,不等待 │ +└──────────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Layer 2: Concurrency Semaphore │ +│ - 全域最多 3 個並發 LLM 推理 │ +│ - 超過限制的請求:排隊等待(非拒絕) │ +└──────────────────┬──────────────────────┘ + │ + ▼ + OpenClaw / Ollama +``` + +### 為何 max_concurrent = 3? + +| 考量 | 說明 | +|------|------| +| Ollama CPU 模式 | .188 純 CPU(無 GPU),每次推理佔 1-2 CPU Core | +| .188 主機規格 | 共享 CPU,同時跑 PostgreSQL + Redis + SigNoz | +| 安全邊界 | 3 = 不超過 .188 可用 CPU 的 60%(保留 40% 給其他服務) | +| 排隊不拒絕 | 超出的請求排隊等待,確保不遺失重要告警 | + +--- + +## 實作規範 + +### 核心實作(`apps/api/src/core/circuit_breaker.py`) + +```python +""" +OpenClaw 推理引擎保護機制 +========================= +ADR-038: 雙層保護策略 +- Layer 1: Circuit Breaker(防失敗傳播) +- Layer 2: Concurrency Semaphore(防 Thundering Herd) + +遵循 leWOOOgo 積木化鐵律: +- 此模組屬於 core/ 基礎設施層 +- 不依賴任何 Service 層 +- 透過 Singleton 提供全域狀態 +""" +import asyncio +import time +from enum import Enum +from dataclasses import dataclass, field + +import structlog + +logger = structlog.get_logger(__name__) + + +class CircuitState(Enum): + CLOSED = "closed" # 正常運作 + OPEN = "open" # 斷路(快速失敗) + HALF_OPEN = "half_open" # 試探性恢復 + + +@dataclass +class CircuitBreakerConfig: + failure_threshold: int = 5 # 連續失敗次數觸發斷路 + timeout_s: float = 60.0 # 斷路後冷卻時間(秒) + max_concurrent: int = 3 # 最大並發 LLM 推理數 + + +class OpenClawGuard: + """ + OpenClaw 雙層推理保護門衛 + + 使用方式: + guard = get_openclaw_guard() + + if guard.is_circuit_open(): + return None # 快速失敗 + + async with guard.semaphore: # 排隊等待 + try: + result = await call_openclaw(...) + guard.record_success() + return result + except Exception: + guard.record_failure() + raise + """ + + def __init__(self, config: CircuitBreakerConfig | None = None): + self.config = config or CircuitBreakerConfig() + self.state = CircuitState.CLOSED + self.failure_count = 0 + self._opened_at: float | None = None + # Semaphore 必須在 event loop 中建立 + self._semaphore: asyncio.Semaphore | None = None + + @property + def semaphore(self) -> asyncio.Semaphore: + if self._semaphore is None: + self._semaphore = asyncio.Semaphore(self.config.max_concurrent) + return self._semaphore + + def is_circuit_open(self) -> bool: + if self.state == CircuitState.OPEN: + if time.time() - self._opened_at > self.config.timeout_s: + self.state = CircuitState.HALF_OPEN + logger.info("circuit_breaker_half_open") + return False + return True + return False + + def record_success(self) -> None: + self.failure_count = 0 + if self.state != CircuitState.CLOSED: + logger.info("circuit_breaker_closed") + self.state = CircuitState.CLOSED + + def record_failure(self) -> None: + self.failure_count += 1 + if self.failure_count >= self.config.failure_threshold: + self.state = CircuitState.OPEN + self._opened_at = time.time() + logger.warning( + "circuit_breaker_opened", + failure_count=self.failure_count, + cooldown_s=self.config.timeout_s, + ) + + def get_metrics(self) -> dict: + return { + "state": self.state.value, + "failure_count": self.failure_count, + "max_concurrent": self.config.max_concurrent, + } + + +# 全域 Singleton +_guard: OpenClawGuard | None = None + + +def get_openclaw_guard() -> OpenClawGuard: + """取得全域 OpenClaw 保護門衛""" + global _guard + if _guard is None: + _guard = OpenClawGuard() + return _guard +``` + +### 呼叫端整合(`sentry_webhook.py` + `signoz_webhook.py`) + +```python +from src.core.circuit_breaker import get_openclaw_guard + +async def call_openclaw_analyzer(error_context: dict) -> ErrorAnalysisResult | None: + guard = get_openclaw_guard() + + # Layer 1: Circuit Breaker 快速失敗 + if guard.is_circuit_open(): + logger.warning("openclaw_circuit_open_skip", metrics=guard.get_metrics()) + return None + + # Layer 2: Semaphore 排隊(最多 3 個並發,超出排隊等待) + async with guard.semaphore: + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{settings.OPENCLAW_BASE_URL}/analyze", + json=error_context, + ) + response.raise_for_status() + guard.record_success() + return ErrorAnalysisResult(**response.json()) + except Exception as e: + guard.record_failure() + logger.exception("openclaw_call_failed", error=str(e)) + return None +``` + +--- + +## 模組化合規驗證 + +| 項目 | 說明 | 合規狀態 | +|------|------|---------| +| 層次 | `core/` 基礎設施層 | ✅ 合規 | +| 依賴 | 只依賴 stdlib + structlog | ✅ 合規 | +| Singleton | `get_openclaw_guard()` 工廠函數 | ✅ 合規 | +| 協議介面 | `OpenClawGuard` 直接使用(無需 Protocol,因為非跨模組) | ✅ 合規 | +| 測試 | 可透過 `_guard = None` 重置 | ✅ 合規 | + +--- + +## 驗收標準 + +| 項目 | 通過條件 | +|------|---------| +| Semaphore 排隊 | 第 4 個請求在前 3 個完成前不呼叫 OpenClaw | +| Circuit Breaker 觸發 | 5 次失敗後 `is_circuit_open()` 返回 True | +| 冷卻恢復 | 60 秒後切換到 HALF_OPEN,1 次成功恢復 CLOSED | +| Graceful Degrade | Circuit OPEN 時呼叫返回 None,不拋例外 | + +--- + +## 相關文件 + +- `docs/proposals/ARCHITECTURAL_RISK_WAR_GAME.md`:風險沙盤推演 +- `apps/api/src/api/v1/sentry_webhook.py`:Sentry Webhook 整合 +- `apps/api/src/api/v1/signoz_webhook.py`:SignOz Webhook 整合 +- ADR-028:Failure Auto-Repair Loop +- ADR-039:全域自動修復熔斷機制 diff --git a/docs/adr/ADR-039-global-autorepair-governance.md b/docs/adr/ADR-039-global-autorepair-governance.md new file mode 100644 index 00000000..ff474342 --- /dev/null +++ b/docs/adr/ADR-039-global-autorepair-governance.md @@ -0,0 +1,245 @@ +# ADR-039: 全域自動修復熔斷機制 + +**狀態**: 已批准 +**日期**: 2026-03-29 14:05 (台北時間) +**決策者**: 統帥 + Antigravity (首席架構師) +**觸發事件**: 沙盤推演發現跨資源 Poison Pill 可導致 AI 骨牌修復死循環 + +--- + +## 問題陳述 + +### 場景:跨資源修復骨牌效應 + +``` +流量激增 + → AI 執行 scale_up (擴容 API Pod) + → API Pod 數量增加,連線池耗盡 PostgreSQL + → AI 收到 DB 告警,執行 restart_api_pod (釋放 DB 連線) + → API 重啟導致 502 激增 + → AI 再次執行 scale_up + → 死循環,資源榨乾 +``` + +**核心問題**:現有 `max_repairs_per_resource: 3` 只限單一資源,無法防止骨牌效應。 + +--- + +## 決策:雙層全域保護機制 + +### 機制一:全域修復冷卻期(Global Action Cooldown) + +當系統整體在過去 15 分鐘內自動修復超過 **5 次**(不論對象),強制凍結所有 Auto-Repair,轉為 `AWAITING_APPROVAL`。 + +### 機制二:StatefulSet 硬禁令(Stateful Service Blacklist) + +有狀態服務(PostgreSQL、Redis、ClickHouse、MinIO 等)**永遠不允許**自動重啟,強制人工介入。 + +--- + +## 實作規範 + +### 全域計數器(Redis 實作) + +```python +# apps/api/src/services/global_repair_cooldown.py +""" +全域修復熔斷機制 +================ +ADR-039:防止跨資源循環修復 + +設計原則: +- Redis TTL 滑動窗口(15 分鐘) +- 失敗降級:Redis 故障時保守跳過自動修復,強制人工確認 +""" + +import structlog +from src.core.redis_client import get_redis + +logger = structlog.get_logger(__name__) + +GLOBAL_COOLDOWN_KEY = "global:auto_repair:count" +GLOBAL_COOLDOWN_TTL = 900 # 15 分鐘窗口 +GLOBAL_COOLDOWN_THRESHOLD = 5 # 超過 5 次強制凍結 + +STATEFUL_SERVICE_BLACKLIST = frozenset({ + "postgres", "postgresql", "awoooi-postgres", + "redis", "awoooi-redis", "redis-stack", + "clickhouse", "signoz-clickhouse", + "elasticsearch", "etcd", + "minio", "awoooi-minio", +}) + + +async def check_global_repair_cooldown( + incident_id: str, + affected_services: list[str], +) -> tuple[bool, str]: + """ + 檢查是否允許自動修復 + + Returns: + (can_repair: bool, reason: str) + """ + redis = get_redis() + + # === 硬禁令:有狀態服務黑名單 === + for service in affected_services: + if any(bl in service.lower() for bl in STATEFUL_SERVICE_BLACKLIST): + reason = f"服務 {service} 為有狀態服務,禁止自動重啟,請統帥手動介入" + logger.warning( + "stateful_service_blocked", + service=service, + incident_id=incident_id, + ) + return False, reason + + # === 全域冷卻期:Redis 計數 === + try: + count_raw = await redis.get(GLOBAL_COOLDOWN_KEY) + current_count = int(count_raw) if count_raw else 0 + + if current_count >= GLOBAL_COOLDOWN_THRESHOLD: + reason = ( + f"系統在過去 15 分鐘內已自動修復 {current_count} 次," + f"超出安全閾值 {GLOBAL_COOLDOWN_THRESHOLD}," + "強制轉為人工審核模式" + ) + logger.warning( + "global_repair_cooldown_active", + current_count=current_count, + threshold=GLOBAL_COOLDOWN_THRESHOLD, + incident_id=incident_id, + ) + return False, reason + + return True, "允許自動修復" + + except Exception as e: + # Redis 故障 → 保守策略:禁止自動修復 + logger.error( + "global_repair_cooldown_redis_error", + error=str(e), + fallback="blocking_auto_repair_for_safety", + ) + return False, f"Redis 連線異常,保守禁止自動修復(原因:{e})" + + +async def record_global_repair_action() -> None: + """ + 記錄一次全域修復動作 + + 使用 INCR + EXPIRE 實現滑動窗口計數 + 注意:INCR 是原子操作,多個 Worker 並發安全 + """ + try: + redis = get_redis() + count = await redis.incr(GLOBAL_COOLDOWN_KEY) + + # 只在第一次設定 TTL(避免頻繁重設導致窗口延長) + if count == 1: + await redis.expire(GLOBAL_COOLDOWN_KEY, GLOBAL_COOLDOWN_TTL) + + logger.info( + "global_repair_action_recorded", + count=count, + threshold=GLOBAL_COOLDOWN_THRESHOLD, + ) + + except Exception as e: + # Redis 故障:靜默失敗 + logger.warning("global_repair_record_failed", error=str(e)) +``` + +### 整合到 auto_repair_service.py + +```python +# auto_repair_service.py - evaluate_auto_repair() 加入前置檢查 + +from src.services.global_repair_cooldown import check_global_repair_cooldown + +async def evaluate_auto_repair(self, incident: Incident) -> AutoRepairDecision: + # === 最優先:全域熔斷檢查(在所有其他邏輯之前)=== + can_repair, cooldown_reason = await check_global_repair_cooldown( + incident_id=incident.incident_id, + affected_services=incident.affected_services or [], + ) + + if not can_repair: + return AutoRepairDecision( + can_auto_repair=False, + reason=cooldown_reason, + blocked_by="GLOBAL_GUARDRAIL", + ) + + # === 現有邏輯:Severity 檢查 === + if incident.severity and incident.severity.value in ["P0", "P1"]: + ... + + # ... 後續現有邏輯 ... +``` + +```python +# auto_repair_service.py - execute_auto_repair() 執行後記錄 + +async def execute_auto_repair(self, incident, playbook) -> AutoRepairResult: + # ... 現有執行邏輯 ... + + # === 執行成功後,記錄全域計數 === + if result.success: + from src.services.global_repair_cooldown import record_global_repair_action + await record_global_repair_action() + + return result +``` + +--- + +## 全域熔斷狀態視覺化(未來) + +``` +Dashboard 首頁 → AI 自治指標面板 → 顯示: +「⚠️ 系統保護模式:過去 15 分鐘修復 5 次,暫停自動修復」 +→ 統帥點擊解除 → POST /api/v1/repair/cooldown/reset(Tier 1 操作) +``` + +--- + +## 閾值設計依據 + +| 參數 | 值 | 理由 | +|------|-----|------| +| `GLOBAL_COOLDOWN_THRESHOLD` | 5 | 正常運作時每天 < 5 次修復;5 次以上代表異常模式 | +| `GLOBAL_COOLDOWN_TTL` | 900s(15 分鐘)| 大多數骨牌效應在 10 分鐘內完成;15 分鐘提供緩衝 | + +--- + +## 模組化合規驗證 + +| 項目 | 說明 | 合規狀態 | +|------|------|---------| +| 層次 | `services/` 獨立服務層 | ✅ 合規 | +| 依賴 | 只依賴 `core/redis_client` | ✅ 合規 | +| Redis 降級 | 故障時保守處理(禁止自動修復)| ✅ 合規 | +| 原子操作 | 使用 INCR(Redis 原生原子性)| ✅ 合規 | + +--- + +## 驗收標準 + +| 項目 | 通過條件 | +|------|---------| +| 有狀態服務保護 | PostgreSQL/Redis 的 Incident 永遠返回 `GLOBAL_GUARDRAIL` | +| 全域計數 | 第 6 次修復請求返回 `can_auto_repair=False` | +| Redis 降級 | Redis 故障時 `check_global_repair_cooldown` 返回 `(False, "Redis 連線異常...")` | +| Dashboard 可見 | (未來)保護模式顯示在 AI 自治指標面板 | + +--- + +## 相關文件 + +- `docs/proposals/ARCHITECTURAL_RISK_WAR_GAME.md`:風險沙盤推演 +- `apps/api/src/services/auto_repair_service.py`:自動修復服務 +- ADR-028:Failure Auto-Repair Loop +- ADR-030:Intelligent Auto-Remediation +- ADR-038:OpenClaw Concurrency Governance(Semaphore + Circuit Breaker)