Files
ewoooc/services/auto_heal_service.py
ogt 551bab5fe6
All checks were successful
CD Pipeline / deploy (push) Successful in 1m22s
fix(ai-ops): 移除 DOCKER_RESTART compose=True 重複呼叫 bug
原本邏輯:先呼叫 docker compose restart(白名單通過)
再馬上覆寫 ok/output 用 docker restart(多餘且不一致)。
compose 選項已無意義,統一用 docker restart(SSH 白名單允許)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:32:08 +08:00

546 lines
23 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
auto_heal_service.py - EwoooC AIOps 自動修復引擎 (ADR-013)
完整閉環:
Exception 觸發
→ create_incident() : 寫入 incidents 表
→ classify_error() : 識別 error_type
→ match_playbook() : 比對 playbooks 規則庫
→ execute_playbook() : 執行修復動作
→ _write_heal_log() : 寫入 heal_logs 表
→ sink_to_km() : store_insight → KM RAG 沉澱
→ notify_telegram() : 推播修復結果
"""
import json
import os
import time
import traceback as tb
from datetime import datetime, timedelta
from typing import Optional, Tuple
import requests
from dotenv import load_dotenv
from database.manager import get_session
from database.autoheal_models import Incident, Playbook, HealLog, SEED_PLAYBOOKS
from services.logger_manager import SystemLogger
load_dotenv()
sys_log = SystemLogger("AutoHeal").get_logger()
# ─── Telegram 設定 ───────────────────────────────────────
_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") or os.getenv("OPENCLAW_BOT_TOKEN", "")
_CHAT_ID = os.getenv("OPENCLAW_GROUP_ID", "-1003940688311")
# ─── SSH 跳板機設定 ──────────────────────────────────────
_JUMP_HOST = os.getenv("SSH_JUMP_HOST", "192.168.0.110")
_JUMP_USER = os.getenv("SSH_JUMP_USER", "wooo")
_TARGET_HOST = os.getenv("SSH_TARGET_HOST", "192.168.0.188")
_TARGET_USER = os.getenv("SSH_TARGET_USER", "ollama")
# SSH 私鑰路徑:優先 envfallback 到 config 目錄rw mount不需重建容器
_SSH_KEY_PATH = os.getenv("SSH_KEY_PATH", "/app/config/autoheal_id_ed25519")
# ─── 白名單允許執行的指令前綴 ────────────────────────────
_CMD_WHITELIST = [
"docker restart ",
"docker compose restart ",
"docker start ",
]
# ─── 錯誤分類對照表keyword → error_type──────────────
_ERROR_CLASSIFY_MAP = {
"DNS_FAIL": ["name resolution", "could not translate host name",
"Temporary failure in name resolution"],
"DB_UNREACHABLE": ["connection refused", "Connection reset by peer",
"could not connect to server", "psycopg2.OperationalError"],
"OOM": ["SIGKILL", "out of memory", "Worker was sent SIGKILL", "MemoryError"],
"SSL_FAIL": ["SSL connection has been closed unexpectedly", "SSL SYSCALL error"],
"AUTH_FAIL": ["invalid_grant", "google_token.pickle", "Token has been expired"],
"CRAWLER_FAIL": ["429 Too Many Requests", "rate limit", "Retry-After",
"CloudflareCaptcha", "webdriver"],
"IMPORT_FAIL": ["import_service", "ImportError", "sync_daily_sales"],
"TIMEOUT": ["Timeout", "timed out", "TimeoutError"],
}
_SEVERITY_MAP = {
"P1": ["OOM", "SSL_FAIL"],
"P2": ["DNS_FAIL", "DB_UNREACHABLE", "AUTH_FAIL"],
"P3": ["CRAWLER_FAIL", "IMPORT_FAIL", "TIMEOUT"],
}
# ──────────────────────────────────────────────────────────
# 工具函數
# ──────────────────────────────────────────────────────────
def _classify_error(error_msg: str) -> Tuple[str, str]:
"""回傳 (error_type, severity)"""
lower = error_msg.lower()
for etype, keywords in _ERROR_CLASSIFY_MAP.items():
if any(k.lower() in lower for k in keywords):
for sev, etypes in _SEVERITY_MAP.items():
if etype in etypes:
return etype, sev
return etype, "P3"
return "UNKNOWN", "P3"
def _is_cmd_allowed(cmd: str) -> bool:
"""白名單驗證:防止任意 RCE"""
c = cmd.strip()
return any(c.startswith(prefix) for prefix in _CMD_WHITELIST)
def _send_telegram(msg: str) -> None:
"""推播訊息至 Telegram 群組"""
if not _BOT_TOKEN:
sys_log.warning("[AutoHeal] TELEGRAM_BOT_TOKEN 未設定,略過推播")
return
try:
requests.post(
f"https://api.telegram.org/bot{_BOT_TOKEN}/sendMessage",
json={"chat_id": _CHAT_ID, "text": msg, "parse_mode": "HTML"},
timeout=10,
)
except Exception as e:
sys_log.error(f"[AutoHeal] Telegram 推播失敗: {e}")
def _execute_ssh_cmd(cmd: str) -> Tuple[bool, str]:
"""
透過 paramiko 執行 SSH 跳板指令。
若 paramiko 不可用則降級為 subprocess + CLI ssh。
"""
if not _is_cmd_allowed(cmd):
return False, f"指令不在白名單中,拒絕執行: {cmd}"
try:
import paramiko
import os as _os
key_path = _SSH_KEY_PATH if _os.path.isfile(_SSH_KEY_PATH) else None
key_kwargs = {"key_filename": key_path} if key_path else {}
jump = paramiko.SSHClient()
jump.set_missing_host_key_policy(paramiko.AutoAddPolicy())
jump.connect(_JUMP_HOST, username=_JUMP_USER, timeout=10,
look_for_keys=True, **key_kwargs)
# 透過跳板機建立隧道
transport = jump.get_transport()
dest_addr = (_TARGET_HOST, 22)
src_addr = (_JUMP_HOST, 0)
chan = transport.open_channel("direct-tcpip", dest_addr, src_addr)
target = paramiko.SSHClient()
target.set_missing_host_key_policy(paramiko.AutoAddPolicy())
target.connect(_TARGET_HOST, username=_TARGET_USER, sock=chan, timeout=15,
look_for_keys=True, **key_kwargs)
_stdin, stdout, stderr = target.exec_command(cmd, timeout=60)
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
exit_code = stdout.channel.recv_exit_status()
target.close()
jump.close()
if exit_code == 0:
return True, out or "指令執行成功"
else:
return False, f"exit_code={exit_code}\n{err or out}"
except ImportError:
# paramiko 尚未安裝,降級到 cli ssh
sys_log.warning("[AutoHeal] paramiko 未安裝,改用 subprocess + CLI ssh")
import subprocess
full_cmd = [
"ssh", "-o", "StrictHostKeyChecking=no",
"-J", f"{_JUMP_USER}@{_JUMP_HOST}",
f"{_TARGET_USER}@{_TARGET_HOST}", cmd,
]
result = subprocess.run(full_cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
return True, result.stdout.strip() or "指令執行成功"
else:
return False, result.stderr.strip() or result.stdout.strip()
except Exception as e:
return False, f"SSH 執行例外: {e}"
# ──────────────────────────────────────────────────────────
# 核心引擎
# ──────────────────────────────────────────────────────────
class AutoHealService:
"""
AIOps 自動修復引擎。
使用方式(在 scheduler.py 的 except 區塊):
from services.auto_heal_service import auto_heal_service
auto_heal_service.handle_exception(
task_name="run_auto_import_task",
exception=e,
traceback_str=traceback.format_exc()
)
"""
# ── 步驟 1統一入口 ────────────────────────────────
def handle_exception(self, task_name: str, exception: Exception,
traceback_str: str = "") -> Optional[int]:
"""
統一例外處理入口。回傳 incident_id若前置失敗則回傳 None。
"""
error_msg = str(exception)
error_type, severity = _classify_error(error_msg)
sys_log.info(f"[AutoHeal] 收到例外 task={task_name} type={error_type} sev={severity}")
incident = self._create_incident(task_name, error_type, error_msg,
traceback_str, severity)
if not incident:
return None
playbook = self._match_playbook(incident)
if not playbook:
sys_log.info(f"[AutoHeal] 未找到匹配 PlayBook (incident_id={incident.id})")
self._notify_no_playbook(incident)
return incident.id
heal_log = self._execute_playbook(incident, playbook)
self._sink_to_km(incident, playbook, heal_log)
self._notify_telegram(incident, playbook, heal_log)
return incident.id
# ── 步驟 2建立 Incident ───────────────────────────
def _create_incident(self, task_name: str, error_type: str, error_msg: str,
traceback_str: str, severity: str) -> Optional[Incident]:
session = get_session()
try:
incident = Incident(
task_name = task_name,
error_type = error_type,
error_message = error_msg[:2000], # 限制長度
error_traceback = traceback_str[:5000],
severity = severity,
status = "open",
created_at = datetime.now(),
updated_at = datetime.now(),
)
session.add(incident)
session.commit()
session.refresh(incident)
session.expunge(incident)
sys_log.info(f"[AutoHeal] 建立 Incident id={incident.id} type={error_type}")
return incident
except Exception as e:
session.rollback()
sys_log.error(f"[AutoHeal] create_incident 失敗: {e}")
return None
finally:
session.close()
# ── 步驟 3PlayBook 匹配 ───────────────────────────
def _match_playbook(self, incident: Incident) -> Optional[Playbook]:
"""
匹配邏輯:
1. error_type 精確比對
2. match_pattern 任一關鍵字命中
3. 冷卻時間檢查(同 playbook 最近一次執行是否已超過 cooldown_min
"""
session = get_session()
try:
candidates = session.query(Playbook).filter_by(
error_type=incident.error_type, is_active=True
).all()
error_lower = incident.error_message.lower()
for pb in candidates:
patterns = pb.get_match_patterns()
if not any(p.lower() in error_lower for p in patterns):
continue
# 冷卻檢查
cooldown_threshold = datetime.now() - timedelta(minutes=pb.cooldown_min)
recent_log = session.query(HealLog).filter(
HealLog.playbook_id == pb.id,
HealLog.created_at >= cooldown_threshold,
HealLog.result == "success",
).first()
if recent_log:
sys_log.info(f"[AutoHeal] PlayBook '{pb.name}' 在冷卻中,略過")
continue
# 上限檢查(同 incident 的 retry_count
if incident.retry_count >= pb.max_retries:
sys_log.warning(f"[AutoHeal] 已達 max_retries({pb.max_retries}),升級為 escalated")
self._escalate_incident(incident)
return None
sys_log.info(f"[AutoHeal] 匹配 PlayBook: '{pb.name}' (id={pb.id})")
return pb
return None
except Exception as e:
sys_log.error(f"[AutoHeal] match_playbook 失敗: {e}")
return None
finally:
session.close()
# ── 步驟 4執行 PlayBook ───────────────────────────
def _execute_playbook(self, incident: Incident, playbook: Playbook) -> HealLog:
"""根據 action_type 執行對應動作,回傳 HealLog"""
t_start = time.time()
params = playbook.get_action_params()
action_detail = ""
result = "failed"
result_output = ""
# 更新 incident 狀態
self._update_incident_status(incident.id, "healing", playbook.id)
try:
if playbook.action_type == "DOCKER_RESTART":
container = params.get("container", "")
cmd = f"docker restart {container}"
action_detail = cmd
ok, output = _execute_ssh_cmd(cmd)
result = "success" if ok else "failed"
result_output = output
elif playbook.action_type == "SSH_CMD":
cmd = params.get("cmd", "")
action_detail = cmd
ok, output = _execute_ssh_cmd(cmd)
result = "success" if ok else "failed"
result_output = output
elif playbook.action_type == "ALERT_ONLY":
msg = params.get("message", "需人工介入")
action_detail = f"[ALERT_ONLY] {msg}"
result = "success"
result_output = msg
elif playbook.action_type == "WAIT_RETRY":
wait_min = params.get("wait_minutes", 30)
action_detail = f"[WAIT_RETRY] 靜默等待 {wait_min} 分鐘後由排程自動重試"
result = "success"
result_output = f"已記錄,排程將在 {wait_min} 分鐘後重試"
else:
action_detail = f"未知 action_type: {playbook.action_type}"
result = "skipped"
result_output = action_detail
except Exception as e:
result = "failed"
result_output = f"執行例外: {e}"
sys_log.error(f"[AutoHeal] execute_playbook 例外: {e}")
duration_ms = (time.time() - t_start) * 1000
heal_log = self._write_heal_log(
incident.id, playbook.id,
playbook.action_type, action_detail,
result, result_output, duration_ms,
)
# 更新 PlayBook 統計
self._update_playbook_stats(playbook.id, result)
# 更新 Incident 最終狀態
final_status = "resolved" if result == "success" else "open"
self._update_incident_status(incident.id, final_status, playbook.id,
increment_retry=(result != "success"))
sys_log.info(f"[AutoHeal] 執行完成 result={result} duration={duration_ms:.0f}ms")
return heal_log
# ── 步驟 5寫入 HealLog ────────────────────────────
def _write_heal_log(self, incident_id, playbook_id, action_type,
action_detail, result, result_output, duration_ms) -> HealLog:
session = get_session()
try:
hl = HealLog(
incident_id = incident_id,
playbook_id = playbook_id,
action_type = action_type,
action_detail = action_detail,
result = result,
result_output = (result_output or "")[:2000],
duration_ms = duration_ms,
created_at = datetime.now(),
)
session.add(hl)
session.commit()
session.refresh(hl) # reload attrs into memory (expire_on_commit cleared them)
session.expunge(hl) # detach so attrs stay accessible after session.close()
return hl
except Exception as e:
session.rollback()
sys_log.error(f"[AutoHeal] write_heal_log 失敗: {e}")
return HealLog(result=result, action_detail=action_detail, duration_ms=duration_ms)
finally:
session.close()
# ── 步驟 6KM 沉澱 ────────────────────────────────
def _sink_to_km(self, incident: Incident, playbook: Playbook, heal_log: HealLog) -> None:
"""將修復知識寫入 ai_insightsKM RAG 雙寫)"""
try:
from services.openclaw_learning_service import store_insight
today = datetime.now().strftime("%Y-%m-%d")
result_zh = {"success": "成功", "failed": "失敗", "skipped": "跳過"}.get(
heal_log.result, heal_log.result
)
content = (
f"[AIOps 自動修復紀錄]\n"
f"事件:{incident.task_name} 發生 {incident.error_type}(嚴重度 {incident.severity}\n"
f"症狀:{incident.error_message[:300]}\n"
f"行動:執行 PlayBook「{playbook.name}」→ {heal_log.action_detail}\n"
f"結果:{result_zh}(耗時 {heal_log.duration_ms:.0f}ms\n"
f"教訓:此類型錯誤({incident.error_type})可透過 {playbook.action_type} 自動修復。\n"
f"處理時間:{today}"
)
store_insight(
insight_type = "auto_heal_playbook",
period = today,
content = content,
metadata = {
"playbook_id": playbook.id,
"incident_id": incident.id,
"error_type": incident.error_type,
"result": heal_log.result,
},
ai_model = "auto_heal_engine_v1",
)
sys_log.info(f"[AutoHeal] KM 沉澱完成 (incident_id={incident.id})")
except Exception as e:
sys_log.warning(f"[AutoHeal] sink_to_km 失敗(不影響主流程): {e}")
# ── 步驟 7Telegram 通知 ───────────────────────────
def _notify_telegram(self, incident: Incident, playbook: Playbook,
heal_log: HealLog) -> None:
sev_icon = {"P1": "🔴", "P2": "🟠", "P3": "🟡"}.get(incident.severity, "")
result = heal_log.result
if result == "success":
header = f"✅ <b>[AIOps] 自動修復成功</b>"
footer = f"💾 知識已沉澱至 KM"
elif result == "failed":
header = f"🚨 <b>[AIOps] 自動修復失敗 — 需人工介入</b>"
footer = (
f"⚠️ 修復指令回傳錯誤,請登入 188 手動排查:\n"
f"<code>docker restart {playbook.get_action_params().get('container', '?')}</code>\n"
f"🆔 Incident #{incident.id}"
)
else:
header = f"⏭️ <b>[AIOps] 修復已略過</b>"
footer = f"🆔 Incident #{incident.id}"
msg = (
f"{sev_icon} {header}\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"📌 <b>{incident.task_name}</b>\n"
f"🔖 {incident.error_type} · {incident.severity}\n"
f"📝 {incident.error_message[:180]}\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"🔧 PlayBook{playbook.name}\n"
f"⚙️ 動作:<code>{heal_log.action_detail}</code>\n"
f"⏱ 耗時:{heal_log.duration_ms:.0f}ms\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"{footer}"
)
_send_telegram(msg)
def _notify_no_playbook(self, incident: Incident) -> None:
"""未找到 PlayBook 時的人工告警"""
sev_icon = {"P1": "🔴", "P2": "🟠", "P3": "🟡"}.get(incident.severity, "")
msg = (
f"{sev_icon} <b>[EwoooC AIOps] 需人工介入</b>\n\n"
f"📌 任務:<code>{incident.task_name}</code>\n"
f"🚨 錯誤類型:<code>{incident.error_type}</code>\n"
f"📝 症狀:{incident.error_message[:300]}\n\n"
f"⚠️ 未找到匹配的 PlayBook請人工排查。\n"
f"🆔 Incident ID{incident.id}"
)
_send_telegram(msg)
# ── 輔助函數 ────────────────────────────────────────
def _update_incident_status(self, incident_id: int, status: str,
playbook_id: Optional[int] = None,
increment_retry: bool = False) -> None:
session = get_session()
try:
inc = session.query(Incident).get(incident_id)
if inc:
inc.status = status
inc.updated_at = datetime.now()
if playbook_id:
inc.playbook_id = playbook_id
if status == "resolved":
inc.resolved_at = datetime.now()
if increment_retry:
inc.retry_count = (inc.retry_count or 0) + 1
session.commit()
except Exception as e:
session.rollback()
sys_log.error(f"[AutoHeal] update_incident_status 失敗: {e}")
finally:
session.close()
def _escalate_incident(self, incident: Incident) -> None:
self._update_incident_status(incident.id, "escalated")
sev_icon = {"P1": "🔴", "P2": "🟠", "P3": "🟡"}.get(incident.severity, "")
msg = (
f"{sev_icon} <b>[EwoooC AIOps] 告警升級 — 需立即人工介入</b>\n\n"
f"📌 任務:<code>{incident.task_name}</code>\n"
f"🚨 錯誤:<code>{incident.error_type}</code>\n"
f"🔁 已重試 {incident.retry_count} 次,自動修復失敗。\n"
f"📝 {incident.error_message[:300]}"
)
_send_telegram(msg)
def _update_playbook_stats(self, playbook_id: int, result: str) -> None:
session = get_session()
try:
pb = session.query(Playbook).get(playbook_id)
if pb:
if result == "success":
pb.success_count = (pb.success_count or 0) + 1
else:
pb.fail_count = (pb.fail_count or 0) + 1
pb.updated_at = datetime.now()
session.commit()
except Exception as e:
session.rollback()
sys_log.error(f"[AutoHeal] update_playbook_stats 失敗: {e}")
finally:
session.close()
# ── 種子 PlayBook 初始化 ────────────────────────────
@staticmethod
def init_seed_playbooks() -> None:
"""首次啟動時植入預設 PlayBook已存在則略過"""
session = get_session()
try:
for seed in SEED_PLAYBOOKS:
exists = session.query(Playbook).filter_by(name=seed["name"]).first()
if not exists:
session.add(Playbook(**seed))
session.commit()
sys_log.info("[AutoHeal] 種子 PlayBook 初始化完成")
except Exception as e:
session.rollback()
sys_log.error(f"[AutoHeal] init_seed_playbooks 失敗: {e}")
finally:
session.close()
# ─── 模組級單例 ─────────────────────────────────────────
auto_heal_service = AutoHealService()