-
Message Queue 트러블슈팅: Gevent Pool 마이그레이션 및 Stateless 체이닝의 한계이코에코(Eco²)/Troubleshooting 2025. 12. 25. 03:16

#11 Prefork 병목 분석에서 I/O-bound 워크로드(65% OpenAI API)에 prefork가 비효율적임을 확인하고, Gevent Pool로 전환했습니다. 이 문서는 전환 과정에서 발생한 7개 문제와 해결 과정을 기록합니다.
2. 문제 목록
1 Gevent + Asyncio 충돌 98% 요청 실패 🔴 Critical ~2시간 2 Redis ReadOnly Replica Result 저장 실패 🔴 Critical ~1시간 3 RPC Result Backend 한계 character.match 타임아웃 🟠 High ~1시간 4 Character Cache 미로드 match 실패, 더미 응답 🟠 High ~2시간 5 SSE 이벤트 처리 오류 reward: null 응답 🟠 High ~1시간 6 DLQ 라우팅 오류 Task 미등록 에러 🟡 Medium ~30분 7 CI 트리거 누락 이미지 미빌드 🟡 Medium ~30분 3. 문제 #1: Gevent + Asyncio Event Loop 충돌
3.1 증상
RuntimeError: Cannot run the event loop while another loop is running부하 테스트 시 98% 요청 실패.
3.2 원인 분석
┌─────────────────────────────────────────────────────────────────────┐ │ Event Loop 충돌 구조 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ Gevent Worker │ │ ┌───────────────────────────────────────────────────────────────┐ │ │ │ libev event loop (Gevent 내장) │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ │ Greenlet #1: vision_task() │ │ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │ │ │ │ run_async(analyze_images_async(...)) │ │ │ │ │ │ │ │ ↓ │ │ │ │ │ │ │ │ asyncio.run_until_complete() │ │ │ │ │ │ │ │ ↓ │ │ │ │ │ │ │ │ asyncio event loop 시작 시도 ❌ │ │ │ │ │ │ │ │ → RuntimeError: 이미 loop 실행 중 │ │ │ │ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘- Gevent는 자체 event loop (libev) 사용
run_async()가 내부적으로asyncio.run_until_complete()호출- 두 event loop가 동시에 실행되어 충돌
3.3 해결
# Before (❌) - AsyncIO 클라이언트 사용 from domains._shared.waste_pipeline.vision import analyze_images_async from domains._shared.celery.async_support import run_async result = run_async(analyze_images_async(prompt, image_url)) # After (✅) - 동기 클라이언트 사용 from domains._shared.waste_pipeline.vision import analyze_images result = analyze_images(prompt, image_url) # Gevent가 httpx socket I/O를 자동으로 greenlet 전환3.4 핵심 원리
┌─────────────────────────────────────────────────────────────────────┐ │ Gevent Monkey Patching 동작 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ Worker 시작 시: │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ from gevent import monkey │ │ │ │ monkey.patch_all() ← 표준 라이브러리 I/O를 greenlet으로 │ │ │ │ │ │ │ │ 패치 대상: │ │ │ │ • socket → gevent.socket │ │ │ │ • ssl → gevent.ssl │ │ │ │ • select → gevent.select │ │ │ │ • threading → gevent.threading │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ 동기 코드 실행 시: │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ result = openai.chat.completions.create(...) │ │ │ │ ↓ │ │ │ │ httpx.post() → socket.send() → gevent.socket.send() │ │ │ │ ↓ │ │ │ │ I/O 대기 시 자동 yield → 다른 greenlet 실행 │ │ │ │ ↓ │ │ │ │ I/O 완료 시 자동 resume → 결과 반환 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘결론: Gevent 환경에서는
OpenAI(동기),AsyncOpenAI(비동기) 중 동기 클라이언트 사용.📄 상세: 18-gevent-asyncio-eventloop-conflict.md
4. 문제 #2: Redis ReadOnly Replica 에러
4.1 증상
redis.exceptions.ReadOnlyError: You can't write against a read only replica. redis.exceptions.ExecAbortError: Transaction discarded because of previous errors.Celery Result 저장 실패.
4.2 원인 분석
┌─────────────────────────────────────────────────────────────────────┐ │ Redis Sentinel 환경의 라우팅 문제 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ClusterIP Service (dev-redis) │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ redis://dev-redis.redis.svc.cluster.local:6379 │ │ │ │ ↓ │ │ │ │ kube-proxy 라운드로빈 │ │ │ │ ↓ │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ node-0 │ │ node-1 │ │ node-2 │ │ │ │ │ │ (Master) │ │ (Replica) │ │ (Replica) │ │ │ │ │ │ Write ✅ │ │ Write ❌ │ │ Write ❌ │ │ │ │ │ │ Read ✅ │ │ Read ✅ │ │ Read ✅ │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ 문제: 2/3 확률로 Replica에 Write 시도 → ReadOnlyError │ │ │ └─────────────────────────────────────────────────────────────────────┘4.3 해결
# Before (❌) - ClusterIP (Replica로 라우팅 가능) - name: CELERY_RESULT_BACKEND value: redis://dev-redis.redis.svc.cluster.local:6379/0 # After (✅) - Headless Service로 Master 직접 지정 - name: CELERY_RESULT_BACKEND value: redis://dev-redis-node-0.dev-redis-headless.redis.svc.cluster.local:6379/0┌─────────────────────────────────────────────────────────────────────┐ │ Headless Service 직접 연결 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ Headless Service (dev-redis-headless) │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ dev-redis-node-0.dev-redis-headless.redis.svc.cluster.local │ │ │ │ ↓ │ │ │ │ DNS 직접 해석 → Master Pod IP │ │ │ │ ↓ │ │ │ │ ┌─────────────┐ │ │ │ │ │ node-0 │ ← 항상 Master │ │ │ │ │ Write ✅ │ │ │ │ │ │ Read ✅ │ │ │ │ │ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘📄 상세: 19-redis-readonly-replica-error.md
5. 문제 #3: RPC Result Backend 타임아웃
5.1 증상
celery.exceptions.TimeoutError: The operation timed out. # character.match 호출 시 10초 타임아웃5.2 원인 분석
┌─────────────────────────────────────────────────────────────────────┐ │ RPC Backend의 구조적 한계 (prefork) │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ scan-worker (Process #1) character-match-worker │ │ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ │ │ scan.reward task │ │ character.match task │ │ │ │ ┌───────────────────────┐ │ │ ┌───────────────────────┐ │ │ │ │ │ send_task( │ │ │ │ 캐시 조회 │ │ │ │ │ │ "character.match" │──┼───┼─▶│ 결과 생성 │ │ │ │ │ │ ) │ │ │ │ return result │ │ │ │ │ │ │ │ │ └───────────┬───────────┘ │ │ │ │ │ async_result.get() │ │ │ │ │ │ │ │ │ ↓ │ │ │ ▼ │ │ │ │ │ 블로킹 대기 ⏳ │ │ │ RPC reply 큐에 저장 │ │ │ │ │ (reply 큐 consume │ │ │ amq.reply.scan-worker-xxx │ │ │ │ │ 못함 - 프로세스 │ │ └─────────────────────────────┘ │ │ │ │ 블로킹 중) │ │ │ │ │ │ ↓ │ │ │ │ │ │ TimeoutError ❌ │ │ │ │ │ └───────────────────────┘ │ │ │ └─────────────────────────────┘ │ │ │ │ 문제: prefork Worker가 블로킹되면 자신의 reply 큐를 consume 불가 │ │ │ └─────────────────────────────────────────────────────────────────────┘5.3 해결: Redis Result Backend
┌─────────────────────────────────────────────────────────────────────┐ │ Redis Result Backend 구조 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ scan-worker character-match-worker │ │ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ │ │ scan.reward task │ │ character.match task │ │ │ │ ┌───────────────────────┐ │ │ ┌───────────────────────┐ │ │ │ │ │ send_task( │ │ │ │ 캐시 조회 │ │ │ │ │ │ "character.match" │──┼───┼─▶│ 결과 생성 │ │ │ │ │ │ ) │ │ │ │ return result │ │ │ │ │ │ │ │ │ └───────────┬───────────┘ │ │ │ │ │ async_result.get() │ │ │ │ │ │ │ │ │ ↓ │ │ │ ▼ │ │ │ │ │ Redis polling ✅ │ │ │ Redis에 결과 저장 │ │ │ │ │ (공유 저장소에서 │◀─┼───┼──celery-task-meta-{task_id} │ │ │ │ │ 결과 조회 가능) │ │ │ │ │ │ │ │ ↓ │ │ └─────────────────────────────┘ │ │ │ │ 결과 수신 ✅ │ │ │ │ │ └───────────────────────┘ │ │ │ └─────────────────────────────┘ │ │ │ │ Redis: 공유 저장소 → 어떤 프로세스든 결과 조회 가능 │ │ │ └─────────────────────────────────────────────────────────────────────┘6. 문제 #4: Character Cache 미로드
6.1 증상
[WARNING] Character cache empty, cannot perform match캐릭터 매칭 실패 → 더미 응답 반환.
6.2 원인 분석
원인 1: DB 스키마 지정 누락
-- Before (❌) - public.characters 조회 (다른 테이블) SELECT id, ... FROM characters -- After (✅) - character.characters 명시 SELECT id, ... FROM character.characters원인 2: Foreign Key 위반
ForeignKeyViolationError: Key (character_id)=(...) is not present in table "characters"┌─────────────────────────────────────────────────────────────────────┐ │ 스키마 불일치 문제 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ PostgreSQL │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ public.characters (테스트/레거시 데이터) │ │ │ │ • id: uuid-A, uuid-B, uuid-C │ │ │ │ │ │ │ │ character.characters (운영 데이터) ← FK 참조 대상 │ │ │ │ • id: uuid-X, uuid-Y, uuid-Z │ │ │ │ │ │ │ │ character.character_ownerships │ │ │ │ • character_id FK → character.characters(id) │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ cache-warmup Job: │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ SELECT * FROM characters ← public.characters 조회 │ │ │ │ → uuid-A, uuid-B, uuid-C 로 캐시 로드 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ character.match 실행: │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 캐시에서 uuid-A 선택 │ │ │ │ → character.character_ownerships INSERT │ │ │ │ → FK 위반! (uuid-A는 character.characters에 없음) │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘6.3 해결
# 스키마 명시적 지정 result = await conn.execute( text("SELECT id, code, name, match_label, type_label, dialog FROM character.characters") )7. 문제 #5: SSE 이벤트 처리 오류
7.1 증상
{ "step": "reward", "status": "completed", "result": { "reward": null } }파이프라인 완료되었으나 reward가 null.
7.2 원인 분석
┌─────────────────────────────────────────────────────────────────────┐ │ task-started 이벤트의 도메인 간 간섭 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 시간순 이벤트 흐름: │ │ │ │ T1: task-started (scan.reward) │ │ → last_received_task = "scan.reward" │ │ │ │ T2: scan.reward 내부에서 character.match dispatch │ │ │ │ T3: task-started (character.match) ← 문제 지점 │ │ → on_task_started 핸들러 실행 │ │ → "새 task 시작 = 이전 task 완료"로 해석 │ │ → scan.reward를 completed(null)로 전송 ❌ │ │ │ │ T4: character.match 완료 │ │ │ │ T5: scan.reward 실제 완료 (reward 포함) │ │ → 이미 completed 전송됨, 무시됨 │ │ │ └─────────────────────────────────────────────────────────────────────┘7.3 해결
def on_task_started(event: dict) -> None: # ... task_name = event.get("name", "") or task_name_map.get(event_task_id, "") # scan.* task만 처리 (character.match 등 다른 도메인 무시) if task_name and not task_name.startswith("scan."): return # ...8. 문제 #6: DLQ 라우팅 오류
8.1 증상
Received unregistered task of type 'dlq.reprocess_my_reward' KeyError: 'dlq.reprocess_my_reward'8.2 원인
autodiscover_tasks가dlq_tasks.py미발견 (tasks.py만 탐색)task_routes에서dlq.*→celery큐로 라우팅 (consumer 없음)
8.3 해결
# domains/_shared/celery/__init__.py - Task 등록 from domains._shared.celery import dlq_tasks as _dlq_tasks # noqa: F401 # domains/_shared/celery/config.py - 도메인별 라우팅 "dlq.reprocess_scan_vision": {"queue": "scan.vision"}, "dlq.reprocess_scan_rule": {"queue": "scan.rule"}, "dlq.reprocess_scan_answer": {"queue": "scan.answer"}, "dlq.reprocess_scan_reward": {"queue": "scan.reward"}, "dlq.reprocess_character_reward": {"queue": "character.reward"}, "dlq.reprocess_my_reward": {"queue": "my.reward"},9. 문제 #7: CI 트리거 누락
9.1 증상
ModuleNotFoundError: No module named 'gevent'9.2 원인
domains/_base/requirements.txt에 gevent 추가했으나, CI가 worker 이미지 미빌드.9.3 해결
# .github/workflows/ci-services.yml paths: - "workloads/domains/character-match-worker/**" - "workloads/domains/celery-beat/**" shared_triggers = { "domains/_shared/cache": ["character"], "domains/_shared/database": ["auth", "character", "my", "scan"], "domains/_base": ["auth", "character", "chat", "image", "location", "my", "scan"], }10. 의존성 변경
# domains/_base/requirements.txt - celery==5.6.0 + celery==5.4.0 # celery-batches 0.9 requires celery<5.5 + gevent>=24.11.1 + pika>=1.3.2 # DLQ direct RabbitMQ access11. 핵심 교훈
# 교훈 적용 1 Gevent는 동기 코드와 함께 사용 AsyncIO 클라이언트 ❌, 동기 클라이언트 ✅ 2 Redis Sentinel은 Master 직접 연결 Headless service 사용 3 Cross-Worker RPC는 Redis Backend 필수 rpc:// ❌, redis:// ✅ 4 SSE 핸들러는 도메인 필터링 필수 task_name.startswith("scan.")5 CI 파이프라인은 의존성 그래프 반영 shared_triggers 확장 '이코에코(Eco²) > Troubleshooting' 카테고리의 다른 글
Streams & Scaling 트러블슈팅: SSE Gateway Sharding (0) 2025.12.27 KEDA 트러블슈팅: RabbitMQ 기반 이벤트 드리븐 오토스케일링 (1) 2025.12.26 Message Queue 트러블슈팅: Quorum Queue -> Classic Queue 마이그레이션 (0) 2025.12.24 Message Queue 트러블슈팅: RabbitMQ 구축 (0) 2025.12.22 분산 트레이싱 트러블슈팅: OpenTelemetry 커버리지 확장 (0) 2025.12.19