-
Gevent+Celery Event+SSE 50 VU 부하 테스트 병목 분석 레포트이코에코(Eco²)/Message Queue 2025. 12. 25. 21:51
테스트 일시: 2025-12-25 19:50 ~ 19:55 KST (약 5분)
테스트 환경: k6 SSE Load Test, 50 VU
엔드포인트: POST /api/v1/scan/classify/completion
https://snapshots.raintank.io/dashboard/snapshot/7xvEQyVzhIXMUxMehz8nlP0jSJXlfrPq
Grafana
If you're seeing this Grafana has failed to load its application files 1. This could be caused by your reverse proxy settings. 2. If you host grafana under subpath make sure your grafana.ini root_url setting includes subpath. If not using a reverse proxy m
snapshots.raintank.io
Executive Summary


구분 결과 테스트 결과 🔴 실패 (503 에러 폭증) 주요 병목 scan-api → RabbitMQ 연결 관리 근본 원인 SSE 연결당 다수의 RabbitMQ 연결 생성 연결 비율 SSE 16개 : RabbitMQ 341개 = 1:21
Prometheus 메트릭 분석
1. RabbitMQ 연결 수

┌─────────────────────────────────────────────────────────┐ │ RabbitMQ Connections (10분 범위) │ ├──────────────┬──────────────────────────────────────────┤ │ 최소 │ 39 │ │ 최대 │ 341 🔥 (8.7배 증가) │ │ 평균 │ 131 │ │ 현재 │ 129 │ └──────────────┴──────────────────────────────────────────┘2. SSE 활성 연결 수
┌─────────────────────────────────────────────────────────┐ │ SSE Active Connections (10분 범위) │ ├──────────────┬──────────────────────────────────────────┤ │ 최소 │ 0 │ │ 최대 │ 16 │ │ 평균 │ 3 │ │ 현재 │ 16 │ └──────────────┴──────────────────────────────────────────┘3. 핵심 비율 분석
SSE 연결 : RabbitMQ 연결 = 16 : 341 = 1 : 21.3각 SSE 클라이언트가 21배수로 RabbitMQ 연결을 유발
4. RabbitMQ 연결 증가율
┌─────────────────────────────────────────────────────────┐ │ Connection Open Rate (1분당) │ ├──────────────┬──────────────────────────────────────────┤ │ 최대 │ 226 연결/분 🔥 │ │ 평균 │ 84.5 연결/분 │ └──────────────┴──────────────────────────────────────────┘5. scan-api 메모리 사용량
┌─────────────────────────────────────────────────────────┐ │ Memory Usage (scan-api 전체) │ ├──────────────┬──────────────────────────────────────────┤ │ 최소 │ 108 Mi │ │ 최대 │ 676 Mi (Limit 512Mi 초과!) │ │ 평균 │ 392 Mi (76% of limit) │ └──────────────┴──────────────────────────────────────────┘6. Pod 스케일링 이벤트
┌─────────────────────────────────────────────────────────┐ │ HPA Scaling Events │ ├──────────────┬──────────────────────────────────────────┤ │ Pod 범위 │ 1 → 4 → 1 (불안정) │ │ 스케일업 │ New size: 3 (CPU above target) │ │ 스케일다운 │ New size: 2 (CPU above target) │ └──────────────┴──────────────────────────────────────────┘7. Queue 메시지 적체
┌─────────────────────────────────────────────────────────┐ │ Queue Message Backlog (Max) │ ├──────────────────────────┬──────────────────────────────┤ │ RPC Reply Queue (UUID) │ 372 🔥 (응답 대기 중) │ │ scan.answer │ 9 │ │ scan.vision │ 9 │ │ scan.reward │ 2 │ └──────────────────────────┴──────────────────────────────┘
🔍 병목 원인 분석
연쇄 실패 시퀀스
1. k6 50 VU 테스트 시작 ↓ 2. SSE 연결 급증 (최대 16개 동시) ↓ 3. 각 SSE 연결이 Celery Events 구독을 위해 별도 RabbitMQ 연결 생성 (341개 폭증) ↓ 4. scan-api 메모리 사용량 증가 (676Mi > 512Mi limit) ↓ 5. CPU 과부하 (HPA 감지: 165%/70%) ↓ 6. Readiness probe 실패 (HTTP 503) ↓ 7. Kubernetes가 Pod를 Unhealthy로 표시 ↓ 8. Envoy가 해당 Pod를 upstream에서 제외 ↓ 9. 503 "no healthy upstream" 에러 반환 ↓ 10. 남은 Pod에 부하 집중 → 연쇄 실패핵심 병목 지점
┌──────────────────────────────────────────────────────────────┐ │ BOTTLENECK │ │ │ │ ┌─────────┐ 1:21 비율 ┌──────────────┐ │ │ │ SSE │ ─────────────────→│ RabbitMQ │ │ │ │ Client │ 연결 폭증 │ Connections │ │ │ └─────────┘ └──────────────┘ │ │ │ │ │ │ │ ↓ │ │ │ ┌──────────────────┐ │ │ │ │ scan-api │ │ │ │ │ Memory > Limit │ │ │ │ │ CPU 165% │ │ │ │ └──────────────────┘ │ │ │ │ │ │ │ ↓ │ │ │ ┌──────────────────┐ │ │ └───────────────────→│ 503 Error │ │ │ │ Readiness Fail │ │ │ └──────────────────┘ │ └──────────────────────────────────────────────────────────────┘
SSE Celery Events 구조 분석
현재 구현
# domains/scan/api/v1/endpoints/completion.py async def stream_task_progress(...): with app.connection() as connection: # 🔥 SSE마다 새 연결 recv = app.events.Receiver(connection, handlers={...}) for _ in recv.itercapture(): # Celery 이벤트 수신 yield format_sse(...)문제점
구성요소 설명 문제 SSE 연결 클라이언트 1개당 1개 정상 Celery Events Receiver SSE 1개당 1개 🔥 과다 RabbitMQ 연결 Receiver 1개당 N개 🔥 폭증 celeryev.* 큐 구독 Receiver마다 생성 🔥 과다 RabbitMQ 연결 내역
1 SSE 클라이언트당: - 1x Celery Events connection - 1x task-sent 이벤트 구독 - 1x task-received 이벤트 구독 - 1x task-started 이벤트 구독 - 1x task-succeeded 이벤트 구독 - 1x task-failed 이벤트 구독 - 1x worker-heartbeat 구독 - 1x 결과 조회 (Redis이지만 추가 연결) ───────────────────────── ≈ 8~10개 연결/SSE50 VU × 10 연결 = 500개 잠재 연결
🏗️ 권장 아키텍처: 오케스트레이션과 SSE 분리
핵심 원칙
Celery/RabbitMQ는 작업 큐로만 쓰고, 단계 이벤트는 Redis Streams에 발행.
SSE 서버는 Streams를 1~소수 consumer로 읽어 fan-out만 담당.
클라이언트 수 증가가 Redis read/AMQP connection 증가로 직결되지 않게 만든다.Celery Events 구독 vs Redis Streams 구독
┌─────────────────────────────────────────────────────────────────┐ │ ❌ 현재 구조 (곱 폭발) │ │ │ │ Client ──SSE──→ scan-api ──→ Celery Events (RabbitMQ) │ │ │ │ │ 클라이언트 × RabbitMQ 연결 = 곱 폭발 │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ ✅ 개선 구조 (분리) │ │ │ │ 1. POST /start → job_id 즉시 반환 → Celery Chain 발행 │ │ │ │ │ ▼ 단계마다 이벤트 1개 발행 │ │ 2. Worker ────────→ Redis Streams (scan:events:{job_id}) │ │ │ │ │ ▼ Pod당 1개 consumer │ │ 3. SSE Server ────→ 해당 job_id 클라이언트에 fan-out │ │ │ │ │ ▼ 최종 결과는 단발 REST │ │ 4. GET /result/{job_id} → 캐시/리플레이 가능 │ └─────────────────────────────────────────────────────────────────┘Stage 이벤트 전달 흐름
Event Streams 구조에서도 stage 진행 상황을 클라이언트에게 전달 가능!
┌─────────────────────────────────────────────────────────────────┐ │ Worker가 Redis Streams에 발행하는 이벤트 (job당 8~12개) │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. {stage: "queued", status: "started"} │ │ 2. {stage: "vision", status: "started"} │ │ 3. {stage: "vision", status: "completed", result: {...}} │ │ 4. {stage: "rule", status: "started"} │ │ 5. {stage: "rule", status: "completed", result: {...}} │ │ 6. {stage: "answer", status: "started"} │ │ 7. {stage: "answer", status: "completed", result: {...}} │ │ 8. {stage: "reward", status: "started"} │ │ 9. {stage: "reward", status: "completed", result: {...}} │ │ 10. {stage: "done", result_url: "/result/{job_id}"} │ │ │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ SSE Server가 클라이언트에 전달하는 이벤트 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ event: stage │ │ data: {"step": "vision", "status": "started"} │ │ │ │ event: stage │ │ data: {"step": "vision", "status": "completed"} │ │ │ │ event: stage │ │ data: {"step": "rule", "status": "started"} │ │ ... │ │ │ │ event: ready │ │ data: {"result_url": "/result/abc123"} │ │ │ └─────────────────────────────────────────────────────────────────┘실시간 진행상황 UX 매핑
이코에코 Scan UX, 현재 서비스에서 사용하는 버전은 비실시간 UI 및 동기 API (/api/v1/scan/classify) 내부 Stage 사용자 친화 라벨 설명 vision✅ 이코가 이미지 확인중 (실시간 연동) 이미지 분석 rule✅ 이코가 쓰레기 분석 중 (실시간 연동) Rule-based retrieval answer✅ 이코가 배출방법 정리 중 (실시간 연동) LLM 답변 생성 reward보유하고 있지 않은 캐릭터 매칭될 경우 획득 애니메이션(유지) 캐릭터 매칭 구현 예시
# 1. Worker: 단계마다 Redis Streams에 발행 import redis import time def publish_stage_event(job_id: str, stage: str, status: str, result: dict = None): """Worker가 호출: 단계 전환마다 1개 이벤트""" r = redis.Redis(host="streams-redis", port=6379, db=1) event = { "stage": stage, "status": status, "ts": str(time.time()), } if result: event["result"] = json.dumps(result) r.xadd( f"scan:events:{job_id}", event, maxlen=50 # retention: 최근 50개만 유지 ) # 2. SSE 엔드포인트: 단일 /completion (기존 API 유지) @router.post("/classify/completion") async def classify_completion(payload: ClassificationRequest): """내부만 Celery Events → Redis Streams로 전환.""" job_id = str(uuid.uuid4()) async def generate(): # 1. 구독 먼저 (이벤트 누락 방지) last_id = "0" # 2. Chain 발행 (구독 후 발행) publish_stage_event(job_id, "queued", "started") chain.apply_async(task_id=job_id) # 3. Redis Streams 구독 루프 (RabbitMQ 연결 없음!) r = await aioredis.from_url("redis://dev-redis:6379/1") while True: events = await r.xread({f"scan:events:{job_id}": last_id}, block=5000) if not events: yield ": keepalive\n\n" continue for _, messages in events: for msg_id, data in messages: last_id = msg_id yield f"event: stage\ndata: {json.dumps(data)}\n\n" if data.get("stage") == "done": return return StreamingResponse(generate(), media_type="text/event-stream")Redis 분리 전략
초기 (합쳐도 OK) 성숙기 (분리 권장) 같은 Redis, DB만 분리 별도 Redis 인스턴스 # 초기: 같은 Redis, DB 분리 REDIS_CACHE_URL: redis://dev-redis:6379/0 # 캐시 (LRU eviction) REDIS_STREAMS_URL: redis://dev-redis:6379/1 # 이벤트 (noeviction) REDIS_RESULT_URL: redis://dev-redis:6379/2 # Celery 결과 # 성숙기: 별도 인스턴스 REDIS_CACHE_URL: redis://cache-redis:6379/0 REDIS_STREAMS_URL: redis://streams-redis:6379/0 # 전용, retention 정책Redis Streams 분리 근거
- 캐시: LRU/LFU eviction 정상 동작 필요
- Streams: eviction 발생 시 이벤트 유실 → UX 깨짐
개선 방안 (우선순위별)
1. 아키텍처 분리 (Redis Streams)
방안 효과 난이도 Redis Streams 도입 이벤트 발행 분리 중간 SSE 서버 리팩토링 RabbitMQ 의존성 제거 중간 결과 REST 분리 /result/{job_id}엔드포인트낮음 2. 최적화 및 확장 (Redis Streams 유지 혹은 Kafka로 확장)
방안 효과 난이도 Redis Streams 분리 캐시/이벤트 Redis 분리 중간 Consumer Group 도입 다중 SSE 서버 지원 중간 이벤트 압축 stage 이벤트 최소화 낮음
리소스 설정 권장
scan-api Deployment
# 현재 resources: requests: cpu: 250m memory: 256Mi limits: cpu: 500m memory: 512Mi # 권장 (50 VU 기준) resources: requests: cpu: 500m memory: 512Mi limits: cpu: 1000m memory: 1GiHPA 설정
# 현재 minReplicas: 1 maxReplicas: 4 targetCPUUtilizationPercentage: 70 # 권장 minReplicas: 2 # 최소 2개로 가용성 확보 maxReplicas: 6 # 최대 증가 targetCPUUtilizationPercentage: 60 # 더 빨리 스케일
핵심 문제
SSE가 "파이프라인 실행 감시"와 "결과 전달"을 동시에 하면서
클라이언트 × RabbitMQ 연결 = 곱 폭발 발생
해결 원칙
오케스트레이션(Celery)과 표현(SSE)을 완전히 분리
- Celery/RabbitMQ: 작업 큐로만 사용
- Redis Streams: 단계 이벤트 발행 (job당 8~12개)
- SSE Server: Streams fan-out만 담당 (Pod당 1 consumer)
- 결과 조회: 단발 REST
/result/{job_id}
Stage UX 유지
새 구조에서도 stage 진행 상황을 클라이언트에게 전달 가능:
vision[started] → vision[completed] → rule[started] → ...Worker가 Redis Streams에 이벤트를 발행하고, SSE Server가 이를 클라이언트에 fan-out하는 구조.
권장 조치
우선순위 조치 기대 효과 1 scan-api Memory 1Gi 50 VU 임시 대응 2 Redis Streams 도입 RabbitMQ 연결 제거 3 SSE 서버 리팩토링 곱 폭발 구조적 해결 4 Redis 인스턴스 분리 캐시/이벤트 격리
Redis Streams 전환
이 분석 결과를 바탕으로 Redis Streams 기반 SSE 전환을 진행했습니다.
최종 결정 구조
┌─────────────────────────────────────────────────────────────────┐ │ ✅ 최종 구조: 단일 /completion + Redis Streams │ │ │ │ POST /completion │ │ │ │ │ ├─ 1. Redis Streams 구독 시작 │ │ ├─ 2. Celery Chain 발행 │ │ └─ 3. Streams 이벤트 → SSE 전송 │ │ │ │ Worker ──XADD──→ Redis Streams ──XREAD──→ scan-api ──SSE──→ Client│ │ │ │ 핵심: "구독 먼저, 발행 나중" → Race condition으로 인한 이벤트 수신 유실 방지│ └─────────────────────────────────────────────────────────────────┘기대 효과
지표 현재 예상 개선 SSE : RabbitMQ 1:21 1:0 100% 제거 SSE : Redis N/A Pod당 1개 상수 메모리 (50 VU) 676Mi ~200Mi 70% 감소 예상 VU 20~30 150~200+ 5~7x 선택 근거
방법 설명 선택 방법 1: 단일 /completion+ Redis StreamsAPI 변경 없음, UX 동일 ✅ 방법 2: /start+/stream/{job_id}분리Race condition 발생 ❌ 방법 3: Polling 실시간 UX 포기 ❌ 상세 구현과 설계는 Event Streams 관련 포스팅에서 기록할 예정
관련 문서
'이코에코(Eco²) > Message Queue' 카테고리의 다른 글