이코에코(Eco²) Knowledge Base/Troubleshooting

Message Queue 트러블슈팅: Gevent Pool 마이그레이션 및 Stateless 체이닝의 한계

mango_fr 2025. 12. 25. 03:16

 

#11 Prefork 병목 분석에서 I/O-bound 워크로드(65% OpenAI API)에 prefork가 비효율적임을 확인하고, Gevent Pool로 전환했습니다. 이 문서는 전환 과정에서 발생한 7개 문제와 해결 과정을 기록합니다.


2. 문제 목록

1Gevent + Asyncio 충돌98% 요청 실패🔴 Critical~2시간
2Redis ReadOnly ReplicaResult 저장 실패🔴 Critical~1시간
3RPC Result Backend 한계character.match 타임아웃🟠 High~1시간
4Character Cache 미로드match 실패, 더미 응답🟠 High~2시간
5SSE 이벤트 처리 오류reward: null 응답🟠 High~1시간
6DLQ 라우팅 오류Task 미등록 에러🟡 Medium~30분
7CI 트리거 누락이미지 미빌드🟡 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 원인

  1. autodiscover_tasksdlq_tasks.py 미발견 (tasks.py만 탐색)
  2. 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 access

11. 핵심 교훈

# 교훈 적용
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 확장