# Celery 워커가 태스크 하나 못 받고 죽을 때 - prefork와 OTel gRPC exporter

Celery 워커가 Starting Pool 직후, ready가 찍히기도 전에 죽는 경우가 있습니다. 태스크 코드는 한 줄도 실행되지 않았고, 재시작하면 멀쩡히 뜨기도 합니다. 원인이 OpenTelemetry 초기화 위치에 있으면 애플리케이션 로그만 봐서는 찾기 어렵습니다.

이 글에서는 prefork 풀이 fork하기 전에 gRPC 코어가 이미 떠 있을 때 무슨 일이 생기는지, "OTel SDK가 fork를 처리한다"는 설명이 왜 절반만 맞는지를 정리합니다. 이어서 풀 종류마다 초기화 시그널이 어떻게 달라지는지 실제로 확인한 결과를 다룹니다.

# 1. 증상 - 풀 기동 직후의 SIGABRT

gRPC가 원인일 때는 로그에 흔적이 남습니다.

I ... fork_posix.cc] Other threads are currently calling into gRPC, skipping fork() handlers
F ... Check failed: ...     # gRPC 코어 내부 검사 실패, 위치는 버전마다 다름
... ForkPoolWorker-N ... exited with 'signal 6 (SIGABRT)'
billiard.exceptions.WorkerLostError: Could not start worker processes

첫 줄은 gRPC 코어의 fork_posix.cc가 남기는 메시지입니다. 마지막 줄은 Celery 이벤트 루프가 풀 자식을 하나도 띄우지 못했을 때 던지는 예외입니다(celery/worker/loops.py). 이 조합에는 다음 특징이 있습니다.

  • 태스크 실행 전, 풀을 띄우는 단계에서 죽습니다.
  • 확률적입니다. 같은 이미지로 재시작하면 성공하기도 합니다.
  • fork 횟수가 많을수록 자주 보입니다. 큐 길이에 따라 0에서 1로 스케일하는 워커는 기동할 때마다 fork하므로 노출이 큽니다.

# 2. 원인 - fork 전에 gRPC 코어가 떠 있다

# 2-1. 흔한 초기화 위치

텔레메트리 설정을 Celery 앱 모듈에서 import할 때 실행하는 코드가 많습니다.

# celery_app.py - 문제가 되는 형태
app = Celery("proj")
setup_telemetry()   # TracerProvider + OTLPSpanExporter(gRPC)

celery -A celery_app worker는 이 모듈을 부모 프로세스에서 import합니다. OTLP gRPC exporter는 생성자에서 채널을 만들기 때문에 gRPC 코어가 부모에서 초기화됩니다. 그 뒤에 prefork 풀이 자식을 fork합니다. Python OTel 배포판은 OTEL_EXPORTER_OTLP_PROTOCOL 기본값을 grpc로 설정하므로, 프로토콜을 지정하지 않은 환경도 이 경로를 탑니다.

# 2-2. gRPC Python은 fork를 기본으로 지원하지 않는다

gRPC 문서는 이유를 짧게 적고 있습니다.

gRPC Python wraps gRPC core, which uses multithreading for performance, and hence doesn't support fork().

실험적인 fork 지원(GRPC_ENABLE_FORK_SUPPORT)은 Python에서 기본값이 '0'입니다. 켜더라도 전제 조건이 있습니다.

For this to be successful, the application must not have multiple threads of its own calling into gRPC when fork is invoked.

1장의 첫 로그는 바로 이 조건이 깨졌다는 뜻입니다. fork하는 순간 다른 스레드가 gRPC 안에 있으면 gRPC는 fork 핸들러를 건너뜁니다. 자식은 일관되지 않은 내부 상태를 물려받고, 내부 검사에 걸려 abort()로 끝납니다. 그 순간 스레드가 어디에 있었느냐로 결과가 갈리니 확률적으로 나타납니다.

같은 문서는 해법도 제시합니다.

if the application only instantiate gRPC Python objects after calling fork(), then fork() will work normally, since there is no C extension binding at this point.

이런 구조가 수개월 동안 문제없다가 Python이나 grpcio를 올린 직후 갑자기 드러나기도 합니다. 버전이 바뀌면 내부 스레드의 타이밍도 바뀝니다. 이때 버전을 되돌리는 것은 확률을 낮출 뿐입니다. 고쳐야 할 것은 구조입니다. Python 문서도 이 조합 자체를 경고합니다.

Even in code that appears to work, it has never been safe to mix threading with os.fork on POSIX platforms.

# 3. "SDK가 fork를 처리한다"가 절반만 맞는 이유

OTel Python에는 fork 대응 코드가 들어가 있습니다. 그런데 컴포넌트마다 처리 범위가 다릅니다.

컴포넌트 fork 뒤 자식에서 근거
BatchSpanProcessor 워커 스레드 다시 만듦 (큐는 비움) os.register_at_fork, SDK 1.7.1부터
OTLP gRPC exporter 채널 그대로 물려받음 생성자에서 한 번 만들고, UNAVAILABLE 오류 때만 재생성
gRPC 코어 fork 지원 꺼짐 GRPC_ENABLE_FORK_SUPPORT 기본값 '0'

SDK 소스의 배치 프로세서는 자식에서 락과 스레드를 새로 만듭니다.

os.register_at_fork(after_in_child=_after_in_child)
# ...
def _at_fork_reinit(self):
    self._export_lock = threading.Lock()
    self._worker_awaken = threading.Event()
    self._queue.clear()
    self._worker_thread = threading.Thread(...)

exporter 모듈에는 fork를 다루는 코드가 없습니다. 공식 문서의 fork 모델 예제는 여전히 "BatchSpanProcessor는 fork-safe하지 않다"고 설명합니다. 이 설명은 1.7.1 이전 기준이라, 문서가 낡았다는 업스트림 이슈(#3307, #4759)가 열려 있습니다. 문서의 설명과 실제 코드가 달라서 판단이 두 방향으로 어긋날 수 있습니다.

  • 문서만 보면 배치 프로세서 교착이 원인이라고 보고 엉뚱한 곳을 고칩니다.
  • 소스만 보면 "fork는 처리된다"고 결론 내리고 부모 초기화를 그대로 둡니다. gRPC 채널 문제는 남습니다.

# 3-1. http/protobuf로 바꾸면 해결인가

OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf로 바꾸면 부모에서 gRPC 코어가 사라집니다. 그래서 1장의 abort는 멈춥니다. 설정만 바꾸면 되니 급할 때 쓰기 좋은 완화책입니다.

다만 HTTP exporter도 fork를 따로 처리하지 않습니다. 부모에서 만든 HTTP 세션을 자식이 물려받아 전송이 조용히 멈춘다는 보고가 업스트림에 있습니다(#4994). 크래시가 없어졌다고 fork 안전성이 확보된 것은 아닙니다. 완화책을 적용한 뒤에도 초기화 위치는 옮겨야 합니다.

# 4. 해법 - 태스크를 실행하는 프로세스에서 초기화한다

# 4-1. Celery가 알려 주는 시점

Celery instrumentation 문서는 초기화 위치를 명시합니다.

When tracing a celery worker process, tracing and instrumentation both must be initialized after the celery worker process is initialized.

그 시점이 worker_process_init 시그널입니다. 여기에는 시간 제한이 있습니다.

Note that handlers attached to this signal mustn't be blocking for more than 4 seconds, or the process will be killed assuming it failed to start.

초기화 코드에서 수집기 연결을 기다리는 식의 블로킹 작업을 하면, 풀 자식이 기동 실패로 처리됩니다.

# 4-2. 부모 초기화를 지우기만 하면 생기는 일

worker_process_init만 남기면 prefork 워커는 고쳐집니다. 대신 이 시그널이 오지 않는 프로세스의 텔레메트리가 사라집니다. Celery 5.6.3에서 풀 종류별로 어떤 시그널이 오는지 직접 실행해 확인했습니다.

실행 형태 태스크를 실행하는 프로세스 받는 시그널
worker -P prefork 풀 자식 부모: worker_init / 자식마다: worker_process_init
worker -P solo 메인 worker_init, worker_process_init 같은 프로세스에서
worker -P threads 메인 worker_init
beat 메인 beat_init

solo 풀은 자식 프로세스가 없는데도 worker_process_init을 보냅니다(celery/concurrency/solo.py). 시그널마다 초기화를 걸어 두면 solo에서는 두 번 초기화됩니다. gevent·eventlet 풀 소스에는 이 시그널을 보내는 코드가 없습니다.

# 4-3. 풀 종류와 무관하게 동작하는 초기화

import os
from celery import Celery
from celery.concurrency import get_implementation
from celery.concurrency.prefork import TaskPool as PreforkPool
from celery.signals import beat_init, worker_init, worker_process_init

app = Celery("proj")
_initialized_pid = None


def setup_telemetry():
    global _initialized_pid
    if _initialized_pid == os.getpid():   # solo 풀의 이중 호출 방지
        return
    _initialized_pid = os.getpid()
    # TracerProvider, BatchSpanProcessor(OTLPSpanExporter()) 구성


@worker_init.connect
def _init_worker_main(sender, **kwargs):
    # prefork 부모는 태스크를 실행하지 않으므로 건너뛴다
    if not issubclass(get_implementation(sender.pool_cls), PreforkPool):
        setup_telemetry()


@worker_process_init.connect(weak=False)
def _init_pool_child(**kwargs):
    setup_telemetry()


@beat_init.connect
def _init_beat(**kwargs):
    setup_telemetry()

worker_init이 발생하는 시점에는 pool_cls가 아직 "prefork" 같은 문자열일 수 있습니다. 그래서 Celery가 내부에서 쓰는 get_implementation으로 클래스를 먼저 확인합니다. pid로 중복을 막으면 fork한 자식은 pid가 달라서 정상적으로 다시 초기화됩니다. flower처럼 워커·beat가 아닌 프로세스는 이 시그널에 기대지 말고 각자의 진입점에서 setup_telemetry()를 호출합니다.

# 5. 직접 확인하는 방법

고쳤는지는 부모 프로세스의 스레드로 판단합니다. Linux에서는 스레드 이름을 볼 수 있습니다.

# 컨테이너 안에서: 부모(메인) 프로세스와 풀 자식의 스레드 비교
ps -eo pid,ppid,nlwp,args | grep '[c]elery'
cat /proc/<부모 pid>/task/*/comm | sort | uniq -c
cat /proc/<자식 pid>/task/*/comm | sort | uniq -c

exporter를 켠 이미지와 끈 이미지의 부모 스레드 목록을 비교하면, gRPC가 만든 스레드를 구분할 수 있습니다. 수정 뒤에는 prefork 부모에서 그 스레드가 사라지고 자식에만 있어야 합니다. beat나 threads 풀 워커에는 그대로 남는 게 정상입니다.

재발 여부는 크래시 흔적으로 확인합니다. skipping fork() handlers, SIGABRT, Could not start worker processes를 검색합니다. 이 메시지는 C 코드나 Celery가 stderr로 직접 쓰기 때문에, 로그 수집 설정에 따라 service.name 같은 리소스 속성이 비어 있을 수 있습니다. 서비스 이름 필터로 찾지 말고 파드나 컨테이너 이름으로 검색합니다.

# 6. 운영 고려사항

  • worker_max_tasks_per_child: 자식을 교체할 때마다 새로 fork하므로 초기화도 그만큼 반복됩니다. 교체되는 자식의 큐에 남은 span이 유실된다는 보고도 있습니다(contrib #4916). worker_process_shutdown에서 force_flush()를 호출하면 줄일 수 있지만, Celery 문서는 이 시그널의 실행을 보장하지 않습니다.
  • 완화와 근본 수정의 순서: 프로토콜을 http/protobuf로 바꾼 완화 상태에서 코드 수정을 먼저 배포합니다. 그다음 grpc로 되돌려 크래시가 재현되지 않는지 확인합니다. 순서를 거꾸로 하면 재발합니다.
증상 원인 해결
ready 전에 WorkerLostError, 로그에 skipping fork() handlers 부모에서 gRPC 코어 초기화 후 fork 초기화를 worker_process_init으로 이동
수정 후 beat의 트레이스가 사라짐 부모 초기화를 조건 없이 제거 beat_init에서 초기화
solo 풀에서 초기화 코드가 두 번 실행됨 worker_initworker_process_init이 같은 프로세스에서 모두 발생 pid 기준 중복 방지
프로토콜을 HTTP로 바꾼 뒤 크래시는 없는데 전송이 조용히 멈춤 부모의 HTTP 세션을 자식이 물려받음 초기화 위치 이동

# 7. 마무리

  • prefork 워커에서 OTel을 모듈 import 시점에 초기화하면 gRPC 코어가 fork 전에 뜹니다. gRPC Python은 기본 설정으로 fork를 지원하지 않아 자식이 확률적으로 abort합니다.
  • OTel SDK의 fork 대응은 배치 프로세서 스레드까지만 해당합니다. exporter 채널은 자식이 그대로 물려받습니다. HTTP로 바꾸는 방법은 완화책입니다.
  • 초기화는 태스크를 실행하는 프로세스에서 합니다. prefork는 worker_process_init, solo·threads는 worker_init, beat는 beat_init에서 하고, solo의 이중 호출은 pid로 막습니다.

OTel Python의 다른 함정은 OpenTelemetry 데코레이터를 직접 만들 때 무너지는 것들"Failed to detach context"가 쏟아지는 이유에서 다뤘습니다.

# 참고