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)
BRPOP 의 B 는 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% | 정상 |
| 처리 속도 | 영향 없음 | 영향 없음 |
정리
| 구분 | 설명 |
|---|---|
| 증상 | 트래픽 스파이크 시 Celery worker CPU 100% |
| 원인 | Kombu 가 brpop 응답을 소켓에서 읽지 않아 epoll 이 무한 트리거 |
| 평소 무증상 이유 | 큐가 비어있으면 brpop 이 Redis 쪽에서 block → 응답 자체가 안 옴 |
| 수정 | connection 별 polling 상태 독립 관리 + brpop 응답 즉시 수신 |
라이브러리 내부의 소켓 I/O 처리 버그는 평상시에 드러나지 않다가, 부하가 걸리는 순간 표면화되는 경우가 많다. 프로파일링 도구 (Pyroscope) 를 통해 CPU 를 어디서 소비하는지 확인하는 것이 디버깅의 핵심이었다.
댓글남기기