Published on

Redis 기반 Celery 유령 작업 근절하기 무한 재시도와 중복 실행을 부르는 acks_late prefetch_multiplier visibility_timeout 충돌 디버깅 체크리스트

Authors

서버가 조용한데도 같은 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 옵션)이 작업 최대 실행 시간보다 짧음

결과:

  1. 워커 A가 작업을 가져갔지만 아직 ack를 안 함(acks_late)
  2. 작업 실행이 길어져 visibility_timeout을 넘김
  3. Redis는 “이 작업은 처리 안 됐네?”라고 판단하고 다시 큐로 노출
  4. 워커 B가 같은 작업을 또 가져가 실행 → 중복 실행
  5. 둘 중 하나가 죽거나 타임아웃으로 재큐잉이 반복되면 → 무한 재시도/유령 작업 폭주

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_late
  • worker_prefetch_multiplier
  • task_reject_on_worker_lost
  • task_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_timeouttime_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단계 운영 검증 플로우 릴리즈 전후로 꼭 확인할 것

  1. 최장 작업 시간 측정
    • p95/p99 실행 시간 + 최악 케이스(외부 API 장애 시) 추정
  2. visibility_timeout을 그보다 크게 설정
  3. 긴 작업/불균등 작업이면 worker_prefetch_multiplier=1
  4. 워커 종료(graceful shutdown) 시나리오 테스트
  5. 멱등 키(주문번호, 요청ID 등) 기준으로 중복 방지 장치 적용

결론

Redis 기반 Celery에서 유령 작업을 만드는 가장 흔한 조합은 **acks_late=True + 높은 prefetch_multiplier + 짧은 visibility_timeout**입니다. 해결의 우선순위는 명확합니다.

  1. 실제 워커 설정을 덤프해서 추측을 제거하고
  2. visibility_timeout을 최악 실행 시간보다 크게, 긴 작업이면 prefetch_multiplier=1로 낮추고
  3. 워커 장애/타임아웃을 전제로 태스크를 멱등하게 만들어 중복 실행을 무해화하세요.

오늘 바로 할 일은 하나입니다. 운영에서 가장 긴 작업 3개를 골라 p99 실행 시간을 뽑고, 그 값으로 visibility_timeout과 prefetch를 재설계해 보세요. 그 순간부터 “끝났는데도 다시 도는 작업”이 눈에 띄게 사라질 겁니다.