- 들어가며 — 대시보드는 있는데 왜 느린지는 모를 때
- 순서를 먼저 정한다
- 1단계 — 자동 계측만으로 어디까지 가는가
- 2단계 — 리소스 속성은 나중에 못 고친다
- 3단계 — 컨텍스트 전파가 끊기는 네 지점
- 4단계 — 수동 스팬은 self time이 큰 곳에만
- 5단계 — 컬렉터를 앱과 백엔드 사이에 두는 이유
- 계측이 앱을 망가뜨리는 방식들
- 마치며 — 계측의 가치는 스팬 수가 아니라 트레이스의 완결성에서 나온다
들어가며 — 대시보드는 있는데 왜 느린지는 모를 때
주문 API의 p99가 1.4초입니다. Grafana에는 CPU, 메모리, 요청 수, 에러율 패널이 이미 있고 전부 초록색입니다. 데이터베이스 대시보드도 정상입니다. 그런데 사용자는 느리다고 하고, 우리는 어느 코드가 그 1.4초를 썼는지 모릅니다.
이 상태에서 필요한 것은 패널 하나 더가 아니라 계측입니다. 계측은 "무슨 일이 언제 얼마나 걸렸는지"를 코드가 스스로 말하게 만드는 작업이고, OpenTelemetry는 그 말하기의 형식과 전송 규약을 표준화한 프로젝트입니다.
이 글은 서비스 하나를 처음부터 끝까지 계측합니다. Python FastAPI 서비스를 예로 쓰지만 순서는 언어와 무관합니다. 검증 기준은 2026년 7월 기준 OpenTelemetry Collector v0.157.0, Semantic Conventions v1.43.0, Python SDK 1.3x대입니다. 시맨틱 규약은 이름이 계속 바뀌는 영역이 남아 있으므로 속성 이름은 항상 해당 버전의 Semantic Conventions 레지스트리에서 확인하는 편이 안전합니다.
순서를 먼저 정한다
계측을 실패하는 팀은 거의 같은 방식으로 실패합니다. 코드에 수동 스팬부터 심기 시작해서, 2주 뒤에 스팬은 300개인데 트레이스는 여전히 서비스 경계에서 끊겨 있는 상태가 됩니다.
작동하는 순서는 아래와 같습니다.
| 단계 | 하는 일 | 걸리는 시간 | 이 단계를 건너뛰면 |
|---|---|---|---|
| 1 | 자동 계측을 켜고 트레이스가 백엔드에 도착하는지 확인 | 반나절 | 이후 모든 디버깅이 추측이 됩니다 |
| 2 | 리소스 속성 확정 (service.name 등) | 반나절 | 나중에 바꾸면 과거 데이터와 끊깁니다 |
| 3 | 서비스 경계를 넘는 전파가 살아 있는지 검증 | 1일 | 스팬을 아무리 늘려도 트레이스가 조각납니다 |
| 4 | self time이 큰 구간에만 수동 스팬 추가 | 계속 | 자동 계측의 빈칸이 영원히 남습니다 |
| 5 | 컬렉터를 앞에 두고 가공과 샘플링을 이관 | 1일 | 정책을 바꿀 때마다 전 서비스를 재배포합니다 |
핵심은 3번이 4번보다 앞이라는 점입니다. 전파가 끊긴 상태에서 수동 스팬을 추가하는 것은 조각난 트레이스를 더 잘게 조각내는 일입니다.
1단계 — 자동 계측만으로 어디까지 가는가
Python은 opentelemetry-instrument 런처가 프로세스 시작 시점에 설치된 계측 패키지를 훅으로 붙입니다. 코드 변경은 없습니다.
pip install \
'opentelemetry-distro[otlp]' \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-requests \
opentelemetry-instrumentation-redis \
opentelemetry-instrumentation-logging
# 설치된 계측 패키지를 자동으로 탐지해서 붙여 준다
opentelemetry-bootstrap --action=install
실행은 환경변수만으로 제어합니다. 이것이 자동 계측의 핵심 장점입니다. 계측 설정이 코드가 아니라 배포 매니페스트에 있으므로, 엔드포인트나 샘플링 비율을 바꾸는 데 코드 리뷰가 필요 없습니다.
export OTEL_SERVICE_NAME=checkout-api
export OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,deployment.environment.name=prod,service.namespace=commerce
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_TRACES_SAMPLER=parentbased_always_on
export OTEL_PYTHON_LOG_CORRELATION=true
opentelemetry-instrument uvicorn app.main:app --host 0.0.0.0 --port 8000
parentbased_always_on으로 시작하는 것을 권합니다. 처음부터 비율 샘플링을 켜면 트레이스가 안 보일 때 그것이 계측 문제인지 샘플링 때문인지 구분할 수 없습니다. 샘플링은 데이터가 흐르는 것을 확인한 뒤 컬렉터에서 붙입니다.
이 상태에서 얻는 것은 정확히 하나, 네트워크 경계입니다.
SERVER checkout-api POST /v1/orders 1421ms
├─ CLIENT GET http://auth.internal/verify 31ms
├─ CLIENT SELECT carts WHERE id = ? 6ms
├─ CLIENT redis GET promo:rules:t-8871 2ms
├─ CLIENT POST http://payment.internal/charge 74ms
└─ (나머지 1308ms는 어떤 스팬에도 속하지 않음)
마지막 줄이 전부입니다. 자동 계측은 어디가 문제가 아닌지를 1308ms의 공백으로 알려 줍니다. 그 공백을 self time이라고 부르고, 4단계에서 여기에만 수동 스팬을 넣습니다.
자동 계측이 절대 보지 못하는 것
- 프로세스 안의 CPU 작업 — 직렬화, 압축, 템플릿 렌더링, 암호화, 이미지 처리
- 락 대기와 커넥션 풀 대기 — 대기 시간이 커넥션을 얻은 뒤의 쿼리 스팬에 포함되지 않습니다
- GIL 경합과 이벤트 루프 지연
- 계측 패키지가 없는 서드파티 SDK 호출
- 비즈니스 로직의 분기 — 어떤 규칙이 몇 개 평가됐는지
2단계 — 리소스 속성은 나중에 못 고친다
리소스는 "이 텔레메트리를 만든 주체가 무엇인가"를 설명하는 속성 집합입니다. 스팬 속성과 달리 리소스 속성은 그 프로세스가 내보내는 모든 신호에 붙습니다. 그리고 한 번 정하면 바꾸기가 어렵습니다. service.name을 바꾸는 순간 대시보드, 알림, 서비스 그래프, 과거 데이터와의 연결이 전부 끊깁니다.
# 최소 집합 — 이 셋이 없으면 데이터가 어디서 왔는지 알 수 없다
OTEL_SERVICE_NAME=checkout-api
OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,deployment.environment.name=prod
# 실무에서 추가로 유용한 것들
OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,\
deployment.environment.name=prod,\
service.namespace=commerce,\
service.instance.id=checkout-api-7d9f4b-x2k9m
Kubernetes에서는 인스턴스 식별자를 하드코딩하지 말고 Downward API로 주입합니다.
# deployment.yaml
env:
- name: OTEL_SERVICE_NAME
value: checkout-api
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OTEL_RESOURCE_ATTRIBUTES
value: >-
service.version=2.7.1,
deployment.environment.name=prod,
service.namespace=commerce,
service.instance.id=$(POD_NAME),
k8s.namespace.name=$(POD_NAMESPACE)
이름 규약에서 자주 틀리는 것 두 가지를 짚습니다.
첫째, 환경 속성의 이름은 deployment.environment.name입니다. 예전 이름인 deployment.environment는 더 이상 쓰지 않습니다. 이름이 다르면 두 개의 별개 속성이 되고, 대시보드 변수는 그중 하나만 읽습니다.
둘째, service.name은 배포 단위가 아니라 서비스 단위여야 합니다. 같은 코드베이스를 카나리로 두 벌 띄웠다면 둘 다 checkout-api이고, 구분은 service.version이나 별도 속성으로 합니다. 카나리를 checkout-api-canary로 부르는 순간 서비스 그래프에 유령 노드가 생깁니다.
| 속성 | 값의 예 | 카디널리티 | 바꿀 수 있는가 |
|---|---|---|---|
| service.name | checkout-api | 서비스 수 | 사실상 불가 |
| service.namespace | commerce | 팀 수 | 어렵다 |
| service.version | 2.7.1 | 배포 횟수 | 매 배포마다 바뀜 |
| deployment.environment.name | prod | 3~5 | 불가 |
| service.instance.id | 파드 이름 | 파드 수 | 매 재시작마다 바뀜 |
service.instance.id는 카디널리티가 높지만 리소스 속성이므로 트레이스와 로그에서는 문제가 없습니다. 다만 이 속성을 메트릭 레이블로 그대로 승격시키면 시계열이 파드 수만큼 곱해집니다. 컬렉터에서 메트릭 파이프라인에 한해 제거하는 것이 일반적입니다.
3단계 — 컨텍스트 전파가 끊기는 네 지점
전파는 트레이스를 만드는 유일한 메커니즘입니다. 호출하는 쪽이 W3C traceparent 헤더에 현재 트레이스 ID와 스팬 ID를 넣고, 받는 쪽이 그것을 읽어 부모로 삼습니다. 그 검증은 명령 한 줄이면 됩니다.
# 게이트웨이 흉내를 내서 헤더를 직접 넣어 보고, 백엔드에서 이 trace ID 로 조회한다
curl -sS -o /dev/null -w '%{http_code}\n' \
http://checkout-api.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'content-type: application/json' \
-d '{"cart_id":"c-1"}'
# 스팬이 이 trace ID 아래에 붙지 않으면, 아래 네 가지 중 하나다
끊김 1 — 스레드풀과 executor
가장 많이 겪는 유형입니다. 컨텍스트는 스레드 로컬(또는 asyncio contextvar)에 들어 있으므로, 작업을 다른 스레드로 넘기면 그 컨텍스트가 따라가지 않습니다.
# 끊긴다 — 워커 스레드에는 컨텍스트가 없어서 새 트레이스가 시작된다
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(max_workers=8)
def enrich_all(items):
return list(pool.map(fetch_details, items))
# 살아남는다 — 현재 컨텍스트를 캡처해서 워커 안에서 다시 활성화한다
from concurrent.futures import ThreadPoolExecutor
from opentelemetry import context as otel_context
pool = ThreadPoolExecutor(max_workers=8)
def _with_context(ctx, fn, *args):
token = otel_context.attach(ctx)
try:
return fn(*args)
finally:
otel_context.detach(token)
def enrich_all(items):
ctx = otel_context.get_current()
futures = [pool.submit(_with_context, ctx, fetch_details, it) for it in items]
return [f.result() for f in futures]
Java에서는 Context.current().wrap(runnable), Go에서는 context.Context를 goroutine 인자로 넘기는 것, Node.js에서는 AsyncLocalStorage가 같은 역할을 합니다. 언어마다 이름은 다르지만 원리는 동일합니다. 컨텍스트는 실행 단위를 따라가지 않으므로 명시적으로 옮겨야 합니다.
끊김 2 — 메시지 큐
큐는 프로세스 경계이자 시간 경계입니다. HTTP처럼 헤더가 자동으로 흐르지 않으므로 메시지에 직접 주입해야 합니다.
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind
tracer = trace.get_tracer("checkout", "2.7.1")
def publish_order(producer, order):
with tracer.start_as_current_span(
"orders publish", kind=SpanKind.PRODUCER
) as span:
span.set_attribute("messaging.system", "kafka")
span.set_attribute("messaging.destination.name", "orders")
headers = {}
propagate.inject(headers) # traceparent 를 dict 에 넣는다
producer.send(
"orders",
value=order.to_bytes(),
headers=[(k, v.encode()) for k, v in headers.items()],
)
컨슈머에서는 추출한 컨텍스트를 부모로 씁니다. 다만 배치로 여러 메시지를 한 번에 처리한다면 부모는 하나뿐이므로 링크를 씁니다.
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind, Link
def consume_batch(messages):
links = []
for m in messages:
headers = {k: v.decode() for k, v in (m.headers or [])}
ctx = propagate.extract(headers)
sc = trace.get_current_span(ctx).get_span_context()
if sc.is_valid:
links.append(Link(sc))
with tracer.start_as_current_span(
"orders process", kind=SpanKind.CONSUMER, links=links
) as span:
span.set_attribute("messaging.batch.message_count", len(messages))
for m in messages:
handle(m)
큐 대기 시간이 몇 분 이상이라면 메시지가 하나여도 링크를 고려합니다. 부모-자식으로 이으면 트레이스 하나의 지속 시간이 대기 시간만큼 늘어나서 백엔드에서 다루기 어려워집니다.
끊김 3 — 백그라운드 작업과 스케줄러
크론, Celery beat, FastAPI의 BackgroundTasks처럼 요청과 무관하게 도는 작업은 부모가 없습니다. 이때 흔한 실수는 요청 컨텍스트를 억지로 이어 붙이는 것입니다. 요청은 이미 응답을 보내고 끝났는데 그 트레이스에 30초짜리 자식이 붙으면 요청 지연 시간 통계가 오염됩니다.
# 배경 작업은 새 루트 트레이스로 시작하고, 원인 요청은 링크로 남긴다
def schedule_reindex(cart_id):
origin = trace.get_current_span().get_span_context()
def run():
links = [Link(origin)] if origin.is_valid else []
with tracer.start_as_current_span(
"cart.reindex", kind=SpanKind.INTERNAL, links=links
) as span:
span.set_attribute("cart.id", cart_id)
reindex(cart_id)
background.add_task(run)
끊김 4 — 헤더를 지우는 중간 계층
프록시, WAF, API 게이트웨이, CDN이 화이트리스트 방식으로 헤더를 필터링하면 traceparent가 조용히 사라집니다. 로그에는 아무것도 남지 않고, 증상은 "게이트웨이 뒤부터 트레이스가 새로 시작된다"입니다.
# 실제로 도착하는 헤더를 확인하는 가장 빠른 방법
kubectl -n commerce exec deploy/checkout-api -- \
sh -c 'timeout 20 tcpdump -A -s0 -i any "tcp port 8000" 2>/dev/null | grep -i traceparent'
# 또는 앱에 임시 엔드포인트를 하나 두고 받은 헤더를 그대로 반환하게 한다
curl -s http://checkout-api.internal/__debug/headers \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' | jq .
Envoy나 Istio를 쓴다면 traceparent, tracestate, baggage가 허용 목록에 있는지 확인합니다. B3 헤더를 쓰는 레거시 서비스가 섞여 있다면 전파기를 복수로 설정합니다.
OTEL_PROPAGATORS=tracecontext,baggage,b3multi
4단계 — 수동 스팬은 self time이 큰 곳에만
1308ms의 공백으로 돌아옵니다. 수동 스팬을 넣을 후보는 다섯 종류입니다.
- 루프와 배치 경계 — 반복 횟수를 속성으로 남깁니다
- 캐시 조회 — 히트 여부를 속성으로 남기면 캐시 효율이 트레이스에서 바로 보입니다
- 계측 패키지가 없는 서드파티 SDK 호출
- 락, 큐, 커넥션 풀 대기
- CPU를 오래 쓰는 구간 — 직렬화, 압축, 리포트 생성
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("checkout", "2.7.1")
async def apply_promotions(cart, tenant_id):
with tracer.start_as_current_span("checkout.apply_promotions") as span:
span.set_attribute("cart.item_count", len(cart.items))
span.set_attribute("tenant.id", tenant_id)
span.set_attribute("promotion.engine", "rules-v3")
try:
with tracer.start_as_current_span("promotion.load_rules") as load:
cached = await rules.from_cache(tenant_id)
load.set_attribute("cache.hit", cached is not None)
ruleset = cached or await rules.compile(tenant_id)
load.set_attribute("promotion.rule_count", len(ruleset))
with tracer.start_as_current_span("promotion.evaluate") as ev:
result = ruleset.evaluate(cart)
ev.set_attribute("promotion.evaluated", result.evaluated)
ev.set_attribute("promotion.matched", len(result.matched))
return result
except Exception as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)))
raise
이 계측을 넣은 뒤 같은 요청의 트레이스는 이렇게 바뀝니다.
SERVER checkout-api POST /v1/orders 1421ms
├─ CLIENT GET http://auth.internal/verify 31ms
├─ CLIENT SELECT carts WHERE id = ? 6ms
├─ INTERNAL checkout.apply_promotions 1298ms
│ ├─ INTERNAL promotion.load_rules cache.hit=false 1241ms <-- 여기
│ └─ INTERNAL promotion.evaluate evaluated=812 54ms
├─ CLIENT POST http://payment.internal/charge 74ms
└─ (self time 12ms)
스팬 이름 규칙 하나만 지키면 됩니다. 이름은 저카디널리티여야 합니다. GET /v1/orders/A-99183이 아니라 GET /v1/orders/:id이고, 구체적인 값은 전부 속성으로 갑니다. 백엔드는 스팬 이름으로 그룹핑해서 지연 시간 통계와 서비스 그래프를 만들기 때문에, 이름에 ID가 들어가면 그 집계 뷰 전체가 무너집니다.
5단계 — 컬렉터를 앱과 백엔드 사이에 두는 이유
SDK가 백엔드로 직접 보내도 동작은 합니다. 그런데도 컬렉터를 두는 이유는 다섯 가지입니다.
- 정책을 재배포 없이 바꿉니다. 샘플링 비율, 속성 필터, 보존 대상은 운영 중에 조정하게 되는 값입니다. 그것이 앱 환경변수에 있으면 20개 서비스를 롤아웃해야 합니다.
- 앱을 백엔드 장애로부터 격리합니다. 백엔드가 느려질 때 SDK의 전송 큐가 차면 앱 메모리가 올라가고, 심하면 요청 처리에 영향을 줍니다. 컬렉터가 앞에 있으면 그 압력을 대신 받습니다.
- 백엔드를 바꿀 수 있습니다. 메트릭은 Prometheus, 트레이스는 ClickHouse, 로그는 OpenSearch처럼 신호별로 다른 목적지를 쓰거나, 두 백엔드를 병행 운영하며 이관하는 일이 컬렉터 설정 파일 한 곳에서 끝납니다.
- 민감 정보를 앱 밖에서 지웁니다. 토큰이나 이메일이 속성에 섞여 들어가는 사고는 반드시 일어납니다. 컬렉터에 방어선을 두면 사고 대응이 재배포가 아니라 설정 변경이 됩니다.
- 테일 샘플링을 할 수 있습니다. 트레이스 결과를 보고 판단하려면 스팬이 한곳에 모여야 하고, 그 장소는 앱이 될 수 없습니다.
배치 크기, 재시도, 메모리 상한을 설정하는 최소 구성입니다.
# otel-collector.yaml — 앱과 같은 노드 또는 사이드카에 두는 에이전트 계층
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# 반드시 첫 번째. 메모리 한계에 닿으면 수신을 거부해서 컬렉터 자체가 죽는 것을 막는다
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
# 쿠버네티스 메타데이터를 리소스 속성으로 붙인다
k8sattributes:
auth_type: serviceAccount
extract:
metadata:
- k8s.namespace.name
- k8s.deployment.name
- k8s.pod.name
- k8s.node.name
# 민감 속성 제거 — 앱을 고치지 않고 여기서 막는다
attributes/redact:
actions:
- key: http.request.header.authorization
action: delete
- key: user.email
action: delete
- key: db.query.text
action: hash
# 항상 마지막. 네트워크 왕복을 줄인다
batch:
timeout: 5s
send_batch_size: 8192
send_batch_max_size: 16384
exporters:
otlp/gateway:
endpoint: otel-gateway.observability.svc:4317
tls:
insecure: true
sending_queue:
enabled: true
num_consumers: 10
queue_size: 5000
retry_on_failure:
enabled: true
initial_interval: 5s
max_elapsed_time: 300s
service:
telemetry:
metrics:
level: detailed
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, attributes/redact, batch]
exporters: [otlp/gateway]
metrics:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp/gateway]
logs:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, attributes/redact, batch]
exporters: [otlp/gateway]
프로세서 순서가 의미를 가집니다. memory_limiter가 첫 번째가 아니면 과부하 상황에서 컬렉터가 OOM으로 죽고, batch가 마지막이 아니면 뒤따르는 프로세서가 배치를 다시 쪼개면서 이득이 사라집니다. Collector 문서에도 같은 순서를 권장합니다.
컬렉터를 두 계층으로 나누는 것이 일반적입니다. 앱 옆의 에이전트는 수집과 메타데이터 부착만 하고, 게이트웨이 계층에서 테일 샘플링과 백엔드 라우팅을 합니다. 테일 샘플링을 쓴다면 게이트웨이 앞에 trace ID 기준 라우팅이 필요합니다. 같은 트레이스의 스팬이 서로 다른 인스턴스로 흩어지면 각자 조각을 보고 판정해서 트레이스가 무작위로 잘립니다.
계측이 앱을 망가뜨리는 방식들
행복한 경로만 보여 주는 가이드는 쓸모가 없으므로, 실제로 겪게 되는 실패를 모아 둡니다.
| 증상 | 원인 | 확인 방법 | 대응 |
|---|---|---|---|
| 배포 후 메모리가 계속 증가 | 백엔드 응답 지연으로 익스포터 큐가 계속 참 | 컬렉터의 큐 크기 메트릭과 앱 RSS 추이 | 큐 크기 상한과 드롭 정책을 명시, 컬렉터 경유로 전환 |
| 스팬이 일부만 도착 | 프로세스가 종료될 때 flush 없이 죽음 | 배치 프로세서 타임아웃과 종료 훅 | 종료 시 shutdown 호출, 컨테이너 terminationGracePeriod 확대 |
| 스팬 하나가 수백 KB | 요청 본문 전체를 속성에 넣음 | 백엔드의 스팬 크기 분포 | 속성 값 길이 상한 설정 |
| 지연 시간이 눈에 띄게 증가 | 동기 익스포터 또는 뜨거운 루프 안의 스팬 생성 | 계측 전후 벤치마크 | 배치 프로세서 사용, 루프 내부가 아닌 루프 경계에 스팬 |
| 트레이스가 게이트웨이에서 새로 시작 | 프록시가 헤더 제거 | 헤더 덤프 | 허용 목록에 traceparent 추가 |
| 서비스 그래프에 유령 노드 | 카나리를 별도 service.name 으로 배포 | 리소스 속성 확인 | service.name 은 서비스 단위로 고정 |
속성 크기는 SDK 차원에서 상한을 걸어 둘 수 있습니다. 기본값은 속성 개수 128개이고 값 길이는 제한이 없으므로, 명시적으로 지정하는 편이 안전합니다.
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048
OTEL_BSP_MAX_QUEUE_SIZE=4096
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_BSP_SCHEDULE_DELAY=2000
종료 시 flush는 언어별로 다릅니다. Python 자동 계측은 정상 종료 시 프로바이더를 shutdown 하지만, SIGKILL로 죽으면 큐에 남은 스팬은 사라집니다. 짧은 배치 작업이라면 명시적으로 flush 하는 편이 확실합니다.
from opentelemetry import trace
def main():
run_job()
# 배치 잡은 반드시 명시적으로 비운다
trace.get_tracer_provider().force_flush(timeout_millis=10_000)
trace.get_tracer_provider().shutdown()
계측이 끝났다고 말할 수 있는 기준
체크리스트를 통과해야 다음 단계로 넘어갑니다.
- 임의의 프로덕션 요청 하나를 골라 트레이스를 열었을 때, 관여한 서비스 수와 SERVER 스팬 수가 일치한다
- 루트 스팬의 지속 시간이 게이트웨이 접근 로그의 응답 시간과 오차 범위 안에서 일치한다
- 가장 큰 self time이 전체의 20% 미만이다
- 트레이스에서 trace ID를 복사해 로그 검색에 넣으면 해당 요청의 로그가 나온다
- 스팬 이름 목록을 카디널리티 순으로 정렬했을 때 상위 20개가 라우트 템플릿이지 ID가 아니다
- 메시지 큐를 넘는 작업이 하나의 트레이스나 링크로 이어져 있다
- 컬렉터를 재시작해도 앱이 영향받지 않는다
마지막 항목은 실제로 해 봐야 압니다. 컬렉터를 한 번 죽여 보고 앱의 에러율과 지연 시간이 흔들리지 않는지 확인합니다. 흔들린다면 익스포터가 동기이거나 큐 정책이 잘못된 것입니다.
마치며 — 계측의 가치는 스팬 수가 아니라 트레이스의 완결성에서 나온다
스팬 500개짜리 조각난 트레이스보다 스팬 12개짜리 완결된 트레이스가 압도적으로 유용합니다. 그래서 투자 순서도 정해집니다. 전파가 끊기지 않는 것이 첫째이고, 리소스 속성이 일관된 것이 둘째이며, 수동 스팬은 마지막입니다.
지금 할 수 있는 가장 값싼 검증은 프로덕션 트레이스 하나를 열어서 SERVER 스팬을 세어 보는 것입니다. 요청이 통과한 서비스가 여섯 개인데 SERVER 스팬이 두 개라면, 이번 주에 할 일은 수동 스팬 추가가 아니라 나머지 네 곳의 전파를 살리는 일입니다.
더 파고들 자료입니다.
현재 단락 (1/307)
주문 API의 p99가 1.4초입니다. Grafana에는 CPU, 메모리, 요청 수, 에러율 패널이 이미 있고 전부 초록색입니다. 데이터베이스 대시보드도 정상입니다. 그런데 사용자...