- Published on
Redis 기반 Celery 유령 작업 근절하기 무한 재시도와 중복 실행을 부르는 acks_late prefetch_multiplier visibility_timeout 충돌 디버깅 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 조용한데도 같은 Celery 작업이 2번, 3번씩 실행되고, 실패 로그는 없는데 재시도 카운트만 계속 올라간다면 거의 확실하게 Redis 기반 Celery의 ack/가시성(visibility) 설정이 서로 충돌하고 있습니다. 현업에서는 이 현상을 흔히 **유령 작업(ghost task)**이라고 부르는데, 작업이 “끝났어야 하는데” 다시 큐로 돌아오거나, “아직 처리 중인데” 다른 워커가 집어가면서 중복 실행이 발생합니다.
이 글은 원인론을 길게 설명하기보다, 실제 장애 상황에서 10~30분 내로 원인을 좁히고 재발을 막는 실전 체크리스트를 제공합니다.
유령 작업이 생기는 핵심 메커니즘 한 장 요약
Redis 브로커에서 Celery는 메시지를 가져가면(consume) 일정 시간 동안 보이지 않게 숨겨두는(visibility timeout) 방식으로 “예약/잠금”을 흉내냅니다. 그런데 다음 조합이 겹치면 유령 작업이 터집니다.
acks_late=True: 작업이 끝난 뒤에 ack(처리 완료 확인)를 보냄worker_prefetch_multiplier > 1: 워커가 한 번에 여러 작업을 미리 당겨서 로컬에 쌓아둠visibility_timeout(Redis transport 옵션)이 작업 최대 실행 시간보다 짧음
결과:
- 워커 A가 작업을 가져갔지만 아직 ack를 안 함(acks_late)
- 작업 실행이 길어져
visibility_timeout을 넘김 - Redis는 “이 작업은 처리 안 됐네?”라고 판단하고 다시 큐로 노출
- 워커 B가 같은 작업을 또 가져가 실행 → 중복 실행
- 둘 중 하나가 죽거나 타임아웃으로 재큐잉이 반복되면 → 무한 재시도/유령 작업 폭주
1단계 증상 분류 체크리스트
아래에서 어느 케이스인지 먼저 분류하면 이후 진단이 빨라집니다.
A. 작업이 성공 로그를 남겼는데도 다시 실행된다
- 동일한 task id 또는 동일한 비즈니스 키(예: order_id)가 짧은 시간 내 반복 처리
- 외부 부작용(결제/메일/DB 업데이트)이 중복 발생
의심 1순위: visibility_timeout < 실제 실행 시간, acks_late=True
B. 워커 재시작/배포 직후 중복 실행이 폭증한다
- 롤링 배포 중에 같은 작업이 여러 번 실행
- SIGTERM 처리/그레이스풀 셧다운이 불완전
의심 1순위: 워커 종료 시 ack 처리/작업 중단 정책 + prefetch 조합
C. 실패도 아닌데 retry가 계속 찍힌다
autoretry_for/retry가 도는 흔적- 혹은 브로커에서 재전달(redelivery)되는데 앱은 “재시도”로 오인
의심 1순위: soft/hard time limit, 워커 OOM, 네트워크 단절로 ack 유실
2단계 현재 설정을 ‘정확히’ 덤프하기
유령 작업은 “설정이 이렇게 되어 있을 것”이라는 추측에서 시간을 날립니다. 실제 워커 프로세스가 어떤 설정으로 떠 있는지부터 확인하세요.
Celery 설정 확인 명령
celery -A yourapp report
celery -A yourapp inspect conf
celery -A yourapp inspect stats
여기서 특히 확인:
task_acks_lateworker_prefetch_multipliertask_reject_on_worker_losttask_time_limit,task_soft_time_limit- 브로커 URL(환경별로 Redis가 다른 경우가 흔함)
Redis transport 옵션 확인 포인트
Celery에서 Redis 브로커는 보통 Kombu transport 옵션으로 제어합니다.
# celery.py
broker_url = "redis://redis:6379/0"
broker_transport_options = {
"visibility_timeout": 3600, # 핵심
}
운영에서 자주 발생하는 함정:
- 코드에는 1시간인데 Helm/ENV에서 5분으로 오버라이드
- worker와 beat(혹은 producer) 설정이 서로 다름
3단계 재현 가능한 최소 실험으로 원인 확정하기
“중복 실행이 가끔”은 디버깅을 어렵게 합니다. 의도적으로 visibility_timeout을 짧게 만들어 재현하면 원인이 확정됩니다.
재현용 태스크
import time
from celery import Celery
app = Celery(
"demo",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1",
)
app.conf.update(
task_acks_late=True,
worker_prefetch_multiplier=4,
broker_transport_options={"visibility_timeout": 10},
)
@app.task(bind=True)
def long_task(self, seconds=30, key=None):
print("START", self.request.id, "key=", key)
time.sleep(seconds)
print("END", self.request.id, "key=", key)
return key
워커 2개를 띄우고:
celery -A demo worker -l INFO -n w1@%h
celery -A demo worker -l INFO -n w2@%h
작업 실행:
python -c "from demo import long_task; long_task.delay(30, key='order-123')"
관찰 포인트:
visibility_timeout=10인데 작업은 30초 → 10초가 지나면 같은 메시지가 다시 노출acks_late=True라 ack는 끝나야만 가므로 중복 실행이 쉽게 발생
이 실험이 운영 증상과 동일하면, 원인은 거의 확정입니다.
4단계 유령 작업을 만드는 3대 충돌 패턴
4-1. acks_late=True + visibility_timeout이 짧다
- 가장 흔한 원인
- 작업이 길거나, 외부 API 지연이 늘어날 때 갑자기 폭발
해결 원칙:
visibility_timeout은 최악의 작업 시간 + 여유(네트워크/GC/CPU 스로틀) 보다 크게- 작업 시간이 예측 불가하면, Redis 브로커 대신 “진짜 ack/lease 갱신”이 있는 브로커/큐(또는 설계 변경)를 고려
4-2. prefetch_multiplier가 크고 작업 시간이 들쭉날쭉하다
- 워커가 한 번에 여러 작업을 당겨서 로컬에 쌓아두면
- 뒤에 있는 작업은 실제로는 아직 실행도 안 했는데
- 브로커 관점에서는 “이미 전달됨” 상태가 됨
- 워커가 죽으면 그 뭉치가 한꺼번에 재전달 → 중복/폭주
해결 원칙:
- 긴 작업/불균등 작업이면
worker_prefetch_multiplier=1로 낮춰 공정 분배
4-3. worker lost(프로세스 죽음) 시 reject 정책이 애매하다
- OOMKill, SIGKILL, 노드 장애로 워커가 죽으면
acks_late=True인 작업은 ack가 안 갔으니 재전달되는 게 정상- 문제는 “이미 외부 부작용을 일부 수행한 뒤” 죽는 경우 → 중복 부작용
해결 원칙:
task_reject_on_worker_lost=True를 검토(버전/요구사항에 따라 의미가 달라질 수 있어 테스트 필수)- 무엇보다 태스크를 멱등(idempotent) 하게 만들기
5단계 운영에서 바로 쓰는 권장 설정 조합
아래는 “유령 작업을 줄이는” 관점에서의 안전한 기본값입니다. (업무 특성에 따라 조정)
긴 작업이 있고, 중복 실행이 치명적인 경우(권장)
# 핵심: 늦은 ack + 낮은 prefetch + 충분한 visibility
app.conf.update(
task_acks_late=True,
worker_prefetch_multiplier=1,
broker_transport_options={
"visibility_timeout": 6 * 60 * 60, # 예: 6시간
},
)
추가로 함께 검토:
app.conf.update(
task_time_limit=60 * 60, # 하드 타임아웃(예)
task_soft_time_limit=55 * 60, # 소프트 타임아웃(예)
)
visibility_timeout은 time_limit보다 충분히 크게 잡는 게 안전합니다(타임아웃 처리/정리 시간 포함).
짧은 작업 위주, 처리량이 더 중요하고 중복이 상대적으로 덜 치명적인 경우
acks_late=False(즉시 ack)로 중복 가능성을 낮추되, 워커 장애 시 작업 유실 가능성이 커집니다.- 이 경우는 “유실을 허용하거나 상위 레벨에서 보상 처리”가 가능한 워크로드에만 권장합니다.
6단계 멱등성으로 ‘중복 실행’ 자체를 무해하게 만들기
설정을 아무리 조여도 분산 시스템에서 at-least-once 전달은 피하기 어렵습니다. 유령 작업을 “근절”하려면 최종적으로 태스크를 멱등하게 만들어야 합니다.
Redis 락으로 중복 실행 차단(간단 버전)
import redis
from contextlib import contextmanager
r = redis.Redis(host="redis", port=6379, db=2)
@contextmanager
def dedupe_lock(key: str, ttl: int = 3600):
lock_key = f"lock:{key}"
acquired = r.set(lock_key, "1", nx=True, ex=ttl)
try:
yield bool(acquired)
finally:
# 작업 성공 시에만 해제하는 패턴도 고려 가능(업무에 따라)
if acquired:
r.delete(lock_key)
@app.task(bind=True)
def charge(self, order_id: str):
with dedupe_lock(f"charge:{order_id}", ttl=6*3600) as ok:
if not ok:
return "SKIP_DUPLICATE"
# 결제 처리(외부 부작용)
return "OK"
주의:
- 락 TTL은 “최악 실행 시간 + 여유”
- 정말 강한 보장을 원하면 DB의 unique constraint/트랜잭션으로 멱등 키를 강제하는 방식이 더 낫습니다.
7단계 트러블슈팅 체크리스트
7-1. 동일 task id가 실제로 같은가, 아니면 비즈니스 키만 같은가
- Celery는 재전달 시 같은 id로 보일 수도, 새 id로 보일 수도 있습니다(설정/재시도 방식에 따라 다름).
- 로그에 반드시 아래를 남기세요.
@app.task(bind=True)
def t(self, *args, **kwargs):
logger.info(
"task",
extra={
"task_id": self.request.id,
"retries": self.request.retries,
"redelivered": getattr(self.request, "redelivered", None),
},
)
7-2. 워커가 죽고 있지 않은가(OOMKill/CPU throttling)
- 유령 작업의 촉발점은 “느려짐”과 “프로세스 죽음”입니다.
- Kubernetes라면
kubectl describe pod에서 OOMKill 이벤트 확인
7-3. soft/hard time limit이 retry를 유발하고 있지 않은가
SoftTimeLimitExceeded가 나면 정리 코드가 돌지 못하고 중간 상태가 남을 수 있습니다.- 타임리밋은 “보호장치”지만, 멱등성이 없으면 중복 부작용을 키웁니다.
7-4. prefetch가 커서 ‘아직 실행도 안 한 작업’을 잃고 있지 않은가
- 긴 작업이 섞여 있는데 prefetch가 크면, 특정 워커에 작업이 몰려 대기열이 생깁니다.
- 해결:
worker_prefetch_multiplier=1+ 동시성(concurrency) 재조정
7-5. 재시도 정책이 과도하지 않은가
autoretry_for=(Exception,)같은 광범위 재시도는 “브로커 재전달”과 합쳐져 지옥을 만듭니다.- 재시도는 “외부 API 429/5xx” 등 명확한 케이스로 좁히고, 지수 백오프/상한을 둡니다.
재시도 설계는 아래 글의 패턴이 Celery에도 그대로 적용됩니다: OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기
8단계 운영 검증 플로우 릴리즈 전후로 꼭 확인할 것
- 최장 작업 시간 측정
- p95/p99 실행 시간 + 최악 케이스(외부 API 장애 시) 추정
visibility_timeout을 그보다 크게 설정- 긴 작업/불균등 작업이면
worker_prefetch_multiplier=1 - 워커 종료(graceful shutdown) 시나리오 테스트
- 롤링 배포 중 작업이 중복 실행되는지 확인
- 스트리밍/장시간 연결이 섞인 서비스라면 인그레스/서버 타임아웃도 함께 점검(종종 “작업은 계속 도는데 클라이언트만 끊김”이 신호가 됩니다):
- 멱등 키(주문번호, 요청ID 등) 기준으로 중복 방지 장치 적용
결론
Redis 기반 Celery에서 유령 작업을 만드는 가장 흔한 조합은 **acks_late=True + 높은 prefetch_multiplier + 짧은 visibility_timeout**입니다. 해결의 우선순위는 명확합니다.
- 실제 워커 설정을 덤프해서 추측을 제거하고
visibility_timeout을 최악 실행 시간보다 크게, 긴 작업이면prefetch_multiplier=1로 낮추고- 워커 장애/타임아웃을 전제로 태스크를 멱등하게 만들어 중복 실행을 무해화하세요.
오늘 바로 할 일은 하나입니다. 운영에서 가장 긴 작업 3개를 골라 p99 실행 시간을 뽑고, 그 값으로 visibility_timeout과 prefetch를 재설계해 보세요. 그 순간부터 “끝났는데도 다시 도는 작업”이 눈에 띄게 사라질 겁니다.