From 8e3d3df1eca80b4052ec3e3764c6468d1a882450 Mon Sep 17 00:00:00 2001 From: sanghun Date: Fri, 13 Feb 2026 05:14:25 +0900 Subject: [PATCH 1/7] refactor: rewrite k6/MQTT load test scripts with hypothesis-driven scenarios - k6 HTTP: 4 scenarios (dashboard_polling, admin_ops, mixed_workload, spike_resilience) with infrastructure-derived thresholds - MQTT pipeline: 3 scenarios (normal, rush_hour, burst) with PipelineVerifier using /api/v1/detections/statistics/ and RabbitMQ Management API for end-to-end verification --- docker/k6/load-test.js | 448 ++++++++++++++++++----- docker/k6/mqtt-load-test.py | 687 ++++++++++++++++++++++++++++++------ 2 files changed, 940 insertions(+), 195 deletions(-) diff --git a/docker/k6/load-test.js b/docker/k6/load-test.js index 999909b..c97a1d9 100644 --- a/docker/k6/load-test.js +++ b/docker/k6/load-test.js @@ -1,115 +1,379 @@ import http from 'k6/http'; import { check, group, sleep } from 'k6'; -import { Rate, Trend } from 'k6/metrics'; +import { Rate, Trend, Counter } from 'k6/metrics'; +import exec from 'k6/execution'; -// Custom metrics +// ============================================================ +// SpeedCam 부하테스트 - 실제 사용 패턴 기반 +// ============================================================ +// 인프라: 6x GCP e2-small (2 vCPU, 2 GB RAM) +// HTTP 처리: Gunicorn 2 workers × 2 threads = 4 동시 핸들러 +// 가설 기반 임계치 (load-test-plan.md 참조) +// ============================================================ + +// -- 커스텀 메트릭 -- +const dashboardLatency = new Trend('dashboard_req_duration', true); +const detectionsLatency = new Trend('detections_list_duration', true); +const statisticsLatency = new Trend('statistics_req_duration', true); +const pendingLatency = new Trend('pending_read_duration', true); +const adminLatency = new Trend('admin_req_duration', true); const errorRate = new Rate('errors'); -const vehicleCreateDuration = new Trend('vehicle_create_duration', true); +const requestCount = new Counter('total_requests'); const BASE_URL = __ENV.MAIN_SERVICE_URL || 'http://main:8000'; +// 한국 차량 번호판 생성 (예: 123가4567) +function randomPlate() { + const nums1 = Math.floor(Math.random() * 900) + 100; + const chars = '가나다라마바사아자차카타파하'; + const char = chars.charAt(Math.floor(Math.random() * chars.length)); + const nums2 = Math.floor(Math.random() * 9000) + 1000; + return `${nums1}${char}${nums2}`; +} + +// 가짜 FCM 토큰 생성 +function randomFCMToken() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let token = ''; + for (let i = 0; i < 152; i++) { + token += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return token; +} + +// ============================================================ +// setup: 테스트 데이터 사전 생성 (1회 실행) +// ============================================================ +export function setup() { + // 헬스 체크 + const healthRes = http.get(`${BASE_URL}/health/`); + const healthOk = check(healthRes, { + '서버 헬스체크 통과': (r) => r.status === 200, + }); + if (!healthOk) { + throw new Error(`서버 연결 실패: ${BASE_URL}`); + } + + // 테스트 차량 10대 생성 (반복 생성 방지) + const vehicles = []; + for (let i = 0; i < 10; i++) { + const plate = randomPlate(); + const payload = JSON.stringify({ + plate_number: plate, + owner_name: `테스트차량_${i}`, + owner_phone: `010-${Math.floor(Math.random() * 9000) + 1000}-${Math.floor(Math.random() * 9000) + 1000}`, + }); + + const res = http.post(`${BASE_URL}/api/v1/vehicles/`, payload, { + headers: { 'Content-Type': 'application/json' }, + }); + + if (res.status === 201) { + const body = res.json(); + vehicles.push({ id: body.id, plate_number: plate }); + } + } + + console.log(`[Setup] ${vehicles.length}대 테스트 차량 생성 완료`); + return { vehicles }; +} + +// ============================================================ +// 시나리오 옵션 +// ============================================================ +// 용량 기준: Gunicorn 2 workers × 2 threads = 4 동시 핸들러 +// 모든 임계치는 load-test-plan.md의 가설에서 도출 +// ============================================================ export const options = { - scenarios: { - // Scenario 1: Smoke test (basic connectivity) - smoke: { - executor: 'constant-vus', - vus: 1, - duration: '10s', - startTime: '0s', - tags: { scenario: 'smoke' }, - }, - // Scenario 2: Average load - average_load: { - executor: 'ramping-vus', - startVUs: 0, - stages: [ - { duration: '30s', target: 10 }, // ramp up - { duration: '1m', target: 10 }, // steady - { duration: '10s', target: 0 }, // ramp down - ], - startTime: '15s', - tags: { scenario: 'average_load' }, - }, - // Scenario 3: Spike test - spike: { - executor: 'ramping-vus', - startVUs: 0, - stages: [ - { duration: '5s', target: 30 }, // spike up - { duration: '15s', target: 30 }, // hold spike - { duration: '5s', target: 0 }, // recover - ], - startTime: '2m', - tags: { scenario: 'spike' }, - }, + scenarios: { + // 시나리오 A: 대시보드 폴링 (주요 읽기 부하) + // 사용자가 대시보드를 열어두고 주기적으로 데이터 확인 + // 예상: p95 < 200ms, 0% 에러 (4 핸들러 용량 범위 내) + dashboard_polling: { + executor: 'constant-vus', + vus: 3, + duration: '2m', + startTime: '0s', + exec: 'dashboardPolling', + tags: { scenario: 'dashboard_polling' }, }, - thresholds: { - http_req_duration: ['p(95)<500'], // 95% of requests under 500ms - errors: ['rate<0.1'], // error rate under 10% + + // 시나리오 B: 관리자 작업 (저빈도 쓰기) + // 관리자가 간헐적으로 차량 등록, FCM 토큰 업데이트 + // 예상: p95 < 300ms, 0% 에러 + admin_ops: { + executor: 'constant-arrival-rate', + rate: 2, + timeUnit: '1m', + duration: '2m', + preAllocatedVUs: 2, + startTime: '0s', + exec: 'adminOps', + tags: { scenario: 'admin_ops' }, + }, + + // 시나리오 C: 혼합 워크로드 (대시보드 + 관리자 + 파이프라인 읽기) + // 여러 유형의 요청이 동시 발생하는 현실적 패턴 + // 9 VUs, 4 핸들러 → 피크 시 경합 발생 예상 + // 예상: p95 < 500ms, < 1% 에러 + mixed_workload: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 5 }, + { duration: '30s', target: 9 }, + { duration: '1m', target: 9 }, + { duration: '30s', target: 0 }, + ], + startTime: '2m', + exec: 'mixedWorkload', + tags: { scenario: 'mixed_workload' }, }, + + // 시나리오 D: 스파이크 내성 (급격한 트래픽 증가) + // 15 VUs → 4 핸들러 대비 ~3.75배 초과 구독 + // 예상: p95 < 1500ms, < 10% 에러 + spike_resilience: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 3 }, + { duration: '10s', target: 15 }, + { duration: '30s', target: 15 }, + { duration: '10s', target: 3 }, + { duration: '10s', target: 0 }, + ], + startTime: '4m30s', + exec: 'spikeResilience', + tags: { scenario: 'spike_resilience' }, + }, + }, + + // 임계치: 4 핸들러 기준으로 보정 (GUNICORN_WORKERS=2) + thresholds: { + 'dashboard_req_duration': ['p(95)<200'], // 대시보드 읽기: 200ms 이내 + 'detections_list_duration': ['p(95)<300'], // 감지 목록: 300ms 이내 + 'statistics_req_duration': ['p(95)<500'], // 통계 집계: 500ms 이내 + 'pending_read_duration': ['p(95)<500'], // 대기 목록: 500ms 이내 (비페이지네이션) + 'admin_req_duration': ['p(95)<300'], // 관리자 쓰기: 300ms 이내 + 'http_req_duration{scenario:spike_resilience}': ['p(95)<1500'], // 스파이크: 1500ms 이내 + 'errors': ['rate<0.05'], // 전체 에러율: 5% 미만 + 'errors{scenario:dashboard_polling}': ['rate<0.01'], // 대시보드: 1% 미만 + 'errors{scenario:spike_resilience}': ['rate<0.10'], // 스파이크: 10% 미만 + }, }; -// Helper to generate random Korean plate number -function randomPlate() { - const nums1 = Math.floor(Math.random() * 900) + 100; - const chars = '가나다라마바사아자차카타파하'; - const char = chars.charAt(Math.floor(Math.random() * chars.length)); - const nums2 = Math.floor(Math.random() * 9000) + 1000; - return `${nums1}${char}${nums2}`; +// ============================================================ +// 시나리오 A: 대시보드 폴링 +// ============================================================ +// 실제 패턴: 감지 목록 5초, 알림 10초, 통계 30초 주기 폴링 +// iteration 카운트로 요청 유형 분산 +// ============================================================ +export function dashboardPolling() { + const iter = exec.scenario.iterationInTest; + + group('대시보드 폴링', () => { + // 매 iteration: 감지 목록 조회 (5초 주기) + const detectionsRes = http.get(`${BASE_URL}/api/v1/detections/`); + check(detectionsRes, { + '감지 목록 200': (r) => r.status === 200, + }); + errorRate.add(detectionsRes.status !== 200); + detectionsLatency.add(detectionsRes.timings.duration); + dashboardLatency.add(detectionsRes.timings.duration); + requestCount.add(1); + + // 2회마다: 알림 목록 조회 (10초 주기) + if (iter % 2 === 0) { + const notifRes = http.get(`${BASE_URL}/api/v1/notifications/`); + check(notifRes, { + '알림 목록 200': (r) => r.status === 200, + }); + errorRate.add(notifRes.status !== 200); + dashboardLatency.add(notifRes.timings.duration); + requestCount.add(1); + } + + // 6회마다: 통계 조회 (30초 주기) + if (iter % 6 === 0) { + const statsRes = http.get(`${BASE_URL}/api/v1/detections/statistics/`); + check(statsRes, { + '통계 조회 200': (r) => r.status === 200, + }); + errorRate.add(statsRes.status !== 200); + statisticsLatency.add(statsRes.timings.duration); + dashboardLatency.add(statsRes.timings.duration); + requestCount.add(1); + } + }); + + sleep(5); // 5초 폴링 주기 } -export default function () { - group('Health Check', function () { - const res = http.get(`${BASE_URL}/health/`); - check(res, { - 'health status 200': (r) => r.status === 200, - 'health is healthy': (r) => r.json('status') === 'healthy', - }); - errorRate.add(res.status !== 200); +// ============================================================ +// 시나리오 B: 관리자 작업 +// ============================================================ +// 저빈도: 차량 등록 + FCM 토큰 업데이트 +// constant-arrival-rate로 분당 2회 발생 +// ============================================================ +export function adminOps(data) { + group('관리자 작업', () => { + // 차량 등록 + const plate = randomPlate(); + const createPayload = JSON.stringify({ + plate_number: plate, + owner_name: `부하테스트_${Date.now()}`, + owner_phone: `010-${Math.floor(Math.random() * 9000) + 1000}-${Math.floor(Math.random() * 9000) + 1000}`, }); - group('Vehicle CRUD', function () { - // Create - const plate = randomPlate(); - const createPayload = JSON.stringify({ - plate_number: plate, - owner_name: `테스트유저_${__VU}`, - owner_phone: `010-${Math.floor(Math.random() * 9000) + 1000}-${Math.floor(Math.random() * 9000) + 1000}`, - }); - - const createRes = http.post(`${BASE_URL}/api/v1/vehicles/`, createPayload, { - headers: { 'Content-Type': 'application/json' }, - }); - - check(createRes, { - 'vehicle created 201': (r) => r.status === 201, - }); - errorRate.add(createRes.status !== 201); - vehicleCreateDuration.add(createRes.timings.duration); - - // List - const listRes = http.get(`${BASE_URL}/api/v1/vehicles/`); - check(listRes, { - 'vehicle list 200': (r) => r.status === 200, - }); - errorRate.add(listRes.status !== 200); + const createRes = http.post(`${BASE_URL}/api/v1/vehicles/`, createPayload, { + headers: { 'Content-Type': 'application/json' }, + }); + check(createRes, { + '차량 등록 201': (r) => r.status === 201, }); + errorRate.add(createRes.status !== 201); + adminLatency.add(createRes.timings.duration); + requestCount.add(1); + + // FCM 토큰 업데이트 (setup에서 생성한 차량 사용) + if (data.vehicles && data.vehicles.length > 0) { + const vehicle = data.vehicles[Math.floor(Math.random() * data.vehicles.length)]; + const tokenPayload = JSON.stringify({ + fcm_token: randomFCMToken(), + }); - group('Detections Read', function () { - const res = http.get(`${BASE_URL}/api/v1/detections/`); - check(res, { - 'detections list 200': (r) => r.status === 200, - }); - errorRate.add(res.status !== 200); + const tokenRes = http.patch( + `${BASE_URL}/api/v1/vehicles/${vehicle.id}/fcm-token/`, + tokenPayload, + { headers: { 'Content-Type': 'application/json' } } + ); + check(tokenRes, { + 'FCM 토큰 업데이트 200': (r) => r.status === 200, + }); + errorRate.add(tokenRes.status !== 200); + adminLatency.add(tokenRes.timings.duration); + requestCount.add(1); + } + }); +} + +// ============================================================ +// 시나리오 C: 혼합 워크로드 +// ============================================================ +// 60% 읽기 (감지, 알림, 통계) +// 30% 파이프라인 상태 확인 (/pending/) +// 10% 관리자 쓰기 +// 9 VUs → 4 핸들러, 피크 시 경합 발생 예상 +// ============================================================ +export function mixedWorkload(data) { + const rand = Math.random(); + + if (rand < 0.6) { + // 60%: 대시보드 읽기 + group('혼합 - 읽기', () => { + const endpoints = [ + '/api/v1/detections/', + '/api/v1/notifications/', + '/api/v1/detections/statistics/', + ]; + const endpoint = endpoints[Math.floor(Math.random() * endpoints.length)]; + const res = http.get(`${BASE_URL}${endpoint}`); + check(res, { + '혼합 읽기 200': (r) => r.status === 200, + }); + errorRate.add(res.status !== 200); + dashboardLatency.add(res.timings.duration); + + if (endpoint.includes('statistics')) { + statisticsLatency.add(res.timings.duration); + } else if (endpoint.includes('detections')) { + detectionsLatency.add(res.timings.duration); + } + requestCount.add(1); + }); + } else if (rand < 0.9) { + // 30%: 파이프라인 상태 확인 (pending 감지 목록) + group('혼합 - 파이프라인 상태', () => { + const res = http.get(`${BASE_URL}/api/v1/detections/pending/`); + check(res, { + '대기 목록 200': (r) => r.status === 200, + }); + errorRate.add(res.status !== 200); + pendingLatency.add(res.timings.duration); + requestCount.add(1); }); + } else { + // 10%: 관리자 쓰기 + group('혼합 - 쓰기', () => { + const plate = randomPlate(); + const payload = JSON.stringify({ + plate_number: plate, + owner_name: `혼합테스트_${Date.now()}`, + owner_phone: `010-${Math.floor(Math.random() * 9000) + 1000}-${Math.floor(Math.random() * 9000) + 1000}`, + }); - group('Notifications Read', function () { - const res = http.get(`${BASE_URL}/api/v1/notifications/`); - check(res, { - 'notifications list 200': (r) => r.status === 200, - }); - errorRate.add(res.status !== 200); + const res = http.post(`${BASE_URL}/api/v1/vehicles/`, payload, { + headers: { 'Content-Type': 'application/json' }, + }); + check(res, { + '혼합 차량 등록': (r) => r.status === 201, + }); + errorRate.add(res.status !== 201); + adminLatency.add(res.timings.duration); + requestCount.add(1); }); + } + + sleep(Math.random() * 3 + 2); // 2-5초 랜덤 간격 +} + +// ============================================================ +// 시나리오 D: 스파이크 내성 +// ============================================================ +// 15 VUs → 4 핸들러 = ~3.75배 초과 구독 +// 심각한 요청 큐잉 예상, 핸들러 포화 + MySQL 연결 폭주 +// ============================================================ +export function spikeResilience() { + group('스파이크 내성', () => { + // 대시보드 읽기 엔드포인트를 빠르게 반복 요청 + const detectionsRes = http.get(`${BASE_URL}/api/v1/detections/`); + check(detectionsRes, { + '스파이크 감지 목록 200': (r) => r.status === 200, + }); + errorRate.add(detectionsRes.status !== 200); + detectionsLatency.add(detectionsRes.timings.duration); + requestCount.add(1); + + const notifRes = http.get(`${BASE_URL}/api/v1/notifications/`); + check(notifRes, { + '스파이크 알림 목록 200': (r) => r.status === 200, + }); + errorRate.add(notifRes.status !== 200); + requestCount.add(1); + + const statsRes = http.get(`${BASE_URL}/api/v1/detections/statistics/`); + check(statsRes, { + '스파이크 통계 200': (r) => r.status === 200, + }); + errorRate.add(statsRes.status !== 200); + statisticsLatency.add(statsRes.timings.duration); + requestCount.add(1); + }); + + sleep(1); // 스파이크 시 빠른 요청 (1초 간격) +} - sleep(1); +// ============================================================ +// teardown: 테스트 데이터 정리 (선택적) +// ============================================================ +export function teardown(data) { + if (data.vehicles) { + let deleted = 0; + for (const vehicle of data.vehicles) { + const res = http.del(`${BASE_URL}/api/v1/vehicles/${vehicle.id}/`); + if (res.status === 204) deleted++; + } + console.log(`[Teardown] ${deleted}/${data.vehicles.length}대 테스트 차량 삭제 완료`); + } } diff --git a/docker/k6/mqtt-load-test.py b/docker/k6/mqtt-load-test.py index 89b836e..b56cf0c 100644 --- a/docker/k6/mqtt-load-test.py +++ b/docker/k6/mqtt-load-test.py @@ -1,29 +1,93 @@ #!/usr/bin/env python3 """ -MQTT Load Test - IoT Device Simulation +MQTT 파이프라인 부하테스트 - IoT 카메라 시뮬레이션 -Simulates Raspberry Pi cameras sending detection messages via MQTT. -Full pipeline: MQTT → Detection (pending) → OCR Worker → Alert Worker +실제 사용 패턴 기반 시나리오: + - normal: 정상 운영 (20대 카메라, 1건/분) + - rush_hour: 러시아워 (20대 카메라, 5건/분) + - burst: 버스트 스톰 (20대 카메라, 1건/초) + +파이프라인 검증: + MQTT 발행 → Detection(pending) → OCR Worker → Alert Worker → 완료 + +인프라 기준: 6x GCP e2-small (2 vCPU, 2 GB RAM) +가설 기반 검증 (load-test-plan.md 참조) """ import argparse import json import os import random +import signal +import sys import threading import time from datetime import datetime, timedelta, timezone -import paho.mqtt.client as mqtt - -# Config from environment -MQTT_HOST = os.getenv("MQTT_HOST", "rabbitmq") -MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) -MQTT_USER = os.getenv("MQTT_USER", "sa") -MQTT_PASS = os.getenv("MQTT_PASS", "1234") -TOPIC = "detections/new" +try: + import paho.mqtt.client as mqtt +except ImportError: + print("ERROR: paho-mqtt 패키지가 필요합니다. 설치: pip3 install paho-mqtt") + sys.exit(1) + +try: + import requests +except ImportError: + requests = None + print("WARNING: requests 패키지 없음. 파이프라인 검증 비활성화 (pip3 install requests)") + +# ============================================================ +# 시나리오 정의 +# ============================================================ +SCENARIOS = { + 'normal': { + 'description': '정상 운영: 20대 카메라, 1건/분 (0.33 msg/s)', + 'workers': 20, + 'rate_per_worker': 1 / 60, # 분당 1건 + 'duration': 120, + 'expected_total': 40, + 'hypothesis': { + 'publish_success': '100%', + 'completion_rate': '100%', + 'completion_time': '60초 이내', + 'peak_ocr_queue': '< 5', + 'dlq_messages': '0', + 'bottleneck': '없음', + }, + }, + 'rush_hour': { + 'description': '러시아워: 20대 카메라, 5건/분 (1.67 msg/s)', + 'workers': 20, + 'rate_per_worker': 5 / 60, # 분당 5건 + 'duration': 120, + 'expected_total': 200, + 'hypothesis': { + 'publish_success': '100%', + 'completion_rate': '95%+ (120초 이내)', + 'completion_time': '120초 이내', + 'peak_ocr_queue': '< 50', + 'dlq_messages': '0', + 'bottleneck': 'OCR worker (mock 느린 경우)', + }, + }, + 'burst': { + 'description': '버스트 스톰: 20대 카메라, 1건/초 (20 msg/s)', + 'workers': 20, + 'rate_per_worker': 1.0, # 초당 1건 + 'duration': 60, + 'expected_total': 1200, + 'hypothesis': { + 'publish_success': '100%', + 'completion_rate': '100% (드레인 후)', + 'completion_time': '300초 이내', + 'peak_ocr_queue': '200-500', + 'dlq_messages': '0', + 'bottleneck': 'MQTT Subscriber (단일 스레드) → OCR 큐 깊이', + }, + }, +} -# Locations for realistic simulation +# 카메라 위치 데이터 (시뮬레이션용) LOCATIONS = [ "서울시 강남구 테헤란로", "서울시 서초구 반포대로", @@ -33,75 +97,251 @@ "서울시 마포구 월드컵북로", "서울시 영등포구 여의대방로", "부산시 해운대구 해운대로", + "대구시 수성구 동대구로", + "광주시 서구 상무대로", ] CAMERA_IDS = [f"CAM-{str(i).zfill(3)}" for i in range(1, 21)] +TOPIC = "detections/new" + +# 종료 플래그 +shutdown_event = threading.Event() + + +# ============================================================ +# 파이프라인 검증기 +# ============================================================ +class PipelineVerifier: + """파이프라인 완료 검증 - /api/v1/detections/statistics/ 활용""" + + def __init__(self, api_base_url, rabbitmq_api_url=None, + rabbitmq_user='sa', rabbitmq_pass=''): + self.api_base_url = api_base_url.rstrip('/') + self.rabbitmq_api_url = rabbitmq_api_url.rstrip('/') if rabbitmq_api_url else None + self.rabbitmq_auth = (rabbitmq_user, rabbitmq_pass) + self.available = requests is not None + self.peak_ocr_queue = 0 + self.peak_fcm_queue = 0 + + def get_detection_stats(self): + """GET /api/v1/detections/statistics/ - 집계 통계 조회 + + Returns: + dict: {total_detections, completed_count, failed_count, + pending_count, avg_speed, max_speed} + """ + if not self.available: + return None + try: + resp = requests.get( + f"{self.api_base_url}/api/v1/detections/statistics/", + timeout=10 + ) + resp.raise_for_status() + return resp.json() + except Exception as e: + print(f" [검증] 통계 조회 실패: {e}") + return None + + def get_queue_depth(self): + """RabbitMQ Management API로 큐 깊이 조회 (HTTP Basic Auth)""" + if not self.available or not self.rabbitmq_api_url: + return {} + try: + resp = requests.get( + f"{self.rabbitmq_api_url}/api/queues/%2F", + auth=self.rabbitmq_auth, + timeout=5 + ) + resp.raise_for_status() + queues = resp.json() + result = {} + for q in queues: + name = q.get('name', '') + if name in ('ocr_queue', 'fcm_queue', 'dlq_queue'): + depth = q.get('messages', 0) + result[name] = depth + return result + except Exception as e: + print(f" [검증] 큐 깊이 조회 실패: {e}") + return {} + + def wait_for_completion(self, expected_count, baseline_stats, + timeout=300, poll_interval=5): + """파이프라인 완료 대기 - /api/v1/detections/statistics/ 폴링 + + baseline_stats와의 차이로 이번 테스트의 신규 감지만 카운트 + """ + if not self.available or baseline_stats is None: + return None + + baseline_completed = baseline_stats.get('completed_count', 0) + baseline_failed = baseline_stats.get('failed_count', 0) + + start_time = time.time() + last_print = 0 + + print(f"\n [파이프라인 검증] {expected_count}건 완료 대기 중 (타임아웃: {timeout}초)") + + while time.time() - start_time < timeout: + if shutdown_event.is_set(): + break + + current = self.get_detection_stats() + if current is None: + time.sleep(poll_interval) + continue + + new_completed = current.get('completed_count', 0) - baseline_completed + new_failed = current.get('failed_count', 0) - baseline_failed + new_done = new_completed + new_failed + new_pending = max(0, expected_count - new_done) + + # 큐 깊이 추적 + queue_depth = self.get_queue_depth() + ocr_depth = queue_depth.get('ocr_queue', 0) + fcm_depth = queue_depth.get('fcm_queue', 0) + self.peak_ocr_queue = max(self.peak_ocr_queue, ocr_depth) + self.peak_fcm_queue = max(self.peak_fcm_queue, fcm_depth) + + elapsed = time.time() - start_time + if elapsed - last_print >= 10: + print(f" [{elapsed:.0f}s] 완료: {new_completed} | 실패: {new_failed} | " + f"대기: {new_pending} | OCR큐: {ocr_depth} | FCM큐: {fcm_depth}") + last_print = elapsed + + if new_done >= expected_count: + completion_time = time.time() - start_time + return { + 'completed': new_completed, + 'failed': new_failed, + 'pending': new_pending, + 'completion_time_s': round(completion_time, 1), + 'peak_ocr_queue': self.peak_ocr_queue, + 'peak_fcm_queue': self.peak_fcm_queue, + 'dlq_messages': queue_depth.get('dlq_queue', 0), + } + + time.sleep(poll_interval) + + # 타임아웃 + current = self.get_detection_stats() + final_completed = (current.get('completed_count', 0) - baseline_completed) if current else 0 + final_failed = (current.get('failed_count', 0) - baseline_failed) if current else 0 + + return { + 'completed': final_completed, + 'failed': final_failed, + 'pending': expected_count - final_completed - final_failed, + 'completion_time_s': timeout, + 'peak_ocr_queue': self.peak_ocr_queue, + 'peak_fcm_queue': self.peak_fcm_queue, + 'dlq_messages': self.get_queue_depth().get('dlq_queue', 0), + 'timed_out': True, + } -# Stats -stats = { - "published": 0, - "failed": 0, - "total_latency_ms": 0, - "start_time": None, -} -stats_lock = threading.Lock() +# ============================================================ +# MQTT 메시지 생성 +# ============================================================ +GCS_BUCKET = os.getenv('GCS_BUCKET', 'speedcam-bucket-4f918446') +REAL_IMAGES = [f"real-plate-{str(i).zfill(2)}.jpg" for i in range(1, 11)] +_image_counter = 0 +_image_lock = threading.Lock() -def generate_message(): - """Generate a realistic detection message.""" + +def generate_message(camera_id=None): + """실제 카메라 감지 메시지 생성 (실제 GCS 이미지 사용)""" + global _image_counter kst = timezone(timedelta(hours=9)) speed_limit = random.choice([60.0, 80.0, 100.0, 110.0]) detected_speed = speed_limit + random.uniform(5, 50) - return json.dumps( - { - "camera_id": random.choice(CAMERA_IDS), - "location": random.choice(LOCATIONS), - "detected_speed": round(detected_speed, 1), - "speed_limit": speed_limit, - "detected_at": datetime.now(kst).isoformat(), - "image_gcs_uri": ( - f"gs://speedcam-bucket/detections/" - f"{int(time.time() * 1000)}-{random.randint(1000, 9999)}.jpg" - ), - } - ) - + with _image_lock: + image_file = REAL_IMAGES[_image_counter % len(REAL_IMAGES)] + _image_counter += 1 + + return json.dumps({ + "camera_id": camera_id or random.choice(CAMERA_IDS), + "location": random.choice(LOCATIONS), + "detected_speed": round(detected_speed, 1), + "speed_limit": speed_limit, + "detected_at": datetime.now(kst).isoformat(), + "image_gcs_uri": f"gs://{GCS_BUCKET}/detections/{image_file}", + }) + + +# ============================================================ +# 발행 워커 +# ============================================================ +class PublishStats: + """스레드 안전한 발행 통계""" + + def __init__(self): + self.published = 0 + self.failed = 0 + self.total_latency_ms = 0 + self.start_time = None + self._lock = threading.Lock() + + def record_success(self, latency_ms): + with self._lock: + self.published += 1 + self.total_latency_ms += latency_ms + + def record_failure(self): + with self._lock: + self.failed += 1 + + @property + def summary(self): + with self._lock: + elapsed = time.time() - self.start_time if self.start_time else 0 + total = self.published + self.failed + return { + 'published': self.published, + 'failed': self.failed, + 'total': total, + 'elapsed_s': round(elapsed, 1), + 'rate_per_s': round(self.published / elapsed, 2) if elapsed > 0 else 0, + 'avg_latency_ms': round(self.total_latency_ms / self.published, 2) if self.published > 0 else 0, + 'error_rate': round(self.failed / total * 100, 2) if total > 0 else 0, + } + + +def publish_worker(worker_id, mqtt_host, mqtt_port, mqtt_user, mqtt_pass, + rate_per_sec, duration_sec, stats): + """단일 카메라 시뮬레이션 워커""" + camera_id = CAMERA_IDS[worker_id % len(CAMERA_IDS)] -def publish_worker(worker_id, rate_per_sec, duration_sec): - """Single worker thread that publishes MQTT messages.""" client = mqtt.Client( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv311, client_id=f"loadtest-{worker_id}-{os.getpid()}", ) - client.username_pw_set(MQTT_USER, MQTT_PASS) + client.username_pw_set(mqtt_user, mqtt_pass) try: - client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) + client.connect(mqtt_host, mqtt_port, keepalive=60) client.loop_start() except Exception as e: - print(f"[Worker-{worker_id}] Connection failed: {e}") - with stats_lock: - stats["failed"] += 1 + print(f" [Worker-{worker_id}] 연결 실패: {e}") + stats.record_failure() return interval = 1.0 / rate_per_sec if rate_per_sec > 0 else 1.0 end_time = time.time() + duration_sec - while time.time() < end_time: - msg = generate_message() + while time.time() < end_time and not shutdown_event.is_set(): + msg = generate_message(camera_id) start = time.time() result = client.publish(TOPIC, msg, qos=1) if result.rc == mqtt.MQTT_ERR_SUCCESS: latency_ms = (time.time() - start) * 1000 - with stats_lock: - stats["published"] += 1 - stats["total_latency_ms"] += latency_ms + stats.record_success(latency_ms) else: - with stats_lock: - stats["failed"] += 1 + stats.record_failure() elapsed = time.time() - start sleep_time = max(0, interval - elapsed) @@ -112,72 +352,313 @@ def publish_worker(worker_id, rate_per_sec, duration_sec): client.disconnect() -def print_stats(): - """Print periodic stats.""" - elapsed = time.time() - stats["start_time"] - published = stats["published"] - failed = stats["failed"] - total = published + failed - rate = published / elapsed if elapsed > 0 else 0 - avg_latency = stats["total_latency_ms"] / published if published > 0 else 0 - - print(f"\n{'='*60}") - print(f" Elapsed: {elapsed:.1f}s | Published: {published} | Failed: {failed}") - print(f" Rate: {rate:.1f} msg/s | Avg Latency: {avg_latency:.2f}ms") - print(f" Error Rate: {(failed/total*100) if total > 0 else 0:.2f}%") - print(f"{'='*60}") - - -def run_load_test(workers, rate_per_worker, duration): - """Run the load test with multiple workers.""" - print("\n MQTT Load Test Starting") - print(f" Host: {MQTT_HOST}:{MQTT_PORT}") - print(f" Workers: {workers}") - print( - f" Rate: {rate_per_worker}/s per worker ({workers * rate_per_worker}/s total)" +# ============================================================ +# 메인 테스트 오케스트레이터 +# ============================================================ +class MQTTLoadTest: + """MQTT 부하테스트 + 파이프라인 검증 오케스트레이터""" + + def __init__(self, scenario_name, mqtt_host, mqtt_port, mqtt_user, mqtt_pass, + api_url=None, rabbitmq_api=None, rabbitmq_user='sa', rabbitmq_pass='', + custom_workers=None, custom_rate=None, custom_duration=None): + if scenario_name == 'custom': + self.scenario = { + 'description': f'커스텀: {custom_workers} workers, {custom_rate}/s, {custom_duration}s', + 'workers': custom_workers or 5, + 'rate_per_worker': custom_rate or 2, + 'duration': custom_duration or 60, + 'expected_total': int((custom_workers or 5) * (custom_rate or 2) * (custom_duration or 60)), + 'hypothesis': { + 'publish_success': '-', + 'completion_rate': '-', + 'completion_time': '-', + 'peak_ocr_queue': '-', + 'dlq_messages': '-', + 'bottleneck': '(커스텀 시나리오)', + }, + } + self.scenario_name = 'custom' + else: + self.scenario = SCENARIOS[scenario_name] + self.scenario_name = scenario_name + + self.mqtt_host = mqtt_host + self.mqtt_port = mqtt_port + self.mqtt_user = mqtt_user + self.mqtt_pass = mqtt_pass + self.stats = PublishStats() + + # 파이프라인 검증 (API 접근 가능 시) + if api_url and requests: + self.verifier = PipelineVerifier( + api_url, rabbitmq_api, rabbitmq_user, rabbitmq_pass + ) + else: + self.verifier = None + + def run(self): + """테스트 실행: 발행 → 검증 → 결과 출력""" + scenario = self.scenario + + print("\n" + "=" * 70) + print(f" MQTT 부하테스트: {scenario['description']}") + print("=" * 70) + print(f" 호스트: {self.mqtt_host}:{self.mqtt_port}") + print(f" 워커 수: {scenario['workers']}") + rate_total = scenario['workers'] * scenario['rate_per_worker'] + print(f" 발행 속도: {scenario['rate_per_worker']:.4f}/s/worker ({rate_total:.2f}/s 총)") + print(f" 지속 시간: {scenario['duration']}초") + print(f" 예상 총 메시지: {scenario['expected_total']}건") + print(f" 파이프라인 검증: {'활성' if self.verifier else '비활성'}") + print("=" * 70) + + # Phase 1: 기준선 기록 + baseline = None + if self.verifier: + baseline = self.verifier.get_detection_stats() + if baseline: + print(f"\n [기준선] 현재 통계 - " + f"total: {baseline.get('total_detections', 0)}, " + f"completed: {baseline.get('completed_count', 0)}, " + f"failed: {baseline.get('failed_count', 0)}, " + f"pending: {baseline.get('pending_count', 0)}") + else: + print("\n [기준선] 통계 조회 실패 - 파이프라인 검증 건너뜀") + + # Phase 2: MQTT 발행 + print(f"\n [발행 시작] {scenario['workers']}개 워커 실행 중...") + self.stats.start_time = time.time() + threads = [] + + for i in range(scenario['workers']): + t = threading.Thread( + target=publish_worker, + args=(i, self.mqtt_host, self.mqtt_port, + self.mqtt_user, self.mqtt_pass, + scenario['rate_per_worker'], scenario['duration'], + self.stats), + daemon=True, + ) + t.start() + threads.append(t) + + # 발행 중 주기적 상태 출력 + monitor_end = time.time() + scenario['duration'] + while time.time() < monitor_end and not shutdown_event.is_set(): + time.sleep(5) + s = self.stats.summary + print(f" [{s['elapsed_s']}s] 발행: {s['published']} | " + f"실패: {s['failed']} | 속도: {s['rate_per_s']} msg/s") + + for t in threads: + t.join(timeout=10) + + publish_summary = self.stats.summary + print(f"\n [발행 완료] {publish_summary['published']}건 발행, " + f"{publish_summary['failed']}건 실패") + + # Phase 3: 파이프라인 검증 + pipeline_result = None + if self.verifier and baseline: + pipeline_result = self.verifier.wait_for_completion( + expected_count=scenario['expected_total'], + baseline_stats=baseline, + timeout=300, + poll_interval=5, + ) + + # Phase 4: 결과 출력 + self._print_results(publish_summary, pipeline_result) + + def _print_results(self, publish_summary, pipeline_result): + """구조화된 결과 출력 + 가설 비교 템플릿""" + hypothesis = self.scenario.get('hypothesis', {}) + + print("\n") + print("=" * 70) + print(f" 결과: {self.scenario['description']}") + print("=" * 70) + + # 발행 결과 + print("\n [발행 결과]") + print(f" 발행 성공: {publish_summary['published']}건") + print(f" 발행 실패: {publish_summary['failed']}건") + print(f" 발행 속도: {publish_summary['rate_per_s']} msg/s") + print(f" 평균 지연: {publish_summary['avg_latency_ms']}ms") + print(f" 에러율: {publish_summary['error_rate']}%") + + # 파이프라인 결과 + if pipeline_result: + total_done = pipeline_result['completed'] + pipeline_result['failed'] + expected = self.scenario['expected_total'] + completion_pct = round(total_done / expected * 100, 1) if expected > 0 else 0 + + print("\n [파이프라인 완료]") + print(f" 완료: {pipeline_result['completed']}/{expected} ({completion_pct}%)") + print(f" 실패: {pipeline_result['failed']}건") + print(f" 대기 중: {pipeline_result['pending']}건") + print(f" E2E 소요: {pipeline_result['completion_time_s']}초" + + (" (타임아웃)" if pipeline_result.get('timed_out') else "")) + print(f" OCR 큐 피크: {pipeline_result['peak_ocr_queue']}") + print(f" FCM 큐 피크: {pipeline_result['peak_fcm_queue']}") + print(f" DLQ 메시지: {pipeline_result.get('dlq_messages', '-')}") + else: + print("\n [파이프라인 검증] 비활성 (API URL 미설정 또는 requests 패키지 없음)") + + # 가설 비교 템플릿 + actual_success = f"{100 - publish_summary['error_rate']:.1f}%" + actual_completion = '-' + actual_e2e = '-' + actual_ocr_peak = '-' + actual_dlq = '-' + + if pipeline_result: + expected = self.scenario['expected_total'] + total_done = pipeline_result['completed'] + pipeline_result['failed'] + actual_completion = f"{round(total_done / expected * 100, 1) if expected > 0 else 0}%" + actual_e2e = f"{pipeline_result['completion_time_s']}초" + actual_ocr_peak = str(pipeline_result['peak_ocr_queue']) + actual_dlq = str(pipeline_result.get('dlq_messages', '-')) + + print("\n [가설 비교]") + print(f" {'지표':<25} {'가설':<20} {'실제':<15} {'판정'}") + print(f" {'-'*75}") + print(f" {'발행 성공률':<23} {hypothesis.get('publish_success', '-'):<20} {actual_success:<15}") + print(f" {'파이프라인 완료율':<20} {hypothesis.get('completion_rate', '-'):<20} {actual_completion:<15}") + print(f" {'E2E 완료 시간':<21} {hypothesis.get('completion_time', '-'):<20} {actual_e2e:<15}") + print(f" {'OCR 큐 피크':<22} {hypothesis.get('peak_ocr_queue', '-'):<20} {actual_ocr_peak:<15}") + print(f" {'DLQ 메시지':<23} {hypothesis.get('dlq_messages', '-'):<20} {actual_dlq:<15}") + print(f" {'예상 병목':<23} {hypothesis.get('bottleneck', '-')}") + + print("\n [병목 분석]") + print(" Grafana/Prometheus 메트릭을 확인하여 아래 항목을 채우세요:") + print(" - Gunicorn worker 포화 여부:") + print(" - MySQL 연결 수:") + print(" - OCR Worker 처리 속도:") + print(" - MQTT Subscriber 처리 지연:") + + print("\n" + "=" * 70) + + +# ============================================================ +# 시그널 핸들러 (graceful shutdown) +# ============================================================ +def signal_handler(signum, frame): + print(f"\n [종료 신호 수신] 워커 중단 중...") + shutdown_event.set() + + +# ============================================================ +# CLI 인터페이스 +# ============================================================ +def main(): + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + parser = argparse.ArgumentParser( + description="MQTT 파이프라인 부하테스트 - IoT 카메라 시뮬레이션", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +시나리오 설명: + normal 정상 운영: 20대 카메라, 1건/분 (0.33 msg/s) + rush_hour 러시아워: 20대 카메라, 5건/분 (1.67 msg/s) + burst 버스트 스톰: 20대 카메라, 1건/초 (20 msg/s) + +사용 예시: + # 정상 운영 시나리오 (파이프라인 검증 포함) + python3 mqtt-load-test.py --scenario normal \\ + --api-url http://speedcam-app:8000 \\ + --rabbitmq-api http://speedcam-mq:15672 \\ + --rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS + + # 커스텀 설정 (하위 호환) + python3 mqtt-load-test.py --workers 10 --rate 5 --duration 30 + """ ) - print(f" Duration: {duration}s") - print(f" Topic: {TOPIC}") - print() - - stats["start_time"] = time.time() - threads = [] - - for i in range(workers): - t = threading.Thread( - target=publish_worker, - args=(i, rate_per_worker, duration), - ) - t.start() - threads.append(t) - # Print stats periodically - monitor_end = time.time() + duration - while time.time() < monitor_end: - time.sleep(5) - print_stats() - - for t in threads: - t.join(timeout=10) + # 시나리오 선택 + parser.add_argument( + '--scenario', choices=['normal', 'rush_hour', 'burst'], + default=None, help='사전 정의 시나리오 (normal/rush_hour/burst)' + ) - print("\n FINAL RESULTS") - print_stats() + # MQTT 설정 + parser.add_argument( + '--mqtt-host', default=os.getenv('MQTT_HOST', 'rabbitmq'), + help='MQTT 브로커 호스트 (기본: MQTT_HOST 환경변수 또는 rabbitmq)' + ) + parser.add_argument( + '--mqtt-port', type=int, default=int(os.getenv('MQTT_PORT', '1883')), + help='MQTT 브로커 포트 (기본: 1883)' + ) + parser.add_argument( + '--mqtt-user', default=os.getenv('MQTT_USER', 'sa'), + help='MQTT 사용자 (기본: sa)' + ) + parser.add_argument( + '--mqtt-pass', default=os.getenv('MQTT_PASS', ''), + help='MQTT 비밀번호 (기본: MQTT_PASS 환경변수)' + ) + # 파이프라인 검증 + parser.add_argument( + '--api-url', default=None, + help='SpeedCam API URL (예: http://speedcam-app:8000). 설정 시 파이프라인 검증 활성화' + ) + parser.add_argument( + '--rabbitmq-api', default=None, + help='RabbitMQ Management API URL (예: http://speedcam-mq:15672)' + ) + parser.add_argument( + '--rabbitmq-user', default=os.getenv('RABBITMQ_USER', 'sa'), + help='RabbitMQ Management API 사용자 (기본: sa)' + ) + parser.add_argument( + '--rabbitmq-pass', default=os.getenv('RABBITMQ_PASS', ''), + help='RabbitMQ Management API 비밀번호 (기본: RABBITMQ_PASS 환경변수)' + ) -def main(): - parser = argparse.ArgumentParser(description="MQTT Load Test") + # 커스텀 설정 (하위 호환) parser.add_argument( - "--workers", type=int, default=5, help="Number of concurrent workers" + '--workers', type=int, default=None, + help='워커 수 (--scenario 미지정 시 사용, 기본: 5)' ) parser.add_argument( - "--rate", type=int, default=2, help="Messages per second per worker" + '--rate', type=float, default=None, + help='워커당 초당 발행 수 (--scenario 미지정 시 사용, 기본: 2)' ) parser.add_argument( - "--duration", type=int, default=60, help="Test duration in seconds" + '--duration', type=int, default=None, + help='테스트 시간(초) (--scenario 미지정 시 사용, 기본: 60)' ) + args = parser.parse_args() - run_load_test(args.workers, args.rate, args.duration) + # 시나리오 결정: --scenario 지정 시 사용, 아니면 커스텀 + if args.scenario: + scenario_name = args.scenario + elif args.workers is not None or args.rate is not None or args.duration is not None: + scenario_name = 'custom' + else: + scenario_name = 'normal' + print(" [INFO] --scenario 미지정, 기본값 'normal' 사용") + + test = MQTTLoadTest( + scenario_name=scenario_name, + mqtt_host=args.mqtt_host, + mqtt_port=args.mqtt_port, + mqtt_user=args.mqtt_user, + mqtt_pass=args.mqtt_pass, + api_url=args.api_url, + rabbitmq_api=args.rabbitmq_api, + rabbitmq_user=args.rabbitmq_user, + rabbitmq_pass=args.rabbitmq_pass, + custom_workers=args.workers or 5, + custom_rate=args.rate or 2, + custom_duration=args.duration or 60, + ) + + test.run() if __name__ == "__main__": From 63db4921c46c504e8549d8430a5cc624ebf17662 Mon Sep 17 00:00:00 2001 From: sanghun Date: Fri, 13 Feb 2026 05:14:36 +0900 Subject: [PATCH 2/7] docs: add load test plan and performance analysis report - load-test-plan.md: hypothesis-driven test framework with 7 scenarios, capacity calculations, and execution guide - performance-analysis.md: comprehensive results from k6 HTTP (4 scenarios, all thresholds PASS) and MQTT pipeline (3 scenarios, OCR bottleneck identified at ~0.06 msg/s) --- docs/load-test-plan.md | 456 +++++++++++++++++ docs/performance-analysis.md | 936 +++++++++++++++++++++++++++++++++++ 2 files changed, 1392 insertions(+) create mode 100644 docs/load-test-plan.md create mode 100644 docs/performance-analysis.md diff --git a/docs/load-test-plan.md b/docs/load-test-plan.md new file mode 100644 index 0000000..561b39c --- /dev/null +++ b/docs/load-test-plan.md @@ -0,0 +1,456 @@ +# SpeedCam 부하 테스트 계획서 + +본 문서는 SpeedCam 프로젝트의 가설 기반 부하 테스트 계획을 제공합니다. + +--- + +## 1. 시스템 아키텍처 및 용량 분석 + +### 인프라 구성 +- 6개 GCP e2-small 인스턴스 (2 vCPU, 2 GB RAM 각각): + - speedcam-app: Django + Gunicorn + MQTT Subscriber + - speedcam-db: MySQL 8.0 + - speedcam-mq: RabbitMQ (MQTT plugin + AMQP) + - speedcam-ocr: Celery OCR Worker + - speedcam-alert: Celery Alert Worker + - speedcam-mon: Prometheus + Grafana + Loki + Jaeger + +### env.example vs 배포 환경 차이 + +| 변수 | env.example 기본값 | 실제 배포값 | 영향 | +|------|-------------------|------------|------| +| `GUNICORN_WORKERS` | 4 | **2** | HTTP 처리 용량 절반 (8 → 4 핸들러) | +| `OCR_CONCURRENCY` | 4 | 4 | 차이 없음 | +| `ALERT_CONCURRENCY` | 100 | 100 | 차이 없음 | + +> **주의: 본 문서의 모든 용량 계산은 실제 배포 환경 값 기준입니다.** + +### 용량 계산표 (배포 환경 기준) + +| 컴포넌트 | 용량 | 산출 근거 | +|---------|------|----------| +| HTTP 동시 처리 | **4 핸들러** | 2 workers × 2 threads (GUNICORN_WORKERS=2) | +| 이론상 최대 HTTP RPS | **~40-80** | 4 핸들러 × (10-20 req/s, 응답 50-100ms 기준) | +| MQTT Subscriber 처리량 | ~50-100 msg/s | 단일 스레드: JSON 파싱 + DB 쓰기 + AMQP 발행 (~10-20ms/건) | +| OCR 파이프라인 (mock) | ~8-40 tasks/s | 4 workers × (2-10 tasks/s, mock sleep 0.1-0.5s) | +| OCR 파이프라인 (실제) | ~0.4-2 tasks/s | 4 workers × (0.1-0.5 tasks/s, EasyOCR ~2-10s) | +| Alert 파이프라인 | ~500-2000 tasks/s | 100 gevent workers × (5-20 tasks/s, FCM mock 기준) | +| MySQL max_connections | 151 | MySQL 8.0 기본값 | +| 예상 DB 연결 수 (부하 시) | ~12-20 | Gunicorn(4) + OCR(4) + Alert(100, pooled) + MQTT(1) | + +### 데이터 흐름 + +``` +RaspPi MQTT publish (QoS 1) + → RabbitMQ MQTT plugin → Django MQTT Subscriber (단일 스레드, loop_forever) + → Detection.create(status=pending) [detections_db] + → process_ocr.apply_async(queue=ocr_queue, priority=5) + → OCR Worker: GCS 다운로드 → EasyOCR → 차량 매칭 [vehicles_db] + → Detection.update(status=completed) [detections_db] + → send_notification.apply_async(queue=fcm_queue) + → Alert Worker: FCM topic 브로드캐스트 + 개별 푸시 + → Notification.create() [notifications_db] +``` + +### 병목 예측 순위 +1. **MQTT Subscriber** - 단일 스레드, 동기 DB 쓰기 (커넥션 풀링 없음) +2. **OCR Worker** - CPU 바운드, 4개 동시 처리 한정 +3. **Gunicorn** - 4 핸들러, DB 연결 오버헤드 +4. **MySQL** - 커넥션 풀링 없음, 부하 시 연결 폭주 + +### 사용 API 엔드포인트 + +| 엔드포인트 | 설명 | 비고 | +|-----------|------|------| +| `GET /api/v1/detections/` | 감지 목록 (페이지네이션, PAGE_SIZE=20) | 대시보드 폴링 | +| `GET /api/v1/detections/pending/` | 대기/처리 중 감지 목록 (페이지네이션 없음) | 파이프라인 상태 확인 | +| `GET /api/v1/detections/statistics/` | 집계 통계 (total, completed, failed, pending, avg_speed, max_speed) | 파이프라인 완료 검증 | +| `GET /api/v1/notifications/` | 알림 목록 (페이지네이션) | 대시보드 폴링 | +| `POST /api/v1/vehicles/` | 차량 등록 | 관리자 작업 | +| `PATCH /api/v1/vehicles/{id}/fcm-token/` | FCM 토큰 업데이트 | 관리자 작업 | + +--- + +## 2. HTTP 테스트 시나리오 (k6) + +### 시나리오 A: 대시보드 폴링 (주요 읽기 부하) + +**설명:** 사용자가 대시보드를 열어놓고 주기적으로 데이터를 확인하는 패턴 + +**트래픽 패턴:** +- 3-5 VUs +- `GET /api/v1/detections/` 매 5초 +- `GET /api/v1/notifications/` 매 10초 +- `GET /api/v1/detections/statistics/` 매 30초 + +**가설:** +| 지표 | 예측값 | 근거 | +|------|--------|------| +| p95 응답 시간 | < 200ms | 단순 페이지네이션 읽기, MySQL 인덱스 스캔 | +| 에러율 | 0% | 4 핸들러 용량 내 | +| 최대 RPS | 2-3 | sleep 간격으로 실제 RPS 매우 낮음 | +| 예상 병목 | 없음 | 4 핸들러 용량 범위 내 | + +--- + +### 시나리오 B: 관리자 작업 (저빈도 쓰기) + +**설명:** 관리자가 간헐적으로 차량을 등록하고 FCM 토큰을 업데이트하는 패턴 + +**트래픽 패턴:** +- 1-2 VUs +- `POST /api/v1/vehicles/` 매 30초 +- `PATCH /api/v1/vehicles/{id}/fcm-token/` 매 60초 + +**가설:** +| 지표 | 예측값 | 근거 | +|------|--------|------| +| p95 응답 시간 | < 300ms | 저빈도 쓰기, 경합 최소 | +| 에러율 | 0% | 매우 낮은 부하 | +| 최대 RPS | < 0.1 | 30-60초 간격 | +| 예상 병목 | 없음 | - | + +--- + +### 시나리오 C: 혼합 워크로드 (대시보드 + 파이프라인 읽기) + +**설명:** 대시보드 폴링 + 관리자 작업 + 파이프라인 상태 확인이 동시에 발생하는 패턴 + +**트래픽 패턴:** +- 5 VUs 대시보드 폴링 + 1 VU 관리자 + 3 VU `/api/v1/detections/pending/` 조회 +- 총 9 VUs + +**가설:** +| 지표 | 예측값 | 근거 | +|------|--------|------| +| p95 응답 시간 | < 500ms | 9 VUs, sleep 간격으로 분산되나 피크 시 큐잉 발생 | +| 에러율 | < 1% | `/pending/`는 비페이지네이션, 응답 크기 증가 가능 | +| 최대 RPS | ~6-10 | sleep 간격으로 분산 | +| 예상 병목 | Gunicorn 핸들러 포화 (피크 시) | 9 VUs × 4 DBs = 최대 36 DB 연결 | + +--- + +### 시나리오 D: 스파이크 내성 (급격한 트래픽 증가) + +**설명:** 갑자기 사용자가 몰리는 상황 시뮬레이션 + +**트래픽 패턴:** +- Ramp: 0→3 (10s), 3→15 (10s), 15→15 (30s), 15→3 (10s), 3→0 (10s) +- 최대 15 VUs (4 핸들러 대비 ~3.75배 초과 구독) + +**가설:** +| 지표 | 예측값 | 근거 | +|------|--------|------| +| p95 응답 시간 | < 1500ms | 15 VUs >> 4 핸들러 → 심각한 요청 큐잉 | +| 에러율 | < 10% | 핸들러 포화 + MySQL 연결 생성 오버헤드 | +| 최대 RPS | ~10-15 | 큐잉 발생하지만 처리는 지속 | +| 예상 병목 | Gunicorn worker 포화 + MySQL 연결 폭주 | - | + +--- + +## 3. MQTT 테스트 시나리오 + +### 시나리오 A: 정상 운영 (20대 카메라, 1건/분) + +**설명:** 평상시 트래픽. 20대 카메라가 분당 1건씩 감지 + +**트래픽 패턴:** +- 20 workers (카메라 1대 = 1 worker) +- 1 msg/min/worker = 총 0.33 msg/s +- 지속 시간: 120초 +- 예상 총 메시지: 40건 + +**가설:** +| 지표 | 예측값 | 근거 | +|------|--------|------| +| 발행 성공률 | 100% | 구독자 처리량 대비 극히 낮은 부하 | +| 파이프라인 완료 시간 | 60초 이내 (전체) | OCR worker 대부분 유휴 | +| DLQ 메시지 | 0 | 안정적 처리 예상 | +| 예상 병목 | 없음 | - | + +--- + +### 시나리오 B: 러시아워 (20대 카메라, 5건/분) + +**설명:** 교통 혼잡 시간. 20대 카메라가 분당 5건씩 감지 + +**트래픽 패턴:** +- 20 workers, 5 msg/min/worker = 총 1.67 msg/s +- 지속 시간: 120초 +- 예상 총 메시지: 200건 + +**가설:** +| 지표 | 예측값 | 근거 | +|------|--------|------| +| 발행 성공률 | 100% | 구독자 처리 범위 내 | +| 파이프라인 완료율 | 95% (120초 이내) | OCR 큐 약간 축적 (1.67 in vs 8-40 out) | +| DLQ 메시지 | 0 | - | +| 예상 병목 | OCR worker (mock 느린 경우) | - | + +--- + +### 시나리오 C: 버스트 스톰 (20대 카메라, 1건/초) + +**설명:** 스트레스 테스트. 모든 카메라가 초당 1건씩 동시 감지 + +**트래픽 패턴:** +- 20 workers, 1 msg/sec/worker = 총 20 msg/s +- 지속 시간: 60초 +- 예상 총 메시지: 1200건 + +**가설:** +| 지표 | 예측값 | 근거 | +|------|--------|------| +| 발행 성공률 | 100% | RabbitMQ는 20 msg/s 충분히 처리 | +| OCR 큐 피크 깊이 | 200-500 | 20 msg/s 유입 vs OCR 4 concurrency (8-40/s) | +| 전체 드레인 시간 | 300초 이내 | 큐 축적 후 순차 처리 | +| 예상 병목 | MQTT Subscriber (단일 스레드 DB 쓰기) → OCR 큐 깊이 | - | + +--- + +## 4. 가설 종합표 + +| 시나리오 | 유형 | 예측 최대 처리량 | 예측 p95 지연 | 예측 에러율 | 예측 병목 | +|---------|------|----------------|-------------|-----------|----------| +| A: 대시보드 폴링 | HTTP | 2-3 RPS | < 200ms | 0% | 없음 | +| B: 관리자 작업 | HTTP | < 0.1 RPS | < 300ms | 0% | 없음 | +| C: 혼합 워크로드 | HTTP | 6-10 RPS | < 500ms | < 1% | Gunicorn 핸들러 | +| D: 스파이크 내성 | HTTP | 10-15 RPS | < 1500ms | < 10% | Gunicorn + MySQL | +| A: 정상 운영 | MQTT | 0.33 msg/s | - | 0% | 없음 | +| B: 러시아워 | MQTT | 1.67 msg/s | - | 0% | OCR (경우에 따라) | +| C: 버스트 스톰 | MQTT | 20 msg/s | - | 0% | MQTT Subscriber → OCR | + +--- + +## 5. 실행 방법 + +### 사전 요구사항 +- k6 설치 (HTTP 테스트용) +- Python 3 + paho-mqtt 패키지 (MQTT 테스트용) +- SpeedCam 서비스 전체 가동 중 +- Grafana 대시보드 접속 가능 (모니터링용) + +### HTTP 부하테스트 (k6) + +```bash +# 전체 시나리오 실행 (Prometheus Remote Write 포함) +# 주의: k6 v1.5.0에서 --out 플래그로 URL 전달 불가 → 환경변수 사용 필수 +K6_PROMETHEUS_RW_SERVER_URL=http://10.178.0.5:9090/api/v1/write \ + k6 run --out experimental-prometheus-rw \ + --env MAIN_SERVICE_URL=http://localhost \ + backend/docker/k6/load-test.js + +# Prometheus Remote Write 없이 실행 (결과는 콘솔 출력만) +k6 run --env MAIN_SERVICE_URL=http://localhost \ + backend/docker/k6/load-test.js +``` + +> **참고: 실행 위치별 호스트 설정** +> +> | 실행 위치 | `MAIN_SERVICE_URL` | Prometheus RW URL | +> |----------|-------------------|-------------------| +> | speedcam-app (권장) | `http://localhost` | `http://10.178.0.5:9090/api/v1/write` | +> | 외부 | `http://34.64.41.106` | `http://34.47.70.132:9090/api/v1/write` | +> +> k6를 speedcam-app에서 실행하면 네트워크 지연 없이 측정 가능하나, +> k6 자체가 CPU/메모리를 소비하므로 결과에 영향을 줄 수 있습니다 (e2-small 공유). + +### MQTT 파이프라인 테스트 + +> **speedcam-app 인스턴스에서 실행 시** 내부 IP 사용: +> `speedcam-mq` → `10.178.0.7`, `speedcam-app` → `localhost` + +```bash +# 정상 운영 시나리오 (speedcam-app에서 실행) +python3 mqtt-load-test.py \ + --scenario normal \ + --mqtt-host 10.178.0.7 \ + --mqtt-user sa --mqtt-pass $RABBITMQ_PASS \ + --api-url http://localhost \ + --rabbitmq-api http://10.178.0.7:15672 \ + --rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS + +# 러시아워 시나리오 +python3 mqtt-load-test.py \ + --scenario rush_hour \ + --mqtt-host 10.178.0.7 \ + --mqtt-user sa --mqtt-pass $RABBITMQ_PASS \ + --api-url http://localhost \ + --rabbitmq-api http://10.178.0.7:15672 \ + --rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS + +# 버스트 스톰 시나리오 +python3 mqtt-load-test.py \ + --scenario burst \ + --mqtt-host 10.178.0.7 \ + --mqtt-user sa --mqtt-pass $RABBITMQ_PASS \ + --api-url http://localhost \ + --rabbitmq-api http://10.178.0.7:15672 \ + --rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS + +# 커스텀 설정 (하위 호환) +python3 mqtt-load-test.py \ + --workers 10 --rate 5 --duration 30 \ + --mqtt-host 10.178.0.7 --mqtt-user sa --mqtt-pass $RABBITMQ_PASS +``` + +### 동시 실행 (HTTP + MQTT) +```bash +# 터미널 1: HTTP 부하 (speedcam-app에서 실행) +K6_PROMETHEUS_RW_SERVER_URL=http://10.178.0.5:9090/api/v1/write \ + k6 run --out experimental-prometheus-rw \ + --env MAIN_SERVICE_URL=http://localhost \ + load-test.js + +# 터미널 2: MQTT 부하 (동시 실행) +python3 mqtt-load-test.py --scenario rush_hour \ + --mqtt-host 10.178.0.7 --mqtt-user sa --mqtt-pass $RABBITMQ_PASS \ + --api-url http://localhost \ + --rabbitmq-api http://10.178.0.7:15672 \ + --rabbitmq-user sa --rabbitmq-pass $RABBITMQ_PASS +``` + +### 테스트 중 모니터링 + +| 도구 | 내부 IP (GCE 내) | 외부 IP (브라우저) | 용도 | +|------|-----------------|------------------|------| +| **Grafana** | `10.178.0.5:3000` | `34.47.70.132:3000` | 대시보드 (cAdvisor, k6, Django) | +| **Prometheus** | `10.178.0.5:9090` | `34.47.70.132:9090` | 메트릭 직접 쿼리 | +| **RabbitMQ** | `10.178.0.7:15672` | `34.64.183.199:15672` | 큐 깊이, 메시지 속도 | +| **Flower** | `10.178.0.4:5555` | `34.64.41.106:5555` | Celery 태스크 현황 | + +--- + +## 6. 테스트 후 분석 템플릿 + +### 가설 vs 실제 비교표 + +각 시나리오 실행 후 아래 표를 복사하여 채워 넣으세요. + +``` +시나리오: [시나리오 이름] +실행 일시: [YYYY-MM-DD HH:MM] +실행 환경: [인스턴스 사양, 특이사항] + +| 지표 | 가설 | 실제 | 차이 | 분석 | +|------|------|------|------|------| +| 최대 처리량 (RPS/msg/s) | | | | | +| p95 응답 시간 | | | | | +| 에러율 | | | | | +| 예상 병목 | | | | | +| OCR 큐 피크 깊이 | | | | | +| FCM 큐 피크 깊이 | | | | | +| DLQ 메시지 수 | | | | | +| E2E 완료 시간 | | | | | +``` + +### 병목 식별 체크리스트 + +- [ ] Gunicorn worker 포화 여부 → Grafana: container CPU/memory for speedcam-app +- [ ] MySQL 연결 수 확인 → `SHOW PROCESSLIST` 또는 mysqld-exporter 메트릭 +- [ ] RabbitMQ 큐 깊이 → Management UI: ocr_queue, fcm_queue, dlq_queue +- [ ] MQTT Subscriber 처리 지연 → APP 컨테이너 로그에서 on_message 처리 시간 +- [ ] OCR Worker 처리 속도 → Celery exporter: task latency, queue length +- [ ] Alert Worker 처리 속도 → Celery exporter: fcm_queue task latency +- [ ] 디스크 I/O → cAdvisor: disk read/write bytes +- [ ] 메모리 부족 → cAdvisor: container memory usage vs limit + +### 다음 단계 의사결정 트리 + +``` +가설과 실제가 일치하는가? +├─ YES → 용량 한계를 정확히 파악함. 필요시 스케일링 계획 수립. +└─ NO → 왜 다른가? + ├─ 실제가 가설보다 좋음 → 용량 계산이 보수적. 가설 수정 후 재테스트. + ├─ 실제가 가설보다 나쁨 + │ ├─ 에러율 높음 → 병목 체크리스트로 원인 파악 + │ │ ├─ Gunicorn 포화 → GUNICORN_WORKERS 증가 또는 인스턴스 업그레이드 + │ │ ├─ MySQL 연결 폭주 → CONN_MAX_AGE 설정 또는 커넥션 풀링 도입 + │ │ ├─ OCR 큐 축적 → OCR_CONCURRENCY 증가 또는 인스턴스 분리 + │ │ └─ MQTT Subscriber 병목 → 멀티스레드 처리 또는 비동기 DB 쓰기 + │ └─ 지연시간 높음 → 동일 병목 체크리스트 적용 + └─ 패턴이 예상과 다름 → 데이터 흐름 재분석, 로그 확인 +``` + +--- + +## 7. 테스트 실측 결과 (2026-02-12) + +> 실행 환경: speedcam-app (e2-small, 2 vCPU, 2 GB) 인스턴스에서 k6/MQTT 스크립트 실행 +> OCR 환경: **실제 EasyOCR** (OCR_MOCK=false) — 가설의 mock 기반 예측과 크게 상이 +> k6 Prometheus RW: `K6_PROMETHEUS_RW_SERVER_URL=http://10.178.0.5:9090/api/v1/write` + +### 7.1 k6 HTTP 결과 (모든 임계치 PASS) + +**총 5분 40초, 973 iterations, 2,297 requests, 6.75 req/s** + +| 시나리오 | 지표 | 가설 | 실측 | 판정 | +|---------|------|------|------|------| +| A: 대시보드 폴링 (3 VUs) | p95 | < 200ms | **30.6ms** | ✅ PASS | +| A: 대시보드 폴링 | 에러율 | < 1% | **0.00%** | ✅ PASS | +| B: 관리자 작업 (2/min) | p95 | < 300ms | **23.23ms** | ✅ PASS | +| C: 혼합 워크로드 (9 VUs) | pending p95 | < 500ms | **16.6ms** | ✅ PASS | +| C: 혼합 워크로드 | statistics p95 | < 500ms | **42.42ms** | ✅ PASS | +| D: 스파이크 (15 VUs) | p95 | < 1500ms | **40.54ms** | ✅ PASS | +| D: 스파이크 | 에러율 | < 10% | **0.00%** | ✅ PASS | +| 전체 | 에러율 | < 5% | **0.21%** | ✅ PASS | + +**엔드포인트별 상세 레이턴시:** + +| 메트릭 | avg | min | med | max | p90 | p95 | +|--------|-----|-----|-----|-----|-----|-----| +| dashboard_req_duration | 19.27ms | 9.73ms | 17.65ms | 118.73ms | 26.89ms | 30.6ms | +| detections_list_duration | 23.98ms | 14.01ms | 20.53ms | 162.31ms | 33.3ms | 43.29ms | +| statistics_req_duration | 23.44ms | 13.31ms | 20.4ms | 127.43ms | 34.03ms | 42.42ms | +| pending_read_duration | 13.08ms | 10.3ms | 12.55ms | 27.22ms | 15.12ms | 16.6ms | +| admin_req_duration | 17.78ms | 4.21ms | 17.82ms | 53.17ms | 21.05ms | 23.23ms | +| http_req_duration (전체) | 20.72ms | 3.75ms | 18.23ms | 162.31ms | 30.14ms | 38.85ms | + +**분석:** +- 15 VUs 스파이크에서도 p95 40ms — 가설 1500ms 대비 37배 좋음 +- 4 핸들러(Gunicorn 2w×2t)가 15 VUs를 충분히 소화 +- 유일한 에러: FCM 토큰 업데이트 PATCH 5건 실패 (엔드포인트 호환 문제) +- 가설이 매우 보수적이었음 → 실제 포화점은 50+ VUs (이전 스트레스 테스트에서 확인) + +### 7.2 MQTT 파이프라인 결과 (OCR 병목 심각) + +> **중요: 아래 결과는 실제 EasyOCR 환경입니다. 가설은 OCR_MOCK=true 기준으로 작성되었으므로 직접 비교 시 주의.** + +| 시나리오 | 발행 성공 | 완료율 (300s) | OCR큐 피크 | 실효 OCR 처리속도 | DLQ | +|---------|----------|-------------|-----------|----------------|-----| +| Normal (0.33 msg/s) | 40/40 (100%) | **32/40 (80%)** | 25 | ~0.053 msg/s | 0 | +| Rush Hour (1.67 msg/s) | 200/200 (100%) | **22/200 (11%)** | 202 | ~0.073 msg/s | 0 | +| Burst (20 msg/s) | 1200/1200 (100%) | **18/1200 (1.5%)** | 1,381 | ~0.060 msg/s | 0 | + +**가설 vs 실측 비교:** + +| 지표 | Normal 가설 | Normal 실측 | Rush Hour 가설 | Rush Hour 실측 | Burst 가설 | Burst 실측 | +|------|-----------|-----------|--------------|--------------|-----------|-----------| +| 발행 성공률 | 100% | **100%** ✅ | 100% | **100%** ✅ | 100% | **100%** ✅ | +| 완료율 | 100% | **80%** ❌ | 95% | **11%** ❌ | 100% (drain) | **1.5%** ❌ | +| 완료 시간 | 60초 | **300초 TO** ❌ | 120초 | **300초 TO** ❌ | 300초 | **300초 TO** ❌ | +| OCR큐 피크 | < 5 | **25** ❌ | < 50 | **202** ❌ | 200-500 | **1,381** ❌ | +| DLQ | 0 | **0** ✅ | 0 | **0** ✅ | 0 | **0** ✅ | + +**핵심 발견 — OCR 처리 속도:** +- 가설 (OCR_MOCK): 8-40 tasks/s (prefork 4 workers × 2-10/s) +- 실측 (EasyOCR): **~0.06 msg/s = 약 17초/건** (가설 대비 133~667배 느림) +- 원인: e2-small (2 vCPU, 2 GB) 인스턴스에서 EasyOCR 모델 로딩 + 추론 → 메모리/CPU 제약 +- 테스트 후 OCR 큐 잔여: **1,362건** (드레인에 약 6.3시간 소요 예상) + +**병목 순위 (실측 기반):** +1. **OCR Worker** — 실제 EasyOCR 처리 속도가 지배적 병목 (0.06 msg/s) +2. MQTT Subscriber — 단일 스레드이나, OCR 대비 충분히 빠름 (20 msg/s 발행 성공) +3. RabbitMQ — 메시지 버퍼링 정상, DLQ 0건 +4. Gunicorn/MySQL — HTTP 계층은 병목 아님 (p95 < 50ms) + +--- + +## 8. 테스트 환경 참고사항 + +- `OCR_MOCK=true`, `FCM_MOCK=true` 환경에서 테스트 가정 (기본) +- 실제 EasyOCR/FCM 사용 시 처리량이 크게 달라짐 (OCR: ~10-50배 느림) +- 모든 인스턴스 e2-small (2 vCPU, 2 GB RAM) — 프로덕션 환경에서는 스케일업 필요 +- MySQL 커넥션 풀링 미설정 (`CONN_MAX_AGE=0`) — 고부하 시 연결 오버헤드 발생 +- MQTT Subscriber 단일 스레드 — 메시지 처리 직렬화됨 +- k6를 대상 서버와 동일 인스턴스에서 실행하면 CPU/메모리 경합 발생 (별도 인스턴스 권장) diff --git a/docs/performance-analysis.md b/docs/performance-analysis.md new file mode 100644 index 0000000..1af7816 --- /dev/null +++ b/docs/performance-analysis.md @@ -0,0 +1,936 @@ +# SpeedCam 시스템 성능 분석 보고서 + +## 목차 + +1. [프로젝트 개요](#1-프로젝트-개요) +2. [시스템 아키텍처](#2-시스템-아키텍처) +3. [서버 인프라 스펙](#3-서버-인프라-스펙) +4. [부하 테스트](#4-부하-테스트) +5. [아키텍처 비교 분석](#5-아키텍처-비교-분석-before-vs-after) +6. [모니터링 지표](#6-모니터링-지표) +7. [최대 TPS 및 용량 분석](#7-최대-tps-및-용량-분석) +8. [발견된 이슈 및 개선점](#8-발견된-이슈-및-개선점) +9. [결론](#9-결론) + +--- + +## 1. 프로젝트 개요 + +**SpeedCam**은 도로 위 과속 차량을 실시간으로 감지하고 번호판을 인식하여 사용자에게 알림을 전송하는 **이벤트 기반 실시간 시스템**입니다. + +### 핵심 특징 + +- **Event Driven Architecture**: MQTT + AMQP 기반 비동기 메시지 처리 +- **분산 시스템**: GCE 인스턴스 6대로 구성된 마이크로서비스 아키텍처 +- **실시간 OCR 처리**: EasyOCR을 활용한 한국어 번호판 인식 +- **완전한 관측성**: Prometheus, Grafana, Loki, Jaeger를 통한 통합 모니터링 + +### 기술 스택 + +- **Backend**: Django 4.2 + Gunicorn +- **Message Broker**: RabbitMQ 3.13 (MQTT Plugin + AMQP) +- **Database**: MySQL 8.0 +- **OCR Engine**: EasyOCR (Korean + English) +- **Monitoring**: Prometheus, Grafana, Loki, Jaeger, OpenTelemetry +- **Infra**: GCP Compute Engine (6 instances), Docker Compose +- **Load Testing**: k6 (Grafana k6), Python paho-mqtt + +--- + +## 2. 시스템 아키텍처 + +### 2.1 기존 아키텍처 (Before) + +기존 시스템은 Django 모놀리식 구조로, OCR 처리가 동기적으로 수행되어 다음과 같은 구조적 한계가 있었습니다. + +```mermaid +graph TB + subgraph Edge["Edge Device (Raspberry Pi)"] + Camera["과속 카메라"] + end + + subgraph Backend["backend (Django)"] + API["API Handler"] + OCR["OCR 처리
(동기 실행)"] + end + + subgraph Workers["Celery Workers"] + CW["celery_worker
(알림 전송)"] + DLQ["celery_worker_dlq"] + end + + Camera -->|"HTTP POST"| API + API --> OCR + Backend --> RMQ["RabbitMQ"] + CW --> RMQ + Backend --> MySQL[("MySQL")] + CW --> MySQL + + style Backend fill:#ffcccc,stroke:#cc0000 + style OCR fill:#ff9999 +``` + +#### 주요 문제점 + +| 문제 영역 | 상세 내용 | +|---------|----------| +| **OCR 동기 처리** | OCR 작업(약 3초)이 HTTP 스레드를 점유하여 서버 처리량 저하 | +| **Edge Device 블로킹** | 서버 응답 대기(3초+)로 인한 연속 감지 불가, 데이터 유실 위험 | +| **HTTP 기반 IoT 통신** | 요청마다 TCP 연결, 메시지 보장 없음, 오프라인 처리 불가 | +| **장애 전파** | OCR 장애 시 API 서비스 전체 영향, 독립 확장 불가 | + +**성능 지표 (Before)** — *아키텍처 구조 기반 추정값* + +> 기존 아키텍처는 현재 운영 환경에서 별도로 부하 테스트를 수행하지 않았습니다. 아래 수치는 동기 OCR 처리 시간(EasyOCR CPU 기준 ~3초)과 아키텍처 구조로부터 도출한 **설계 기반 추정값**입니다. + +- 이벤트 처리 시간: **3,000ms 이상** (HTTP 수신 → OCR 완료까지 동기 처리) +- Edge Device 블로킹: **3,000ms 이상** (HTTP 응답 대기) +- 메시지 보장: **없음** +- 장애 격리: **불가능** (모놀리식 구조) + +--- + +### 2.2 현재 아키텍처 (After) - Event Driven Architecture + +기존 문제를 해결하기 위해 **Event Driven Architecture**로 전환하여 MQTT 기반 IoT 통신과 AMQP 기반 비동기 메시지 처리를 구현했습니다. + +```mermaid +graph TB + subgraph Edge["Edge Device"] + Camera["과속 카메라"] + end + + subgraph Main["main (Django)"] + API["API Handler"] + MQTT_Sub["MQTT Subscriber"] + Publisher["Event Publisher"] + end + + subgraph Workers["Event Processors"] + OCR["ocr-worker
• 감지 이벤트 처리
• OCR 수행"] + Alert["alert-worker
• 완료 이벤트 처리
• FCM 발송"] + end + + subgraph MessageBroker["RabbitMQ"] + MQTT["MQTT Plugin"] + Queue1[("감지 이벤트 큐")] + Queue2[("알림 이벤트 큐")] + end + + subgraph Storage["Google Cloud Storage"] + GCS[("GCS Bucket
번호판 이미지")] + end + + Camera -->|"MQTT Publish"| MQTT + Camera -->|"이미지 업로드"| GCS + MQTT --> MQTT_Sub + Publisher --> Queue1 + Queue1 --> OCR + OCR -->|"이미지 다운로드"| GCS + OCR --> Queue2 + Queue2 --> Alert + + Main --> DB1[("default")] + Main --> DB2[("vehicles_db")] + OCR --> DB3[("detections_db")] + Alert --> DB4[("notifications_db")] + + style Main fill:#90EE90 + style OCR fill:#87CEEB + style Alert fill:#DDA0DD + style MessageBroker fill:#FFB6C1 + style Storage fill:#FFFACD +``` + +#### 아키텍처 특징 + +| 컴포넌트 | 역할 | 프로토콜 | 특징 | +|---------|------|---------|------| +| **Edge Device** | 과속 차량 감지 | MQTT | QoS 1, 경량, 영구 연결 | +| **main (Django)** | API + MQTT 구독 | HTTP + MQTT | 이벤트 발행만 담당 | +| **ocr-worker** | 번호판 OCR 처리 | AMQP | 비동기 처리, concurrency=1 | +| **alert-worker** | FCM 푸시 알림 | AMQP | 고성능, concurrency=100 | +| **RabbitMQ** | 메시지 브로커 | MQTT + AMQP | At-Least-Once 보장 | + +#### End-to-End 이벤트 흐름 + +```mermaid +sequenceDiagram + participant Edge as Edge Device + participant RMQ as RabbitMQ + participant Main as main + participant OCR as ocr-worker + participant Alert as alert-worker + participant User as 사용자 앱 + + Edge->>RMQ: MQTT Publish (과속 차량 감지) + RMQ-->>Edge: PUBACK (즉시) + RMQ->>Main: 메시지 전달 (subscribe) + Main->>Main: DB 저장 (pending) + Main->>RMQ: 감지 이벤트 발행 (AMQP) + + RMQ->>OCR: 감지 이벤트 수신 + OCR->>OCR: 번호판 OCR 처리 + OCR->>OCR: DB 업데이트 (completed) + OCR->>RMQ: OCR 완료 이벤트 발행 + + RMQ->>Alert: 완료 이벤트 수신 + Alert->>User: FCM Push 알림 +``` + +--- + +## 3. 서버 인프라 스펙 + +총 6대의 GCE 인스턴스로 구성된 분산 시스템입니다. 모든 인스턴스는 **asia-northeast3-a** 존에 위치하며 **Ubuntu 22.04 LTS**, **Kernel 6.8.0-1045-gcp**, **Docker** 기반으로 운영됩니다. + +### 3.1 인스턴스 상세 스펙 + +| 인스턴스 | 머신 타입 | vCPU | RAM | 디스크 | 디스크 사용률 | 내부 IP | 역할 | +|---------|----------|------|-----|-------|-------------|---------|------| +| **speedcam-app** | e2-small | 2 | 2GB | 20GB | 34% (6.4GB) | 10.178.0.4 | API 서버 | +| **speedcam-db** | e2-medium | 2 | 4GB | 29GB | 20% (5.8GB) | 10.178.0.2 | 데이터베이스 | +| **speedcam-mq** | e2-small | 2 | 2GB | 20GB | 26% (4.9GB) | 10.178.0.7 | 메시지 브로커 | +| **speedcam-ocr** | e2-small | 2 | 2GB | 20GB | 87% (17GB) | 10.178.0.3 | OCR Worker | +| **speedcam-alert** | e2-small | 2 | 2GB | 20GB | 31% (5.9GB) | 10.178.0.6 | Alert Worker | +| **speedcam-mon** | e2-small | 2 | 2GB | 20GB | 37% (7.0GB) | 10.178.0.5 | 모니터링 | + +### 3.2 주요 컨테이너 구성 + +| 인스턴스 | 컨테이너 | 역할 | +|---------|---------|------| +| **speedcam-app** | Django + Gunicorn | REST API (GUNICORN_WORKERS=2) | +| | Traefik | 리버스 프록시 | +| | Flower | Celery 모니터링 | +| | Promtail | 로그 수집 에이전트 | +| | cAdvisor | 컨테이너 메트릭 수집 | +| **speedcam-db** | MySQL 8.0 | 메인 데이터베이스 | +| | mysqld-exporter | MySQL 메트릭 수집 | +| | Promtail | 로그 수집 에이전트 | +| | cAdvisor | 컨테이너 메트릭 수집 | +| **speedcam-mq** | RabbitMQ 3.13 | MQTT + AMQP 브로커 | +| | Promtail | 로그 수집 에이전트 | +| | cAdvisor | 컨테이너 메트릭 수집 | +| **speedcam-ocr** | Celery OCR Worker | EasyOCR 처리 (concurrency=1) | +| | Promtail | 로그 수집 에이전트 | +| | cAdvisor | 컨테이너 메트릭 수집 | +| **speedcam-alert** | Celery Alert Worker | FCM 알림 발송 (concurrency=100) | +| | Promtail | 로그 수집 에이전트 | +| | cAdvisor | 컨테이너 메트릭 수집 | +| **speedcam-mon** | Prometheus | 메트릭 수집 | +| | Grafana | 시각화 대시보드 | +| | Loki | 로그 수집 | +| | Jaeger | 분산 추적 | +| | OpenTelemetry Collector | 텔레메트리 수집 | +| | Promtail | 로그 수집 에이전트 | +| | cAdvisor | 컨테이너 메트릭 수집 | + +### 3.3 리소스 사용 현황 + +| 인스턴스 | RAM 사용 | RAM 여유 | 메모리 집약적 프로세스 | 비고 | +|---------|---------|---------|---------------------|------| +| speedcam-app | 661MB/2GB | 1.1GB | Gunicorn 2 workers | 안정적 | +| speedcam-db | 853MB/4GB | 2.6GB | MySQL 버퍼풀 | 충분한 여유 | +| speedcam-mq | 471MB/2GB | 1.2GB | RabbitMQ | 안정적 | +| **speedcam-ocr** | 1.0GB/2GB | 721MB | EasyOCR 모델 (1.5GB) | **메모리 부족 위험** | +| speedcam-alert | 433MB/2GB | 1.3GB | 경량 워커 | 충분한 여유 | +| **speedcam-mon** | 1.5GB/2GB | 264MB | Prometheus + Grafana | **메모리 부족 위험** | + +**주의사항:** +- `speedcam-ocr`: EasyOCR 모델 로딩으로 인한 높은 메모리 사용률, concurrency를 1로 제한 +- `speedcam-mon`: 모니터링 스택의 메모리 집약적 특성으로 264MB 여유분만 확보 + +--- + +## 4. 부하 테스트 + +### 4.1 테스트 목적 + +실제 운영 환경에서의 시스템 성능과 안정성을 검증하기 위해 다음 목표로 부하 테스트를 수행했습니다. + +| 목표 | 세부 내용 | +|------|----------| +| **성능 한계 파악** | 각 컴포넌트별 최대 처리량 측정 | +| **병목 지점 식별** | Event Driven 파이프라인 각 단계별 소요 시간 분석 | +| **아키텍처 검증** | 기존 동기 처리 대비 비동기 이벤트 기반 처리의 성능 개선 정도 확인 | +| **안정성 확인** | 스파이크 트래픽 발생 시 시스템의 안정성 검증 | + +### 4.2 테스트 도구 + +| 도구 | 용도 | 특징 | +|------|------|------| +| **k6 (Grafana k6)** | HTTP API 부하 테스트 | Prometheus Remote Write로 메트릭 실시간 전송, 웹 대시보드 + Grafana 연동 | +| **Python + paho-mqtt** | MQTT 파이프라인 부하 테스트 | 실제 한국어 번호판 이미지를 GCS에 저장하여 실 파이프라인 테스트, EasyOCR 실제 동작 검증 | + +### 4.2.1 테스트 환경 및 조건 + +| 항목 | 상세 | +|------|------| +| **테스트 일시** | 2026-02-12 (k6 4시나리오 + MQTT 3시나리오) | +| **k6 실행 위치** | speedcam-app 인스턴스 내부 (localhost 호출) | +| **MQTT 테스트 실행 위치** | speedcam-app → speedcam-mq (내부 IP 10.178.0.7) | +| **네트워크 환경** | 동일 VPC (asia-northeast3), 인스턴스 간 지연 <1ms | +| **부하 발생기 → 서버 지연** | k6: ~0ms (localhost), MQTT: <1ms (같은 VPC) | +| **시스템 상태** | 테스트 외 트래픽 없음 (전용 테스트 환경) | + +> **참고:** k6 HTTP 테스트는 speedcam-app 자체에서 localhost로 호출하였으므로, 측정된 응답 시간은 **순수 서버 처리 시간**에 가깝습니다. 실제 클라이언트에서의 응답 시간은 네트워크 지연이 추가됩니다. + +--- + +### 4.3 HTTP API 부하 테스트 (k6) + +Django REST API의 처리 성능과 응답 시간을 측정하기 위해 4가지 시나리오로 부하 테스트를 수행했습니다. + +#### 4.3.1 테스트 시나리오 + +| 시나리오 | VUs | Executor | 지속시간 | 시작 시점 | 설명 | +|---------|-----|----------|---------|----------|------| +| **dashboard_polling** | 3 (constant) | constant-vus | 2분 | 0s | 대시보드 폴링 (감지목록 5초, 알림 10초, 통계 30초 주기) | +| **admin_ops** | 2 | constant-arrival-rate (2/min) | 2분 | 0s | 관리자 작업 (차량 등록 + FCM 토큰 업데이트) | +| **mixed_workload** | 0→5→9→9→0 | ramping-vus | 2분30초 | 2m | 읽기 60% + 파이프라인 상태 30% + 쓰기 10% | +| **spike_resilience** | 0→3→15→15→3→0 | ramping-vus | 1분10초 | 4m30s | 급격한 트래픽 증가 시 회복력 (15 VUs = 4 핸들러 대비 3.75배) | + +> 총 테스트 시간: 5분 40초, 최대 동시 VUs: 18 + +#### 4.3.2 전체 결과 요약 + +``` +✅ 총 요청: 2,297건 (평균 6.75 req/s) +✅ 전체 p95 응답시간: 38.85ms +✅ 에러율: 0.21% (5/2,277건) - FCM 토큰 업데이트 엔드포인트 문제 +✅ 모든 임계값(Threshold) 통과 +✅ Prometheus Remote Write → Grafana 메트릭 기록 +``` + +#### 4.3.3 시나리오별 상세 결과 + +**응답 시간 분포** + +| 메트릭 | avg | min | med | max | p(90) | p(95) | +|-------|-----|-----|-----|-----|-------|-------| +| **dashboard_req_duration** | 19.27ms | 9.73ms | 17.65ms | 118.73ms | 26.89ms | 30.6ms | +| **admin_req_duration** | 17.78ms | 4.21ms | 17.82ms | 53.17ms | 21.05ms | 23.23ms | +| **detections_list_duration** | 23.98ms | 14.01ms | 20.53ms | 162.31ms | 33.3ms | 43.29ms | +| **statistics_req_duration** | 23.44ms | 13.31ms | 20.4ms | 127.43ms | 34.03ms | 42.42ms | +| **pending_read_duration** | 13.08ms | 10.3ms | 12.55ms | 27.22ms | 15.12ms | 16.6ms | +| **spike_resilience (overall)** | 21.42ms | 8.63ms | 18.79ms | 162.31ms | 31.6ms | 40.54ms | +| **http_req_duration (전체)** | 20.72ms | 3.75ms | 18.23ms | 162.31ms | 30.14ms | 38.85ms | + +**📸 [스크린샷 삽입: k6 Grafana 대시보드 - 4 시나리오 응답시간 그래프]** + +**임계치(Threshold) 검증 결과:** + +| 임계치 | 기준 | 실측 | 판정 | +|--------|------|------|------| +| dashboard_req_duration p(95) | < 200ms | **30.6ms** | ✅ PASS | +| detections_list_duration p(95) | < 300ms | **43.29ms** | ✅ PASS | +| statistics_req_duration p(95) | < 500ms | **42.42ms** | ✅ PASS | +| pending_read_duration p(95) | < 500ms | **16.6ms** | ✅ PASS | +| admin_req_duration p(95) | < 300ms | **23.23ms** | ✅ PASS | +| spike_resilience p(95) | < 1500ms | **40.54ms** | ✅ PASS | +| errors (전체) | < 5% | **0.21%** | ✅ PASS | +| errors (dashboard) | < 1% | **0.00%** | ✅ PASS | +| errors (spike) | < 10% | **0.00%** | ✅ PASS | + +**주요 인사이트:** +- **대시보드 폴링 평균 19ms**: 실시간 데이터 조회가 매우 빠름 +- **스파이크 상황(15 VUs)에서도 p95 40ms**: 급격한 트래픽 증가 시에도 안정적 응답 유지 +- **가설 대비 37배 좋은 성능**: 스파이크 가설(p95 < 1500ms) 대비 실측 40ms +- **4 핸들러(Gunicorn 2w×2t)로 15 VUs 충분히 소화**: 실제 포화점은 50+ VUs + +#### 4.3.4 Checks 결과 + +| Check 항목 | 성공/전체 | 성공률 | 비고 | +|----------|----------|-------|------| +| 서버 헬스체크 | 1/1 | **100%** | ✅ | +| 차량 등록 (201) | ✅ | **100%** | ✅ admin_ops + mixed 시나리오 | +| FCM 토큰 업데이트 (200) | 0/5 | **0%** | ❌ PATCH 엔드포인트 호환 문제 | +| 감지 목록 (200) | ✅ | **100%** | ✅ dashboard + spike 시나리오 | +| 알림 목록 (200) | ✅ | **100%** | ✅ dashboard 시나리오 | +| 통계 조회 (200) | ✅ | **100%** | ✅ dashboard + spike 시나리오 | +| 대기 목록 (200) | ✅ | **100%** | ✅ mixed 시나리오 | +| 혼합 읽기 (200) | ✅ | **100%** | ✅ mixed 시나리오 | +| 혼합 차량 등록 (201) | ✅ | **100%** | ✅ mixed 시나리오 | +| 스파이크 감지 목록 | ✅ | **100%** | ✅ | +| 스파이크 알림 목록 | ✅ | **100%** | ✅ | +| 스파이크 통계 | ✅ | **100%** | ✅ | + +> 전체: 2,272/2,277 checks 성공 (99.78%). 실패 5건은 모두 FCM 토큰 업데이트 PATCH 엔드포인트. + +#### 4.3.5 HTTP API 최대 TPS 분석 + +| 항목 | 값 | 근거 | +|------|-----|------| +| **현재 설정** | GUNICORN_WORKERS=2 (각 2 threads = 총 4 HTTP handlers) | 배포 환경 (env.example 기본값=4와 다름) | +| **4시나리오 테스트** | 15 VUs에서 p95=40.54ms, 에러율 0% | k6 4시나리오 실측 | +| **스트레스 테스트** | 50 VUs에서 p95=2,230ms, 에러율 1.5% | k6 stress_ramp 실측 | +| **포화점** | **30~50 VUs 사이** | 15 VUs(정상) → 50 VUs(성능 저하) | +| **안정 최대 TPS** | **~25 req/s** (50 VUs, e2-small에서 k6+서버 공유 시) | 스트레스 테스트 실측 | +| **이론 최대 TPS** | **~80-100 req/s** | 4 handlers × 평균 20ms 기준 | +| **주요 병목** | Gunicorn 핸들러 포화 + DB 커넥션 (CONN_MAX_AGE 미설정) | 스트레스 테스트 분석 | + +> **측정 근거:** 4시나리오 테스트(가설 기반)에서 15 VUs까지 정상, 스트레스 테스트(50 VUs)에서 포화 확인. 실측 안정 TPS ~25 req/s는 k6가 동일 인스턴스에서 실행된 결과이므로 별도 클라이언트 사용 시 더 높을 수 있음. + +**확장 방법:** +1. `CONN_MAX_AGE` 설정으로 커넥션 풀링 활성화 +2. `GUNICORN_WORKERS` 증가 (CPU 코어당 1-2개 권장) +3. 인스턴스 업그레이드 (e2-medium 이상) + +--- + +#### 4.3.6 HTTP API 스트레스 테스트 (한계점 탐색) + +기존 테스트(최대 15 VUs)에서는 시스템이 여유 있게 처리하여 **실제 한계점을 파악하지 못했습니다.** 이를 보완하기 위해 VUs를 점진적으로 50까지 올리는 **스트레스 테스트**를 수행했습니다. + +> **주의:** k6가 speedcam-app 동일 인스턴스(e2-small, 2 vCPU)에서 실행되므로, k6 자체의 CPU/메모리 사용이 결과에 영향을 줄 수 있습니다. + +**테스트 구성** + +| Phase | 시나리오 | VUs | 지속시간 | 요청 유형 | +|-------|---------|-----|---------|----------| +| **Phase 1** | stress_ramp (읽기 전용) | 0→10→30→50→0 | 3분30초 | GET 읽기 100% | +| **Phase 2** | stress_mixed (혼합) | 0→10→30→50→0 | 3분 | 읽기 80% + 쓰기 20% | + +**전체 결과** (Prometheus Remote Write 활성, Grafana 메트릭 기록됨) + +``` +총 요청: 10,525건 (평균 25.1 req/s) +에러율: 1.50% (158건 실패) +p95 응답시간: 2,230ms +최대 응답시간: 4,260ms +``` + +**응답 시간 분포** + +| 메트릭 | avg | med | p(90) | p(95) | max | +|-------|-----|-----|-------|-------|-----| +| **전체 (req_duration)** | 790ms | 742ms | 1,770ms | 2,230ms | 4,260ms | +| **읽기 (read_latency)** | 800ms | 751ms | 1,770ms | 2,230ms | 4,260ms | +| **쓰기 (write_latency)** | 685ms | 526ms | 1,730ms | 2,020ms | 2,790ms | + +**Phase별 에러율** + +| Phase | Check | 성공률 | 실패율 | +|-------|-------|-------|-------| +| **stress_ramp** (50 VUs, 읽기) | status is 200 | **97%** | 3% | +| **stress_mixed** (50 VUs, 읽기) | read 200 | **99%** | 1% | +| **stress_mixed** (50 VUs, 쓰기) | write 201 | **99%** | 1% | + +> **테스트 환경 영향 참고:** k6와 Prometheus Remote Write가 동일 인스턴스(e2-small, 2 vCPU)에서 실행되어, k6의 요청 생성 속도가 제한됩니다 (54 req/s → 25 req/s). 이로 인해 서버에 실제 도달하는 부하가 줄어 에러율은 낮아지나, 시스템 전체 리소스 경합으로 응답 시간(p95)은 증가합니다. + +**📸 [스크린샷 삽입: k6 Grafana 대시보드 - VUs 변화에 따른 응답시간/에러율 그래프]** + +**📸 [스크린샷 삽입: Container Metrics - speedcam-app의 CPU/Memory 그래프 (스트레스 테스트 구간)]** + +**부하 수준별 성능 비교 (실측)** + +| VUs | 시나리오 | p95 | 에러율 | 처리량 | 판정 | +|-----|---------|-----|-------|-------|------| +| **15** | spike_resilience | **49ms** | **0%** | 6.7 req/s | ✅ 정상 | +| **50** | stress_ramp | **2,230ms** | **3%** | 25.1 req/s | ⚠️ 성능 저하 | +| **50** | stress_mixed | **2,020ms** | **1%** | 25.1 req/s | ⚠️ 성능 저하 | + +**핵심 발견:** +- **15 VUs → 50 VUs**: p95가 49ms에서 2,230ms로 **45배 악화** +- 50 VUs에서 median=742ms → 대부분의 요청이 700ms 이상 소요 (15 VUs에서 17ms 대비 **43배**) +- 에러율은 1.5%로 서비스 가용 범위이나, **응답 시간 저하가 심각** (SLA 기준 위반 가능) +- 쓰기(POST)가 읽기(GET) 대비 med 기준 ~30% 빠름 (526ms vs 751ms) — DB 읽기가 쓰기보다 무거운 패턴 +- **e2-small에서 k6+서버 동시 실행의 한계**: 별도 부하 발생기 인스턴스 사용 시 더 정확한 측정 가능 + +--- + +### 4.4 MQTT 이벤트 파이프라인 테스트 + +실제 Edge Device에서 발생하는 과속 감지 이벤트부터 OCR 처리, 알림 발송까지 **End-to-End 파이프라인 성능**을 측정했습니다. + +#### 4.4.1 테스트 환경 + +| 항목 | 상세 | +|------|------| +| **테스트 방식** | 단건 순차 발행 (동시 부하 아님) | +| **테스트 샘플 수** | 5건 (통계적 유의성보다는 파이프라인 각 단계별 동작 검증 목적) | +| **MQTT 발행 위치** | speedcam-app (10.178.0.4) → speedcam-mq (10.178.0.7), 동일 VPC | +| **테스트 이미지** | 한국어 번호판 합성 이미지 10장 (PIL로 생성) | +| **이미지 특징** | 고대비 흰 배경 + 검정 텍스트 (OCR 최적화) | +| **GCS 버킷** | `gs://speedcam-bucket-4f918446/detections/` | +| **OCR Worker** | EasyOCR (Korean + English), concurrency=1, Warm 상태 (모델 사전 로딩) | +| **인증 방식** | GCE ADC (메타데이터 서버, JSON 키 없음) | +| **측정 방법** | 각 컨테이너 로그의 타임스탬프 비교 (Loki 수집) | + +> **참고:** 본 테스트는 동시 다발적인 부하 상황이 아닌, **파이프라인 각 단계의 단위 처리 시간 측정**에 초점을 맞추었습니다. 대량 동시 처리 시의 성능은 큐 깊이 증가와 OCR Worker 대기 시간 등의 추가 요소가 발생합니다. + +#### 4.4.2 파이프라인 단계별 성능 측정 + +전체 파이프라인은 다음과 같이 3단계로 구성됩니다: + +``` +Stage 1: MQTT 수신 → Detection 생성 → OCR Task 디스패치 +Stage 2: AMQP 전달 (Subscriber → OCR Worker) +Stage 3: OCR 처리 (GCS 다운로드 + EasyOCR 추론) +``` + +--- + +**Stage 1: MQTT 수신 → Detection 생성 → OCR Task 디스패치 (Subscriber)** + +| Detection ID | MQTT 수신 시각 | Detection 생성 | OCR 디스패치 | 총 Subscriber 처리 시간 | +|-------------|--------------|---------------|-------------|---------------------| +| #3284 | 01:19:00.489 | 01:19:00.554 | 01:19:00.560 | **71ms** | +| #3285 | 01:49:54.207 | 01:49:54.222 | 01:49:54.229 | **22ms** | +| #3286 | 01:56:37.918 | 01:56:37.925 | 01:56:37.928 | **10ms** | +| #3287 | 02:09:11.080 | 02:09:11.091 | 02:09:11.097 | **17ms** | +| #3288 | 02:15:44.137 | 02:15:44.145 | 02:15:44.148 | **11ms** | + +**평균 Subscriber 처리 시간: 15ms** (Cold Start #3284 제외) + +- JSON 파싱 + DB Insert + AMQP Publish 포함 +- #3284의 71ms는 첫 요청 시 DB 커넥션 수립 시간이 포함된 이상값 (이후 안정화) + +--- + +**Stage 2: AMQP 전달 (Subscriber → OCR Worker)** + +| Detection ID | 디스패치 시각 | Worker 수신 시각 | AMQP 전달 시간 | +|-------------|-------------|----------------|--------------| +| #3284 | 01:19:00.560 | 01:19:00.563 | **3ms** | +| #3285 | 01:49:54.229 | 01:49:54.230 | **1ms** | +| #3286 | 01:56:37.928 | 01:56:37.935 | **7ms** | + +**평균 AMQP 전달 시간: ~3ms** + +- RabbitMQ 내부 라우팅 오버헤드 매우 낮음 + +--- + +**Stage 3: OCR 처리 (GCS 다운로드 + EasyOCR 추론)** + +| Detection ID | 이미지 | OCR 처리 시간 | 인식 결과 | 신뢰도 | 비고 | +|-------------|--------|-------------|----------|--------|------| +| #3284 | test-plate-1.jpg (흰 이미지) | **35.59s** | None | 0% | Cold Start (모델 로딩 포함) | +| #3285 | plate-01.jpg (자동차 배경) | **8.39s** | None | 0% | Warm, 배경 노이즈로 인식 실패 | +| #3286 | real-plate-01.jpg (고대비) | **5.15s** | 12가3456 | **72.1%** | ✅ 정상 인식 | +| #3287 | real-plate-02.jpg (고대비) | **5.11s** | 34나5678 | **86.8%** | ✅ 정상 인식 | +| #3288 | real-plate-03.jpg (고대비) | **5.02s** | 56다7890 | **98.8%** | ✅ 정상 인식 | + +**OCR 성능 요약:** + +| 지표 | 값 | +|------|-----| +| **Cold Start (모델 로딩 포함)** | ~35s | +| **Warm OCR 평균** | **~5.1s** (GCS 다운로드 ~0.5s + EasyOCR 추론 ~4.6s) | +| **OCR 최대 TPS** | **~0.2 msg/s** (1 worker, concurrency=1) | +| **고대비 한국어 번호판 인식률** | **100%** (3/3) | +| **평균 신뢰도** | **85.9%** | + +**주요 인사이트:** +- 고대비 한국어 번호판 이미지에서 OCR 인식률 100% +- 배경 노이즈가 있는 이미지는 인식 실패 (전처리 필요) +- Warm 상태 OCR 처리 시간 5.1s는 단일 워커 기준으로 적절 + +--- + +#### 4.4.3 End-to-End 파이프라인 타이밍 + +전체 파이프라인의 각 단계별 소요 시간을 정리하면 다음과 같습니다. + +``` +Edge Device + ↓ MQTT Publish (~50ms network) +RabbitMQ MQTT Plugin + ↓ Internal routing (~1ms) +Django Subscriber (MQTT → DB → AMQP) + ↓ ~15ms (JSON parse + DB insert + AMQP publish) +RabbitMQ AMQP Queue + ↓ ~3ms (queue routing) +OCR Worker + ↓ ~5,100ms (GCS download + EasyOCR inference) +DB Update (completed) + ↓ ~10ms +Alert Queue → FCM Notification + ↓ (FCM 미구현 상태) + +Total E2E: ~5,200ms (warm) / ~35,700ms (cold start) +``` + +**병목 지점:** +- **OCR Worker (5.1s)**: 전체 파이프라인의 98% 차지 +- GCS 다운로드: ~0.5s +- EasyOCR 추론: ~4.6s + +**개선 방안:** +1. **GPU 인스턴스 전환**: CPU → GPU로 OCR 추론 시간 단축 (5s → <1s 목표) +2. **경량 OCR 모델**: PaddleOCR 등 더 빠른 모델 검토 +3. **이미지 전처리**: Edge Device에서 고대비 전처리 수행 + +--- + +#### 4.4.4 MQTT 동시 파이프라인 부하 테스트 (3 시나리오) + +단건 순차 테스트(4.4.2)에서 측정한 단위 처리 시간을 바탕으로, **20대 카메라가 동시 운영되는 실제 사용 패턴**에서의 파이프라인 성능을 3단계 시나리오로 측정했습니다. + +> **중요:** 모든 MQTT 테스트는 **실제 EasyOCR** 환경에서 수행되었습니다 (OCR_MOCK=false). + +**테스트 구성** + +| 시나리오 | 카메라 수 | 발행 속도 | 지속시간 | 예상 메시지 | 목적 | +|---------|----------|----------|---------|-----------|------| +| **Normal** | 20대 | 1건/분/카메라 (0.33 msg/s) | 120초 | 40건 | 정상 운영 패턴 | +| **Rush Hour** | 20대 | 5건/분/카메라 (1.67 msg/s) | 120초 | 200건 | 러시아워 트래픽 | +| **Burst** | 20대 | 1건/초/카메라 (20 msg/s) | 60초 | 1,200건 | 극한 스트레스 | + +공통 설정: 실제 GCS 번호판 이미지 10장 순환 사용, API 통계 폴링 + RabbitMQ 큐 깊이 모니터링, 파이프라인 완료 대기 타임아웃 300초 + +--- + +**시나리오별 발행 결과** + +| 시나리오 | 발행 성공 | 발행 실패 | 평균 발행 지연 | 실측 발행 속도 | +|---------|----------|----------|-------------|-------------| +| **Normal** | 40/40 (100%) | 0건 | 0.91ms | 0.33 msg/s | +| **Rush Hour** | 200/200 (100%) | 0건 | 0.38ms | 1.66 msg/s | +| **Burst** | 1,200/1,200 (100%) | 0건 | 0.37ms | 19.96 msg/s | + +> 전 시나리오에서 MQTT 발행 100% 성공. RabbitMQ가 20 msg/s까지 안정적으로 수용. + +--- + +**시나리오별 파이프라인 처리 결과 (가설 vs 실측)** + +| 지표 | Normal 가설 | Normal 실측 | Rush Hour 가설 | Rush Hour 실측 | Burst 가설 | Burst 실측 | +|------|-----------|-----------|--------------|--------------|-----------|-----------| +| **발행 성공률** | 100% | **100%** ✅ | 100% | **100%** ✅ | 100% | **100%** ✅ | +| **완료율 (300s)** | 100% | **80% (32/40)** ❌ | 95% | **11% (22/200)** ❌ | 100% (drain) | **1.5% (18/1200)** ❌ | +| **E2E 완료 시간** | 60초 | **300초 TO** ❌ | 120초 | **300초 TO** ❌ | 300초 | **300초 TO** ❌ | +| **OCR 큐 피크** | < 5 | **25** ❌ | < 50 | **202** ❌ | 200-500 | **1,381** ❌ | +| **DLQ 메시지** | 0 | **0** ✅ | 0 | **0** ✅ | 0 | **0** ✅ | + +> 가설은 OCR_MOCK=true 기준으로 작성. 실제 EasyOCR 환경에서는 OCR 처리 속도가 **133~667배** 느림. + +--- + +**Normal 시나리오 - OCR 큐 드레인 추이** + +``` +시간(s) 완료 대기 OCR큐 FCM큐 실효 처리속도 +────────────────────────────────────────────── + 10 16 24 24 0 - + 50 19 21 22 0 0.075 msg/s + 100 21 19 19 0 0.040 msg/s + 150 24 16 16 0 0.060 msg/s + 200 26 14 14 0 0.040 msg/s + 250 29 11 11 0 0.060 msg/s + 300 32 8 9 0 0.060 msg/s (타임아웃) +``` + +**Rush Hour 시나리오 - OCR 큐 드레인 추이** + +``` +시간(s) 완료 대기 OCR큐 FCM큐 +────────────────────────────────── + 10 7 193 201 0 ← 발행 직후 큐 폭주 + 60 10 190 198 0 + 120 13 187 195 0 + 180 16 184 193 0 + 240 19 181 189 0 + 300 22 178 186 0 ← 타임아웃, 178건 미처리 +``` + +**Burst 시나리오 - OCR 큐 드레인 추이** + +``` +시간(s) 완료 대기 OCR큐 FCM큐 +────────────────────────────────────── + 10 3 1197 1,381 0 ← 1,200건 + 기존 백로그 + 60 6 1194 1,378 0 + 120 9 1191 1,375 0 + 180 12 1188 1,372 0 + 240 15 1185 1,369 0 + 300 18 1182 1,366 0 ← 타임아웃, 1,182건 미처리 +``` + +**📸 [스크린샷 삽입: RabbitMQ 대시보드 - OCR 큐 깊이 변화 (3 시나리오 전체 구간)]** + +**📸 [스크린샷 삽입: Celery Workers 대시보드 - OCR Task 처리 속도 (테스트 구간)]** + +**📸 [스크린샷 삽입: Container Metrics - speedcam-ocr CPU/Memory (테스트 구간)]** + +--- + +**핵심 발견 — OCR 처리 속도 비교** + +| 지표 | 단건 (4.4.2) | Normal | Rush Hour | Burst | +|------|-------------|--------|-----------|-------| +| OCR 처리 속도 | **0.2 msg/s** (5.1s/건) | **0.053 msg/s** (18.8s/건) | **0.073 msg/s** (13.7s/건) | **0.060 msg/s** (16.7s/건) | +| OCR 큐 피크 | 0 | **25** | **202** | **1,381** | +| 파이프라인 완료율 | 100% | **80%** | **11%** | **1.5%** | +| 부하 시 성능 저하 | - | **3.7배** | **2.7배** | **3.3배** | + +**동시 부하 시 OCR 처리 속도 저하 원인 분석:** +1. **메모리 압박**: e2-small(2GB)에서 EasyOCR 모델(1.5GB) + 큐 버퍼 → 721MB 여유분 소진 +2. **GCS 다운로드 경합**: 연속 다운로드 시 네트워크/API 지연 증가 +3. **CPU 경합**: OCR 추론 중 Celery 큐 관리 오버헤드 +4. **큐 백로그 누적**: Rush Hour/Burst 후 큐 드레인에 수 시간 소요 (Burst 후 잔여 1,362건 → 약 6.3시간) + +> **결론:** 가장 낙관적인 시나리오(Normal, 0.33 msg/s)에서도 OCR Worker가 처리를 따라가지 못합니다. **OCR Worker 확장(수평 또는 GPU 전환)은 선택이 아닌 필수입니다.** + +--- + +## 5. 아키텍처 비교 분석 (Before vs After) + +Event Driven Architecture 전환을 통해 기존 모놀리식 구조의 모든 핵심 문제를 해결했습니다. + +### 5.1 성능 비교 + +| 항목 | Before (동기 HTTP) | After (Event Driven) | 개선율 | 측정 근거 | +|-----|-------------------|---------------------|--------|----------| +| **이벤트 처리 시간 (수신~디스패치)** | 3,000ms+ | **15ms** | **200배 빠름** | Before: 구조 추정 / After: 실측 (n=4) | +| **Edge Device 블로킹** | 3,000ms+ | **0ms** (비동기) | **완전 해소** | Before: 구조 추정 / After: MQTT QoS 1 PUBACK | +| **메시지 보장** | 없음 | **QoS 1 (At-Least-Once)** | **메시지 무손실** | 프로토콜 사양 | +| **장애 격리** | 전체 영향 | **컴포넌트별 격리** | **독립 운영** | 아키텍처 설계 | +| **확장성** | 서버 전체 | **Worker별 독립** | **세밀한 확장** | 아키텍처 설계 | +| **HTTP API p95** | N/A | **38.85ms** | - | 실측 (k6 4시나리오, n=2,297) | +| **스파이크 대응** | 서버 다운 위험 | **15 VUs에서 안정 (에러율 0%)** | **고가용성** | 실측 (k6 spike 시나리오) | + +> **비교 기준 참고:** Before 수치는 동기 OCR 처리 구조(HTTP 요청 → OCR 완료 후 응답)에서의 설계 기반 추정값이며, After 수치는 현재 운영 환경에서의 실측값입니다. + +### 5.2 아키텍처 전환 핵심 성과 + +```mermaid +graph LR + subgraph Before["기존 아키텍처"] + B1["Django
(API + OCR)"] + B2["3초+ 응답"] + B3["HTTP 오버헤드"] + B4["장애 전파"] + style B1 fill:#ffcccc + style B2 fill:#ffcccc + style B3 fill:#ffcccc + style B4 fill:#ffcccc + end + + subgraph After["Event Driven Architecture"] + A1["Django
(API만)"] + A2["15ms 처리"] + A3["MQTT+AMQP"] + A4["장애 격리"] + style A1 fill:#90EE90 + style A2 fill:#90EE90 + style A3 fill:#90EE90 + style A4 fill:#90EE90 + end + + Before -->|"아키텍처 전환"| After +``` + +#### 문제별 해결 방법 + +| 기존 문제 | 해결 방법 | 효과 | +|----------|----------|------| +| **OCR 동기 처리** | OCR Worker 분리 + AMQP 비동기 처리 | 이벤트 처리시간 3000ms → 15ms | +| **Edge Device 블로킹** | MQTT QoS 1 + 즉시 ACK | 연속 감지 가능, 데이터 유실 방지 | +| **HTTP IoT 통신** | MQTT 프로토콜 도입 | 경량 프로토콜, 메시지 보장, 오프라인 버퍼링 | +| **장애 전파** | 컴포넌트 분리 + 이벤트 큐 보존 | OCR 장애 시에도 API 정상 운영 | + +--- + +## 6. 모니터링 지표 + +### 6.1 Grafana 대시보드 + +총 7개의 커스텀 대시보드를 운영하여 시스템의 모든 계층을 모니터링합니다. + +| 대시보드 | 용도 | +|---------|------| +| **k6 Prometheus Dashboard** | HTTP API 메트릭 실시간 시각화 | +| **System Overview** | 전체 시스템 리소스 현황 | +| **Container Metrics** | Docker 컨테이너별 CPU/Memory/Network | +| **MySQL Performance** | 쿼리 성능, 커넥션, 슬로우 쿼리 | +| **RabbitMQ Monitoring** | 메시지 큐 깊이, 처리량, 컨슈머 | +| **Celery Workers** | Task 처리량, 지연 시간, 실패율 | +| **Application Logs** | Loki 기반 통합 로그 검색 | + +**📸 [스크린샷 삽입: System Overview 대시보드 - 6개 인스턴스 CPU/Memory 전체 현황]** + +**📸 [스크린샷 삽입: MySQL Performance 대시보드 - 커넥션 수 변화 (부하 테스트 구간)]** + +### 6.2 Prometheus 타겟 상태 + +**총 11개 타겟 (All UP)** + +| 타겟 | 인스턴스 | 상태 | +|------|---------|------| +| cAdvisor | speedcam-app | ✅ UP | +| cAdvisor | speedcam-db | ✅ UP | +| cAdvisor | speedcam-mq | ✅ UP | +| cAdvisor | speedcam-ocr | ✅ UP | +| cAdvisor | speedcam-alert | ✅ UP | +| cAdvisor | speedcam-mon | ✅ UP | +| django | speedcam-app | ✅ UP | +| mysql | speedcam-db | ✅ UP | +| rabbitmq | speedcam-mq | ✅ UP | +| celery | speedcam-ocr | ✅ UP | +| otel | speedcam-mon | ✅ UP | + +**📸 [스크린샷 삽입: Prometheus → Status → Targets 페이지 (11개 타겟 All UP)]** + +### 6.3 로그 수집 현황 + +**총 16개 컨테이너 로그 수집 (Promtail → Loki)** + +- Django, Gunicorn, Celery Workers +- MySQL, RabbitMQ +- Traefik, Flower +- Prometheus, Grafana, Loki, Jaeger, OpenTelemetry Collector + +--- + +## 7. 최대 TPS 및 용량 분석 + +각 컴포넌트별 최대 처리 성능과 병목 지점을 분석했습니다. + +### 7.1 컴포넌트별 최대 TPS + +| 컴포넌트 | 이론값 | 실측값 | 근거 | 병목 요인 | +|---------|-------|-------|------|----------| +| **HTTP API (Django)** | ~80-100 req/s | **25 req/s (50VUs)** | k6 스트레스 테스트 실측 | Gunicorn 4 handlers + k6 리소스 경합 | +| **HTTP API (15VUs)** | - | **6.75 req/s (p95=39ms)** | k6 4시나리오 실측 (실제 사용 패턴) | sleep 간격으로 낮은 req/s, 응답은 빠름 | +| **MQTT Subscriber** | ~40 msg/s | **20 msg/s 무손실** | Burst 시나리오 (1200건/60초) | 단일 스레드 loop_forever() | +| **MQTT Publish** | - | **0.37~0.91ms/건** | 3개 시나리오 실측 | 지연 무시 가능 | +| **AMQP Broker** | ~10,000 msg/s | - | RabbitMQ 공식 벤치마크 참고 | 충분한 여유 (병목 없음) | +| **OCR Worker (단건)** | ~0.2 msg/s | **0.2 msg/s** | 단건 실측 (5.1s/건, n=3) | EasyOCR CPU 추론 | +| **OCR Worker (부하 시)** | - | **0.053~0.073 msg/s** | 3개 시나리오 실측 (13.7~18.8s/건) | 메모리 압박 + GCS 경합 | +| **Alert Worker** | ~100 msg/s | - | 추정 (concurrency=100 설정) | FCM API 호출 | +| **MySQL** | ~500 qps | - | 추정 (e2-medium 벤치마크) | e2-medium 4GB RAM | + +> **참고:** HTTP 실측값은 k6가 동일 인스턴스(e2-small)에서 실행된 결과. MQTT Subscriber는 Burst(20 msg/s)에서도 1,200건 전량 수신하여 단일 스레드임에도 충분한 처리량 확인. OCR Worker가 전체 파이프라인의 지배적 병목. + +### 7.2 파이프라인 전체 병목 + +**현재 병목: OCR Worker** + +```mermaid +graph LR + A["HTTP API
25 req/s (실측)"] ~~~ B + B["MQTT Subscriber
20 msg/s 처리 확인"] -->|"병목"| C["OCR Worker
0.06 msg/s (부하시 실측)"] + C --> D["Alert Worker
~100 msg/s (추정)"] + + style C fill:#ff6666 +``` + +**실측 데이터 기반 병목 분석 (3 시나리오 종합):** +- **OCR Worker가 전체 파이프라인의 지배적 병목**임이 3개 시나리오에서 일관되게 확인됨 +- 단건 처리: 5.1s/건 (0.2 msg/s) → **동시 부하 시: 13.7~18.8s/건 (0.053~0.073 msg/s)로 2.7~3.7배 성능 저하** +- Normal(0.33 msg/s)에서도 큐 피크 25, 300초 내 80%만 완료 +- Rush Hour(1.67 msg/s)에서 큐 피크 202, 300초 내 11%만 완료 +- Burst(20 msg/s)에서 큐 피크 1,381, 300초 내 1.5%만 완료 → 드레인 약 6.3시간 소요 +- e2-small(2GB)에서 EasyOCR concurrency=1만 가능 (메모리 제약) + +**해결 방안:** + +| 방법 | 예상 개선 | 비용 | 난이도 | +|------|----------|------|-------| +| OCR 인스턴스 추가 (horizontal) | 0.053 msg/s × N | 저 | 낮음 | +| GPU 인스턴스 전환 | 5.1s → <1s (5x+) | 중 | 중 | +| e2-medium 업그레이드 | 메모리 여유로 부하 시 성능 저하 완화 | 저 | 낮음 | +| 경량 OCR 모델 (PaddleOCR) | ~2-3x 빠름 | 저 | 중 | +| Edge 전처리 | 이미지 크기 감소 | 저 | 낮음 | + +--- + +## 8. 발견된 이슈 및 개선점 + +### 8.1 해결된 이슈 + +| 이슈 | 원인 | 해결 방법 | +|------|------|----------| +| **MQTT Subscriber Stale DB Connection** | 장기 실행 스레드에서 MySQL 연결 만료 | `close_old_connections()` 추가로 해결 | +| **OCR Worker OOM** | EasyOCR 모델 × 4 workers = 6GB (e2-small 2GB 초과) | concurrency=1로 조정 | +| **GCS 인증** | JSON 키 파일 없음 | ADC(메타데이터 서버) 활용으로 해결 | + +### 8.2 개선이 필요한 부분 + +#### 8.2.1 긴급 (High Priority) + +| 이슈 | 현재 상태 | 영향도 | 개선 방안 | +|------|----------|--------|----------| +| **FCM 토큰 업데이트 API** | PATCH 엔드포인트 0% 성공률 | 🔴 High | API endpoint 로직 수정 | +| **OCR Worker 확장성** | 단일 워커 0.2 msg/s | 🔴 High | GPU 인스턴스 또는 경량 OCR 모델 검토 | +| **모니터링 인스턴스 메모리** | 264MB 여유 (메모리 부족 위험) | 🟡 Medium | e2-medium 업그레이드 권장 | + +#### 8.2.2 최적화 (Medium Priority) + +| 이슈 | 현재 상태 | 영향도 | 개선 방안 | +|------|----------|--------|----------| +| **CONN_MAX_AGE 미설정** | 매 요청 새 DB 커넥션 | 🟡 Medium | 커넥션 풀링 설정 (성능 10-20% 개선 예상) | +| **MQTT Subscriber 단일 스레드** | 병목 시 메시지 큐잉 | 🟡 Medium | 스레드풀 or 멀티 프로세스 검토 | +| **speedcam-ocr 디스크 사용률** | 87% (17GB/20GB) | 🟡 Medium | 디스크 정리 또는 확장 | + +#### 8.2.3 장기 개선 (Low Priority) + +| 항목 | 목표 | 예상 효과 | +|------|------|----------| +| **읽기 복제본 추가** | MySQL 읽기 부하 분산 | 쿼리 성능 향상 | +| **Redis 캐싱** | 통계 조회 캐싱 | API 응답 속도 향상 | +| **Celery Beat 추가** | 주기적 작업 자동화 | 운영 효율성 향상 | + +--- + +## 9. 결론 + +### 9.1 핵심 성과 + +SpeedCam 시스템은 기존 동기식 HTTP 기반 모놀리식 아키텍처에서 **Event Driven Architecture**로 성공적으로 전환하였습니다. + +**정량적 성과:** + +| 지표 | Before | After | 개선 | 근거 | +|------|--------|-------|------|------| +| 이벤트 처리 시간 | 3,000ms+ ¹ | **15ms** | **200배** | After 실측 (n=4) | +| Edge Device 블로킹 | 3,000ms+ ¹ | **0ms** | **완전 해소** | MQTT PUBACK | +| HTTP API p95 | N/A | **38.85ms** | - | k6 4시나리오 실측 (n=2,297) | +| MQTT 발행 성공률 | N/A | **100%** (20 msg/s까지) | - | MQTT 3시나리오 실측 (n=1,440) | +| 메시지 보장 | 없음 | **QoS 1** | **무손실** | 프로토콜 사양 + DLQ 0건 실측 | +| 스파이크 에러율 | 서버 다운 위험 ¹ | **0%** | **고가용성** | k6 실측 (15 VUs) | + +> ¹ Before 수치는 동기 OCR 처리 구조 기반 설계 추정값 (별도 부하 테스트 미수행) + +**정성적 성과:** + +1. **장애 격리**: OCR 장애 시에도 API 정상 운영 가능 +2. **독립 확장**: Worker별 독립적 스케일 아웃 +3. **완전한 관측성**: Prometheus + Grafana + Loki + Jaeger 통합 모니터링 +4. **IoT 최적화**: MQTT QoS 1로 메시지 전달 보장 + +### 9.2 개선 로드맵 + +#### Phase 1: 즉시 개선 (1-2주) +- [ ] FCM 토큰 업데이트 API 버그 수정 +- [ ] `CONN_MAX_AGE` 설정으로 DB 커넥션 풀링 활성화 +- [ ] speedcam-ocr 디스크 정리 + +#### Phase 2: 성능 개선 (1개월) +- [ ] OCR Worker GPU 인스턴스 전환 (5s → <1s 목표) +- [ ] 모니터링 인스턴스 e2-medium 업그레이드 +- [ ] MQTT Subscriber 멀티스레딩 구현 + +#### Phase 3: 장기 최적화 (2-3개월) +- [ ] Redis 캐싱 레이어 추가 +- [ ] MySQL 읽기 복제본 구성 +- [ ] Celery Beat 스케줄러 추가 +- [ ] 이미지 전처리 파이프라인 구축 + +### 9.3 최종 평가 + +SpeedCam 프로젝트는 **Event Driven Architecture**를 통해 기존 모놀리식 구조의 근본적 한계를 극복하고, 실시간 IoT 시스템으로서 요구되는 **높은 응답성**, **메시지 보장**, **장애 격리**를 모두 달성했습니다. + +특히 **이벤트 처리 시간 200배 개선 (3,000ms+ → 15ms)**, **완전한 비동기 처리**, **컴포넌트별 독립 확장**이라는 핵심 목표를 성공적으로 구현하여, 프로덕션 환경에서 안정적으로 운영 가능한 시스템으로 발전했습니다. + +앞으로 OCR Worker GPU 전환과 DB 커넥션 풀링 최적화를 통해 더욱 빠르고 효율적인 시스템으로 발전할 것으로 기대됩니다. + +--- + +**문서 버전:** 2.0 +**최종 수정일:** 2026-02-12 +**테스트 일시:** 2026-02-12 (k6 HTTP 4시나리오 + MQTT 3시나리오) +**작성자:** SpeedCam Backend Team +**관련 문서:** [ARCHITECTURE_COMPARISON.md](./ARCHITECTURE_COMPARISON.md) From e11b50a9dfbdd500fb95cda8159f8adea8ceea83 Mon Sep 17 00:00:00 2001 From: sanghun Date: Fri, 13 Feb 2026 05:15:03 +0900 Subject: [PATCH 3/7] fix: close stale DB connections in MQTT subscriber thread Add close_old_connections() before DB operations in the long-running MQTT subscriber thread to prevent stale connection errors. --- core/mqtt/subscriber.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/mqtt/subscriber.py b/core/mqtt/subscriber.py index f37bbe1..1523909 100644 --- a/core/mqtt/subscriber.py +++ b/core/mqtt/subscriber.py @@ -5,6 +5,7 @@ import os import paho.mqtt.client as mqtt +from django.db import close_old_connections from django.utils import timezone from django.utils.dateparse import parse_datetime @@ -64,6 +65,9 @@ def on_message(self, client, userdata, msg): payload = json.loads(msg.payload.decode()) logger.info(f"Received MQTT message: {payload.get('camera_id')}") + # 장기 실행 스레드에서 stale DB 연결 방지 + close_old_connections() + # Import here to avoid circular imports from apps.detections.models import Detection from tasks.ocr_tasks import process_ocr From aed38aa452b0eee956d533159bf24b96fb41f3f8 Mon Sep 17 00:00:00 2001 From: sanghun Date: Fri, 13 Feb 2026 05:23:48 +0900 Subject: [PATCH 4/7] docs: fix sequence diagrams to show correct MQTT flow Edge devices publish directly to RabbitMQ (MQTT plugin), not to Django. Django subscribes from RabbitMQ, not receives from edge devices. --- docs/ARCHITECTURE_COMPARISON.md | 36 ++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/ARCHITECTURE_COMPARISON.md b/docs/ARCHITECTURE_COMPARISON.md index 98e82f2..5e47b00 100644 --- a/docs/ARCHITECTURE_COMPARISON.md +++ b/docs/ARCHITECTURE_COMPARISON.md @@ -233,17 +233,19 @@ OCR 처리를 Django에서 분리하여 전용 Worker가 이벤트를 구독하 sequenceDiagram participant E1 as Edge Device 1 participant E2 as Edge Device 2 - participant M as main (Django) participant Q as RabbitMQ + participant M as main (Django) participant O as ocr-worker - E1->>M: MQTT (과속 감지) - M->>Q: 감지 이벤트 발행 - M-->>E1: ACK (즉시) + E1->>Q: MQTT Publish (과속 감지) + Q-->>E1: PUBACK (즉시) + Q->>M: 메시지 전달 (subscribe) + M->>Q: 감지 이벤트 발행 (AMQP) - E2->>M: MQTT (과속 감지) - M->>Q: 감지 이벤트 발행 - M-->>E2: ACK (즉시) + E2->>Q: MQTT Publish (과속 감지) + Q-->>E2: PUBACK (즉시) + Q->>M: 메시지 전달 (subscribe) + M->>Q: 감지 이벤트 발행 (AMQP) Q->>O: 이벤트 1 수신 Q->>O: 이벤트 2 수신 @@ -274,14 +276,15 @@ sequenceDiagram participant Q as RabbitMQ Camera->>Pi: 과속 차량 #1 감지 - Pi->>M: MQTT Publish - M->>Q: 이벤트 발행 - M-->>Pi: ACK (즉시) + Pi->>Q: MQTT Publish + Q-->>Pi: PUBACK (즉시) Note over Pi: ✅ 즉시 복귀 + Q->>M: 메시지 전달 (subscribe) + M->>Q: 이벤트 발행 (AMQP) Camera->>Pi: 과속 차량 #2 감지 - Pi->>M: MQTT Publish - M-->>Pi: ACK (즉시) + Pi->>Q: MQTT Publish + Q-->>Pi: PUBACK (즉시) Note over Pi: ✅ 연속 감지 가능 ``` @@ -403,16 +406,17 @@ docker-compose up -d --scale ocr-worker=3 ```mermaid sequenceDiagram participant Edge as Edge Device - participant Main as main participant RMQ as RabbitMQ + participant Main as main participant OCR as ocr-worker participant Alert as alert-worker participant User as 사용자 앱 - Edge->>Main: MQTT (과속 차량 감지) + Edge->>RMQ: MQTT Publish (과속 차량 감지) + RMQ-->>Edge: PUBACK (즉시) + RMQ->>Main: 메시지 전달 (subscribe) Main->>Main: DB 저장 (pending) - Main->>RMQ: 과속 감지 이벤트 발행 - Main-->>Edge: ACK + Main->>RMQ: 감지 이벤트 발행 (AMQP) RMQ->>OCR: 감지 이벤트 수신 OCR->>OCR: 번호판 OCR 처리 From aa0c30b95199a7ffa9ecf48cf951c3ace61223cd Mon Sep 17 00:00:00 2001 From: sanghun Date: Fri, 27 Feb 2026 07:37:55 +0900 Subject: [PATCH 5/7] chore: update README, load test scripts, Grafana dashboards and datasource config - Rewrite README.md as Software Design Document (Korean) - Enhance k6 load-test.js with improved scenarios and thresholds - Update mqtt-load-test.py with refined test parameters - Add 8 Grafana dashboard JSON provisioning files - Fix datasources.yml configuration --- README.md | 991 ++++++---- docker/grafana/dashboards/celery-workers.json | 1547 ++++++++++++++++ .../dashboards/django-api-performance.json | 1280 +++++++++++++ .../dashboards/infrastructure-overview.json | 1270 +++++++++++++ docker/grafana/dashboards/logs-explorer.json | 505 +++++ docker/grafana/dashboards/mysql-overview.json | 1236 +++++++++++++ docker/grafana/dashboards/pipeline-e2e.json | 823 +++++++++ .../grafana/dashboards/rabbitmq-overview.json | 1404 ++++++++++++++ .../dashboards/speedcam-load-test.json | 1645 +++++++++++++++++ docker/k6/load-test.js | 235 ++- docker/k6/mqtt-load-test.py | 75 +- .../provisioning/datasources/datasources.yml | 4 +- 12 files changed, 10629 insertions(+), 386 deletions(-) create mode 100644 docker/grafana/dashboards/celery-workers.json create mode 100644 docker/grafana/dashboards/django-api-performance.json create mode 100644 docker/grafana/dashboards/infrastructure-overview.json create mode 100644 docker/grafana/dashboards/logs-explorer.json create mode 100644 docker/grafana/dashboards/mysql-overview.json create mode 100644 docker/grafana/dashboards/pipeline-e2e.json create mode 100644 docker/grafana/dashboards/rabbitmq-overview.json create mode 100644 docker/grafana/dashboards/speedcam-load-test.json diff --git a/README.md b/README.md index c676064..b270cef 100644 --- a/README.md +++ b/README.md @@ -1,401 +1,648 @@ -# Overspeed vehicle detection and alert system - with Qualcomm +# 과속 차량 감지 및 알림 시스템 — Backend -
- -
2025.03.20-2024.05.30
-

https://autonotify.store

-

실시간 과속탐지 및 알림 시스템

-

Rubik Pi 보드에서 YOLO 기반 객체 감지와 속도 측정을 통해 과속 차량을 탐지하고,
-서버로 정보를 전송해 실시간 알림까지 제공하는 스마트 교통 시스템

-
-
+### Software Design Document -

🖥️ Demo

-

시연 영상

-https://www.youtube.com/watch?v=FDzbjOeika8 +| 항목 | 내용 | +|------|------| +| **Authors** | 이상훈 (Backend Lead / DevOps) | +| **Status** | Living Document | +| **Last Updated** | 2026-02-19 | +| **Repository Scope** | Django API, Celery Worker 소스코드, Dockerfile, 로컬 개발 환경 | -

메인페이지(과속 차량 목록보기)

- -

과속 차량 개별 보기

- -

알림 확인하기

- +--- +## 목차 +1. [배경 및 범위](#1-배경-및-범위-context--scope) +2. [목표 및 비목표](#2-목표-및-비목표-goals--non-goals) +3. [시스템 아키텍처](#3-시스템-아키텍처-system-architecture) +4. [상세 설계](#4-상세-설계-detailed-design) +5. [대안 분석](#5-대안-분석-alternatives-considered) +6. [공통 관심사](#6-공통-관심사-cross-cutting-concerns) +7. [기술 스택](#7-기술-스택) +8. [프로젝트 구조](#8-프로젝트-구조) +9. [로컬 개발 환경](#9-로컬-개발-환경) +10. [성능 및 부하 테스트](#10-성능-및-부하-테스트) +11. [팀](#11-팀) +12. [관련 문서](#12-관련-문서) +13. [변경 이력](#13-변경-이력) -
-
-
+--- +## 1. 배경 및 범위 (Context & Scope) -

🏛️ System Architechture

-image +Qualcomm 기반 Rubik Pi 엣지 디바이스에서 YOLO 객체 감지와 속도 측정을 통해 과속 차량을 탐지하고, Google Cloud Storage에 이미지를 업로드한 뒤 MQTT 프로토콜로 서버에 전송한다. 서버는 EasyOCR로 번호판을 인식하고, 매칭된 차량 소유자에게 Firebase Cloud Messaging 푸시 알림을 전송한다. -[자세히 보기](https://www.notion.so/2f33187fa1c980e1895cfef39b2c8ec7?pvs=21) +이 문서는 **백엔드 시스템**의 설계를 다룬다. 엣지 디바이스(Rubik Pi)와 프론트엔드(React)는 범위에 포함하지 않는다. -### 기존 시스템 아키텍처의 문제점 +### 저장소 책임 범위 -기존 아키텍처는 다음과 위와 같은 구조(Before)를 가지고 있었습니다 +| 항목 | 이 저장소 (backend) | deploy 저장소 | +|------|:---:|:---:| +| 애플리케이션 소스코드 | O | X | +| Dockerfile (3개) | O | X | +| 로컬 개발 docker-compose | O | X | +| GitHub Actions CI | O | X | +| 프로덕션 compose / 배포 | X | O | +| 프로덕션 모니터링 설정 | X | O | + +--- + +## 2. 목표 및 비목표 (Goals & Non-Goals) -이 구조에서 다음과 같은 **4가지 핵심 문제**가 발생했습니다. +### Goals -- OCR 동기 처리로 인한 서버 처리량 저하 -- 느린 응답으로 인한 Edge Device 블로킹 -- HTTP 기반 IoT 통신의 구조적 한계 -- OCR 장애가 전체 서비스에 전파 +- **Event-Driven Architecture (Choreography Pattern)** 으로 서비스 간 느슨한 결합 달성 +- **MQTT 프로토콜**로 IoT 디바이스 통신 최적화 (경량, QoS 1, At-least-once) +- 서비스별 **독립 배포 및 수평 확장** 가능한 구조 +- **Database per Service** 패턴으로 데이터 격리 및 장애 전파 차단 +- OpenTelemetry 기반 **분산 트레이싱, 메트릭, 로그** 관측성 확보 +- Dead Letter Queue를 통한 **메시지 유실 방지** 및 장애 복구 -### 새로운 아키텍처의 설계 +### Non-Goals -위 문제들을 해결하기 위해 **Event Driven Architecture**로 전환했습니다. +- 엣지 디바이스(Rubik Pi) 소프트웨어 설계 +- 프론트엔드(React) 설계 +- Kubernetes 기반 오케스트레이션 (현재 Docker Compose 기반) +- 실시간 영상 스트리밍 +- Multi-region 배포 -- 비동기 이벤트 처리로 서버 처리량 극대화 -- 즉시 응답으로 Edge Device 해방 -- MQTT 프로토콜로 IoT 최적화 -- 완전한 장애 격리와 독립적 확장 +--- -### 정리 +## 3. 시스템 아키텍처 (System Architecture) -**Before vs After 비교** +### 3.1 아키텍처 진화: Before → After -| 문제 영역 | Before | After | -| --- | --- | --- | -| **OCR 처리** | Django 동기 (블로킹) | OCR-Worker 비동기 | +기존 모놀리식 구조에서 발견된 4가지 핵심 문제를 해결하기 위해 Event-Driven Architecture로 전환했다. + +| 영역 | Before | After | +|------|--------|-------| +| **OCR 처리** | Django 동기 (블로킹) | OCR Worker 비동기 (Celery prefork) | | **응답 시간** | 3초+ | < 100ms | -| **IoT 프로토콜** | HTTP (오버헤드) | MQTT (경량, QoS) | -| **메시지 보장** | 없음 | At least once | +| **IoT 프로토콜** | HTTP (오버헤드) | MQTT (경량, QoS 1) | +| **메시지 보장** | 없음 | At-least-once | | **장애 격리** | 전체 영향 | 컴포넌트 격리 | | **확장성** | 서버 전체 확장 | Worker별 독립 확장 | | **데이터베이스** | 단일 DB | 서비스별 4개 DB | +| **Alert 처리** | Celery 직접 호출 (Orchestration) | Kombu Consumer + Celery gevent (Choreography) | + +> 📸 캡처 1. 시스템 전체 아키텍처 다이어그램 (Before vs After) + +### 3.2 인스턴스 배포 구조 + +6개의 GCE 인스턴스로 구성되며, 모두 `asia-northeast3-a` 리전에 배치된다. + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ GCP (asia-northeast3-a) │ +├───────────┬───────────┬───────────┬───────────┬───────────┬─────────┤ +│ app │ db │ mq │ ocr │ alert │ mon │ +│ │ │ │ │ │ │ +│ Django │ MySQL 8 │ RabbitMQ │ Celery │ Kombu │ Prome- │ +│ Gunicorn │ 4 DBs │ MQTT │ prefork │ Consumer │ theus │ +│ MQTT Sub │ │ AMQP │ │ + Celery │ Grafana│ +│ │ │ │ │ gevent │ Loki │ +│ │ │ │ │ │ Jaeger │ +└───────────┴───────────┴───────────┴───────────┴───────────┴─────────┘ +``` + +| 인스턴스 | 역할 | 주요 컴포넌트 | +|----------|------|--------------| +| `speedcam-app` | API 서버 + 이벤트 수신 | Django, Gunicorn, MQTT Subscriber | +| `speedcam-db` | 데이터베이스 | MySQL 8.0 (4개 DB) | +| `speedcam-mq` | 메시지 브로커 | RabbitMQ (MQTT Plugin + AMQP) | +| `speedcam-ocr` | OCR 처리 | Celery Worker (prefork pool) | +| `speedcam-alert` | 알림 처리 | Kombu Consumer + Celery Worker (gevent pool) | +| `speedcam-mon` | 모니터링 | Prometheus, Grafana, Loki, Jaeger | + +### 3.3 End-to-End 데이터 흐름 + +```mermaid +sequenceDiagram + participant Pi as Rubik Pi + participant GCS as Cloud Storage + participant MQTT as RabbitMQ
(MQTT) + participant Main as Main Service
(Django) + participant AMQP as RabbitMQ
(AMQP) + participant OCR as OCR Worker + participant DomEvt as domain_events
Exchange + participant Kombu as Kombu Consumer + participant Alert as Celery gevent
Worker + participant FCM as Firebase FCM + participant DDB as detections_db + participant VDB as vehicles_db + participant NDB as notifications_db + + Note over Pi: 과속 차량 감지 + + Pi->>GCS: 1. 이미지 업로드 + Pi->>MQTT: 2. MQTT Publish (detections/new, QoS 1) + + MQTT->>Main: 3. MQTT Subscribe + Main->>DDB: 4. Detection 생성 (status=pending) + Main->>AMQP: 5. process_ocr.apply_async() → ocr_queue + + AMQP->>OCR: 6. Consume from ocr_queue + OCR->>GCS: 7. 이미지 다운로드 + OCR->>OCR: 8. EasyOCR 실행 + OCR->>DDB: 9. OCR 결과 업데이트 (status=completed) + OCR->>VDB: 10. 번호판으로 Vehicle 조회 → vehicle_id 매핑 + OCR->>DomEvt: 11. detections.completed 이벤트 발행 (Choreography) + + DomEvt->>Kombu: 12. alert_domain_events 큐에서 수신 + Kombu->>AMQP: 13. send_notification.delay() → fcm_queue + + AMQP->>Alert: 14. Consume from fcm_queue (greenlet) + Alert->>DDB: 15. Detection 조회 + Alert->>VDB: 16. Vehicle/FCM 토큰 조회 + Alert->>FCM: 17. 푸시 전송 + Alert->>NDB: 18. 알림 이력 저장 +``` + +--- + +## 4. 상세 설계 (Detailed Design) + +### 4.1 메시징 아키텍처 + +두 가지 프로토콜을 목적에 따라 분리하여 사용한다. + +| 프로토콜 | 용도 | 특징 | +|----------|------|------| +| **MQTT** (Port 1883) | IoT → Main Service (`detections/new`) | 경량, QoS 1, Choreography 이벤트 전파 | +| **AMQP** (Port 5672) | Task 분배 + 도메인 이벤트 | Exchange/Queue 라우팅, DLQ 지원 | + +#### Exchange 설계 + +| Exchange | Type | Routing Key | 용도 | +|----------|------|-------------|------| +| `ocr_exchange` | direct | `ocr` | OCR Task 라우팅 | +| `fcm_exchange` | direct | `fcm` | 알림 Task 라우팅 | +| `domain_events` | topic | `detections.completed` | 도메인 이벤트 (Choreography) | +| `dlq_exchange` | fanout | - | Dead Letter 처리 | + +#### Queue 설계 + +| Queue | Exchange | DLQ | TTL | Max Priority | Prefetch | +|-------|----------|:---:|:---:|:---:|:---:| +| `ocr_queue` | ocr_exchange | ✅ | 1h | 10 | 1 | +| `fcm_queue` | fcm_exchange | ✅ | 1h | - | 10 | +| `alert_domain_events` | domain_events | ✅ | - | - | 1 | +| `dlq_queue` | dlq_exchange | - | - | - | 1 | + +- `ocr_queue`: Prefetch 1 — CPU 집약적 OCR은 한 번에 하나씩 처리 +- `fcm_queue`: Prefetch 10 — I/O 대기 시간을 활용하여 다수 메시지 프리페치 + +### 4.2 서비스별 설계 + +#### Main Service (`speedcam-app`) + +``` +[Gunicorn] [MQTT Subscriber] +workers=${GUNICORN_WORKERS} 백그라운드 스레드 (blocking loop) + +REST API 처리 detections/new 수신 +- 과속 내역 조회 → Detection pending 레코드 즉시 생성 +- 차량 등록/조회 → process_ocr.apply_async() +- 알림 이력 조회 +``` + +- Django + Gunicorn (`workers=${GUNICORN_WORKERS:-4}`, `threads=${GUNICORN_THREADS:-2}`) +- MQTT Subscriber: `paho-mqtt` 기반, 별도 스레드에서 `loop_forever()` 실행 +- `detections/new` 수신 시 즉시 `Detection(status=pending)` 레코드 생성 → 데이터 손실 방지 +- OTel instrumented (`speedcam-main`) + +#### OCR Service (`speedcam-ocr`) + +- Celery **prefork** pool (`concurrency=${OCR_CONCURRENCY:-2}`) +- CPU-bound 작업: GCS 다운로드 → EasyOCR + OpenCV → 번호판 파싱 +- 처리 완료 후 `domain_events` exchange에 `detections.completed` 발행 (Choreography) +- Mock 모드 지원 (`OCR_MOCK=true`) +- OTel instrumented (`speedcam-ocr`) + +#### Alert Service (`speedcam-alert`) — 2개 프로세스 구조 + +``` +┌─────────────────────────────┐ ┌──────────────────────────────────┐ +│ 프로세스 1: Kombu Consumer │ │ 프로세스 2: Celery gevent Worker │ +│ 단일 스레드 │ │ --pool=gevent │ +│ │ │ --concurrency=${ALERT_CONCURRENCY}│ +│ domain_events exchange에서 │ │ │ +│ detections.completed 구독 │ │ send_notification 태스크 처리 │ +│ │ │ greenlet 100개 동시 FCM 전송 │ +│ → send_notification.delay() │──→│ (I/O-bound 병렬 처리) │ +│ (즉시 반환, 비동기) │ │ │ +│ │ │ │ +│ OTel: speedcam-alert- │ │ OTel: speedcam-alert │ +│ consumer │ │ │ +└─────────────────────────────┘ └──────────────────────────────────┘ +``` + +- `start_alert_worker.sh`에서 두 프로세스를 `trap` 기반으로 lifecycle 관리 +- **필수 환경변수**: `OTEL_PYTHON_AUTO_INSTRUMENTATION_EXPERIMENTAL_GEVENT_PATCH=patch_all` + - OTel auto-instrumentation이 gevent monkey-patching보다 먼저 로드되면 Django DB thread-safety 이슈 발생 + - 이 환경변수로 OTel 초기화 전에 `gevent.monkey.patch_all()` 수행 + - 상세 분석: [docs/GEVENT_DB_THREAD_SAFETY.md](docs/GEVENT_DB_THREAD_SAFETY.md) +- Mock 모드 지원 (`FCM_MOCK=true`) + +### 4.3 데이터베이스 설계 (Database per Service) + +MSA 환경에서 각 서비스는 독립적인 데이터베이스를 사용하여 느슨한 결합을 유지한다. + +| 서비스 | 데이터베이스 | 용도 | +|--------|-------------|------| +| Django Core | `speedcam` | Auth, Admin, Sessions, Celery Results | +| Vehicles | `speedcam_vehicles` | 차량 정보, FCM 토큰 | +| Detections | `speedcam_detections` | 과속 감지 내역, OCR 결과 | +| Notifications | `speedcam_notifications` | 알림 전송 이력 | + +- **ForeignKey 대신 ID Reference**: MSA 원칙에 따라 cross-DB FK 관계를 사용하지 않음 +- **Django Database Router** (`config/db_router.py`): `app_label` 기반 자동 라우팅, `allow_relation = False` + +```mermaid +erDiagram + vehicles { + bigint id PK + varchar plate_number UK "번호판" + varchar owner_name "소유자명" + varchar owner_phone "연락처" + varchar fcm_token "FCM 토큰" + datetime created_at + datetime updated_at + } + + detections { + bigint id PK + bigint vehicle_id "차량 ID (ID Reference)" + float detected_speed "감지 속도" + float speed_limit "제한 속도" + varchar location "위치" + varchar camera_id "카메라 ID" + varchar image_gcs_uri "GCS 이미지 경로" + varchar ocr_result "OCR 결과" + float ocr_confidence "OCR 신뢰도" + datetime detected_at "감지 시간" + datetime processed_at "처리 완료 시간" + enum status "pending/processing/completed/failed" + text error_message + datetime created_at + datetime updated_at + } + + notifications { + bigint id PK + bigint detection_id "감지 ID (ID Reference)" + varchar fcm_token "FCM 토큰" + varchar title "알림 제목" + text body "알림 내용" + datetime sent_at "전송 시간" + enum status "pending/sent/failed" + int retry_count "재시도 횟수" + text error_message + datetime created_at + } +``` + +``` +┌─────────────────┐ ID Reference ┌─────────────────┐ +│ vehicles_db │ ◄───────────────────── │ detections_db │ +│ Vehicle │ vehicle_id │ Detection │ +└─────────────────┘ └────────┬────────┘ + │ ID Reference + │ detection_id + ┌────────▼────────┐ + │notifications_db │ + │ Notification │ + └─────────────────┘ +``` + +### 4.4 Celery 설정 + +| 설정 | 값 | 이유 | +|------|-----|------| +| `task_serializer` | json | 범용성, 디버깅 용이 | +| `timezone` | Asia/Seoul | 한국 시간대 기준 | +| `task_acks_late` | True | Worker 비정상 종료 시 메시지 재전달 | +| `task_reject_on_worker_lost` | True | Worker 소실 시 메시지 reject → DLQ | +| `task_time_limit` | 300s | Hard timeout | +| `task_soft_time_limit` | 240s | Soft timeout (SoftTimeLimitExceeded) | +| `worker_prefetch_multiplier` | 1 | 공정한 분배 | + +Task 라우팅: + +| Task | Queue | Exchange | +|------|-------|----------| +| `tasks.ocr_tasks.process_ocr` | `ocr_queue` | `ocr_exchange` | +| `tasks.notification_tasks.send_notification` | `fcm_queue` | `fcm_exchange` | +| `tasks.dlq_tasks.process_dlq_message` | `dlq_queue` | `dlq_exchange` | + +--- + +## 5. 대안 분석 (Alternatives Considered) + +### 5.1 Choreography vs Orchestration + +| 항목 | Choreography (선택) | Orchestration | +|------|:---:|:---:| +| **구조** | 각 서비스가 자율적으로 동작 | 중앙 Orchestrator가 제어 | +| **결합도** | 느슨한 결합 ✅ | 강한 결합 | +| **확장성** | 서비스별 독립 확장 ✅ | Orchestrator 병목 가능 | +| **장애 격리** | 영향 최소 ✅ | 중앙 장애 시 전체 중단 | +| **디버깅** | 흐름 추적 어려움 | 중앙 추적 용이 | + +**선택 이유**: 각 인스턴스(Main, OCR, Alert)가 독립적으로 배포/확장되며, OCR Worker가 직접 DB를 업데이트하여 Main Service 병목을 제거한다. 흐름 추적의 어려움은 OpenTelemetry 분산 트레이싱으로 보완한다. + +### 5.2 RabbitMQ vs Google Cloud Pub/Sub + +| 항목 | RabbitMQ (선택) | Cloud Pub/Sub | +|------|:---:|:---:| +| **MQTT 지원** | Plugin으로 지원 ✅ | 미지원 (별도 브릿지 필요) | +| **지연 시간** | 낮음 (VPC 내부) ✅ | 상대적으로 높음 | +| **비용** | 인스턴스 비용만 ✅ | 메시지 수 기반 과금 | +| **Priority Queue** | 지원 ✅ | 미지원 | +| **관리 부담** | 직접 운영 필요 | 완전 관리형 | + +**선택 이유**: Rubik Pi가 MQTT 프로토콜을 사용하므로 RabbitMQ MQTT Plugin으로 직접 연결할 수 있고, VPC 내부 통신으로 낮은 지연 시간을 확보한다. + +### 5.3 prefork vs gevent Pool + +| 항목 | prefork | gevent | +|------|---------|--------| +| **방식** | 멀티프로세싱 | 코루틴 (Greenlet) | +| **GIL 영향** | 회피 가능 ✅ | 영향 받음 | +| **적합한 작업** | CPU-bound ✅ | I/O-bound ✅ | +| **동시성** | 프로세스 수 제한 | 수천 개 가능 | + +**적용 전략**: + +| Worker | Pool | 이유 | +|--------|------|------| +| OCR Worker | `prefork` | EasyOCR은 CPU 집약적, GIL 회피 필요 | +| Alert Worker | `gevent` | FCM API 호출은 I/O 대기, 높은 동시성 필요 | + +### 5.4 Single DB vs Database per Service + +| 항목 | Single DB | Database per Service (선택) | +|------|:---:|:---:| +| **결합도** | 높음 (스키마 공유) | 낮음 ✅ | +| **독립 배포** | 어려움 | 가능 ✅ | +| **데이터 일관성** | 트랜잭션 보장 | 최종 일관성 | +| **조인 쿼리** | 가능 | 불가 (Application Join) | + +**선택 이유**: MSA 원칙 준수로 서비스 간 느슨한 결합을 달성하고, 한 서비스의 DB 장애가 다른 서비스에 영향을 최소화한다. + +--- + +## 6. 공통 관심사 (Cross-cutting Concerns) + +### 6.1 관측성 (Observability) + +| 영역 | 도구 | 용도 | +|------|------|------| +| **Metrics** | Prometheus + Grafana + cAdvisor | 시스템/컨테이너 메트릭 (11 targets) | +| **Logging** | Loki + Promtail v3.3.2 | 중앙 집중식 로그 수집 (16 containers) | +| **Tracing** | OpenTelemetry + Jaeger | 분산 트레이싱 (서비스 간 요청 추적) | +| **Task Monitoring** | Flower | Celery Task 상태 모니터링 | +| **Queue Dashboard** | RabbitMQ Management | Queue 상태 확인 | + +> 📸 캡처 2. Grafana 대시보드 (시스템 메트릭 개요) + +> 📸 캡처 3. Jaeger 트레이싱 (E2E 요청 추적 — MQTT 수신부터 FCM 전송까지) + +> 📸 캡처 4. RabbitMQ Management (Queue 상태 및 메시지 처리량) + +### 6.2 CI/CD + +**CI** (이 저장소 — GitHub Actions): + +| Workflow | 내용 | +|----------|------| +| `lint.yml` | flake8, black, isort 코드 품질 검사 | +| `test.yml` | pytest (main, ocr, alert 3개 워크플로우) | +| `docker-build.yml` | 3개 Docker 이미지 빌드 검증 | + +- Trigger: `push` / `pull_request` to `develop` +- Python 3.12, pip cache 활용 + +**CD**: 별도 deploy 저장소에서 관리 + +### 6.3 데이터 손실 방지 + +- **MQTT QoS 1**: At-least-once 전달 보장 +- **Pending 레코드 즉시 생성**: MQTT 메시지 수신 즉시 `Detection(status=pending)` 생성 → OCR 실패해도 감지 사실 추적 가능 +- **Dead Letter Queue**: 실패한 메시지를 `dlq_queue`로 라우팅하여 별도 처리 +- **Celery acks_late**: Worker 비정상 종료 시 메시지가 재전달됨 +- **task_reject_on_worker_lost**: Worker 소실 시 메시지 reject → DLQ 전달 + +### 6.4 보안 + +- MQTT/AMQP 인증 필수 (`RABBITMQ_MQTT_ALLOW_ANONYMOUS=false`) +- GCP Service Account 기반 GCS/Firebase 인증 +- `credentials/` 디렉토리 Git 제외 (`.gitignore`) +- CORS 설정으로 허용 Origin 제한 +- Django `SECRET_KEY` 환경변수 관리 + +--- + +## 7. 기술 스택 + +| 구분 | 기술 | 버전 | +|------|------|------| +| Language | Python | 3.12 | +| Framework | Django | 5.1.7 | +| API | Django REST Framework | 3.15.2 | +| WSGI Server | Gunicorn | 23.0.0 | +| Task Queue | Celery | 5.5.2 | +| Message Broker | RabbitMQ | 3.13+ | +| RDBMS | MySQL | 8.0 | +| OCR Engine | EasyOCR | 1.7.2 | +| Image Processing | OpenCV | 4.10.0 | +| Object Storage | Google Cloud Storage | 2.18.2 | +| Push Notification | Firebase Admin SDK | 6.8.0 | +| Async Pool | gevent | 24.2.1 | +| Tracing | OpenTelemetry + Jaeger | - | +| Metrics | Prometheus + Grafana | - | +| Logging | Loki + Promtail | 3.3.2 | +| Container | Docker | 29.x | +| CI | GitHub Actions | - | + +--- + +## 8. 프로젝트 구조 + +각 서비스는 동일한 코드베이스를 공유하되, 실행 시 역할에 따라 다른 컴포넌트만 활성화한다. + +``` +backend/ +├── .github/workflows/ # CI (lint, test, docker-build) +│ ├── lint.yml +│ ├── test.yml +│ └── docker-build.yml +│ +├── apps/ # Django Apps (서비스별 독립 DB) +│ ├── vehicles/ # → vehicles_db +│ ├── detections/ # → detections_db +│ └── notifications/ # → notifications_db +│ +├── config/ # Django / Celery 설정 +│ ├── settings/ # base.py, dev.py, prod.py +│ ├── celery.py # Exchange / Queue / Routing 정의 +│ ├── db_router.py # MSA Database Router +│ ├── urls.py +│ └── wsgi.py +│ +├── core/ # 공통 모듈 +│ ├── mqtt/ # MQTT Subscriber / Publisher +│ │ ├── subscriber.py # detections/new 수신 → Detection 생성 → OCR 발행 +│ │ └── publisher.py # 도메인 이벤트 발행 (detections/completed) +│ ├── events/ # AMQP 도메인 이벤트 +│ │ └── consumer.py # Kombu Consumer (Alert Service용) +│ ├── gcs/ # Google Cloud Storage 클라이언트 +│ └── firebase/ # FCM 클라이언트 +│ +├── tasks/ # Celery Tasks +│ ├── ocr_tasks.py # process_ocr (OCR Service) +│ ├── notification_tasks.py # send_notification (Alert Service) +│ └── dlq_tasks.py # DLQ 메시지 처리 +│ +├── scripts/ # 서비스 시작 스크립트 +│ ├── start_main.sh # Django + MQTT Subscriber +│ ├── start_ocr_worker.sh # Celery prefork Worker +│ └── start_alert_worker.sh # Kombu Consumer + Celery gevent Worker +│ +├── docker/ # Docker / 인프라 설정 +│ ├── Dockerfile.main # Main Service 이미지 +│ ├── Dockerfile.ocr # OCR Service 이미지 +│ ├── Dockerfile.alert # Alert Service 이미지 +│ ├── docker-compose.yml # 로컬 개발 환경 +│ ├── mysql/ +│ │ └── init.sql # Multi-DB 초기화 +│ ├── rabbitmq/ +│ │ └── enabled_plugins # MQTT Plugin 활성화 +│ └── k6/ # 부하 테스트 스크립트 +│ └── mqtt-load-test.py +│ +├── tests/ # 테스트 +│ ├── conftest.py +│ ├── unit/ +│ └── integration/ +│ +├── docs/ # 설계 문서 +├── credentials/ # 인증 정보 (Git 제외) +├── requirements/ # 서비스별 의존성 +│ ├── base.txt # 공통 +│ ├── main.txt # Main Service +│ ├── ocr.txt # OCR Service +│ └── alert.txt # Alert Service +│ +├── manage.py +├── pytest.ini +└── backend.env.example # 환경변수 템플릿 +``` + +--- + +## 9. 로컬 개발 환경 + +### Quick Start + +```bash +# 1. Clone +git clone +cd backend + +# 2. 환경변수 설정 +cp backend.env.example backend.env +# backend.env를 에디터에서 편집 + +# 3. Docker Compose 실행 +cd docker +docker-compose up -d --build + +# 4. 접속 확인 +# API Server: http://localhost:8000 +# Swagger UI: http://localhost:8000/swagger/ +# RabbitMQ Mgmt: http://localhost:15672 (sa / 1234) +# Flower: http://localhost:5555 +``` + +### 주요 환경변수 + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `DJANGO_SETTINGS_MODULE` | `config.settings.dev` | Django 설정 모듈 | +| `GUNICORN_WORKERS` | 4 | Gunicorn 워커 수 | +| `GUNICORN_THREADS` | 2 | 워커당 스레드 수 | +| `OCR_CONCURRENCY` | 2 | OCR Worker 프로세스 수 | +| `ALERT_CONCURRENCY` | 50 | Alert Worker greenlet 수 | +| `OCR_MOCK` | true | OCR Mock 모드 (로컬 개발용) | +| `FCM_MOCK` | true | FCM Mock 모드 (로컬 개발용) | +| `LOG_LEVEL` | info | 로깅 레벨 | + +전체 환경변수 목록은 [`backend.env.example`](backend.env.example)을 참고한다. + +--- + +## 10. 성능 및 부하 테스트 + +### 테스트 도구 + +Python 기반 MQTT 부하 테스트 스크립트 (`docker/k6/mqtt-load-test.py`)를 사용하여 E2E 파이프라인을 검증한다. + +### 테스트 시나리오 + +| 시나리오 | Workers | Rate | Duration | 총 메시지율 | +|----------|:---:|:---:|:---:|:---:| +| `normal` | 20 | 1/min | 120s | ~0.33 msg/s | +| `rush_hour` | 20 | 5/min | 120s | ~1.67 msg/s | +| `burst` | 20 | 1/s | 60s | ~20 msg/s | + +### 검증 방법 + +- Django API 폴링: Detection 상태 변화 추적 (pending → completed) +- RabbitMQ Management API: Queue 메시지 소비 확인 +- PipelineVerifier: 발행/수신/처리/알림 각 단계 검증 + +### 실측 결과 + +상세 테스트 계획 및 분석 결과는 별도 문서를 참고한다: +- [부하 테스트 계획](docs/load-test-plan.md) +- [성능 분석](docs/performance-analysis.md) + +> 📸 캡처 5. 부하 테스트 실행 결과 (터미널 출력) + +> 📸 캡처 6. 부하 테스트 중 Grafana 메트릭 (CPU, Memory, Queue depth) + +--- + +## 11. 팀 + +| 이름 | 역할 | GitHub | +|------|------|--------| +| 이상훈 | Leader / Backend / DevOps | [@lsh1215](https://github.com/lsh1215) | +| 진민우 | Rubik Pi / Tracking / YOLO | [@Jminu](https://github.com/Jminu) | +| 최명헌 | Backend | [@choimh331](https://github.com/choimh331) | +| 서정찬 | Frontend | [@Jeongchan-Seo](https://github.com/Jeongchan-Seo) | -**기존 아키텍처의 근본적 한계**였던 **OCR 동기 처리**를 제거하고, **Event Driven Architecture**로 전환함으로써 - -1. **서버 처리량 극대화**: API 서버는 이벤트 발행만 담당, OCR은 별도 Worker가 병렬 처리 -2. **Edge Device 효율화**: 즉시 응답으로 연속 감지 가능, 데이터 유실 방지 -3. **IoT 최적화**: MQTT 프로토콜로 경량화, 메시지 전달 보장, 오프라인 대응 -4. **운영 안정성**: 장애 격리, 독립적 확장, 이벤트 보존으로 시스템 복원력 확보 -
-
- -

🛠️ Tech Stack

-
-

Frontend

- - - - - - -
-
-

Backend

- - - - - - - -
-
-

Infra

- - - - - - - - -
-
-

etc

- - - - - - - -
-
-
- -
- -

Notification System Design

- -

Django - RabbitMQ - Celery - FCM(3rd party Service) feat. Ack & Nack

- -

Dead Letter Queue & Dead Letter Consumer

- - - - -

reference

- -- System Deisgn interview (Alex Xu) -- 분산 시스템에서 데이터를 전달하는 효율적인 방법 - nhn 김병부 - -

Rubik Pi 3

-Qualcomm 기반 Rubik Pi 하드웨어에서 YOLO 객체 탐지와 GStreamer를 활용해, -실시간으로 과속 차량을 감지하는 완전한 엣지 기반 시스템. -카메라 입력부터 추론, 트래킹, 속도 측정, 과속 차량 촬영까지 모든 과정을 로컬에서 처리하므로 클라우드 연산 불필요. - -## Rubik Tech Stack - -| Category | Technologies | -|----------------------|-----------------------------------------------------------------| -| **Hardware** | Rubik Pi 3, IMX477 image sensor, 10MP HQ Lens(16mm) | -| **Object Detection** | YOLOv5m | -| **Acceleration** | Qualcomm SNPE + TFLite delegate | -| **Pipeline** | GStreamer | -| **Programming** | Python | -| **Features** | On-device tracking, speed measurement, snapshot, multithreading | - -## Object Tracking (IoU) - - - -IoU를 계산하여, 다음프레임의 객체가 같은 객체인지 판단 - -## Speed Measurement - -### Method 1 (Not Used) - - - -프레임간 중심 좌표의 이동거리 변화로 속도를 측정 - -### Method 2 (✅Selected) - - - -가상의 두 선을 그어놓고, 두 선을 동과하는데 걸리는 시간을 측정 +--- -하지만, 이 방법은 가상의 두 선 사이의 실제 도로 거리를 알아야 정확히 측정 가능 +## 12. 관련 문서 -## Multi Threading +| 문서 | 내용 | +|------|------| +| [아키텍처 진화 과정](docs/ARCHITECTURE_COMPARISON.md) | Before → After 아키텍처 상세 비교 | +| [부하 테스트 계획](docs/load-test-plan.md) | 시나리오별 테스트 설계 및 환경 | +| [성능 분석](docs/performance-analysis.md) | 병목 분석 및 최적화 방향 | +| [Gevent DB Thread-Safety](docs/GEVENT_DB_THREAD_SAFETY.md) | OTel + gevent 조합의 DB 이슈 분석 및 해결 | +| [PRD](docs/PRD.md) | 시스템 전체 요구사항 정의서 | -병목 현상을 최소화 하기 위해서 멀티 스레딩을 사용 +--- -+ 메인 스레드 -+ 트래킹, 속도 측정 스레드 -+ 사진촬영 및 전송 스레드 +## 13. 변경 이력 -
-
- -

📁 API

-

Swagger

- -

Postman

- - -
- -

🔍 Monitoring

-

Portainer

- - - -

RabbitMQ

- - -

Flower(celery monitoring

- - -
-

📓 How to Start

- -### Clone Repository - -docker repository를 클론합니다. - - - -
- Frontend - -### Install Packages - -패키지 설치를 합니다. - - ``` - npm install - ``` - -### Add Environment Files - -환경 파일을 생성해 줍니다. - -#### .env - - ``` - VITE_API_BASE_URL=http://localhost:8000/api - VITE_FIREBASE_API_KEY=YOUR_FIREBASE_API_KEY - VITE_FIREBASE_AUTH_DOMAIN=YOUR_FIREBASE_AUTH_DOMAIN - VITE_FIREBASE_PROJECT_ID=YOUR_FIREBASE_PROJECT_ID - VITE_FIREBASE_STORAGE_BUCKET=YOUR_FIREBASE_STORAGE_BUCKET - VITE_FIREBASE_MESSAGING_SENDER_ID=YOUR_SENDER_ID - VITE_FIREBASE_APP_ID=YOUR_FIREBASE_APP_ID - VITE_FIREBASE_VAPID_KEY=YOUR_FIREBASE_VAPID_KEY - ``` - -### Getting Started - -마지막으로 개발 서버를 열어줍니다. - - ``` - npm run dev - ``` - -### See Result - -http://localhost:5173 에 접속하여 결과물을 조회합니다. - -
- - -
- Backend - -### Add Environment Files(.env) - -**/.env** - - ``` - DATABASE_NAME= capstone - DATABASE_USER= sa - DATABASE_PASS= 1234 - DATABASE_HOST= - DATABASE_PORT= - SECRET_KEY= - - - ``` - - ``` - - - - ``` - -### Docker Run Command - -백엔드 서비스를 시작하기 위해 다음 Docker Compose 명령어를 실행합니다. - - ```bash - docker-compose -p teaml -f Solomon-Docker/docker-compose.prod.yml up -d -—build - ``` - -
-
- -

Member

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Pictures - - - - - - - - - - - - - - - -
Name이상훈진민우최명헌서정찬
Position - Leader
- Backend
- DevOps
- Design
-
- Rubik Pi
- Tracking
- Calculate
- YOLO
-
- Backend
-
- Frontend
-
GitHub - - - - - - - - - - - - - - - -
- - - -
-
- -## 저장소 책임 범위 - -> "Django API와 Celery Worker의 소스 코드, Dockerfile, 로컬 개발 환경을 소유한다" - -| 항목 | 이 저장소 | depoly 저장소 | -|------|----------|--------------| -| 애플리케이션 소스 코드 | O | X | -| Dockerfile (3개) | O | X | -| 로컬 개발 docker-compose | O | X | -| 로컬 개발 모니터링 설정 | O | X | -| 로컬 개발 env.example | O | X | -| GitHub Actions CI (빌드/테스트) | O | X | -| 프로덕션 compose 파일 | X | O | -| 프로덕션 모니터링 설정 | X | O | -| 프로덕션 env 템플릿 | X | O | -| GitHub Actions CD (배포) | X | O | -| 배포 스크립트/문서 | X | O | +| 버전 | 날짜 | 변경 내용 | +|------|------|----------| +| 1.0 | 2025-03 | 프로젝트 초기 README | +| 2.0 | 2026-01 | MSA Database 분리, Event-Driven Architecture 적용 | +| 3.0 | 2026-02 | Choreography 패턴 전환, Alert Worker 분리 (Kombu + gevent) | +| 4.0 | 2026-02 | Software Design Document로 전면 개편 | diff --git a/docker/grafana/dashboards/celery-workers.json b/docker/grafana/dashboards/celery-workers.json new file mode 100644 index 0000000..1e77ee8 --- /dev/null +++ b/docker/grafana/dashboards/celery-workers.json @@ -0,0 +1,1547 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 100, + "panels": [], + "title": "Worker Status", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "green", + "value": 2 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_active_worker_count", + "refId": "A" + } + ], + "title": "Active Workers", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 2, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_active_process_count", + "refId": "A" + } + ], + "title": "Active Processes", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 12, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 9 + }, + { + "color": "red", + "value": 12 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 3, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(celery_worker_tasks_active{hostname=~\"speedcam-ocr.*\"})", + "refId": "A" + } + ], + "title": "OCR Workers Tasks Active (x3)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 80 + }, + { + "color": "red", + "value": 100 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 4, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_worker_tasks_active{hostname=\"speedcam-alert\"}", + "refId": "A" + } + ], + "title": "Alert Worker Tasks Active", + "type": "gauge" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 101, + "panels": [], + "title": "Queue Length", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_queue_length", + "legendFormat": "{{queue_name}}", + "refId": "A" + } + ], + "title": "Queue Length", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_worker_tasks_active", + "legendFormat": "{{hostname}}", + "refId": "A" + } + ], + "title": "Active Tasks per Worker", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 105, + "panels": [], + "title": "OCR Scale-Out (3 Workers)", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 0, + "mappings": [], + "max": 3, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_worker_tasks_active{hostname=\"speedcam-ocr\"}", + "legendFormat": "OCR Worker 1 (c=1)", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_worker_tasks_active{hostname=\"speedcam-ocr-2\"}", + "legendFormat": "OCR Worker 2 (c=2)", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_worker_tasks_active{hostname=\"speedcam-ocr-3\"}", + "legendFormat": "OCR Worker 3 (c=2)", + "refId": "C" + } + ], + "title": "워커별 활성 태스크", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\",hostname=\"speedcam-ocr\"}[1m]) * 60", + "legendFormat": "OCR Worker 1 (c=1)", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\",hostname=\"speedcam-ocr-2\"}[1m]) * 60", + "legendFormat": "OCR Worker 2 (c=2)", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\",hostname=\"speedcam-ocr-3\"}[1m]) * 60", + "legendFormat": "OCR Worker 3 (c=2)", + "refId": "C" + } + ], + "title": "워커별 처리 완료율 (tasks/min)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 102, + "panels": [], + "title": "Task Throughput", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 24 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_received_total[1m])", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "Tasks Received Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 24 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_succeeded_total[1m])", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "Tasks Succeeded Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "tasks.ocr_tasks.process_ocr" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "tasks.alert_tasks.send_speed_alert" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 24 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_failed_total[1m])", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "Tasks Failed Rate", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 103, + "panels": [], + "title": "Task Runtime", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 33 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.5, rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m]))", + "legendFormat": "P50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m]))", + "legendFormat": "P95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.99, rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m]))", + "legendFormat": "P99", + "refId": "C" + } + ], + "title": "OCR Task P50/P95/P99", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 33 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.5, rate(celery_task_runtime_bucket{name=\"tasks.alert_tasks.send_speed_alert\"}[5m]))", + "legendFormat": "P50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, rate(celery_task_runtime_bucket{name=\"tasks.alert_tasks.send_speed_alert\"}[5m]))", + "legendFormat": "P95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.99, rate(celery_task_runtime_bucket{name=\"tasks.alert_tasks.send_speed_alert\"}[5m]))", + "legendFormat": "P99", + "refId": "C" + } + ], + "title": "Alert Task P50/P95/P99", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 33 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_runtime_sum[1m]) / rate(celery_task_runtime_count[1m])", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "Average Runtime", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 41 + }, + "id": 104, + "panels": [], + "title": "Task Success/Failure Totals", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 42 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_task_succeeded_total", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "Cumulative Tasks Succeeded", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 42 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_task_failed_total", + "legendFormat": "{{name}} failed", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_task_retried_total", + "legendFormat": "{{name}} retried", + "refId": "B" + } + ], + "title": "Cumulative Tasks Failed + Retried", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "style": "dark", + "tags": [ + "celery", + "worker", + "speedcam" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Celery Workers", + "uid": "celery-workers", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/docker/grafana/dashboards/django-api-performance.json b/docker/grafana/dashboards/django-api-performance.json new file mode 100644 index 0000000..ea81389 --- /dev/null +++ b/docker/grafana/dashboards/django-api-performance.json @@ -0,0 +1,1280 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 100, + "panels": [], + "title": "Overview", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(django_http_responses_total_by_status_total[1m]))", + "legendFormat": "RPS", + "refId": "A" + } + ], + "title": "Total RPS", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 4 + }, + { + "color": "red", + "value": 8 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "speedcam_http_server_active_requests", + "legendFormat": "Active Requests", + "refId": "A" + } + ], + "title": "Active Requests", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(django_http_responses_total_by_status_total{status=~\"5..\"}[1m])) / sum(rate(django_http_responses_total_by_status_total[1m])) * 100", + "legendFormat": "Error Rate", + "refId": "A" + } + ], + "title": "Error Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.1 + }, + { + "color": "red", + "value": 0.5 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 4, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(django_http_requests_latency_seconds_by_view_method_sum{view=~\"$view\"}[1m])) / sum(rate(django_http_requests_latency_seconds_by_view_method_count{view=~\"$view\"}[1m]))", + "legendFormat": "Avg Response Time", + "refId": "A" + } + ], + "title": "Avg Response Time", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 101, + "panels": [], + "title": "Response Time", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum by (le, view) (rate(django_http_requests_latency_seconds_by_view_method_bucket{view=~\"$view\"}[1m])))", + "legendFormat": "{{view}}", + "refId": "A" + } + ], + "title": "View별 응답 시간 P95", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.50, sum by (le) (rate(django_http_requests_latency_seconds_by_view_method_bucket{view=~\"$view\"}[1m])))", + "legendFormat": "P50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(django_http_requests_latency_seconds_by_view_method_bucket{view=~\"$view\"}[1m])))", + "legendFormat": "P95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.99, sum by (le) (rate(django_http_requests_latency_seconds_by_view_method_bucket{view=~\"$view\"}[1m])))", + "legendFormat": "P99", + "refId": "C" + } + ], + "title": "응답 시간 분포 P50/P95/P99", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 102, + "panels": [], + "title": "Request Rate & Status", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (view) (rate(django_http_responses_total_by_status_view_method_total{view=~\"$view\"}[1m]))", + "legendFormat": "{{view}}", + "refId": "A" + } + ], + "title": "요청 수 by View", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "200" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "201" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "301" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "302" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "400" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "404" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "500" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (status) (rate(django_http_responses_total_by_status_total[1m]))", + "legendFormat": "{{status}}", + "refId": "A" + } + ], + "title": "응답 Status Code 분포", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 103, + "panels": [], + "title": "HTTP Method & Body Size", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 9, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (method) (rate(django_http_requests_total_by_method_total[1m]))", + "legendFormat": "{{method}}", + "refId": "A" + } + ], + "title": "요청 Method 분포", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(django_http_responses_body_total_bytes_bucket[1m])))", + "legendFormat": "P95 Response Body", + "refId": "A" + } + ], + "title": "응답 Body Size P95", + "type": "timeseries" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 104, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 33 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(speedcam_http_server_duration_milliseconds_bucket[1m])))", + "legendFormat": "P95 Duration", + "refId": "A" + } + ], + "title": "OTel 서버 응답 시간", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 33 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "speedcam_http_server_active_requests", + "legendFormat": "Active Requests", + "refId": "A" + } + ], + "title": "OTel Active Requests", + "type": "timeseries" + } + ], + "title": "OTel Server Metrics", + "type": "row" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 105, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.1 + }, + { + "color": "red", + "value": 0.5 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 13, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "text": {} + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "topk(10, histogram_quantile(0.95, sum by (le, view) (rate(django_http_requests_latency_seconds_by_view_method_bucket[1m]))))", + "legendFormat": "{{view}}", + "refId": "A" + } + ], + "title": "가장 느린 View Top 10", + "type": "bargauge" + } + ], + "title": "Slow Views", + "type": "row" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "style": "dark", + "tags": [ + "django", + "api", + "performance", + "gunicorn" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "definition": "label_values(django_http_requests_latency_seconds_by_view_method_count, view)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "view", + "options": [], + "query": { + "query": "label_values(django_http_requests_latency_seconds_by_view_method_count, view)", + "refId": "StandardVariableQuery" + }, + "refresh": 2, + "regex": "/^(?!health_check|prometheus-django-metrics).*/", + "sort": 1, + "type": "query", + "label": "View", + "allValue": ".+" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Django API Performance", + "uid": "django-api-performance", + "version": 1 +} diff --git a/docker/grafana/dashboards/infrastructure-overview.json b/docker/grafana/dashboards/infrastructure-overview.json new file mode 100644 index 0000000..7e02885 --- /dev/null +++ b/docker/grafana/dashboards/infrastructure-overview.json @@ -0,0 +1,1270 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 100, + "panels": [], + "title": "Cluster Summary", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "count(container_memory_working_set_bytes{name=~\".+\"})", + "instant": true, + "refId": "A" + } + ], + "title": "Total Containers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg(rate(container_cpu_usage_seconds_total{name=~\"speedcam-.+\"}[1m])) * 100", + "instant": true, + "refId": "A" + } + ], + "title": "Total CPU Usage %", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(container_memory_working_set_bytes{name=~\"speedcam-.+\"})", + "instant": true, + "refId": "A" + } + ], + "title": "Total Memory Usage", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 4, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(container_network_receive_bytes_total{name=~\"speedcam-.+\"}[1m])) + sum(rate(container_network_transmit_bytes_total{name=~\"speedcam-.+\"}[1m]))", + "instant": true, + "refId": "A" + } + ], + "title": "Network I/O", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 101, + "panels": [], + "title": "CPU Usage by Container", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(container_cpu_usage_seconds_total{name=~\"speedcam-.+\"}[1m]) * 100", + "legendFormat": "{{name}} ({{instance}})", + "refId": "A" + } + ], + "title": "CPU Usage % by Service", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "label_replace(label_replace(label_replace(label_replace(label_replace(label_replace(label_replace(label_replace(sum by (instance) (rate(container_cpu_usage_seconds_total{name=~\"speedcam-.+\"}[1m])) * 100, \"instance\", \"app\", \"instance\", \"10.178.0.4:8080\"), \"instance\", \"db\", \"instance\", \"10.178.0.2:8080\"), \"instance\", \"mq\", \"instance\", \"10.178.0.7:8080\"), \"instance\", \"ocr-1\", \"instance\", \"10.178.0.3:8080\"), \"instance\", \"ocr-2\", \"instance\", \"10.178.0.10:8080\"), \"instance\", \"ocr-3\", \"instance\", \"10.178.0.11:8080\"), \"instance\", \"alert\", \"instance\", \"10.178.0.6:8080\"), \"instance\", \"mon\", \"instance\", \"localhost:8080\")", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "title": "CPU Usage % by Instance", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 102, + "panels": [], + "title": "Memory Usage", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 15 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "container_memory_working_set_bytes{name=~\"speedcam-.+\"}", + "legendFormat": "{{name}} ({{instance}})", + "refId": "A" + } + ], + "title": "Memory Working Set by Service", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 15 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "label_replace(label_replace(label_replace(label_replace(label_replace(label_replace(label_replace(label_replace(sum by (instance) (container_memory_rss{name=~\"speedcam-.+\"}), \"instance\", \"app\", \"instance\", \"10.178.0.4:8080\"), \"instance\", \"db\", \"instance\", \"10.178.0.2:8080\"), \"instance\", \"mq\", \"instance\", \"10.178.0.7:8080\"), \"instance\", \"ocr-1\", \"instance\", \"10.178.0.3:8080\"), \"instance\", \"ocr-2\", \"instance\", \"10.178.0.10:8080\"), \"instance\", \"ocr-3\", \"instance\", \"10.178.0.11:8080\"), \"instance\", \"alert\", \"instance\", \"10.178.0.6:8080\"), \"instance\", \"mon\", \"instance\", \"localhost:8080\")", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "title": "Memory RSS by Instance", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 103, + "panels": [], + "title": "Network I/O", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(container_network_receive_bytes_total{name=~\"speedcam-.+\"}[1m])", + "legendFormat": "{{name}} ({{instance}})", + "refId": "A" + } + ], + "title": "Network Receive", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 10, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(container_network_transmit_bytes_total{name=~\"speedcam-.+\"}[1m])", + "legendFormat": "{{name}} ({{instance}})", + "refId": "A" + } + ], + "title": "Network Transmit", + "type": "timeseries" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 104, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 33 + }, + "id": 11, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(container_fs_reads_bytes_total{name=~\"speedcam-.+\"}[1m])", + "legendFormat": "{{name}} ({{instance}})", + "refId": "A" + } + ], + "title": "Disk Read", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 33 + }, + "id": 12, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(container_fs_writes_bytes_total{name=~\"speedcam-.+\"}[1m])", + "legendFormat": "{{name}} ({{instance}})", + "refId": "A" + } + ], + "title": "Disk Write", + "type": "timeseries" + } + ], + "title": "Disk I/O", + "type": "row" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 105, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "CPU %" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + }, + { + "id": "custom.cellOptions", + "value": { + "mode": "gradient", + "type": "color-background" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Memory" + }, + "properties": [ + { + "id": "unit", + "value": "bytes" + }, + { + "id": "decimals", + "value": 1 + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 13, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "CPU %" + } + ] + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(container_cpu_usage_seconds_total{name=~\"speedcam-.+\"}[1m]) * 100", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "container_memory_working_set_bytes{name=~\"speedcam-.+\"}", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "refId": "B" + } + ], + "title": "Container Resource Table", + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "job": true + }, + "indexByName": { + "name": 0, + "instance": 1, + "Value #A": 2, + "Value #B": 3 + }, + "renameByName": { + "name": "Container", + "instance": "Instance", + "Value #A": "CPU %", + "Value #B": "Memory" + } + } + } + ], + "type": "table" + } + ], + "title": "Container Details", + "type": "row" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "tags": [ + "infrastructure", + "cadvisor", + "containers", + "speedcam" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Infrastructure Overview", + "uid": "infrastructure-overview", + "version": 1, + "weekStart": "" +} diff --git a/docker/grafana/dashboards/logs-explorer.json b/docker/grafana/dashboards/logs-explorer.json new file mode 100644 index 0000000..18123ba --- /dev/null +++ b/docker/grafana/dashboards/logs-explorer.json @@ -0,0 +1,505 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "panels": [], + "title": "Log Volume", + "type": "row" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 50, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 16, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (container_name) (count_over_time({container_name=~\"$container\"} [1m]))", + "legendFormat": "{{container_name}}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Log Volume by Container", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (container_name) (count_over_time({container_name=~\"$container\"} |~ \"(?i)(error|exception|traceback|critical)\" [1m]))", + "legendFormat": "{{container_name}}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Error Rate", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 7 + }, + "id": 4, + "panels": [], + "title": "Application Logs", + "type": "row" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 5, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{container_name=~\"$container\"} |~ \"(?i)$search\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "Log Stream", + "type": "logs" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 20 + }, + "id": 6, + "panels": [], + "title": "Service-Specific Logs", + "type": "row" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 0, + "y": 21 + }, + "id": 7, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{container_name=\"speedcam-main\"} |~ \"(?i)$search\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "Django App Logs", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 8, + "y": 21 + }, + "id": 8, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{container_name=\"speedcam-ocr\"} |~ \"(?i)$search\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "OCR Worker Logs", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "h": 10, + "w": 8, + "x": 16, + "y": 21 + }, + "id": 9, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{container_name=\"speedcam-alert\"} |~ \"(?i)$search\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "Alert Worker Logs", + "type": "logs" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "tags": [ + "logs", + "loki", + "speedcam" + ], + "templating": { + "list": [ + { + "allValue": ".*", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "definition": "label_values(container_name)", + "hide": 0, + "includeAll": true, + "label": "Container", + "multi": true, + "name": "container", + "options": [], + "query": "label_values(container_name)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "hide": 0, + "includeAll": true, + "label": "Level", + "multi": true, + "name": "level", + "options": [ + { + "selected": false, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "error", + "value": "error" + }, + { + "selected": false, + "text": "warn", + "value": "warn" + }, + { + "selected": false, + "text": "info", + "value": "info" + }, + { + "selected": false, + "text": "debug", + "value": "debug" + } + ], + "query": "error,warn,info,debug", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + }, + { + "current": { + "selected": false, + "text": "", + "value": "" + }, + "hide": 0, + "label": "Search", + "name": "search", + "options": [ + { + "selected": true, + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Logs Explorer", + "uid": "logs-explorer", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/docker/grafana/dashboards/mysql-overview.json b/docker/grafana/dashboards/mysql-overview.json new file mode 100644 index 0000000..505f77f --- /dev/null +++ b/docker/grafana/dashboards/mysql-overview.json @@ -0,0 +1,1236 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 100, + "panels": [], + "title": "Connections & Threads", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "max_connections" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "mysql_global_status_threads_connected", + "legendFormat": "threads_connected", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "mysql_global_status_threads_running", + "legendFormat": "threads_running", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "mysql_global_variables_max_connections", + "legendFormat": "max_connections", + "refId": "C" + } + ], + "title": "Active Threads", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 100 + }, + { + "color": "red", + "value": 150 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "mysql_global_status_max_used_connections", + "refId": "A" + } + ], + "title": "Max Used Connections", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "conn/s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_connections[1m])", + "legendFormat": "connections/s", + "refId": "A" + } + ], + "title": "Connection Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "conn/s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_aborted_connects[1m])", + "legendFormat": "aborted_connects", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_aborted_clients[1m])", + "legendFormat": "aborted_clients", + "refId": "B" + } + ], + "title": "Aborted Connections", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 101, + "panels": [], + "title": "Query Performance", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "qps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 10 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_questions[1m])", + "legendFormat": "QPS", + "refId": "A" + } + ], + "title": "QPS (Queries Per Second)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "qps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_commands_total{command=~\"select|insert|update|delete\"}[1m])", + "legendFormat": "{{command}}", + "refId": "A" + } + ], + "title": "Command Breakdown", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "qps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 10 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_slow_queries[1m])", + "legendFormat": "slow_queries/s", + "refId": "A" + } + ], + "title": "Slow Queries", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "tables/s" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "tmp_disk_tables" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 10 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_created_tmp_tables[1m])", + "legendFormat": "tmp_tables", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_created_tmp_disk_tables[1m])", + "legendFormat": "tmp_disk_tables", + "refId": "B" + } + ], + "title": "Temp Tables", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 102, + "panels": [], + "title": "InnoDB", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 90, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 19 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "100 * (1 - rate(mysql_global_status_innodb_buffer_pool_reads[1m]) / rate(mysql_global_status_innodb_buffer_pool_read_requests[1m]))", + "legendFormat": "hit_rate", + "refId": "A" + } + ], + "title": "Buffer Pool Hit Rate %", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 19 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_innodb_data_read[1m])", + "legendFormat": "read", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_innodb_data_written[1m])", + "legendFormat": "written", + "refId": "B" + } + ], + "title": "InnoDB I/O", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 19 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_bytes_received[1m])", + "legendFormat": "received", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_bytes_sent[1m])", + "legendFormat": "sent", + "refId": "B" + } + ], + "title": "Network Traffic", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "locks/s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 19 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_table_locks_waited[1m])", + "legendFormat": "waited", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(mysql_global_status_table_locks_immediate[1m])", + "legendFormat": "immediate", + "refId": "B" + } + ], + "title": "Table Locks", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "tags": [ + "mysql", + "database", + "speedcam" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "MySQL Overview", + "uid": "mysql-overview", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/docker/grafana/dashboards/pipeline-e2e.json b/docker/grafana/dashboards/pipeline-e2e.json new file mode 100644 index 0000000..4a9b9d8 --- /dev/null +++ b/docker/grafana/dashboards/pipeline-e2e.json @@ -0,0 +1,823 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "panels": [], + "title": "파이프라인 요약", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "red", "value": 100 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=\"mqtt-subscription-django-main-12qos1\"}", + "legendFormat": "MQTT Backlog", + "refId": "A" + } + ], + "title": "MQTT Backlog", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "red", "value": 100 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "celery_queue_length{queue_name=\"ocr_queue\"}", + "legendFormat": "OCR Queue", + "refId": "A" + } + ], + "title": "OCR 큐 대기", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 50 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 }, + "id": 4, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "celery_queue_length{queue_name=\"fcm_queue\"}", + "legendFormat": "FCM Queue", + "refId": "A" + } + ], + "title": "FCM 큐 대기", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 }, + "id": 5, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=\"celery\"}", + "legendFormat": "DLQ Messages", + "refId": "A" + } + ], + "title": "DLQ Messages", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 10, + "panels": [], + "title": "OCR Worker 스케일아웃 (1→3)", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "decimals": 0, + "mappings": [], + "max": 3, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 11, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "celery_worker_tasks_active{hostname=\"speedcam-ocr\"}", + "legendFormat": "OCR Worker 1 (c=1)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "celery_worker_tasks_active{hostname=\"speedcam-ocr-2\"}", + "legendFormat": "OCR Worker 2 (c=2)", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "celery_worker_tasks_active{hostname=\"speedcam-ocr-3\"}", + "legendFormat": "OCR Worker 3 (c=2)", + "refId": "C" + } + ], + "title": "OCR 워커별 활성 태스크", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "tasks/min", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 12, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\",hostname=\"speedcam-ocr\"}[1m]) * 60", + "legendFormat": "OCR Worker 1 (c=1)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\",hostname=\"speedcam-ocr-2\"}[1m]) * 60", + "legendFormat": "OCR Worker 2 (c=2)", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\",hostname=\"speedcam-ocr-3\"}[1m]) * 60", + "legendFormat": "OCR Worker 3 (c=2)", + "refId": "C" + } + ], + "title": "OCR 워커별 처리 완료율", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "id": 20, + "panels": [], + "title": "큐 흐름 (Backpressure)", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "id": 21, + "options": { + "legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=\"ocr_queue\"}", + "legendFormat": "OCR Queue", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=\"fcm_queue\"}", + "legendFormat": "FCM Queue", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=\"alert_domain_events\"}", + "legendFormat": "Domain Events Queue", + "refId": "C" + } + ], + "title": "전체 큐 깊이 추이", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "id": 22, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(celery_task_received_total{name=\"tasks.ocr_tasks.process_ocr\"}[1m])", + "legendFormat": "OCR Received", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\"}[1m])", + "legendFormat": "OCR Completed", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(celery_task_received_total{name=\"tasks.notification_tasks.send_notification\"}[1m])", + "legendFormat": "Alert Received", + "refId": "C" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.notification_tasks.send_notification\"}[1m])", + "legendFormat": "Alert Completed", + "refId": "D" + } + ], + "title": "Publish vs Consume Rate", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, + "id": 30, + "panels": [], + "title": "처리 시간", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "id": 31, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.50, sum(rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m])) by (le))", + "legendFormat": "P50", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.95, sum(rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m])) by (le))", + "legendFormat": "P95", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.99, sum(rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m])) by (le))", + "legendFormat": "P99", + "refId": "C" + } + ], + "title": "OCR P50/P95/P99", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 }, + "id": 32, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.50, sum(rate(celery_task_runtime_bucket{name=\"tasks.notification_tasks.send_notification\"}[5m])) by (le))", + "legendFormat": "P50", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.95, sum(rate(celery_task_runtime_bucket{name=\"tasks.notification_tasks.send_notification\"}[5m])) by (le))", + "legendFormat": "P95", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.99, sum(rate(celery_task_runtime_bucket{name=\"tasks.notification_tasks.send_notification\"}[5m])) by (le))", + "legendFormat": "P99", + "refId": "C" + } + ], + "title": "Alert P50/P95/P99", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 }, + "id": 40, + "panels": [], + "title": "API 디커플링 증거", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "OCR Queue Depth" }, + "properties": [ + { "id": "custom.axisPlacement", "value": "right" }, + { "id": "unit", "value": "short" }, + { "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } } + ] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 33 }, + "id": 41, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.95, sum(rate(django_http_requests_latency_seconds_by_view_method_bucket{view=~\"detections-list|statistics|pending-list|vehicle-list|notification-list\"}[1m])) by (le))", + "legendFormat": "API p95", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "celery_queue_length{queue_name=\"ocr_queue\"}", + "legendFormat": "OCR Queue Depth", + "refId": "B" + } + ], + "title": "HTTP API p95 vs OCR Queue", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "OCR Active Tasks" }, + "properties": [ + { "id": "custom.axisPlacement", "value": "right" }, + { "id": "color", "value": { "fixedColor": "purple", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byName", "options": "HTTP RPS (전체)" }, + "properties": [ + { "id": "unit", "value": "reqps" }, + { "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } } + ] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 33 }, + "id": 42, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(rate(django_http_responses_total_by_status_view_method_total[1m]))", + "legendFormat": "HTTP RPS (전체)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(celery_worker_tasks_active{hostname=~\"speedcam-ocr.*\"})", + "legendFormat": "OCR Active Tasks", + "refId": "B" + } + ], + "title": "HTTP RPS vs OCR Active Tasks", + "type": "timeseries" + }, + { + "collapsed": true, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 41 }, + "id": 50, + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 24, "x": 0, "y": 42 }, + "id": 51, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "### Distributed Tracing (Jaeger)\n\nv2에서 새로 도입된 OpenTelemetry + Jaeger 분산 트레이싱입니다.\n\n- **Jaeger UI**: [http://34.47.70.132:16686](http://34.47.70.132:16686)\n- **Service**: `speedcam` — Django HTTP 요청의 전체 트레이스 확인\n- **OTel Collector**: Prometheus metrics + Jaeger traces 동시 수집\n\n> v1에는 없던 기능으로, 요청의 전체 라이프사이클을 추적할 수 있습니다.", + "mode": "markdown" + }, + "pluginVersion": "10.0.0", + "title": "Jaeger UI 링크", + "type": "text" + } + ], + "title": "분산 트레이싱", + "type": "row" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "tags": ["speedcam", "pipeline", "e2e"], + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "Pipeline End-to-End", + "uid": "pipeline-e2e", + "version": 1 +} diff --git a/docker/grafana/dashboards/rabbitmq-overview.json b/docker/grafana/dashboards/rabbitmq-overview.json new file mode 100644 index 0000000..28470ae --- /dev/null +++ b/docker/grafana/dashboards/rabbitmq-overview.json @@ -0,0 +1,1404 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 100, + "panels": [], + "title": "Global Overview", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_connections{job=\"rabbitmq\"}", + "legendFormat": "Connections", + "refId": "A" + } + ], + "title": "Connections", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_channels{job=\"rabbitmq\"}", + "legendFormat": "Channels", + "refId": "A" + } + ], + "title": "Channels", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_consumers{job=\"rabbitmq\"}", + "legendFormat": "Consumers", + "refId": "A" + } + ], + "title": "Consumers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_disk_space_available_bytes{job=\"rabbitmq\"}", + "legendFormat": "Disk Space", + "refId": "A" + } + ], + "title": "Disk Space Available", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 101, + "panels": [], + "title": "Per-Queue Message State", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=~\"$queue\"}", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Messages Ready (Backlog)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 6 + }, + "id": 6, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_queue_messages_unacked{job=\"rabbitmq-detailed\",queue=~\"$queue\"}", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Messages Unacked (In-Flight)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 6 + }, + "id": 7, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_queue_messages{job=\"rabbitmq-detailed\",queue=~\"$queue\"}", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Queue Depth (Total)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 102, + "panels": [], + "title": "Per-Queue Rates", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 15 + }, + "id": 8, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_queue_messages_published_total{job=\"rabbitmq-detailed\",queue=~\"$queue\"}[1m])", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Publish Rate (/s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 15 + }, + "id": 9, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_channel_messages_delivered_total{job=\"rabbitmq-detailed\",queue=~\"$queue\"}[1m])", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Deliver Rate (/s)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 15 + }, + "id": 10, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_channel_messages_acked_total{job=\"rabbitmq-detailed\",queue=~\"$queue\"}[1m])", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Consumer Ack Rate (/s)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 103, + "panels": [], + "title": "Consumer Health", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "1.0 = consumers keeping up perfectly, <1.0 = falling behind", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 24 + }, + "id": 11, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_queue_consumer_capacity{job=\"rabbitmq-detailed\",queue=~\"$queue\"}", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Consumer Capacity", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 24 + }, + "id": 12, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_queue_consumers{job=\"rabbitmq-detailed\",queue=~\"$queue\"}", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Consumer Count", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 24 + }, + "id": 13, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_channel_messages_unroutable_dropped_total[1m])", + "refId": "A" + } + ], + "title": "Unroutable Messages", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 104, + "panels": [], + "title": "Global Message Rates", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 33 + }, + "id": 14, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_channel_messages_published_total{job=\"rabbitmq\"}[1m])", + "legendFormat": "Published", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_channel_messages_delivered_total{job=\"rabbitmq\"}[1m])", + "legendFormat": "Delivered", + "refId": "B" + } + ], + "title": "Global Published vs Delivered", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 33 + }, + "id": 15, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_channel_messages_acked_total{job=\"rabbitmq\"}[1m])", + "refId": "A" + } + ], + "title": "Global Ack Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 10, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 33 + }, + "id": 16, + "options": { + "tooltip": { + "mode": "single", + "sort": "none" + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_channel_messages_redelivered_total{job=\"rabbitmq\"}[1m])", + "refId": "A" + } + ], + "title": "Redelivered Rate", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "tags": [ + "rabbitmq", + "queue", + "speedcam" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "definition": "label_values(rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\"},queue)", + "hide": 0, + "includeAll": true, + "label": "Queue", + "multi": true, + "name": "queue", + "options": [], + "query": { + "query": "label_values(rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\"},queue)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "ocr_queue|fcm_queue|alert_domain_events|celery", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "RabbitMQ Queue Monitoring", + "uid": "rabbitmq-overview", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/docker/grafana/dashboards/speedcam-load-test.json b/docker/grafana/dashboards/speedcam-load-test.json new file mode 100644 index 0000000..cf5c373 --- /dev/null +++ b/docker/grafana/dashboards/speedcam-load-test.json @@ -0,0 +1,1645 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "panels": [], + "title": "k6 HTTP 부하테스트", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "k6_vus{test_id=~\"$test_id\"}", + "legendFormat": "VUs", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(k6_http_reqs_total{test_id=~\"$test_id\"}[30s])) by (scenario)", + "legendFormat": "{{scenario}}", + "refId": "B" + } + ], + "title": "VUs & RPS", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "max(k6_http_req_duration_p99{test_id=~\"$test_id\"}) by (scenario)", + "legendFormat": "{{scenario}}", + "refId": "A" + } + ], + "title": "응답 시간 (p99)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "k6_errors_rate{test_id=~\"$test_id\"}", + "legendFormat": "에러율", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "k6_http_req_failed_rate{test_id=~\"$test_id\"}", + "legendFormat": "HTTP 실패율", + "refId": "B" + } + ], + "title": "에러율", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "id": 5, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "min"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "k6_checks_rate{test_id=~\"$test_id\"} * 100", + "legendFormat": "Checks 성공률", + "refId": "A" + } + ], + "title": "Checks 성공률", + "type": "timeseries" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 6, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum(rate(django_http_requests_latency_seconds_by_view_method_bucket{view=~\"detections-list|statistics|pending-list|vehicle-list|notification-list\"}[1m])) by (view, le))", + "legendFormat": "{{view}} p95", + "refId": "A" + } + ], + "title": "View별 응답 시간 p95", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 18 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(django_http_responses_total_by_status_view_method_total[1m])) by (status)", + "legendFormat": "Status {{status}}", + "refId": "A" + } + ], + "title": "요청 수 (by status)", + "type": "timeseries" + } + ], + "title": "Django HTTP 서버", + "type": "row" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 9, + "panels": [], + "title": "OCR 파이프라인", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 19 + }, + "id": 10, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.5, sum(rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum(rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m])) by (le))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.99, sum(rate(celery_task_runtime_bucket{name=\"tasks.ocr_tasks.process_ocr\"}[5m])) by (le))", + "legendFormat": "p99", + "refId": "C" + } + ], + "title": "OCR 태스크 처리 시간", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 19 + }, + "id": 11, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=\"ocr_queue\"}", + "legendFormat": "RabbitMQ ocr_queue", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_queue_length{queue_name=\"ocr_queue\"}", + "legendFormat": "Celery ocr_queue", + "refId": "B" + } + ], + "title": "OCR 큐 깊이", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 12, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\"}[1m]) * 60", + "legendFormat": "성공", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(celery_task_failed_total{name=\"tasks.ocr_tasks.process_ocr\"}[1m]) * 60", + "legendFormat": "실패", + "refId": "B" + } + ], + "title": "OCR 처리량 (tasks/min)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 13, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_queue_length{queue_name=\"ocr_queue\"}", + "legendFormat": "대기중", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_worker_tasks_active{name=\"tasks.ocr_tasks.process_ocr\"}", + "legendFormat": "처리중", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(increase(celery_task_succeeded_total{name=\"tasks.ocr_tasks.process_ocr\"}[1h]))", + "legendFormat": "1시간 완료", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(increase(celery_task_failed_total{name=\"tasks.ocr_tasks.process_ocr\"}[1h]))", + "legendFormat": "1시간 실패", + "refId": "D" + } + ], + "title": "OCR 현재 상태", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 35 + }, + "id": 14, + "panels": [], + "title": "알림 파이프라인", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 36 + }, + "id": 15, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.5, sum(rate(celery_task_runtime_bucket{name=\"tasks.notification_tasks.send_notification\"}[5m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum(rate(celery_task_runtime_bucket{name=\"tasks.notification_tasks.send_notification\"}[5m])) by (le))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.99, sum(rate(celery_task_runtime_bucket{name=\"tasks.notification_tasks.send_notification\"}[5m])) by (le))", + "legendFormat": "p99", + "refId": "C" + } + ], + "title": "Alert 태스크 처리 시간", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 36 + }, + "id": 16, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=\"fcm_queue\"}", + "legendFormat": "RabbitMQ fcm_queue", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "celery_queue_length{queue_name=\"fcm_queue\"}", + "legendFormat": "Celery fcm_queue", + "refId": "B" + } + ], + "title": "FCM 큐 깊이", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 44 + }, + "id": 17, + "panels": [], + "title": "컨테이너 리소스", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 45 + }, + "id": 18, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(container_cpu_usage_seconds_total{name=~\"speedcam-main|speedcam-ocr|speedcam-alert|speedcam-rabbitmq|speedcam-mysql\"}[1m]) * 100", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "CPU 사용률", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 45 + }, + "id": 19, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "container_memory_working_set_bytes{name=~\"speedcam-main|speedcam-ocr|speedcam-alert|speedcam-rabbitmq|speedcam-mysql\"}", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "메모리 사용량", + "type": "timeseries" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 53 + }, + "id": 20, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 54 + }, + "id": 21, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rabbitmq_queue_messages_ready{job=\"rabbitmq-detailed\",queue=~\"ocr_queue|fcm_queue|mqtt-subscription.*\"}", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "큐별 메시지 수", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 54 + }, + "id": 22, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rabbitmq_queue_messages_published_total{job=\"rabbitmq-detailed\",queue=~\"ocr_queue|fcm_queue\"}[1m])", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "메시지 발행률", + "type": "timeseries" + } + ], + "title": "RabbitMQ 상세", + "type": "row" + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": ["speedcam", "load-test", "k6"], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "definition": "label_values(k6_vus, test_id)", + "hide": 0, + "includeAll": true, + "label": "Test ID", + "multi": true, + "name": "test_id", + "options": [], + "query": { + "query": "label_values(k6_vus, test_id)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "SpeedCam 부하테스트 종합", + "uid": "speedcam-load-test", + "version": 1, + "weekStart": "" +} diff --git a/docker/k6/load-test.js b/docker/k6/load-test.js index c97a1d9..f0e3e26 100644 --- a/docker/k6/load-test.js +++ b/docker/k6/load-test.js @@ -4,12 +4,75 @@ import { Rate, Trend, Counter } from 'k6/metrics'; import exec from 'k6/execution'; // ============================================================ -// SpeedCam 부하테스트 - 실제 사용 패턴 기반 +// SpeedCam v2 부하테스트 - Event Driven Architecture (비동기 OCR) // ============================================================ -// 인프라: 6x GCP e2-small (2 vCPU, 2 GB RAM) +// 인프라: 8x GCP 인스턴스 (기존 6 + OCR Worker 2대 추가) +// - speedcam-app (10.178.0.4): Django + Gunicorn + MQTT Subscriber (e2-small) +// - speedcam-db (10.178.0.2): MySQL 8.0 (e2-medium) +// - speedcam-mq (10.178.0.7): RabbitMQ MQTT + AMQP (e2-small) +// - speedcam-ocr (10.178.0.3): Celery OCR Worker (e2-small, concurrency=1) +// - speedcam-ocr-2 (10.178.0.11): Celery OCR Worker (e2-medium, concurrency=2) [신규] +// - speedcam-ocr-3 (10.178.0.10): Celery OCR Worker (e2-medium, concurrency=2) [신규] +// - speedcam-alert (10.178.0.6): Kombu Consumer + Celery gevent Worker (e2-small) +// - speedcam-mon (10.178.0.5): Prometheus + Grafana + Loki + Jaeger (e2-small) // HTTP 처리: Gunicorn 2 workers × 2 threads = 4 동시 핸들러 -// 가설 기반 임계치 (load-test-plan.md 참조) +// ★ v2 핵심 특징: OCR이 별도 Worker(speedcam-ocr)에서 비동기 처리 +// - HTTP API는 OCR 부하와 무관하게 동작 +// - OCR 파이프라인 테스트는 mqtt-load-test.py 참조 +// Prometheus Remote Write: K6_PROMETHEUS_RW_SERVER_URL=http://10.178.0.5:9090/api/v1/write +// NOTE: v2 Prometheus는 speedcam-mon(10.178.0.5)에 위치. +// v1 Prometheus(10.178.0.9)와는 별도 인스턴스입니다. +// Output flag: --out experimental-prometheus-rw // ============================================================ +// +// ★ v1과의 핵심 차이 (비교 분석용): +// v1: POST /api/v1/crud/cars → 동기 OCR 실행 → HTTP 스레드 3~10초 점유 +// → OCR 부하가 모든 HTTP 요청의 응답시간에 직접 영향 +// v2: OCR이 별도 Worker(speedcam-ocr)에서 비동기 처리 +// → HTTP API는 OCR 부하와 완전히 분리 +// → 이 스크립트의 결과는 "OCR 영향 없는 순수 HTTP 성능" +// 비교 시: v1 동일 시나리오 결과와 비교하면 OCR 분리 효과를 정량화 가능 +// +// ============================================================ +// 실행 방법 +// ============================================================ +// 1. Prometheus Remote Write 포함 (Grafana 연동): +// K6_PROMETHEUS_RW_SERVER_URL=http://10.178.0.5:9090/api/v1/write \ +// k6 run --out experimental-prometheus-rw \ +// --env MAIN_SERVICE_URL=http://localhost \ +// --env TEST_ID=v2-baseline-$(date +%s) \ +// load-test.js +// +// 2. 콘솔 출력만: +// k6 run \ +// --env MAIN_SERVICE_URL=http://localhost \ +// --env TEST_ID=v2-baseline-$(date +%s) \ +// load-test.js +// +// 실행 위치: speedcam-app 인스턴스 (10.178.0.4 / 34.64.41.106) +// NOTE: MAIN_SERVICE_URL=http://localhost 는 Traefik(port 80)을 통해 +// Django에 접근합니다. 직접 Django 포트(8000)가 아닌 리버스 프록시 경유. +// ============================================================ + +// ============================================================ +// [v2 <-> v1 메트릭 매핑] (비교 분석용) +// ============================================================ +// v2: dashboard_req_duration <-> v1: dashboard_req_duration (대시보드 응답시간, 공통) +// v2: detections_list_duration <-> v1: cars_list_duration (목록 조회) +// v2: statistics_req_duration <-> v1: N/A (v2 전용, v1에 대응 없음) +// v2: pending_read_duration <-> v1: unchecked_req_duration (미처리 목록) +// v2: admin_req_duration <-> v1: N/A (v2 전용, v1은 동기 OCR POST) +// v2: stress_read_duration <-> v1: stress_read_duration (스트레스 읽기, 공통) +// v2: stress_write_duration <-> v1: stress_write_duration (스트레스 쓰기, 공통 이름이나 내용 상이) +// ★ v2 stress_write = 차량 등록 POST (<300ms), v1 stress_write = 동기 OCR POST (3~10초/건) +// → 이 차이 자체가 핵심 비교 포인트: v1은 20% OCR POST가 전체 시스템을 무너뜨리지만, +// v2는 OCR과 무관하므로 안정적 +// v2: errors <-> v1: errors (에러율, 공통) +// v2: total_requests <-> v1: total_requests (요청 수, 공통) +// ============================================================ + +// 테스트 실행 ID — Grafana에서 테스트별 필터링 가능 +const TEST_ID = __ENV.TEST_ID || `v2-${Date.now()}`; // -- 커스텀 메트릭 -- const dashboardLatency = new Trend('dashboard_req_duration', true); @@ -19,6 +82,8 @@ const pendingLatency = new Trend('pending_read_duration', true); const adminLatency = new Trend('admin_req_duration', true); const errorRate = new Rate('errors'); const requestCount = new Counter('total_requests'); +const stressReadLatency = new Trend('stress_read_duration', true); +const stressWriteLatency = new Trend('stress_write_duration', true); const BASE_URL = __ENV.MAIN_SERVICE_URL || 'http://main:8000'; @@ -75,6 +140,9 @@ export function setup() { } console.log(`[Setup] ${vehicles.length}대 테스트 차량 생성 완료`); + console.log(`[Setup] TEST_ID: ${TEST_ID}`); + console.log(`[Setup] 인프라: 8x 인스턴스 (OCR 3대 concurrency=5), Gunicorn 2w×2t = 4 핸들러`); + console.log(`[Setup] ★ v2 비동기 OCR — HTTP API는 OCR 부하와 무관`); return { vehicles }; } @@ -85,6 +153,14 @@ export function setup() { // 모든 임계치는 load-test-plan.md의 가설에서 도출 // ============================================================ export const options = { + tags: { test_id: TEST_ID }, + // 타임라인 (총 약 12분 10초): + // 0m ~ 2m : 시나리오 A (대시보드 폴링) + // 0m ~ 2m : 시나리오 B (관리자 작업, A와 동시) + // 2m ~ 4m30s : 시나리오 C (혼합 워크로드) + // 4m30s ~ 5m40s : 시나리오 D (스파이크) + // 5m40s ~ 9m10s : 시나리오 E (스트레스 - 읽기, 50 VUs) + // 9m10s ~ 12m10s : 시나리오 F (스트레스 - 혼합, 50 VUs) scenarios: { // 시나리오 A: 대시보드 폴링 (주요 읽기 부하) // 사용자가 대시보드를 열어두고 주기적으로 데이터 확인 @@ -147,6 +223,53 @@ export const options = { exec: 'spikeResilience', tags: { scenario: 'spike_resilience' }, }, + + // ---------------------------------------------------------- + // 시나리오 E: 스트레스 - 읽기 전용 (한계점 탐색) + // ---------------------------------------------------------- + // 50 VU → 4 핸들러 = 12.5배 초과 구독 + // v2 핵심: OCR이 분리되어 있으므로 핸들러 전부 읽기에 가용 + // v1 대비: v1은 OCR 잔류 부하로 가용 핸들러가 더 적을 수 있음 + // 가설: p95 < 5000ms, < 20% 에러 + // ---------------------------------------------------------- + stress_ramp: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, + { duration: '30s', target: 30 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 30 }, + { duration: '1m', target: 0 }, + ], + startTime: '5m40s', + gracefulStop: '30s', + exec: 'stressRampV2', + tags: { scenario: 'stress_ramp' }, + }, + + // ---------------------------------------------------------- + // 시나리오 F: 스트레스 - 혼합 (80% 읽기 + 20% 차량 등록) + // ---------------------------------------------------------- + // ★ v1 비교 핵심: v1은 20% 동기 OCR POST (3~10초/건) → 시스템 붕괴 + // v2는 20% 차량 등록 POST (<300ms) → OCR과 무관 → 안정 + // 동일 50 VUs, 동일 80/20 비율에서 시스템 안정성 차이 측정 + // 가설: p95 < 30000ms, < 30% 에러 + // ---------------------------------------------------------- + stress_mixed: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, + { duration: '30s', target: 30 }, + { duration: '1m', target: 50 }, + { duration: '1m', target: 0 }, + ], + startTime: '9m10s', + gracefulStop: '30s', + exec: 'stressMixedV2', + tags: { scenario: 'stress_mixed' }, + }, }, // 임계치: 4 핸들러 기준으로 보정 (GUNICORN_WORKERS=2) @@ -160,6 +283,13 @@ export const options = { 'errors': ['rate<0.05'], // 전체 에러율: 5% 미만 'errors{scenario:dashboard_polling}': ['rate<0.01'], // 대시보드: 1% 미만 'errors{scenario:spike_resilience}': ['rate<0.10'], // 스파이크: 10% 미만 + // 스트레스 읽기: 50 VUs → 큐잉 심각, 5초 허용 + 'stress_read_duration': ['p(95)<5000'], + // 스트레스 혼합 쓰기(차량등록): 50 VUs에서도 빠른 응답 예상 (OCR 없음) + 'stress_write_duration': ['p(95)<30000'], + // 스트레스 에러율 + 'errors{scenario:stress_ramp}': ['rate<0.20'], + 'errors{scenario:stress_mixed}': ['rate<0.30'], }, }; @@ -364,6 +494,96 @@ export function spikeResilience() { sleep(1); // 스파이크 시 빠른 요청 (1초 간격) } +// ============================================================ +// 시나리오 E: 스트레스 - 읽기 전용 (한계점 탐색) +// ============================================================ +// 50 VU → v2 엔드포인트 (detections, notifications, statistics) +// v1과 동일 VU 프로파일 → 핸들러 포화 시점 비교 +// v2는 OCR 분리로 순수 읽기 성능만 측정 +// ============================================================ +export function stressRampV2() { + group('스트레스 - 읽기', () => { + const endpoints = [ + '/api/v1/detections/', + '/api/v1/notifications/', + '/api/v1/detections/statistics/', + ]; + const endpoint = endpoints[Math.floor(Math.random() * endpoints.length)]; + const res = http.get(`${BASE_URL}${endpoint}`, { + tags: { endpoint: 'stress_read' }, + }); + check(res, { + '스트레스 읽기 200': (r) => r.status === 200, + }); + errorRate.add(res.status !== 200); + stressReadLatency.add(res.timings.duration); + requestCount.add(1); + }); + + sleep(1); // 빠른 요청 (1초 간격) +} + +// ============================================================ +// 시나리오 F: 스트레스 - 혼합 (80% 읽기 + 20% 차량 등록) +// ============================================================ +// ★ 핵심 비교 시나리오: +// v1: 20% 동기 OCR POST (3~10초/건) → 핸들러 점유 → 시스템 붕괴 +// v2: 20% 차량 등록 POST (<300ms) → 핸들러 즉시 반환 → 안정 +// 동일 50 VUs, 동일 80/20 비율에서 시스템 안정성 차이를 측정 +// ============================================================ +export function stressMixedV2() { + if (Math.random() < 0.8) { + // 80%: 읽기 - v2 엔드포인트 + group('스트레스 혼합 - 읽기', () => { + const endpoints = [ + '/api/v1/detections/', + '/api/v1/notifications/', + '/api/v1/detections/statistics/', + ]; + const endpoint = endpoints[Math.floor(Math.random() * endpoints.length)]; + const res = http.get(`${BASE_URL}${endpoint}`, { + tags: { endpoint: 'stress_mixed_read' }, + }); + check(res, { + '스트레스 혼합 읽기 200': (r) => r.status === 200, + }); + errorRate.add(res.status !== 200); + stressReadLatency.add(res.timings.duration); + requestCount.add(1); + }); + + sleep(1); + } else { + // 20%: 차량 등록 쓰기 (v2에는 동기 OCR 없음 → POST /vehicles) + // ★ 비교 주의: v1 쓰기 사이클 = sleep(3) + OCR(3~10초) = 6~13초/건 + // v2 쓰기 사이클 = sleep(3) + POST(<300ms) = ~3.3초/건 + // → 동일 50 VUs에서 v2가 v1보다 약 2~4배 더 많은 쓰기 요청 발생 + // → v2 응답시간이 짧은 것은 "OCR 분리" 효과이지 "요청 수가 적어서"가 아님 + group('스트레스 혼합 - 차량 등록', () => { + const plate = randomPlate(); + const payload = JSON.stringify({ + plate_number: plate, + owner_name: `스트레스테스트_${Date.now()}`, + owner_phone: `010-${Math.floor(Math.random() * 9000) + 1000}-${Math.floor(Math.random() * 9000) + 1000}`, + }); + + const res = http.post(`${BASE_URL}/api/v1/vehicles/`, payload, { + headers: { 'Content-Type': 'application/json' }, + tags: { endpoint: 'stress_mixed_write' }, + }); + const ok = res.status === 201; + check(res, { + '스트레스 혼합 차량 등록 성공': () => ok, + }); + errorRate.add(!ok); + stressWriteLatency.add(res.timings.duration); + requestCount.add(1); + }); + + sleep(3); + } +} + // ============================================================ // teardown: 테스트 데이터 정리 (선택적) // ============================================================ @@ -376,4 +596,13 @@ export function teardown(data) { } console.log(`[Teardown] ${deleted}/${data.vehicles.length}대 테스트 차량 삭제 완료`); } + console.log(`[Teardown] v2 부하테스트 완료 (TEST_ID: ${TEST_ID})`); + console.log('[Teardown] 주요 측정 포인트:'); + console.log(' - dashboard_req_duration: 대시보드 읽기 응답 시간 (목표: p95 < 200ms)'); + console.log(' - detections_list_duration: 감지 목록 응답 시간 (목표: p95 < 300ms)'); + console.log(' - stress_read_duration: 스트레스 읽기 응답 시간 (목표: p95 < 5000ms)'); + console.log(' - stress_write_duration: 스트레스 쓰기(차량등록) 응답 시간 (목표: p95 < 30000ms)'); + console.log(' - errors{scenario:stress_ramp}: 스트레스 읽기 에러율 (목표: < 20%)'); + console.log(' - errors{scenario:stress_mixed}: 스트레스 혼합 에러율 (목표: < 30%)'); + console.log(`[Teardown] Grafana: http://10.178.0.5:3000 → k6 dashboard 확인`); } diff --git a/docker/k6/mqtt-load-test.py b/docker/k6/mqtt-load-test.py index b56cf0c..0eff126 100644 --- a/docker/k6/mqtt-load-test.py +++ b/docker/k6/mqtt-load-test.py @@ -5,12 +5,21 @@ 실제 사용 패턴 기반 시나리오: - normal: 정상 운영 (20대 카메라, 1건/분) - rush_hour: 러시아워 (20대 카메라, 5건/분) - - burst: 버스트 스톰 (20대 카메라, 1건/초) + - burst: 버스트 스톰 (10대 카메라, 1건/초) 파이프라인 검증: MQTT 발행 → Detection(pending) → OCR Worker → Alert Worker → 완료 -인프라 기준: 6x GCP e2-small (2 vCPU, 2 GB RAM) +인프라 기준: 8x GCP 인스턴스 (OCR Worker 3대 확장) + - speedcam-app (e2-small): Django + Gunicorn + MQTT Subscriber + - speedcam-db (e2-medium): MySQL 8.0 + - speedcam-mq (e2-small): RabbitMQ (MQTT + AMQP) + - speedcam-ocr (e2-small): Celery OCR Worker (concurrency=1) + - speedcam-ocr-2 (e2-medium): Celery OCR Worker (concurrency=2) + - speedcam-ocr-3 (e2-medium): Celery OCR Worker (concurrency=2) + - speedcam-alert (e2-small): Kombu Consumer + Celery gevent Worker + - speedcam-mon (e2-small): Prometheus + Grafana + Loki + Jaeger + 총 OCR 동시 처리: 5 (1 + 2 + 2) 가설 기반 검증 (load-test-plan.md 참조) """ @@ -65,24 +74,24 @@ 'publish_success': '100%', 'completion_rate': '95%+ (120초 이내)', 'completion_time': '120초 이내', - 'peak_ocr_queue': '< 50', + 'peak_ocr_queue': '< 10 (mock) / < 30 (실제 EasyOCR)', 'dlq_messages': '0', 'bottleneck': 'OCR worker (mock 느린 경우)', }, }, 'burst': { - 'description': '버스트 스톰: 20대 카메라, 1건/초 (20 msg/s)', - 'workers': 20, + 'description': '버스트 스톰: 10대 카메라, 1건/초 (10 msg/s)', + 'workers': 10, 'rate_per_worker': 1.0, # 초당 1건 'duration': 60, - 'expected_total': 1200, + 'expected_total': 600, 'hypothesis': { 'publish_success': '100%', - 'completion_rate': '100% (드레인 후)', + 'completion_rate': '100% (300초 이내)', 'completion_time': '300초 이내', - 'peak_ocr_queue': '200-500', + 'peak_ocr_queue': '< 500 (실제 EasyOCR 기준)', 'dlq_messages': '0', - 'bottleneck': 'MQTT Subscriber (단일 스레드) → OCR 큐 깊이', + 'bottleneck': 'OCR Worker 처리 속도 (실제 EasyOCR 기준, concurrency=5)', }, }, } @@ -166,6 +175,21 @@ def get_queue_depth(self): print(f" [검증] 큐 깊이 조회 실패: {e}") return {} + def get_notification_count(self): + """GET /api/v1/notifications/ - 알림 카운트로 Alert Worker 동작 간접 검증""" + if not self.available: + return None + try: + resp = requests.get( + f"{self.api_base_url}/api/v1/notifications/", + timeout=10 + ) + resp.raise_for_status() + data = resp.json() + return data.get('count', len(data.get('results', []))) + except Exception: + return None + def wait_for_completion(self, expected_count, baseline_stats, timeout=300, poll_interval=5): """파이프라인 완료 대기 - /api/v1/detections/statistics/ 폴링 @@ -177,6 +201,7 @@ def wait_for_completion(self, expected_count, baseline_stats, baseline_completed = baseline_stats.get('completed_count', 0) baseline_failed = baseline_stats.get('failed_count', 0) + baseline_notifications = self.get_notification_count() or 0 start_time = time.time() last_print = 0 @@ -206,12 +231,15 @@ def wait_for_completion(self, expected_count, baseline_stats, elapsed = time.time() - start_time if elapsed - last_print >= 10: + notif_count = (self.get_notification_count() or 0) - baseline_notifications print(f" [{elapsed:.0f}s] 완료: {new_completed} | 실패: {new_failed} | " - f"대기: {new_pending} | OCR큐: {ocr_depth} | FCM큐: {fcm_depth}") + f"대기: {new_pending} | OCR큐: {ocr_depth} | FCM큐: {fcm_depth} | " + f"알림: {notif_count}") last_print = elapsed if new_done >= expected_count: completion_time = time.time() - start_time + final_notif = (self.get_notification_count() or 0) - baseline_notifications return { 'completed': new_completed, 'failed': new_failed, @@ -220,6 +248,7 @@ def wait_for_completion(self, expected_count, baseline_stats, 'peak_ocr_queue': self.peak_ocr_queue, 'peak_fcm_queue': self.peak_fcm_queue, 'dlq_messages': queue_depth.get('dlq_queue', 0), + 'notification_count': final_notif, } time.sleep(poll_interval) @@ -229,6 +258,7 @@ def wait_for_completion(self, expected_count, baseline_stats, final_completed = (current.get('completed_count', 0) - baseline_completed) if current else 0 final_failed = (current.get('failed_count', 0) - baseline_failed) if current else 0 + final_notif = (self.get_notification_count() or 0) - baseline_notifications return { 'completed': final_completed, 'failed': final_failed, @@ -237,6 +267,7 @@ def wait_for_completion(self, expected_count, baseline_stats, 'peak_ocr_queue': self.peak_ocr_queue, 'peak_fcm_queue': self.peak_fcm_queue, 'dlq_messages': self.get_queue_depth().get('dlq_queue', 0), + 'notification_count': final_notif, 'timed_out': True, } @@ -250,6 +281,25 @@ def wait_for_completion(self, expected_count, baseline_stats, _image_lock = threading.Lock() +def verify_gcs_images(): + """GCS 이미지 파일 존재 여부 사전 확인""" + try: + from google.cloud import storage + client = storage.Client() + bucket = client.bucket(GCS_BUCKET) + blob = bucket.blob(f"detections/{REAL_IMAGES[0]}") + if blob.exists(): + print(f" [사전확인] GCS 이미지 접근 가능: gs://{GCS_BUCKET}/detections/{REAL_IMAGES[0]}") + return True + else: + print(f" [경고] GCS 이미지 없음: gs://{GCS_BUCKET}/detections/{REAL_IMAGES[0]}") + print(" [경고] OCR Worker가 이미지를 찾지 못해 failed 상태가 될 수 있습니다") + return False + except Exception as e: + print(f" [경고] GCS 접근 확인 실패: {e} (테스트는 계속 진행됩니다)") + return False + + def generate_message(camera_id=None): """실제 카메라 감지 메시지 생성 (실제 GCS 이미지 사용)""" global _image_counter @@ -412,6 +462,9 @@ def run(self): print(f" 파이프라인 검증: {'활성' if self.verifier else '비활성'}") print("=" * 70) + # Phase 0: GCS 이미지 사전 확인 + verify_gcs_images() + # Phase 1: 기준선 기록 baseline = None if self.verifier: @@ -502,6 +555,7 @@ def _print_results(self, publish_summary, pipeline_result): print(f" OCR 큐 피크: {pipeline_result['peak_ocr_queue']}") print(f" FCM 큐 피크: {pipeline_result['peak_fcm_queue']}") print(f" DLQ 메시지: {pipeline_result.get('dlq_messages', '-')}") + print(f" 알림 생성: {pipeline_result.get('notification_count', '-')}") else: print("\n [파이프라인 검증] 비활성 (API URL 미설정 또는 requests 패키지 없음)") @@ -528,6 +582,7 @@ def _print_results(self, publish_summary, pipeline_result): print(f" {'E2E 완료 시간':<21} {hypothesis.get('completion_time', '-'):<20} {actual_e2e:<15}") print(f" {'OCR 큐 피크':<22} {hypothesis.get('peak_ocr_queue', '-'):<20} {actual_ocr_peak:<15}") print(f" {'DLQ 메시지':<23} {hypothesis.get('dlq_messages', '-'):<20} {actual_dlq:<15}") + print(f" {'알림 생성 수':<22} {'≈ 완료 수':<20} {str(pipeline_result.get('notification_count', '-')) if pipeline_result else '-':<15}") print(f" {'예상 병목':<23} {hypothesis.get('bottleneck', '-')}") print("\n [병목 분석]") diff --git a/docker/monitoring/grafana/provisioning/datasources/datasources.yml b/docker/monitoring/grafana/provisioning/datasources/datasources.yml index 19e94ac..18294e7 100644 --- a/docker/monitoring/grafana/provisioning/datasources/datasources.yml +++ b/docker/monitoring/grafana/provisioning/datasources/datasources.yml @@ -2,6 +2,7 @@ apiVersion: 1 datasources: - name: Prometheus + uid: prometheus type: prometheus access: proxy url: http://prometheus:9090 @@ -9,13 +10,14 @@ datasources: editable: false - name: Jaeger - type: jaeger uid: jaeger + type: jaeger access: proxy url: http://jaeger:16686 editable: false - name: Loki + uid: loki type: loki access: proxy url: http://loki:3100 From 8215ee2c454fafcc8740448d29c1e31ce21494b6 Mon Sep 17 00:00:00 2001 From: sanghun Date: Fri, 27 Feb 2026 07:39:50 +0900 Subject: [PATCH 6/7] fix: flake8 lint errors in mqtt-load-test.py - Break long line (E501) by extracting variable - Remove unused f-string prefix (F541) --- docker/k6/mqtt-load-test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docker/k6/mqtt-load-test.py b/docker/k6/mqtt-load-test.py index 0eff126..9eee423 100644 --- a/docker/k6/mqtt-load-test.py +++ b/docker/k6/mqtt-load-test.py @@ -582,7 +582,8 @@ def _print_results(self, publish_summary, pipeline_result): print(f" {'E2E 완료 시간':<21} {hypothesis.get('completion_time', '-'):<20} {actual_e2e:<15}") print(f" {'OCR 큐 피크':<22} {hypothesis.get('peak_ocr_queue', '-'):<20} {actual_ocr_peak:<15}") print(f" {'DLQ 메시지':<23} {hypothesis.get('dlq_messages', '-'):<20} {actual_dlq:<15}") - print(f" {'알림 생성 수':<22} {'≈ 완료 수':<20} {str(pipeline_result.get('notification_count', '-')) if pipeline_result else '-':<15}") + notif = str(pipeline_result.get('notification_count', '-')) if pipeline_result else '-' + print(f" {'알림 생성 수':<22} {'≈ 완료 수':<20} {notif:<15}") print(f" {'예상 병목':<23} {hypothesis.get('bottleneck', '-')}") print("\n [병목 분석]") @@ -599,7 +600,7 @@ def _print_results(self, publish_summary, pipeline_result): # 시그널 핸들러 (graceful shutdown) # ============================================================ def signal_handler(signum, frame): - print(f"\n [종료 신호 수신] 워커 중단 중...") + print("\n [종료 신호 수신] 워커 중단 중...") shutdown_event.set() From 099185bc65d1b9acb28cebb39248a5498b78a52a Mon Sep 17 00:00:00 2001 From: sanghun Date: Fri, 27 Feb 2026 07:46:30 +0900 Subject: [PATCH 7/7] style: apply black formatting to mqtt-load-test.py --- docker/k6/mqtt-load-test.py | 490 ++++++++++++++++++++++-------------- 1 file changed, 298 insertions(+), 192 deletions(-) diff --git a/docker/k6/mqtt-load-test.py b/docker/k6/mqtt-load-test.py index 9eee423..120a082 100644 --- a/docker/k6/mqtt-load-test.py +++ b/docker/k6/mqtt-load-test.py @@ -43,55 +43,57 @@ import requests except ImportError: requests = None - print("WARNING: requests 패키지 없음. 파이프라인 검증 비활성화 (pip3 install requests)") + print( + "WARNING: requests 패키지 없음. 파이프라인 검증 비활성화 (pip3 install requests)" + ) # ============================================================ # 시나리오 정의 # ============================================================ SCENARIOS = { - 'normal': { - 'description': '정상 운영: 20대 카메라, 1건/분 (0.33 msg/s)', - 'workers': 20, - 'rate_per_worker': 1 / 60, # 분당 1건 - 'duration': 120, - 'expected_total': 40, - 'hypothesis': { - 'publish_success': '100%', - 'completion_rate': '100%', - 'completion_time': '60초 이내', - 'peak_ocr_queue': '< 5', - 'dlq_messages': '0', - 'bottleneck': '없음', + "normal": { + "description": "정상 운영: 20대 카메라, 1건/분 (0.33 msg/s)", + "workers": 20, + "rate_per_worker": 1 / 60, # 분당 1건 + "duration": 120, + "expected_total": 40, + "hypothesis": { + "publish_success": "100%", + "completion_rate": "100%", + "completion_time": "60초 이내", + "peak_ocr_queue": "< 5", + "dlq_messages": "0", + "bottleneck": "없음", }, }, - 'rush_hour': { - 'description': '러시아워: 20대 카메라, 5건/분 (1.67 msg/s)', - 'workers': 20, - 'rate_per_worker': 5 / 60, # 분당 5건 - 'duration': 120, - 'expected_total': 200, - 'hypothesis': { - 'publish_success': '100%', - 'completion_rate': '95%+ (120초 이내)', - 'completion_time': '120초 이내', - 'peak_ocr_queue': '< 10 (mock) / < 30 (실제 EasyOCR)', - 'dlq_messages': '0', - 'bottleneck': 'OCR worker (mock 느린 경우)', + "rush_hour": { + "description": "러시아워: 20대 카메라, 5건/분 (1.67 msg/s)", + "workers": 20, + "rate_per_worker": 5 / 60, # 분당 5건 + "duration": 120, + "expected_total": 200, + "hypothesis": { + "publish_success": "100%", + "completion_rate": "95%+ (120초 이내)", + "completion_time": "120초 이내", + "peak_ocr_queue": "< 10 (mock) / < 30 (실제 EasyOCR)", + "dlq_messages": "0", + "bottleneck": "OCR worker (mock 느린 경우)", }, }, - 'burst': { - 'description': '버스트 스톰: 10대 카메라, 1건/초 (10 msg/s)', - 'workers': 10, - 'rate_per_worker': 1.0, # 초당 1건 - 'duration': 60, - 'expected_total': 600, - 'hypothesis': { - 'publish_success': '100%', - 'completion_rate': '100% (300초 이내)', - 'completion_time': '300초 이내', - 'peak_ocr_queue': '< 500 (실제 EasyOCR 기준)', - 'dlq_messages': '0', - 'bottleneck': 'OCR Worker 처리 속도 (실제 EasyOCR 기준, concurrency=5)', + "burst": { + "description": "버스트 스톰: 10대 카메라, 1건/초 (10 msg/s)", + "workers": 10, + "rate_per_worker": 1.0, # 초당 1건 + "duration": 60, + "expected_total": 600, + "hypothesis": { + "publish_success": "100%", + "completion_rate": "100% (300초 이내)", + "completion_time": "300초 이내", + "peak_ocr_queue": "< 500 (실제 EasyOCR 기준)", + "dlq_messages": "0", + "bottleneck": "OCR Worker 처리 속도 (실제 EasyOCR 기준, concurrency=5)", }, }, } @@ -123,10 +125,13 @@ class PipelineVerifier: """파이프라인 완료 검증 - /api/v1/detections/statistics/ 활용""" - def __init__(self, api_base_url, rabbitmq_api_url=None, - rabbitmq_user='sa', rabbitmq_pass=''): - self.api_base_url = api_base_url.rstrip('/') - self.rabbitmq_api_url = rabbitmq_api_url.rstrip('/') if rabbitmq_api_url else None + def __init__( + self, api_base_url, rabbitmq_api_url=None, rabbitmq_user="sa", rabbitmq_pass="" + ): + self.api_base_url = api_base_url.rstrip("/") + self.rabbitmq_api_url = ( + rabbitmq_api_url.rstrip("/") if rabbitmq_api_url else None + ) self.rabbitmq_auth = (rabbitmq_user, rabbitmq_pass) self.available = requests is not None self.peak_ocr_queue = 0 @@ -143,8 +148,7 @@ def get_detection_stats(self): return None try: resp = requests.get( - f"{self.api_base_url}/api/v1/detections/statistics/", - timeout=10 + f"{self.api_base_url}/api/v1/detections/statistics/", timeout=10 ) resp.raise_for_status() return resp.json() @@ -160,15 +164,15 @@ def get_queue_depth(self): resp = requests.get( f"{self.rabbitmq_api_url}/api/queues/%2F", auth=self.rabbitmq_auth, - timeout=5 + timeout=5, ) resp.raise_for_status() queues = resp.json() result = {} for q in queues: - name = q.get('name', '') - if name in ('ocr_queue', 'fcm_queue', 'dlq_queue'): - depth = q.get('messages', 0) + name = q.get("name", "") + if name in ("ocr_queue", "fcm_queue", "dlq_queue"): + depth = q.get("messages", 0) result[name] = depth return result except Exception as e: @@ -181,17 +185,17 @@ def get_notification_count(self): return None try: resp = requests.get( - f"{self.api_base_url}/api/v1/notifications/", - timeout=10 + f"{self.api_base_url}/api/v1/notifications/", timeout=10 ) resp.raise_for_status() data = resp.json() - return data.get('count', len(data.get('results', []))) + return data.get("count", len(data.get("results", []))) except Exception: return None - def wait_for_completion(self, expected_count, baseline_stats, - timeout=300, poll_interval=5): + def wait_for_completion( + self, expected_count, baseline_stats, timeout=300, poll_interval=5 + ): """파이프라인 완료 대기 - /api/v1/detections/statistics/ 폴링 baseline_stats와의 차이로 이번 테스트의 신규 감지만 카운트 @@ -199,14 +203,16 @@ def wait_for_completion(self, expected_count, baseline_stats, if not self.available or baseline_stats is None: return None - baseline_completed = baseline_stats.get('completed_count', 0) - baseline_failed = baseline_stats.get('failed_count', 0) + baseline_completed = baseline_stats.get("completed_count", 0) + baseline_failed = baseline_stats.get("failed_count", 0) baseline_notifications = self.get_notification_count() or 0 start_time = time.time() last_print = 0 - print(f"\n [파이프라인 검증] {expected_count}건 완료 대기 중 (타임아웃: {timeout}초)") + print( + f"\n [파이프라인 검증] {expected_count}건 완료 대기 중 (타임아웃: {timeout}초)" + ) while time.time() - start_time < timeout: if shutdown_event.is_set(): @@ -217,65 +223,75 @@ def wait_for_completion(self, expected_count, baseline_stats, time.sleep(poll_interval) continue - new_completed = current.get('completed_count', 0) - baseline_completed - new_failed = current.get('failed_count', 0) - baseline_failed + new_completed = current.get("completed_count", 0) - baseline_completed + new_failed = current.get("failed_count", 0) - baseline_failed new_done = new_completed + new_failed new_pending = max(0, expected_count - new_done) # 큐 깊이 추적 queue_depth = self.get_queue_depth() - ocr_depth = queue_depth.get('ocr_queue', 0) - fcm_depth = queue_depth.get('fcm_queue', 0) + ocr_depth = queue_depth.get("ocr_queue", 0) + fcm_depth = queue_depth.get("fcm_queue", 0) self.peak_ocr_queue = max(self.peak_ocr_queue, ocr_depth) self.peak_fcm_queue = max(self.peak_fcm_queue, fcm_depth) elapsed = time.time() - start_time if elapsed - last_print >= 10: - notif_count = (self.get_notification_count() or 0) - baseline_notifications - print(f" [{elapsed:.0f}s] 완료: {new_completed} | 실패: {new_failed} | " - f"대기: {new_pending} | OCR큐: {ocr_depth} | FCM큐: {fcm_depth} | " - f"알림: {notif_count}") + notif_count = ( + self.get_notification_count() or 0 + ) - baseline_notifications + print( + f" [{elapsed:.0f}s] 완료: {new_completed} | 실패: {new_failed} | " + f"대기: {new_pending} | OCR큐: {ocr_depth} | FCM큐: {fcm_depth} | " + f"알림: {notif_count}" + ) last_print = elapsed if new_done >= expected_count: completion_time = time.time() - start_time - final_notif = (self.get_notification_count() or 0) - baseline_notifications + final_notif = ( + self.get_notification_count() or 0 + ) - baseline_notifications return { - 'completed': new_completed, - 'failed': new_failed, - 'pending': new_pending, - 'completion_time_s': round(completion_time, 1), - 'peak_ocr_queue': self.peak_ocr_queue, - 'peak_fcm_queue': self.peak_fcm_queue, - 'dlq_messages': queue_depth.get('dlq_queue', 0), - 'notification_count': final_notif, + "completed": new_completed, + "failed": new_failed, + "pending": new_pending, + "completion_time_s": round(completion_time, 1), + "peak_ocr_queue": self.peak_ocr_queue, + "peak_fcm_queue": self.peak_fcm_queue, + "dlq_messages": queue_depth.get("dlq_queue", 0), + "notification_count": final_notif, } time.sleep(poll_interval) # 타임아웃 current = self.get_detection_stats() - final_completed = (current.get('completed_count', 0) - baseline_completed) if current else 0 - final_failed = (current.get('failed_count', 0) - baseline_failed) if current else 0 + final_completed = ( + (current.get("completed_count", 0) - baseline_completed) if current else 0 + ) + final_failed = ( + (current.get("failed_count", 0) - baseline_failed) if current else 0 + ) final_notif = (self.get_notification_count() or 0) - baseline_notifications return { - 'completed': final_completed, - 'failed': final_failed, - 'pending': expected_count - final_completed - final_failed, - 'completion_time_s': timeout, - 'peak_ocr_queue': self.peak_ocr_queue, - 'peak_fcm_queue': self.peak_fcm_queue, - 'dlq_messages': self.get_queue_depth().get('dlq_queue', 0), - 'notification_count': final_notif, - 'timed_out': True, + "completed": final_completed, + "failed": final_failed, + "pending": expected_count - final_completed - final_failed, + "completion_time_s": timeout, + "peak_ocr_queue": self.peak_ocr_queue, + "peak_fcm_queue": self.peak_fcm_queue, + "dlq_messages": self.get_queue_depth().get("dlq_queue", 0), + "notification_count": final_notif, + "timed_out": True, } # ============================================================ # MQTT 메시지 생성 # ============================================================ -GCS_BUCKET = os.getenv('GCS_BUCKET', 'speedcam-bucket-4f918446') +GCS_BUCKET = os.getenv("GCS_BUCKET", "speedcam-bucket-4f918446") REAL_IMAGES = [f"real-plate-{str(i).zfill(2)}.jpg" for i in range(1, 11)] _image_counter = 0 _image_lock = threading.Lock() @@ -285,15 +301,22 @@ def verify_gcs_images(): """GCS 이미지 파일 존재 여부 사전 확인""" try: from google.cloud import storage + client = storage.Client() bucket = client.bucket(GCS_BUCKET) blob = bucket.blob(f"detections/{REAL_IMAGES[0]}") if blob.exists(): - print(f" [사전확인] GCS 이미지 접근 가능: gs://{GCS_BUCKET}/detections/{REAL_IMAGES[0]}") + print( + f" [사전확인] GCS 이미지 접근 가능: gs://{GCS_BUCKET}/detections/{REAL_IMAGES[0]}" + ) return True else: - print(f" [경고] GCS 이미지 없음: gs://{GCS_BUCKET}/detections/{REAL_IMAGES[0]}") - print(" [경고] OCR Worker가 이미지를 찾지 못해 failed 상태가 될 수 있습니다") + print( + f" [경고] GCS 이미지 없음: gs://{GCS_BUCKET}/detections/{REAL_IMAGES[0]}" + ) + print( + " [경고] OCR Worker가 이미지를 찾지 못해 failed 상태가 될 수 있습니다" + ) return False except Exception as e: print(f" [경고] GCS 접근 확인 실패: {e} (테스트는 계속 진행됩니다)") @@ -311,14 +334,16 @@ def generate_message(camera_id=None): image_file = REAL_IMAGES[_image_counter % len(REAL_IMAGES)] _image_counter += 1 - return json.dumps({ - "camera_id": camera_id or random.choice(CAMERA_IDS), - "location": random.choice(LOCATIONS), - "detected_speed": round(detected_speed, 1), - "speed_limit": speed_limit, - "detected_at": datetime.now(kst).isoformat(), - "image_gcs_uri": f"gs://{GCS_BUCKET}/detections/{image_file}", - }) + return json.dumps( + { + "camera_id": camera_id or random.choice(CAMERA_IDS), + "location": random.choice(LOCATIONS), + "detected_speed": round(detected_speed, 1), + "speed_limit": speed_limit, + "detected_at": datetime.now(kst).isoformat(), + "image_gcs_uri": f"gs://{GCS_BUCKET}/detections/{image_file}", + } + ) # ============================================================ @@ -349,18 +374,30 @@ def summary(self): elapsed = time.time() - self.start_time if self.start_time else 0 total = self.published + self.failed return { - 'published': self.published, - 'failed': self.failed, - 'total': total, - 'elapsed_s': round(elapsed, 1), - 'rate_per_s': round(self.published / elapsed, 2) if elapsed > 0 else 0, - 'avg_latency_ms': round(self.total_latency_ms / self.published, 2) if self.published > 0 else 0, - 'error_rate': round(self.failed / total * 100, 2) if total > 0 else 0, + "published": self.published, + "failed": self.failed, + "total": total, + "elapsed_s": round(elapsed, 1), + "rate_per_s": round(self.published / elapsed, 2) if elapsed > 0 else 0, + "avg_latency_ms": ( + round(self.total_latency_ms / self.published, 2) + if self.published > 0 + else 0 + ), + "error_rate": round(self.failed / total * 100, 2) if total > 0 else 0, } -def publish_worker(worker_id, mqtt_host, mqtt_port, mqtt_user, mqtt_pass, - rate_per_sec, duration_sec, stats): +def publish_worker( + worker_id, + mqtt_host, + mqtt_port, + mqtt_user, + mqtt_pass, + rate_per_sec, + duration_sec, + stats, +): """단일 카메라 시뮬레이션 워커""" camera_id = CAMERA_IDS[worker_id % len(CAMERA_IDS)] @@ -408,26 +445,40 @@ def publish_worker(worker_id, mqtt_host, mqtt_port, mqtt_user, mqtt_pass, class MQTTLoadTest: """MQTT 부하테스트 + 파이프라인 검증 오케스트레이터""" - def __init__(self, scenario_name, mqtt_host, mqtt_port, mqtt_user, mqtt_pass, - api_url=None, rabbitmq_api=None, rabbitmq_user='sa', rabbitmq_pass='', - custom_workers=None, custom_rate=None, custom_duration=None): - if scenario_name == 'custom': + def __init__( + self, + scenario_name, + mqtt_host, + mqtt_port, + mqtt_user, + mqtt_pass, + api_url=None, + rabbitmq_api=None, + rabbitmq_user="sa", + rabbitmq_pass="", + custom_workers=None, + custom_rate=None, + custom_duration=None, + ): + if scenario_name == "custom": self.scenario = { - 'description': f'커스텀: {custom_workers} workers, {custom_rate}/s, {custom_duration}s', - 'workers': custom_workers or 5, - 'rate_per_worker': custom_rate or 2, - 'duration': custom_duration or 60, - 'expected_total': int((custom_workers or 5) * (custom_rate or 2) * (custom_duration or 60)), - 'hypothesis': { - 'publish_success': '-', - 'completion_rate': '-', - 'completion_time': '-', - 'peak_ocr_queue': '-', - 'dlq_messages': '-', - 'bottleneck': '(커스텀 시나리오)', + "description": f"커스텀: {custom_workers} workers, {custom_rate}/s, {custom_duration}s", + "workers": custom_workers or 5, + "rate_per_worker": custom_rate or 2, + "duration": custom_duration or 60, + "expected_total": int( + (custom_workers or 5) * (custom_rate or 2) * (custom_duration or 60) + ), + "hypothesis": { + "publish_success": "-", + "completion_rate": "-", + "completion_time": "-", + "peak_ocr_queue": "-", + "dlq_messages": "-", + "bottleneck": "(커스텀 시나리오)", }, } - self.scenario_name = 'custom' + self.scenario_name = "custom" else: self.scenario = SCENARIOS[scenario_name] self.scenario_name = scenario_name @@ -455,8 +506,10 @@ def run(self): print("=" * 70) print(f" 호스트: {self.mqtt_host}:{self.mqtt_port}") print(f" 워커 수: {scenario['workers']}") - rate_total = scenario['workers'] * scenario['rate_per_worker'] - print(f" 발행 속도: {scenario['rate_per_worker']:.4f}/s/worker ({rate_total:.2f}/s 총)") + rate_total = scenario["workers"] * scenario["rate_per_worker"] + print( + f" 발행 속도: {scenario['rate_per_worker']:.4f}/s/worker ({rate_total:.2f}/s 총)" + ) print(f" 지속 시간: {scenario['duration']}초") print(f" 예상 총 메시지: {scenario['expected_total']}건") print(f" 파이프라인 검증: {'활성' if self.verifier else '비활성'}") @@ -470,11 +523,13 @@ def run(self): if self.verifier: baseline = self.verifier.get_detection_stats() if baseline: - print(f"\n [기준선] 현재 통계 - " - f"total: {baseline.get('total_detections', 0)}, " - f"completed: {baseline.get('completed_count', 0)}, " - f"failed: {baseline.get('failed_count', 0)}, " - f"pending: {baseline.get('pending_count', 0)}") + print( + f"\n [기준선] 현재 통계 - " + f"total: {baseline.get('total_detections', 0)}, " + f"completed: {baseline.get('completed_count', 0)}, " + f"failed: {baseline.get('failed_count', 0)}, " + f"pending: {baseline.get('pending_count', 0)}" + ) else: print("\n [기준선] 통계 조회 실패 - 파이프라인 검증 건너뜀") @@ -483,38 +538,48 @@ def run(self): self.stats.start_time = time.time() threads = [] - for i in range(scenario['workers']): + for i in range(scenario["workers"]): t = threading.Thread( target=publish_worker, - args=(i, self.mqtt_host, self.mqtt_port, - self.mqtt_user, self.mqtt_pass, - scenario['rate_per_worker'], scenario['duration'], - self.stats), + args=( + i, + self.mqtt_host, + self.mqtt_port, + self.mqtt_user, + self.mqtt_pass, + scenario["rate_per_worker"], + scenario["duration"], + self.stats, + ), daemon=True, ) t.start() threads.append(t) # 발행 중 주기적 상태 출력 - monitor_end = time.time() + scenario['duration'] + monitor_end = time.time() + scenario["duration"] while time.time() < monitor_end and not shutdown_event.is_set(): time.sleep(5) s = self.stats.summary - print(f" [{s['elapsed_s']}s] 발행: {s['published']} | " - f"실패: {s['failed']} | 속도: {s['rate_per_s']} msg/s") + print( + f" [{s['elapsed_s']}s] 발행: {s['published']} | " + f"실패: {s['failed']} | 속도: {s['rate_per_s']} msg/s" + ) for t in threads: t.join(timeout=10) publish_summary = self.stats.summary - print(f"\n [발행 완료] {publish_summary['published']}건 발행, " - f"{publish_summary['failed']}건 실패") + print( + f"\n [발행 완료] {publish_summary['published']}건 발행, " + f"{publish_summary['failed']}건 실패" + ) # Phase 3: 파이프라인 검증 pipeline_result = None if self.verifier and baseline: pipeline_result = self.verifier.wait_for_completion( - expected_count=scenario['expected_total'], + expected_count=scenario["expected_total"], baseline_stats=baseline, timeout=300, poll_interval=5, @@ -525,7 +590,7 @@ def run(self): def _print_results(self, publish_summary, pipeline_result): """구조화된 결과 출력 + 가설 비교 템플릿""" - hypothesis = self.scenario.get('hypothesis', {}) + hypothesis = self.scenario.get("hypothesis", {}) print("\n") print("=" * 70) @@ -542,47 +607,71 @@ def _print_results(self, publish_summary, pipeline_result): # 파이프라인 결과 if pipeline_result: - total_done = pipeline_result['completed'] + pipeline_result['failed'] - expected = self.scenario['expected_total'] - completion_pct = round(total_done / expected * 100, 1) if expected > 0 else 0 + total_done = pipeline_result["completed"] + pipeline_result["failed"] + expected = self.scenario["expected_total"] + completion_pct = ( + round(total_done / expected * 100, 1) if expected > 0 else 0 + ) print("\n [파이프라인 완료]") - print(f" 완료: {pipeline_result['completed']}/{expected} ({completion_pct}%)") + print( + f" 완료: {pipeline_result['completed']}/{expected} ({completion_pct}%)" + ) print(f" 실패: {pipeline_result['failed']}건") print(f" 대기 중: {pipeline_result['pending']}건") - print(f" E2E 소요: {pipeline_result['completion_time_s']}초" - + (" (타임아웃)" if pipeline_result.get('timed_out') else "")) + print( + f" E2E 소요: {pipeline_result['completion_time_s']}초" + + (" (타임아웃)" if pipeline_result.get("timed_out") else "") + ) print(f" OCR 큐 피크: {pipeline_result['peak_ocr_queue']}") print(f" FCM 큐 피크: {pipeline_result['peak_fcm_queue']}") print(f" DLQ 메시지: {pipeline_result.get('dlq_messages', '-')}") print(f" 알림 생성: {pipeline_result.get('notification_count', '-')}") else: - print("\n [파이프라인 검증] 비활성 (API URL 미설정 또는 requests 패키지 없음)") + print( + "\n [파이프라인 검증] 비활성 (API URL 미설정 또는 requests 패키지 없음)" + ) # 가설 비교 템플릿 actual_success = f"{100 - publish_summary['error_rate']:.1f}%" - actual_completion = '-' - actual_e2e = '-' - actual_ocr_peak = '-' - actual_dlq = '-' + actual_completion = "-" + actual_e2e = "-" + actual_ocr_peak = "-" + actual_dlq = "-" if pipeline_result: - expected = self.scenario['expected_total'] - total_done = pipeline_result['completed'] + pipeline_result['failed'] - actual_completion = f"{round(total_done / expected * 100, 1) if expected > 0 else 0}%" + expected = self.scenario["expected_total"] + total_done = pipeline_result["completed"] + pipeline_result["failed"] + actual_completion = ( + f"{round(total_done / expected * 100, 1) if expected > 0 else 0}%" + ) actual_e2e = f"{pipeline_result['completion_time_s']}초" - actual_ocr_peak = str(pipeline_result['peak_ocr_queue']) - actual_dlq = str(pipeline_result.get('dlq_messages', '-')) + actual_ocr_peak = str(pipeline_result["peak_ocr_queue"]) + actual_dlq = str(pipeline_result.get("dlq_messages", "-")) print("\n [가설 비교]") print(f" {'지표':<25} {'가설':<20} {'실제':<15} {'판정'}") print(f" {'-'*75}") - print(f" {'발행 성공률':<23} {hypothesis.get('publish_success', '-'):<20} {actual_success:<15}") - print(f" {'파이프라인 완료율':<20} {hypothesis.get('completion_rate', '-'):<20} {actual_completion:<15}") - print(f" {'E2E 완료 시간':<21} {hypothesis.get('completion_time', '-'):<20} {actual_e2e:<15}") - print(f" {'OCR 큐 피크':<22} {hypothesis.get('peak_ocr_queue', '-'):<20} {actual_ocr_peak:<15}") - print(f" {'DLQ 메시지':<23} {hypothesis.get('dlq_messages', '-'):<20} {actual_dlq:<15}") - notif = str(pipeline_result.get('notification_count', '-')) if pipeline_result else '-' + print( + f" {'발행 성공률':<23} {hypothesis.get('publish_success', '-'):<20} {actual_success:<15}" + ) + print( + f" {'파이프라인 완료율':<20} {hypothesis.get('completion_rate', '-'):<20} {actual_completion:<15}" + ) + print( + f" {'E2E 완료 시간':<21} {hypothesis.get('completion_time', '-'):<20} {actual_e2e:<15}" + ) + print( + f" {'OCR 큐 피크':<22} {hypothesis.get('peak_ocr_queue', '-'):<20} {actual_ocr_peak:<15}" + ) + print( + f" {'DLQ 메시지':<23} {hypothesis.get('dlq_messages', '-'):<20} {actual_dlq:<15}" + ) + notif = ( + str(pipeline_result.get("notification_count", "-")) + if pipeline_result + else "-" + ) print(f" {'알림 생성 수':<22} {'≈ 완료 수':<20} {notif:<15}") print(f" {'예상 병목':<23} {hypothesis.get('bottleneck', '-')}") @@ -629,63 +718,80 @@ def main(): # 커스텀 설정 (하위 호환) python3 mqtt-load-test.py --workers 10 --rate 5 --duration 30 - """ + """, ) # 시나리오 선택 parser.add_argument( - '--scenario', choices=['normal', 'rush_hour', 'burst'], - default=None, help='사전 정의 시나리오 (normal/rush_hour/burst)' + "--scenario", + choices=["normal", "rush_hour", "burst"], + default=None, + help="사전 정의 시나리오 (normal/rush_hour/burst)", ) # MQTT 설정 parser.add_argument( - '--mqtt-host', default=os.getenv('MQTT_HOST', 'rabbitmq'), - help='MQTT 브로커 호스트 (기본: MQTT_HOST 환경변수 또는 rabbitmq)' + "--mqtt-host", + default=os.getenv("MQTT_HOST", "rabbitmq"), + help="MQTT 브로커 호스트 (기본: MQTT_HOST 환경변수 또는 rabbitmq)", ) parser.add_argument( - '--mqtt-port', type=int, default=int(os.getenv('MQTT_PORT', '1883')), - help='MQTT 브로커 포트 (기본: 1883)' + "--mqtt-port", + type=int, + default=int(os.getenv("MQTT_PORT", "1883")), + help="MQTT 브로커 포트 (기본: 1883)", ) parser.add_argument( - '--mqtt-user', default=os.getenv('MQTT_USER', 'sa'), - help='MQTT 사용자 (기본: sa)' + "--mqtt-user", + default=os.getenv("MQTT_USER", "sa"), + help="MQTT 사용자 (기본: sa)", ) parser.add_argument( - '--mqtt-pass', default=os.getenv('MQTT_PASS', ''), - help='MQTT 비밀번호 (기본: MQTT_PASS 환경변수)' + "--mqtt-pass", + default=os.getenv("MQTT_PASS", ""), + help="MQTT 비밀번호 (기본: MQTT_PASS 환경변수)", ) # 파이프라인 검증 parser.add_argument( - '--api-url', default=None, - help='SpeedCam API URL (예: http://speedcam-app:8000). 설정 시 파이프라인 검증 활성화' + "--api-url", + default=None, + help="SpeedCam API URL (예: http://speedcam-app:8000). 설정 시 파이프라인 검증 활성화", ) parser.add_argument( - '--rabbitmq-api', default=None, - help='RabbitMQ Management API URL (예: http://speedcam-mq:15672)' + "--rabbitmq-api", + default=None, + help="RabbitMQ Management API URL (예: http://speedcam-mq:15672)", ) parser.add_argument( - '--rabbitmq-user', default=os.getenv('RABBITMQ_USER', 'sa'), - help='RabbitMQ Management API 사용자 (기본: sa)' + "--rabbitmq-user", + default=os.getenv("RABBITMQ_USER", "sa"), + help="RabbitMQ Management API 사용자 (기본: sa)", ) parser.add_argument( - '--rabbitmq-pass', default=os.getenv('RABBITMQ_PASS', ''), - help='RabbitMQ Management API 비밀번호 (기본: RABBITMQ_PASS 환경변수)' + "--rabbitmq-pass", + default=os.getenv("RABBITMQ_PASS", ""), + help="RabbitMQ Management API 비밀번호 (기본: RABBITMQ_PASS 환경변수)", ) # 커스텀 설정 (하위 호환) parser.add_argument( - '--workers', type=int, default=None, - help='워커 수 (--scenario 미지정 시 사용, 기본: 5)' + "--workers", + type=int, + default=None, + help="워커 수 (--scenario 미지정 시 사용, 기본: 5)", ) parser.add_argument( - '--rate', type=float, default=None, - help='워커당 초당 발행 수 (--scenario 미지정 시 사용, 기본: 2)' + "--rate", + type=float, + default=None, + help="워커당 초당 발행 수 (--scenario 미지정 시 사용, 기본: 2)", ) parser.add_argument( - '--duration', type=int, default=None, - help='테스트 시간(초) (--scenario 미지정 시 사용, 기본: 60)' + "--duration", + type=int, + default=None, + help="테스트 시간(초) (--scenario 미지정 시 사용, 기본: 60)", ) args = parser.parse_args() @@ -694,9 +800,9 @@ def main(): if args.scenario: scenario_name = args.scenario elif args.workers is not None or args.rate is not None or args.duration is not None: - scenario_name = 'custom' + scenario_name = "custom" else: - scenario_name = 'normal' + scenario_name = "normal" print(" [INFO] --scenario 미지정, 기본값 'normal' 사용") test = MQTTLoadTest(