
OpenTelemetry와 SigNoz를 사용한 다단계 ML 파이프라인 계측 (Instrumenting)
요약
OpenTelemetry와 SigNoz를 활용하여 FastAPI와 Celery로 구성된 다단계 ML 파이프라인의 분산 트레이싱을 구현하는 방법을 설명합니다. 로그만으로는 파악하기 어려운 모델 추론 지연 및 데이터베이스 병목 현상을 추적하는 과정을 다룹니다.
핵심 포인트
- OpenTelemetry를 통한 FastAPI와 Celery 서비스의 통합 계측
- SigNoz를 활용한 로컬 분산 트레이싱 데이터 시각화 및 쿼리
- BatchSpanProcessor를 사용하여 성능 저하 없는 백그라운드 데이터 전송
- ML 파이프라인 내 각 단계별(CLIP, MTCNN 등) 병목 지점 식별
저는 자연어 검색과 얼굴 인식을 사용하여 사진을 정리하는 백엔드인 AetherPhoto를 개발해 왔습니다. 이 아키텍처는 두 가지 주요 구성 요소로 작업을 분리합니다. HTTP 요청(검색 및 OAuth 등)을 처리하는 FastAPI 서버와 무거운 작업을 처리하는 Celery 워커 풀(worker pool)입니다.
사진 배치를 업로드하거나 Google Photos에서 동기화할 때, API는 Redis에 작업을 큐(queue)에 넣습니다. Celery 워커들이 이를 가져와 각 이미지를 다음과 같은 일련의 과정(gauntlet)을 거치게 합니다: EXIF 추출, 의미론적 검색을 위한 CLIP 임베딩 (embedding), 제로샷 분류 (zero-shot classification), 지각 해싱 (perceptual hashing), 그리고 얼굴 탐지를 위한 MTCNN/FaceNet입니다. 마지막으로 데이터는 Neon PostgreSQL 데이터베이스와 Qdrant 벡터 스토어 (vector store)에 저장됩니다.
graph LR
User[Browser] -->|HTTP| API[FastAPI API<br/>aetherphoto-api]
API -->|OTLP/gRPC| Collector[SigNoz OTel Collector<br/>:4317]
...
데이터 수집(ingestion) 작업이 4초가 아닌 40초가 걸렸을 때, 저의 표준 로깅(logging) 설정은 도움이 되지 않았습니다. API와 워커 전반에 걸친 로그는 볼 수 있었지만, Qdrant가 지연되고 있는지 아니면 CLIP 모델 추론 (inference)이 느린 것인지 파악하기 위해 타임스탬프(timestamps)를 기준으로 로그를 하나로 엮는 작업은 매우 고통스러웠습니다. 저에게는 분산 트레이싱 (distributed tracing)이 필요했습니다.
저는 OpenTelemetry를 사용하여 전체 파이프라인을 계측(instrument)하고, 트레이스(traces)를 로컬 SigNoz 인스턴스로 내보내기로 결정했습니다. SigNoz를 선택한 이유는 OTLP를 직접 수용하며, 로컬 개발 중에 데이터를 관리형 서비스로 전송하지 않고도 트레이스를 쿼리할 수 있는 UI를 제공하기 때문입니다.
텔레메트리(Telemetry) 기반 구축
먼저 중앙 집중식 telemetry.py 모듈을 만드는 것부터 시작했습니다. FastAPI 앱과 Celery 워커 모두 트레이싱 (tracing)을 초기화해야 하며, 저는 이들이 일관된 리소스 속성(resource attributes)과 함께 동일한 OTLP 엔드포인트(endpoint)를 가리키기를 원했습니다.
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
...
저는 BatchSpanProcessor를 사용하여 트레이서(tracer)가 API나 ML 워커(worker) 스레드를 차단하지 않고 백그라운드에서 스팬(span)을 내보내도록 했습니다. main.py에서 init_telemetry("aetherphoto-api")를 호출하고 celery_app.py에서 init_telemetry("aetherphoto-worker")를 호출함으로써, SigNoz는 두 개를 즉시 서로 다른 서비스로 인식했습니다.
빠른 성과: 자동 계측 (Auto-Instrumentation)
OpenTelemetry의 Python 생태계에는 런타임에 표준 라이브러리를 패치하는 자동 계측기(auto-instrumentors)가 포함되어 있습니다. 저는 FastAPI, SQLAlchemy, HTTPX, 그리고 Celery를 위한 계측기를 추가했습니다.
main.py에서의 설정은 다음과 같습니다:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
...
/health 엔드포인트에 접속하자마자 즉시 트레이스(trace)가 생성되었습니다. FastAPIInstrumentor는 경로(route)와 HTTP 메서드를 루트 스팬(root span)으로 캡처했고, SQLAlchemyInstrumentor는 정확한 SQL 문을 포함한 SELECT 1 쿼리를 자식 스팬(child span)으로 캡처했습니다.
Celery 계측기는 단연코 가장 가치 있는 도구입니다. 워커 설정에서 CeleryInstrumentor().instrument()를 호출함으로써, API가 .delay()를 호출할 때 OpenTelemetry는 현재의 트레이스 컨텍스트(trace context)를 Redis 메시지 헤더에 주입합니다. 워커가 작업을 가져올 때, 해당 헤더를 추출하여 트레이스를 이어갑니다.
간극 메우기: 수동 스팬 (Manual Spans)
자동 계측 (Auto-instrumentation)은 HTTP와 SQL은 처리하지만, 제 ML 파이프라인이 무엇을 하고 있는지는 알지 못합니다. embed_photo_task.py 내부에서 단일 Celery 태스크는 이미지 다운로드, EXIF 추출, CLIP 임베딩 (embedding), 그리고 얼굴 탐지 (face detection)를 순차적으로 실행합니다. 수동 스팬 (manual spans)이 없다면, SigNoz에서는 이 전체 프로세스가 하나의 거대하고 불투명한 실행 시간 블록으로 표시될 것입니다.
저는 Qdrant 벡터 삽입 (upsert)과 비교하여 CLIP 추론 (inference)이 정확히 얼마나 걸리는지 확인해야 했습니다. 그래서 get_tracer(__name__)를 임포트하고, tracer.start_as_current_span()을 사용하여 각 개별 작업을 감싸도록(wrap) 했습니다.
다음은 제가 Qdrant 및 제로샷 분류 (zero-shot classification) 단계를 계측한 방법입니다:
# 3. Qdrant 벡터 데이터베이스에 임베딩 저장
with tracer.start_as_current_span(
"qdrant.upsert_embedding",
...
photo.category 및 photo.id와 같은 커스텀 속성 (custom attributes)을 추가하는 것도 유용했습니다. 이제 SigNoz에서 CLIP 모델이 이미지를 "documents"로 분류한 모든 트레이스 (traces)를 필터링하여, 해당 이미지들이 "landscapes"보다 처리 시간이 더 오래 걸리는지 확인할 수 있습니다.
저는 이와 동일한 패턴을 코드베이스 전반에 적용했습니다. DBSCAN 얼굴 클러스터링 (face clustering), 지각 해시 (perceptual hash) 중복 탐지, 그리고 Google Photos Picker 폴링 루프 (polling loop)를 모두 감싸주었습니다.
숨겨진 실패 포착하기
제 Google Photos 인제스션 (ingestion) 흐름은 Picker API에서 임시 URL을 가져오는 것에 의존합니다. 이 URL들은 일시적이며 약 한 시간 후에 만료됩니다. 만약 사용자가 5,000장의 사진을 선택하면, 백그라운드 인제스션이 한 시간보다 오래 걸릴 수 있으며, 이는 마지막 수백 장의 이미지 다운로드가 실패함을 의미합니다.
트레이싱 (Tracing)을 하기 전에는 Celery 로그에서 단순히 일반적인 작업 실패(generic task failure)로만 확인했을 것입니다. 하지만 수동 스팬 (manual spans)을 통해 예외 (exceptions)를 기록함으로써, 실패 모드가 명확해졌습니다. 저는 image.download 스팬 내부에서 예외를 포착하고, span.record_exception(e)를 호출한 뒤, 스팬 상태를 에러 (error)로 설정합니다:
with tracer.start_as_current_span("image.download") as span:
try:
response = httpx.get(photo.file_path_or_url)
...
토큰이 만료되면, HTTPX 클라이언트는 403 Forbidden에 대해 HTTPStatusError를 발생시킵니다. 루트 (root) embed_photo_task 스팬은 에러로 표시되며, 정확한 자식 스팬 (image.download)이 403 응답 텍스트를 보유하게 됩니다. 저는 photo.source="google_photos" 및 status=ERROR로 트레이스를 필터링하여, 토큰 만료로 인해 얼마나 많은 사진이 실패했는지 즉시 확인할 수 있습니다.
토폴로지 (Topology) 매핑하기
SQLAlchemyInstrumentor에 engine을 전달하고, Qdrant 호출을 db.system: qdrant와 같은 속성 (attributes)으로 감쌌기 때문에, SigNoz는 제 애플리케이션의 토폴로지를 자동으로 추론했습니다.
서비스 맵 (Service Map) 뷰는 이를 아름답게 시각화해 줍니다. API 서버가 Redis로 메시지를 전달하고, 워커 (worker)가 이를 소비하며, 다시 워커가 PostgreSQL, Qdrant, 그리고 외부 Google 도메인으로 연결을 확장 (fanning out)하는 모습을 볼 수 있습니다. 이를 프로덕션 (production) 환경에 배포하면, 이 맵은 지연 시간 (latency)이 어디에 쌓이고 있는지—데이터베이스 쿼리가 느려지는 것인지, 아니면 벡터 스토어 (vector store)가 부하를 견디지 못하는 것인지—에 대한 즉각적인 시각적 지표를 제공할 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기



