docs: ADR-038/039 + LOGBOOK 更新

- ADR-038: OpenClaw 併發治理架構
- ADR-039: 全域自動修復熔斷
- LOGBOOK: 今日進度記錄

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-29 15:48:09 +08:00
parent 27509db212
commit bf06737eed
3 changed files with 522 additions and 2 deletions

View File

@@ -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 完成)
### 完成項目

View File

@@ -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 次連續失敗 → OPEN60 秒冷卻) │
│ - 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_OPEN1 次成功恢復 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-028Failure Auto-Repair Loop
- ADR-039全域自動修復熔斷機制

View File

@@ -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/resetTier 1 操作)
```
---
## 閾值設計依據
| 參數 | 值 | 理由 |
|------|-----|------|
| `GLOBAL_COOLDOWN_THRESHOLD` | 5 | 正常運作時每天 < 5 次修復5 次以上代表異常模式 |
| `GLOBAL_COOLDOWN_TTL` | 900s15 分鐘)| 大多數骨牌效應在 10 分鐘內完成15 分鐘提供緩衝 |
---
## 模組化合規驗證
| 項目 | 說明 | 合規狀態 |
|------|------|---------|
| 層次 | `services/` 獨立服務層 | ✅ 合規 |
| 依賴 | 只依賴 `core/redis_client` | ✅ 合規 |
| Redis 降級 | 故障時保守處理(禁止自動修復)| ✅ 合規 |
| 原子操作 | 使用 INCRRedis 原生原子性)| ✅ 合規 |
---
## 驗收標準
| 項目 | 通過條件 |
|------|---------|
| 有狀態服務保護 | 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-028Failure Auto-Repair Loop
- ADR-030Intelligent Auto-Remediation
- ADR-038OpenClaw Concurrency GovernanceSemaphore + Circuit Breaker