Kombu Redis Cluster에서 brpop 응답 미수신으로 인한 CPU 스파이크 해결

Celery worker 에서 트래픽이 몰릴 때 CPU 가 100% 까지 치솟는 현상을 디버깅한 내용을 정리한다.

증상

평소에는 정상이다가, 트래픽 스파이크가 오면 Celery worker 의 CPU 가 급격히 치솟는 현상이 발생했다. Pyroscope 프로파일링 결과, on_poll_start 에서 4~6분 이상 을 소비하고 있었다.

특이한 점은 task 처리 자체가 느린 것이 아니라, task 를 꺼내오는 polling 과정 에서 CPU 를 점유하고 있었다는 것이다.

Background: brpop 과 epoll 의 동작 방식

Celery 는 Redis 를 broker 로 사용할 때, 내부적으로 Kombu 라이브러리를 통해 Redis 의 BRPOP 명령으로 큐에서 task 를 꺼내온다.

brpop (Blocking Right Pop)

BRPOPB 는 Blocking 이다. 큐에 데이터가 없으면 Redis 쪽에서 응답을 보내지 않고 대기한다. 데이터가 들어오면 그때서야 응답을 반환한다.

큐가 비어있을 때:
  Kombu → Redis: BRPOP myqueue 1
  Redis: (큐 비었으니 대기...)
  ...시간 경과...
  (메시지 도착)
  Redis → Kombu: 응답 반환

큐에 데이터가 있을 때:
  Kombu → Redis: BRPOP myqueue 1
  Redis → Kombu: 즉시 응답 반환

epoll

Linux 커널은 epoll 이라는 메커니즘으로 소켓의 상태를 감시한다. 소켓의 recv buffer 에 읽을 데이터가 도착하면, OS 커널이 어플리케이션에 알려준다.

Redis 응답 → 소켓 recv buffer 에 도착
                ↓
OS Kernel (epoll): "이 소켓에 읽을 데이터 있어!"
                ↓
어플리케이션 (Kombu) 에 통지

중요한 점은, 어플리케이션이 데이터를 읽을 때까지 epoll 은 계속 “데이터 있다” 고 알려준다는 것이다 (level-triggered mode).

핵심: brpop 응답을 안 읽는 버그

문제의 원인은 Kombu 의 Redis Cluster transport 코드에 있었다. brpop 으로 Redis 에서 응답을 받았는데, 그 응답을 소켓에서 읽지 않았다.

Kombu  →  Redis:        brpop 명령 전송
Redis  →  Socket:       응답이 소켓 recv buffer 에 도착
OS     →  Kombu:        epoll 통지 "소켓에 읽을 데이터 있어!"
Kombu:                  on_poll_start() 실행 — 그런데 응답을 안 읽음!
                        ↓
OS     →  Kombu:        "아직 데이터 있어!"
Kombu:                  on_poll_start() 또 실행 — 또 안 읽음!
                        ↓
                        ... 무한 반복 → CPU 100%

소켓 buffer 에 데이터가 남아있으니 epoll 이 끊임없이 트리거되고, Kombu 는 매번 깨어나지만 데이터를 읽지 않으니 다시 트리거되는 busy loop 에 빠진 것이다.

왜 평소에는 문제가 없었나?

이 버그가 평소에 드러나지 않은 이유는 brpop 의 blocking 특성 때문이다.

상황 Redis 큐 상태 brpop 동작 소켓 buffer epoll
평상시 (트래픽 적음) 비어있음 Redis 쪽에서 block (응답 안 옴) 비어있음 정상 대기, CPU 한가
트래픽 스파이크 메시지 계속 쌓임 즉시 응답 반환 데이터 있음 무한 트리거, CPU 100%

평소에는 큐가 비어있으니 Redis 가 응답 자체를 보내지 않는다. 소켓 buffer 에 아무것도 안 쌓이니, “안 읽는” 버그 코드 경로를 탈 일이 없었다.

트래픽이 몰려서 큐에 task 가 계속 쌓여있으면, brpop 이 매번 즉시 응답을 반환한다. 이때 비로소 “응답을 안 읽는” 경로가 활성화되면서 busy loop 이 시작된다.

수정

kombu 패치 (325a795) 에서 핵심 변경 사항:

Before: channel 레벨의 _in_poll 플래그로 brpop 상태를 관리. brpop timeout 이 0.1초로 짧아서 빈번하게 재시도하며, 응답을 제때 읽지 못하는 타이밍 문제가 존재했다.

# Before: channel 단위 polling 관리
class RedisNodeConnection:
    def __init__(self, node):
        self.node = node  # node 기준으로 connection 식별

# brpop 발행 조건이 channel._in_poll 에 의존
def _register_BRPOP(self, channel):
    if channel._in_poll:
        channel._brpop_start()

After: connection 별로 in_poll 상태를 독립 관리하고, 각 queue key 마다 전용 polling lifecycle 을 갖도록 변경. brpop timeout 도 1초로 늘려 불필요한 재시도를 줄였다.

# After: connection(key) 단위 polling 관리
class RedisNodeConnection:
    def __init__(self, key):
        self.key = key        # queue key 기준으로 connection 식별
        self.in_poll = False  # connection 별 독립 상태 관리

# 각 connection 이 자신의 polling 상태를 추적
def _brpop_start(self):
    for conn in self._node_connections:
        if conn.key == key and not conn.in_poll:
            conn.in_poll = True
            conn.client.brpop(key, timeout=1)

이렇게 하면 각 queue key 의 brpop 응답이 독립적으로 추적되어, 하나의 응답이 누락되는 상황을 방지한다.

결과

항목 Before After
on_poll_start 소요 시간 4~6분 이상 1초 미만
CPU 메트릭 트래픽 몰리면 100% 정상
처리 속도 영향 없음 영향 없음

Mermaid Chart

kombu-issue-chart

정리

구분 설명
증상 트래픽 스파이크 시 Celery worker CPU 100%
원인 Kombu 가 brpop 응답을 소켓에서 읽지 않아 epoll 이 무한 트리거
평소 무증상 이유 큐가 비어있으면 brpop 이 Redis 쪽에서 block → 응답 자체가 안 옴
수정 connection 별 polling 상태 독립 관리 + brpop 응답 즉시 수신

라이브러리 내부의 소켓 I/O 처리 버그는 평상시에 드러나지 않다가, 부하가 걸리는 순간 표면화되는 경우가 많다. 프로파일링 도구 (Pyroscope) 를 통해 CPU 를 어디서 소비하는지 확인하는 것이 디버깅의 핵심이었다.

Reference

댓글남기기