#!/bin/bash # Gateway Watchdog v5.2 - Emergency Recovery 에스컬레이션 # # v5.2 개선사항 (2026-02-08): # - Backoff 진입 시 Emergency Recovery (Level 3) 즉시 호출 # - Claude CLI 자율 진단 + 복구 시도 (30분) # - 무한 재시작 루프 방지 + 근본 원인 해결 # # v5.1 개선사항: # - 복구 성공 시 크론 catch-up 자동 실행 # # v4 개선사항: # - 크래시 카운터 자동 감쇠 (6시간 후 리셋, 정상 시 1씩 감소) # - Exponential Backoff (10초 → 30초 → 90초 → 180초 → 300초 → 600초) # - 복구 성공 알림 추가 # - 의존성 Pre-flight Check (launchd 서비스 자동 등록) # - Healing Rate Limiter (동시 healing 방지) # - 기존 v3 기능 모두 유지 set -euo pipefail # ============================================================================ # 설정 # ============================================================================ GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-}" LAUNCHD_SERVICE="ai.openclaw.gateway" LAUNCHD_PLIST="$HOME/Library/LaunchAgents/ai.openclaw.gateway.plist" LOG_DIR="$HOME/.openclaw/logs" LOG_FILE="$LOG_DIR/watchdog.log" STATE_DIR="$HOME/.openclaw/watchdog" COOLDOWN_FILE="$STATE_DIR/last-restart" CRASH_COUNTER_FILE="$STATE_DIR/crash-counter" CRASH_TIMESTAMP_FILE="$STATE_DIR/crash-timestamp" ALERT_FILE="$STATE_DIR/pending-alert" RECOVERY_START_FILE="$STATE_DIR/recovery-start" HEALING_LOCK="/tmp/openclaw-healing.lock" ALERT_SCRIPT="$HOME/.openclaw/scripts/alert.sh" # 설정값 HEALTH_TIMEOUT=5 # HTTP 요청 타임아웃 (초) MAX_TOTAL_RETRIES=6 # 최대 총 재시작 시도 횟수 CRASH_DECAY_HOURS=6 # 크래시 카운터 자동 리셋 시간 MEMORY_WARN_MB=1536 # 메모리 경고 임계치 (1.5GB) MEMORY_CRITICAL_MB=2048 # 메모리 위험 임계치 (2GB) # Exponential Backoff 설정 (초) BACKOFF_DELAYS=(10 30 90 180 300 600) # dry-run 모드 DRY_RUN=false if [[ "${1:-}" == "--dry-run" ]]; then DRY_RUN=true fi # ============================================================================ # 초기화 # ============================================================================ mkdir -p "$STATE_DIR" mkdir -p "$LOG_DIR" # ============================================================================ # 유틸리티 함수 # ============================================================================ log() { local level="$1" local message="$2" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [$level] $message" >> "$LOG_FILE" if $DRY_RUN; then echo "[$timestamp] [$level] $message" fi } # ============================================================================ # v4 신규: Healing Rate Limiter # ============================================================================ acquire_healing_lock() { if mkdir "$HEALING_LOCK" 2>/dev/null; then trap "rmdir '$HEALING_LOCK' 2>/dev/null || true" EXIT return 0 else # 락이 10분 이상 됐으면 강제 해제 (stale lock) local lock_age=$(( $(date +%s) - $(stat -f %m "$HEALING_LOCK" 2>/dev/null || echo "0") )) if [[ $lock_age -gt 600 ]]; then rmdir "$HEALING_LOCK" 2>/dev/null || true mkdir "$HEALING_LOCK" 2>/dev/null && return 0 fi return 1 fi } # ============================================================================ # v4 신규: 크래시 카운터 자동 감쇠 # ============================================================================ get_crash_count() { if [[ -f "$CRASH_COUNTER_FILE" ]]; then cat "$CRASH_COUNTER_FILE" else echo "0" fi } # 시간 기반 자동 리셋 체크 check_crash_decay() { if [[ ! -f "$CRASH_TIMESTAMP_FILE" ]]; then return fi local last_crash=$(cat "$CRASH_TIMESTAMP_FILE") local now=$(date +%s) local elapsed=$((now - last_crash)) local decay_seconds=$((CRASH_DECAY_HOURS * 3600)) if [[ $elapsed -ge $decay_seconds ]]; then log "INFO" "크래시 카운터 자동 리셋 (${CRASH_DECAY_HOURS}시간 경과)" echo "0" > "$CRASH_COUNTER_FILE" rm -f "$CRASH_TIMESTAMP_FILE" fi } increment_crash_count() { local count=$(get_crash_count) echo $((count + 1)) > "$CRASH_COUNTER_FILE" date +%s > "$CRASH_TIMESTAMP_FILE" } # v4 신규: 정상 작동 시 카운터 감소 (감쇠) decrement_crash_count() { local count=$(get_crash_count) if [[ $count -gt 0 ]]; then echo $((count - 1)) > "$CRASH_COUNTER_FILE" log "INFO" "크래시 카운터 감소: $count → $((count - 1))" fi } reset_crash_count() { echo "0" > "$CRASH_COUNTER_FILE" rm -f "$CRASH_TIMESTAMP_FILE" } # ============================================================================ # v4 신규: Exponential Backoff # ============================================================================ get_backoff_delay() { local crash_count=$(get_crash_count) local index=$((crash_count - 1)) if [[ $index -lt 0 ]]; then index=0 elif [[ $index -ge ${#BACKOFF_DELAYS[@]} ]]; then index=$((${#BACKOFF_DELAYS[@]} - 1)) fi echo "${BACKOFF_DELAYS[$index]}" } is_in_cooldown() { if [[ ! -f "$COOLDOWN_FILE" ]]; then return 1 fi local last_restart=$(cat "$COOLDOWN_FILE") local now=$(date +%s) local elapsed=$((now - last_restart)) local required_cooldown=$(get_backoff_delay) if [[ $elapsed -lt $required_cooldown ]]; then local remaining=$((required_cooldown - elapsed)) log "INFO" "Backoff 쿨다운 중: ${remaining}초 남음 (필요: ${required_cooldown}초)" return 0 fi return 1 } set_last_restart() { date +%s > "$COOLDOWN_FILE" } # ============================================================================ # v4 신규: 의존성 Pre-flight Check # ============================================================================ preflight_check() { local issues=() # 1. launchd 서비스 등록 확인 if ! launchctl list 2>/dev/null | grep -q "$LAUNCHD_SERVICE"; then if [[ -f "$LAUNCHD_PLIST" ]]; then log "WARN" "Gateway launchd 서비스 미등록 - 자동 등록 시도" if ! $DRY_RUN; then launchctl bootstrap "gui/$(id -u)" "$LAUNCHD_PLIST" 2>/dev/null || true sleep 2 fi issues+=("launchd 서비스 재등록됨") else log "ERROR" "Gateway plist 파일 없음: $LAUNCHD_PLIST" issues+=("plist 파일 누락") fi fi # 2. Docker 확인 (옵션) if command -v docker &>/dev/null; then if ! docker info &>/dev/null 2>&1; then log "WARN" "Docker 미실행" # Docker 자동 시작은 하지 않음 (사용자 의도 존중) fi fi # 3. 포트 충돌 확인 local port_user=$(lsof -i ":$GATEWAY_PORT" -sTCP:LISTEN -t 2>/dev/null | head -1) local gateway_pid=$(launchctl list 2>/dev/null | grep "$LAUNCHD_SERVICE" | awk '{print $1}' | grep -v "^-$") if [[ -n "$port_user" ]] && [[ "$port_user" != "$gateway_pid" ]]; then log "WARN" "포트 $GATEWAY_PORT가 다른 프로세스(PID: $port_user)에 의해 사용 중" issues+=("포트 충돌") fi if [[ ${#issues[@]} -gt 0 ]]; then echo "${issues[*]}" else echo "OK" fi } # ============================================================================ # 상태 확인 함수 # ============================================================================ check_pid_status() { local status=$(launchctl list 2>/dev/null | grep "$LAUNCHD_SERVICE" || echo "") if [[ -z "$status" ]]; then echo "NOT_LOADED" return fi local pid=$(echo "$status" | awk '{print $1}') local exit_code=$(echo "$status" | awk '{print $2}') if [[ "$pid" != "-" ]] && [[ "$pid" -gt 0 ]] 2>/dev/null; then echo "PID:$pid" elif [[ "$exit_code" -lt 0 ]] 2>/dev/null; then echo "CRASHED:signal_$exit_code" else echo "STOPPED:exit_$exit_code" fi } check_http_health() { local url="http://127.0.0.1:$GATEWAY_PORT/health" local response if response=$(curl -s -o /dev/null -w "%{http_code}" \ --max-time $HEALTH_TIMEOUT \ "$url" 2>/dev/null); then if [[ "$response" == "200" ]]; then echo "OK" else echo "HTTP_$response" fi else echo "UNREACHABLE" fi } check_memory_usage() { local pid_status=$(check_pid_status) if [[ "$pid_status" != PID:* ]]; then echo "0" return fi local pid="${pid_status#PID:}" local mem_kb=$(ps -p "$pid" -o rss= 2>/dev/null | tr -d ' ') if [[ -z "$mem_kb" ]]; then echo "0" return fi echo $((mem_kb / 1024)) } # ============================================================================ # 알림 함수 # ============================================================================ send_alert() { local level="$1" local title="$2" local message="$3" local fields="${4:-}" echo "$message" > "$ALERT_FILE" log "ALERT" "$message" if [[ -x "$ALERT_SCRIPT" ]]; then if $DRY_RUN; then log "DRY-RUN" "알림 전송 (실제 안함): $title - $message" else "$ALERT_SCRIPT" "$level" "$title" "$message" "$fields" 2>/dev/null || \ log "ERROR" "알림 전송 실패" fi else log "WARN" "알림 스크립트 없음: $ALERT_SCRIPT" fi } # v4 신규: 복구 성공 알림 send_recovery_alert() { if [[ ! -f "$ALERT_FILE" ]]; then return fi local recovery_time="unknown" if [[ -f "$RECOVERY_START_FILE" ]]; then local start=$(cat "$RECOVERY_START_FILE") local now=$(date +%s) recovery_time="$((now - start))초" rm -f "$RECOVERY_START_FILE" fi send_alert "success" "Gateway 복구 완료" \ "서비스가 정상 복구되었습니다." \ "[{\"name\":\"복구 소요\",\"value\":\"$recovery_time\",\"inline\":true}]" rm -f "$ALERT_FILE" } clear_alert() { rm -f "$ALERT_FILE" rm -f "$RECOVERY_START_FILE" } # ============================================================================ # 재시작 요청 # ============================================================================ request_restart() { local reason="$1" # 복구 시작 시간 기록 date +%s > "$RECOVERY_START_FILE" if $DRY_RUN; then log "DRY-RUN" "재시작 요청됨 (실제 실행 안 함): $reason" return 0 fi log "ACTION" "재시작 요청: $reason" local pid_status=$(check_pid_status) if [[ "$pid_status" == PID:* ]]; then local pid="${pid_status#PID:}" # v5: 좀비 프로세스 감지 - HTTP 응답 없으면 SIGKILL 사용 local http_status=$(check_http_health) if [[ "$http_status" != "OK" ]]; then # HTTP 응답 없음 = 좀비 상태 의심 → 강제 종료 log "ACTION" "좀비 프로세스 의심 (PID: $pid, HTTP: $http_status) - SIGKILL 강제 종료" # 1. SIGTERM 먼저 시도 (graceful) kill -TERM "$pid" 2>/dev/null || true sleep 2 # 2. 여전히 살아있으면 SIGKILL if kill -0 "$pid" 2>/dev/null; then log "ACTION" "SIGTERM 무응답 - SIGKILL 강제 종료" kill -9 "$pid" 2>/dev/null || true sleep 1 fi # 3. 프로세스 종료 확인 if kill -0 "$pid" 2>/dev/null; then log "ERROR" "프로세스 종료 실패 (PID: $pid)" else log "ACTION" "프로세스 종료 완료 (PID: $pid)" fi # 4. launchctl로 재시작 sleep 1 launchctl kickstart -k "gui/$(id -u)/$LAUNCHD_SERVICE" 2>/dev/null || \ launchctl start "$LAUNCHD_SERVICE" 2>/dev/null || true log "ACTION" "launchctl 재시작 실행" else # HTTP 응답 있음 = 정상 상태 → soft restart kill -USR1 "$pid" 2>/dev/null || true log "ACTION" "SIGUSR1 전송 (PID: $pid)" fi else launchctl kickstart -k "gui/$(id -u)/$LAUNCHD_SERVICE" 2>/dev/null || \ launchctl start "$LAUNCHD_SERVICE" 2>/dev/null || true log "ACTION" "launchctl 재시작 실행" fi set_last_restart } # ============================================================================ # 메인 로직 # ============================================================================ log "INFO" "========== Watchdog v5.1 체크 시작 ==========" if $DRY_RUN; then log "INFO" "*** DRY-RUN 모드 ***" fi # v4: Healing Rate Limiter if ! acquire_healing_lock; then log "INFO" "다른 healing 프로세스 진행 중 - 스킵" exit 0 fi # v4: 크래시 카운터 시간 기반 감쇠 체크 check_crash_decay # v4: Pre-flight Check preflight_result=$(preflight_check) if [[ "$preflight_result" != "OK" ]]; then log "INFO" "Pre-flight 이슈: $preflight_result" fi # 1. PID 상태 확인 pid_status=$(check_pid_status) log "INFO" "PID 상태: $pid_status" # 2. 프로세스가 없으면 처리 if [[ "$pid_status" == "NOT_LOADED" ]] || [[ "$pid_status" == STOPPED:* ]] || [[ "$pid_status" == CRASHED:* ]]; then log "WARN" "Gateway 프로세스 없음" crash_count=$(get_crash_count) # 최대 재시도 횟수 초과 시 - v4: 시간 경과 후 자동 리셋되므로 계속 시도 if [[ $crash_count -ge $MAX_TOTAL_RETRIES ]]; then log "WARN" "재시도 횟수 높음 ($crash_count/$MAX_TOTAL_RETRIES) - Backoff 적용 중" # v5.2: Backoff 쿨다운 중이면 Emergency Recovery (Level 3) 즉시 호출 if is_in_cooldown; then log "INFO" "Backoff 쿨다운 중 - Emergency Recovery (Level 3) 호출" # Emergency Recovery 스크립트 존재 확인 local emergency_script="$HOME/openclaw/scripts/emergency-recovery.sh" if [[ -x "$emergency_script" ]]; then log "ACTION" "Emergency Recovery 시작 (백그라운드)" if ! $DRY_RUN; then # 백그라운드로 실행 (Watchdog 블로킹 방지) nohup bash "$emergency_script" >> "$LOG_DIR/emergency-recovery.stdout.log" 2>&1 & log "ACTION" "Emergency Recovery PID: $!" send_alert "critical" "Gateway 복구 에스컬레이션" \ "Backoff 진입 - Level 3 (Claude AI) 자율 복구 시작" \ "[{\"name\":\"재시도 횟수\",\"value\":\"${crash_count}/${MAX_TOTAL_RETRIES}\",\"inline\":true},{\"name\":\"복구 시간\",\"value\":\"최대 30분\",\"inline\":true}]" else log "DRY-RUN" "Emergency Recovery 호출됨 (실제 실행 안 함)" fi else log "ERROR" "Emergency Recovery 스크립트 없음: $emergency_script" send_alert "critical" "Gateway 복구 실패" \ "Emergency Recovery 스크립트 없음\n수동 개입 필요" \ "[{\"name\":\"재시도 횟수\",\"value\":\"${crash_count}/${MAX_TOTAL_RETRIES}\",\"inline\":true}]" fi log "INFO" "========== 체크 완료 ==========" exit 0 fi fi # 쿨다운 체크 if is_in_cooldown; then log "INFO" "Backoff 쿨다운 중이므로 재시작 보류" send_alert "warning" "Gateway 다운 감지" \ "Backoff 쿨다운 중 - 재시작 대기" \ "[{\"name\":\"상태\",\"value\":\"$pid_status\",\"inline\":true},{\"name\":\"대기\",\"value\":\"$(get_backoff_delay)초\",\"inline\":true}]" else increment_crash_count crash_count=$(get_crash_count) backoff=$(get_backoff_delay) log "WARN" "크래시 카운트: $crash_count/$MAX_TOTAL_RETRIES (Backoff: ${backoff}초)" request_restart "프로세스 없음 ($pid_status)" send_alert "warning" "Gateway 재시작 시도" \ "프로세스 없음 감지 - 재시작 중" \ "[{\"name\":\"원인\",\"value\":\"$pid_status\",\"inline\":true},{\"name\":\"재시도\",\"value\":\"${crash_count}/${MAX_TOTAL_RETRIES}\",\"inline\":true},{\"name\":\"Backoff\",\"value\":\"${backoff}초\",\"inline\":true}]" fi log "INFO" "========== 체크 완료 ==========" exit 0 fi # 3. PID가 있으면 HTTP Health Check http_status=$(check_http_health) log "INFO" "HTTP 상태: $http_status" if [[ "$http_status" == "OK" ]]; then # 정상 작동 mem_mb=$(check_memory_usage) log "INFO" "메모리 사용량: ${mem_mb}MB" # 메모리 체크 if [[ $mem_mb -ge $MEMORY_CRITICAL_MB ]]; then log "WARN" "메모리 위험 수준: ${mem_mb}MB" send_alert "critical" "Gateway 메모리 위험" \ "메모리 사용량이 위험 수준입니다\n재시작을 권장합니다" \ "[{\"name\":\"사용량\",\"value\":\"${mem_mb}MB\",\"inline\":true},{\"name\":\"임계치\",\"value\":\"${MEMORY_CRITICAL_MB}MB\",\"inline\":true}]" elif [[ $mem_mb -ge $MEMORY_WARN_MB ]]; then log "WARN" "메모리 경고 수준: ${mem_mb}MB" send_alert "warning" "Gateway 메모리 경고" \ "메모리 사용량이 높습니다" \ "[{\"name\":\"사용량\",\"value\":\"${mem_mb}MB\",\"inline\":true},{\"name\":\"임계치\",\"value\":\"${MEMORY_WARN_MB}MB\",\"inline\":true}]" fi # v4: 복구 성공 알림 if [[ -f "$ALERT_FILE" ]]; then send_recovery_alert # v5.1: 크론 catch-up 실행 (놓친 크론 자동 실행) log "ACTION" "크론 catch-up 시작..." if [[ -x "$HOME/openclaw/scripts/cron-catchup.sh" ]]; then # 백그라운드로 실행 (Watchdog 블로킹 방지) nohup bash "$HOME/openclaw/scripts/cron-catchup.sh" >> "$LOG_DIR/cron-catchup.log" 2>&1 & log "ACTION" "크론 catch-up 백그라운드 실행 (PID: $!)" else log "WARN" "cron-catchup.sh 스크립트 없음" fi fi # v4: 크래시 카운터 감쇠 (정상 시 1 감소) decrement_crash_count log "INFO" "Gateway 정상 작동 중" log "INFO" "========== 체크 완료 ==========" exit 0 fi # 4. HTTP 응답 없음 - 좀비 프로세스 가능성 log "WARN" "PID 있지만 HTTP 응답 없음 (좀비 의심)" if is_in_cooldown; then log "INFO" "Backoff 쿨다운 중이므로 재시작 보류" send_alert "warning" "Gateway 응답 없음" \ "프로세스는 있으나 HTTP 응답 없음\nBackoff 쿨다운 중" \ "[{\"name\":\"HTTP 상태\",\"value\":\"$http_status\",\"inline\":true}]" log "INFO" "========== 체크 완료 ==========" exit 0 fi increment_crash_count crash_count=$(get_crash_count) backoff=$(get_backoff_delay) log "WARN" "크래시 카운트: $crash_count/$MAX_TOTAL_RETRIES (Backoff: ${backoff}초)" request_restart "HTTP 응답 없음 ($http_status)" send_alert "warning" "Gateway 재시작 시도" \ "HTTP 응답 없음 (좀비 프로세스 의심)" \ "[{\"name\":\"HTTP 상태\",\"value\":\"$http_status\",\"inline\":true},{\"name\":\"재시도\",\"value\":\"${crash_count}/${MAX_TOTAL_RETRIES}\",\"inline\":true}]" log "INFO" "========== 체크 완료 =========="