diff --git a/app/Console/Commands/BackupCheckCommand.php b/app/Console/Commands/BackupCheckCommand.php new file mode 100644 index 0000000..221928b --- /dev/null +++ b/app/Console/Commands/BackupCheckCommand.php @@ -0,0 +1,115 @@ +info('DB 백업 상태 확인 시작...'); + + $statusFile = $this->option('path') + ? rtrim($this->option('path'), '/') . '/.backup_status' + : env('BACKUP_STATUS_FILE', '/data/backup/mysql/.backup_status'); + + $errors = []; + + // 1. 상태 파일 존재 여부 + if (! file_exists($statusFile)) { + $errors[] = '백업 상태 파일 없음: ' . $statusFile; + $this->reportErrors($monitorService, $errors); + + return self::FAILURE; + } + + $status = json_decode(file_get_contents($statusFile), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $errors[] = '상태 파일 JSON 파싱 실패: ' . json_last_error_msg(); + $this->reportErrors($monitorService, $errors); + + return self::FAILURE; + } + + // 2. last_run이 25시간 이내인지 + $lastRun = strtotime($status['last_run'] ?? ''); + if (! $lastRun || (time() - $lastRun) > 25 * 3600) { + $lastRunStr = $status['last_run'] ?? 'unknown'; + $errors[] = "마지막 백업이 25시간 초과: {$lastRunStr}"; + } + + // 3. status가 success인지 + if (($status['status'] ?? '') !== 'success') { + $errors[] = '백업 상태 실패: ' . ($status['status'] ?? 'unknown'); + } + + // 4. 각 DB 백업 파일 크기 검증 + $minSizes = [ + 'sam' => (int) env('BACKUP_MIN_SIZE_SAM', 1048576), + 'sam_stat' => (int) env('BACKUP_MIN_SIZE_STAT', 102400), + ]; + + $databases = $status['databases'] ?? []; + foreach ($minSizes as $dbName => $minSize) { + if (! isset($databases[$dbName])) { + $errors[] = "{$dbName} DB 백업 정보 없음"; + + continue; + } + + $sizeBytes = $databases[$dbName]['size_bytes'] ?? 0; + if ($sizeBytes < $minSize) { + $errors[] = "{$dbName} 백업 파일 크기 부족: {$sizeBytes} bytes (최소 {$minSize})"; + } + } + + // 결과 처리 + if (! empty($errors)) { + $this->reportErrors($monitorService, $errors); + + return self::FAILURE; + } + + $this->info('✅ DB 백업 상태 정상'); + Log::info('db:backup-check 정상', [ + 'last_run' => $status['last_run'], + 'databases' => array_keys($databases), + ]); + + return self::SUCCESS; + } + + private function reportErrors(StatMonitorService $monitorService, array $errors): void + { + $errorMessage = implode("\n", $errors); + + $this->error('❌ DB 백업 이상 감지:'); + foreach ($errors as $error) { + $this->error(" - {$error}"); + } + + // stat_alerts에 기록 + $monitorService->recordBackupFailure( + '[backup] DB 백업 이상 감지', + $errorMessage + ); + + // Slack 알림 전송 + app(SlackNotificationService::class)->sendBackupAlert( + 'DB 백업 이상 감지', + $errorMessage + ); + + Log::error('db:backup-check 실패', ['errors' => $errors]); + } +} \ No newline at end of file diff --git a/app/Services/SlackNotificationService.php b/app/Services/SlackNotificationService.php new file mode 100644 index 0000000..9faaa76 --- /dev/null +++ b/app/Services/SlackNotificationService.php @@ -0,0 +1,136 @@ +webhookUrl = env('SLACK_ALERT_WEBHOOK_URL') ?: env('LOG_SLACK_WEBHOOK_URL'); + $this->serverName = env('SLACK_ALERT_SERVER_NAME', config('app.env', 'unknown')); + $this->enabled = (bool) env('SLACK_ALERT_ENABLED', false); + } + + /** + * 일반 알림 전송 + */ + public function sendAlert(string $title, string $message, string $severity = 'critical'): void + { + $color = match ($severity) { + 'critical' => '#FF0000', + 'warning' => '#FFA500', + default => '#3498DB', + }; + + $emoji = match ($severity) { + 'critical' => ':rotating_light:', + 'warning' => ':warning:', + default => ':information_source:', + }; + + $this->send([ + 'attachments' => [ + [ + 'color' => $color, + 'blocks' => [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => "{$emoji} {$title}", + 'emoji' => true, + ], + ], + [ + 'type' => 'section', + 'fields' => [ + [ + 'type' => 'mrkdwn', + 'text' => "*서버:*\n{$this->serverName}", + ], + [ + 'type' => 'mrkdwn', + 'text' => '*시간:*\n' . now()->format('Y-m-d H:i:s'), + ], + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => "*상세:*\n{$message}", + ], + ], + [ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'mrkdwn', + 'text' => "환경: `" . config('app.env') . "` | 심각도: `{$severity}`", + ], + ], + ], + ], + ], + ], + ]); + } + + /** + * 백업 실패 알림 + */ + public function sendBackupAlert(string $title, string $message): void + { + $this->sendAlert("[SAM 백업 실패] {$title}", $message, 'critical'); + } + + /** + * 통계/모니터링 알림 + */ + public function sendStatAlert(string $title, string $message, string $domain): void + { + $this->sendAlert("[SAM {$domain}] {$title}", $message, 'critical'); + } + + /** + * Slack 웹훅으로 메시지 전송 + */ + private function send(array $payload): void + { + if (! $this->enabled) { + Log::debug('Slack 알림 비활성화 상태 (SLACK_ALERT_ENABLED=false)'); + + return; + } + + if (empty($this->webhookUrl)) { + Log::warning('Slack 웹훅 URL 미설정'); + + return; + } + + try { + $response = Http::timeout(10)->post($this->webhookUrl, $payload); + + if (! $response->successful()) { + Log::error('Slack 알림 전송 실패', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } + } catch (\Throwable $e) { + Log::error('Slack 알림 전송 예외', [ + 'error' => $e->getMessage(), + ]); + } + } +} \ No newline at end of file diff --git a/app/Services/Stats/StatMonitorService.php b/app/Services/Stats/StatMonitorService.php index 292df47..044532f 100644 --- a/app/Services/Stats/StatMonitorService.php +++ b/app/Services/Stats/StatMonitorService.php @@ -3,6 +3,7 @@ namespace App\Services\Stats; use App\Models\Stats\StatAlert; +use App\Services\SlackNotificationService; use Illuminate\Support\Facades\Log; class StatMonitorService @@ -26,6 +27,13 @@ public function recordAggregationFailure(int $tenantId, string $domain, string $ 'is_resolved' => false, 'created_at' => now(), ]); + + // critical 알림 Slack 전송 + app(SlackNotificationService::class)->sendStatAlert( + "[{$jobType}] 집계 실패", + mb_substr($errorMessage, 0, 300), + $domain + ); } catch (\Throwable $e) { Log::error('stat_alert 기록 실패', [ 'tenant_id' => $tenantId, @@ -94,6 +102,13 @@ public function recordMismatch(int $tenantId, string $domain, string $label, flo 'is_resolved' => false, 'created_at' => now(), ]); + + // critical 알림 Slack 전송 + app(SlackNotificationService::class)->sendStatAlert( + "[{$label}] 정합성 불일치", + "원본={$expected}, 통계={$actual}, 차이=" . ($actual - $expected), + $domain + ); } catch (\Throwable $e) { Log::error('stat_alert 기록 실패 (mismatch)', [ 'tenant_id' => $tenantId, @@ -103,6 +118,33 @@ public function recordMismatch(int $tenantId, string $domain, string $label, flo } } + /** + * 백업 실패 알림 기록 (시스템 레벨, tenantId=0) + */ + public function recordBackupFailure(string $title, string $message): void + { + try { + StatAlert::create([ + 'tenant_id' => 0, + 'alert_type' => 'backup_failure', + 'domain' => 'backup', + 'severity' => 'critical', + 'title' => $title, + 'message' => mb_substr($message, 0, 500), + 'current_value' => 0, + 'threshold_value' => 0, + 'is_read' => false, + 'is_resolved' => false, + 'created_at' => now(), + ]); + } catch (\Throwable $e) { + Log::error('stat_alert 기록 실패 (backup_failure)', [ + 'title' => $title, + 'error' => $e->getMessage(), + ]); + } + } + /** * 알림 해결 처리 */ diff --git a/routes/console.php b/routes/console.php index 190c612..e749df0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -120,6 +120,19 @@ \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]); }); +// ─── DB 백업 모니터링 ─── + +// 매일 새벽 05:00에 DB 백업 상태 확인 (04:30 백업 완료 후 점검) +Schedule::command('db:backup-check') + ->dailyAt('05:00') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ db:backup-check 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ db:backup-check 스케줄러 실행 실패', ['time' => now()]); + }); + // 매일 오전 09:00에 KPI 목표 대비 알림 체크 Schedule::command('stat:check-kpi-alerts') ->dailyAt('09:00') diff --git a/scripts/backup/backup.conf.example b/scripts/backup/backup.conf.example new file mode 100644 index 0000000..d665c10 --- /dev/null +++ b/scripts/backup/backup.conf.example @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# ============================================================================= +# SAM DB Backup Configuration +# ============================================================================= +# 사용법: 이 파일을 backup.conf로 복사 후 환경에 맞게 수정 +# cp backup.conf.example backup.conf +# chmod 600 backup.conf +# ============================================================================= + +# DB 접속 정보 +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=codebridge +DB_PASS="code**bridge" + +# 백업 대상 DB (공백 구분) +DATABASES="sam sam_stat" + +# 백업 저장 경로 +BACKUP_BASE_DIR=/data/backup/mysql + +# 보관 정책 +DAILY_RETENTION_DAYS=7 +WEEKLY_RETENTION_DAYS=28 + +# 로그 +LOG_FILE=/data/backup/mysql/logs/backup.log + +# 상태 파일 (Laravel 모니터링용) +STATUS_FILE=/data/backup/mysql/.backup_status + +# 최소 백업 파일 크기 (bytes) — 이보다 작으면 실패로 간주 +MIN_SIZE_SAM=1048576 # 1MB +MIN_SIZE_SAM_STAT=102400 # 100KB \ No newline at end of file diff --git a/scripts/backup/sam-db-backup.sh b/scripts/backup/sam-db-backup.sh new file mode 100755 index 0000000..893e2de --- /dev/null +++ b/scripts/backup/sam-db-backup.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# ============================================================================= +# SAM DB Backup Script +# ============================================================================= +# 용도: MySQL 데이터베이스 백업 (mysqldump + gzip) +# 실행: crontab에서 매일 04:30 실행 +# 30 4 * * * /home/webservice/api/scripts/backup/sam-db-backup.sh +# ============================================================================= + +set -euo pipefail + +# 스크립트 경로 기준으로 설정 파일 로드 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONF_FILE="${SCRIPT_DIR}/backup.conf" + +if [[ ! -f "$CONF_FILE" ]]; then + echo "[FATAL] 설정 파일 없음: $CONF_FILE" >&2 + exit 1 +fi + +# shellcheck source=backup.conf.example +source "$CONF_FILE" + +# ============================================================================= +# 변수 초기화 +# ============================================================================= +TODAY=$(date +%Y-%m-%d) +TIMESTAMP=$(date +%Y%m%d_%H%M) +DAY_OF_WEEK=$(date +%u) # 1=월 ~ 7=일 +DAILY_DIR="${BACKUP_BASE_DIR}/daily/${TODAY}" +WEEKLY_DIR="${BACKUP_BASE_DIR}/weekly" +LOG_DIR=$(dirname "$LOG_FILE") +ERRORS=() +DB_RESULTS=() + +# ============================================================================= +# 함수 정의 +# ============================================================================= + +log() { + local level="$1" + shift + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE" +} + +ensure_dirs() { + mkdir -p "$DAILY_DIR" "$WEEKLY_DIR" "$LOG_DIR" +} + +backup_database() { + local db_name="$1" + local output_file="${DAILY_DIR}/${db_name}_${TIMESTAMP}.sql.gz" + + log "INFO" "백업 시작: ${db_name}" + + if mysqldump \ + --host="$DB_HOST" \ + --port="$DB_PORT" \ + --user="$DB_USER" \ + --password="$DB_PASS" \ + --single-transaction \ + --routines \ + --triggers \ + --quick \ + --lock-tables=false \ + "$db_name" 2>>"$LOG_FILE" | gzip > "$output_file"; then + + local file_size + file_size=$(stat -f%z "$output_file" 2>/dev/null || stat -c%s "$output_file" 2>/dev/null || echo 0) + + # 최소 크기 검증 + local min_size_var="MIN_SIZE_$(echo "$db_name" | tr '[:lower:]' '[:upper:]')" + local min_size="${!min_size_var:-0}" + + if [[ "$file_size" -lt "$min_size" ]]; then + log "ERROR" "백업 파일 크기 부족: ${db_name} (${file_size} bytes < ${min_size} bytes)" + ERRORS+=("${db_name}: 파일 크기 부족 (${file_size} < ${min_size})") + DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"$(basename "$output_file")\",\"size_bytes\":${file_size},\"status\":\"size_error\"}") + return 1 + fi + + log "INFO" "백업 완료: ${db_name} (${file_size} bytes)" + DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"$(basename "$output_file")\",\"size_bytes\":${file_size},\"status\":\"success\"}") + + # 일요일이면 weekly 복사 + if [[ "$DAY_OF_WEEK" -eq 7 ]]; then + local weekly_file="${WEEKLY_DIR}/${db_name}_${TIMESTAMP}_week.sql.gz" + cp "$output_file" "$weekly_file" + log "INFO" "주간 백업 복사: ${db_name}" + fi + + return 0 + else + log "ERROR" "mysqldump 실패: ${db_name}" + ERRORS+=("${db_name}: mysqldump 실패") + DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"\",\"size_bytes\":0,\"status\":\"dump_error\"}") + rm -f "$output_file" + return 1 + fi +} + +cleanup_old_backups() { + log "INFO" "오래된 백업 정리 시작" + + # daily: DAILY_RETENTION_DAYS일 초과 디렉토리 삭제 + if [[ -d "${BACKUP_BASE_DIR}/daily" ]]; then + find "${BACKUP_BASE_DIR}/daily" -mindepth 1 -maxdepth 1 -type d -mtime +"$DAILY_RETENTION_DAYS" -exec rm -rf {} \; 2>>"$LOG_FILE" + local daily_deleted=$? + log "INFO" "일간 백업 정리 완료 (${DAILY_RETENTION_DAYS}일 초과 삭제)" + fi + + # weekly: WEEKLY_RETENTION_DAYS일 초과 파일 삭제 + if [[ -d "$WEEKLY_DIR" ]]; then + find "$WEEKLY_DIR" -type f -name "*.sql.gz" -mtime +"$WEEKLY_RETENTION_DAYS" -delete 2>>"$LOG_FILE" + log "INFO" "주간 백업 정리 완료 (${WEEKLY_RETENTION_DAYS}일 초과 삭제)" + fi +} + +write_status_file() { + local status="success" + local errors_json="[]" + + if [[ ${#ERRORS[@]} -gt 0 ]]; then + status="failure" + # 에러 배열을 JSON 배열로 변환 + errors_json="[" + for i in "${!ERRORS[@]}"; do + [[ $i -gt 0 ]] && errors_json+="," + errors_json+="\"${ERRORS[$i]}\"" + done + errors_json+="]" + fi + + # databases 객체 구성 + local databases_json="{" + for i in "${!DB_RESULTS[@]}"; do + [[ $i -gt 0 ]] && databases_json+="," + local result="${DB_RESULTS[$i]}" + local db_name + db_name=$(echo "$result" | sed 's/.*"db":"\([^"]*\)".*/\1/') + local file_name + file_name=$(echo "$result" | sed 's/.*"file":"\([^"]*\)".*/\1/') + local size + size=$(echo "$result" | sed 's/.*"size_bytes":\([0-9]*\).*/\1/') + databases_json+="\"${db_name}\":{\"file\":\"${file_name}\",\"size_bytes\":${size}}" + done + databases_json+="}" + + cat > "$STATUS_FILE" <