Skip to content

필사 모드: OpenTelemetry 완전 해부 — Trace/Metric/Log/Profile 4기둥, Collector, 샘플링, OTLP까지

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며 — "관측성"이라는 단어의 인플레이션

"Observability" — 한때 SRE들만 쓰던 단어가 지금은 모든 DevOps 면접의 필수 질문이다. 하지만 질문을 해보면 대답은 대부분 이렇다.

> "로그 모으고, 메트릭 보고, Jaeger에서 trace 봅니다."

이건 **관측성이 아니라 모니터링이다**. 둘의 차이는:

- **모니터링** — "미리 정해둔 지표로 시스템이 정상인지 확인"

- **관측성** — "예상하지 못했던 문제도 외부 신호만으로 내부 상태를 추론할 수 있는 성질"

후자가 되려면 신호가 **풍부하고, 연결돼 있고, 무작위 질문에 답할 수 있어야** 한다. OpenTelemetry는 이걸 위한 **표준**이다.

> 이전 글 [Service Mesh 완전 해부](/blog/culture/2026-04-15-service-mesh-envoy-istio-linkerd-cilium-ebpf-ambient-xds-mtls-deep-dive-guide-2025)에서 Mesh가 자동으로 메트릭/trace를 뿌려준다고 했다. 이 글은 **그 신호들이 어떻게 수집·전송·저장·질의되는지**의 이야기다.

1. OpenTelemetry의 탄생 배경 — 왜 또 다른 표준인가

분열된 2010년대

관측성 도구는 2010년대 내내 파편화돼 있었다.

- **Jaeger** (Uber, 2017) — 분산 trace

- **Zipkin** (Twitter, 2012) — 분산 trace

- **Prometheus** (SoundCloud, 2012) — 메트릭

- **StatsD** (Etsy, 2011) — 메트릭

- **Fluentd** / **Logstash** — 로그 수집

각자 **SDK, 프로토콜, 데이터 모델이 전부 달랐다**. 서비스 A는 OpenTracing, 서비스 B는 OpenCensus를 썼다면 trace가 이어지지 않는다.

두 표준의 통합: OpenTelemetry

- **OpenTracing** (CNCF, 2016) — 벤더 중립 trace API

- **OpenCensus** (Google, 2017) — Google Cloud의 SDK

두 프로젝트는 목적이 겹쳤다. 2019년 CNCF가 두 팀을 **OpenTelemetry (OTel)**로 병합. 2021년 trace가 GA, 2023년 metric GA, 2024년 log GA, **2025년 profile이 네 번째 기둥**으로 추가됐다.

이름에 담긴 야심

"Telemetry"는 "원격 측정". 항공/우주/의료에서 쓰이던 이 단어를 선택한 건 **모든 신호를 하나의 프레임워크로**라는 의지다.

2. 세 기둥에서 네 기둥으로 — 신호의 구조

전통적인 세 기둥:

1. **Traces** — 요청 하나가 시스템을 거치며 남긴 인과 그래프

2. **Metrics** — 숫자로 집계된 시계열 (카운터, 게이지, 히스토그램)

3. **Logs** — 시점별 이벤트 메시지

그리고 새로 추가된:

4. **Profiles** — CPU/메모리 프로파일의 연속 시계열 (Continuous Profiling)

네 신호가 **하나의 trace context로 묶이는 것**이 OTel의 핵심이다.

Trace ID: abc123

┌────────────────┼────────────────┐

│ │ │

Span Metric Log

(기간) (집계값) (이벤트)

│ │ │

└────────── Profile ──────────────┘

(CPU flame graph)

사용자가 "이 요청 왜 느려?"라고 물으면, Trace ID 하나로 관련된 로그·메트릭·프로파일을 모두 찾을 수 있어야 한다. 이게 **correlation**이다.

3. Trace의 해부 — Span, Context, Baggage

Span의 구조

Span은 trace의 기본 단위다. 한 작업의 시작과 끝.

{

"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",

"spanId": "00f067aa0ba902b7",

"parentSpanId": "a2fb4a1d1a96d312",

"name": "GET /api/users/:id",

"kind": "SERVER",

"startTime": "2026-04-15T10:00:00.123Z",

"endTime": "2026-04-15T10:00:00.245Z",

"attributes": {

"http.method": "GET",

"http.status_code": 200,

"db.system": "postgresql",

"user.id": "u-42"

},

"events": [

{"name": "cache.miss", "time": "10:00:00.130Z"}

],

"status": { "code": "OK" }

}

핵심 필드:

- **traceId** — 128비트. 하나의 요청 전체에 공통

- **spanId** — 64비트. 이 span만의 고유 ID

- **parentSpanId** — 부모 span (루트면 없음)

- **kind** — SERVER, CLIENT, PRODUCER, CONSUMER, INTERNAL

Trace Context Propagation — 표준화된 헤더

서비스 A가 B를 호출할 때, HTTP 헤더로 context를 전달해야 trace가 이어진다. **W3C Trace Context** (2019 표준화):

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

└┬┘ └──────────── trace-id ─────────────┘ └───parent-id──┘└┬┘

version flags

- **version** — 00 (현재)

- **trace-id** — 128비트 hex

- **parent-id** — 부모 span ID

- **flags** — 00 (기록 안 함) / 01 (샘플링됨)

그리고 W3C가 함께 정의한 **Baggage** 헤더:

baggage: user.id=42,feature.flag=beta

Baggage는 **비즈니스 컨텍스트**를 전파한다. "이 요청은 VIP 사용자야"라는 정보를 모든 서비스가 받을 수 있다.

언어별 SDK의 자동 계측

OTel SDK는 대부분의 언어에서 **자동 계측(auto-instrumentation)**을 지원한다.

- **Java/Python/Node.js** — Java agent / 원숭이 패치로 기존 라이브러리에 훅 삽입

- **Go/Rust** — 컴파일러 제약으로 기본은 수동. eBPF로 자동화 시도 중

예: Python에서 Flask + requests + psycopg2를 쓰면, auto-instrumentation 한 줄로 다음이 전부 자동 trace된다.

opentelemetry-instrument --traces_exporter otlp python app.py

모든 HTTP 요청이 span이 되고, 나가는 requests 호출도 span이 되고, DB 쿼리도 span이 된다. 코드 수정 없음.

4. Metric의 새 데이터 모델

Prometheus 모델의 한계

Prometheus는 **pull** 기반. 서버가 앱의 `/metrics` endpoint를 스크레이프.

- 문제 1: 단기 수명 워크로드(Lambda, Job)는 스크레이프 전에 사라짐

- 문제 2: 고차원 라벨(user_id 같은 것)이면 cardinality 폭발

- 문제 3: 이종 시스템 간 의미 통일이 없음

OTel Metric의 구조

OTel은 네 가지 instrument를 정의한다:

- **Counter** — 단조 증가 (요청 수)

- **UpDownCounter** — 증감 가능 (동시 연결 수)

- **Histogram** — 분포 (지연)

- **Gauge** — 비동기 측정 (메모리 사용량)

그리고 핵심은 **Aggregation Temporality**:

- **Cumulative** — 시작부터 누적값 (Prometheus 스타일)

- **Delta** — 직전 export 이후 증분

Delta는 단기 워크로드에 적합. Cumulative는 재시작 추적에 강함.

Exemplar — 메트릭과 Trace의 다리

히스토그램의 특정 버킷에 "이 데이터 포인트에 해당하는 trace ID"를 붙일 수 있다.

http_request_duration_bucket{le="1.0"} 1523

exemplar: traceID=abc123

Grafana에서 히스토그램을 보다가 "P99 느린 요청은 뭐야?" 클릭 → 해당 trace로 점프. 이게 exemplar의 힘이다.

5. Log의 재정의

"왜 log가 새 표준이 필요해?"

ELK/Splunk/Loki가 이미 있는데 OTel이 Log 표준을 낸 이유:

- **Trace와의 연결** — 기존 로그는 trace ID가 없거나 따로 넣어야 함

- **Severity의 표준화** — 앱마다 "INFO", "warning", "warn"이 섞여 혼란

- **Attribute 모델** — key-value를 구조화

LogRecord 구조

{

"timestamp": "2026-04-15T10:00:00Z",

"severityText": "ERROR",

"severityNumber": 17,

"body": "Failed to charge card: insufficient_funds",

"traceId": "4bf92f35...",

"spanId": "00f067aa...",

"attributes": {

"user.id": 42,

"payment.amount": 29.99

},

"resource": {

"service.name": "payment-api",

"k8s.pod.name": "payment-api-7d4f-x9k2"

}

}

traceId/spanId가 기본 필드라는 점이 핵심. 어떤 로그도 trace에 자동 연결된다.

Bridges

기존 로그 라이브러리(Log4j, logback, zap, zerolog)는 OTel Log SDK로 직접 쓰지 않고 **Bridge**를 거친다. 기존 코드를 바꾸지 않고 OTel로 보낼 수 있다.

6. Profile — 네 번째 기둥

Continuous Profiling의 부상

2020년대 초 Grafana Pyroscope와 Polar Signals Parca가 "**항상 켜져 있는** CPU/메모리 프로파일"을 제공. 전통적 프로파일러(pprof, async-profiler)는 문제 발생 시 수동으로 돌렸다면, 이젠 항시 수집 후 과거로 돌아가 확인.

OTel Profiles Signal (2024 RC, 2025 GA)

프로토콜: **pprof** 포맷을 OTLP에 실어 보냄.

- Linux perf / eBPF로 **Unwind 없이** (frame pointer 또는 DWARF) 스택 샘플

- 1Hz 기본 샘플로 CPU 1% 미만 오버헤드

- Flame graph로 시각화

다른 신호와의 통합

Pyroscope + OTel 조합으로:

Trace 보기 → 느린 span 클릭 → "이 시점 CPU 프로파일" 버튼 →

해당 Pod의 해당 5초간 flame graph

함수 레벨까지 **어디서 시간이 샜나**가 보인다.

7. OTLP — 하나의 프로토콜

단일 와이어 포맷

네 신호 모두 **OTLP (OpenTelemetry Protocol)**로 전송된다.

- **OTLP/gRPC** (기본) — Protocol Buffers + gRPC

- **OTLP/HTTP** — Protocol Buffers 또는 JSON + HTTP

스키마 일부 (ExportTraceServiceRequest):

message Span {

bytes trace_id = 1;

bytes span_id = 2;

bytes parent_span_id = 4;

string name = 5;

SpanKind kind = 6;

fixed64 start_time_unix_nano = 7;

fixed64 end_time_unix_nano = 8;

repeated KeyValue attributes = 9;

repeated Event events = 11;

Status status = 15;

}

**중요**: OTLP는 엔드포인트 간 프로토콜일 뿐. 저장소(Jaeger, Tempo, Prometheus, Loki)와는 별개다.

8. Collector — 관측성 파이프라인의 심장

왜 Collector가 필요한가

앱이 직접 백엔드에 보내면 문제들:

- 앱마다 인증·재시도 로직

- 백엔드 바꾸면 앱 전부 재배포

- 라벨 rewrite 같은 전처리가 앱에 섞임

Collector는 **앱과 백엔드 사이의 유연한 중간자**.

3단계 파이프라인

Receiver → Processor → Exporter

[OTLP] [Batch] [Jaeger]

[Prometheus] [Filter] → [Loki]

[Zipkin] [Sampling] [Prometheus]

[HostMetrics][Transform] [Kafka]

[...30+] [Redact] [S3]

- **Receiver** — 신호를 받는 입구 (40+종 지원)

- **Processor** — 변환 (배칭, 샘플링, 재명명, PII 마스킹)

- **Exporter** — 백엔드로 내보냄 (40+종)

배포 토폴로지

**Agent 모드** (DaemonSet):

- 각 Node에 하나. 로컬 신호 수집 + 전달.

**Gateway 모드** (Deployment):

- 클러스터당 몇 개. Agent → Gateway → 백엔드.

- 장점: 백엔드 자격증명이 한 곳에, 큰 배칭 가능, Tail 샘플링 가능

현실에서는 **Agent + Gateway 2단 구성**이 가장 흔하다.

9. 샘플링 — 데이터 폭발을 다스리는 법

왜 샘플링인가

초당 100만 요청 × 평균 span 20개 = 초당 2000만 span. 1KB씩 계산하면 초당 20GB. 한 달이면 52TB. **저장과 쿼리 비용이 터진다**.

Head Sampling — 시작에서 결정

요청의 첫 span에서 결정. 이후 모든 자식 span은 이 결정을 물려받음.

장점: 간단, 적은 메모리

단점: "이 요청이 에러였는지"를 시작 시점에 모른다 → **에러 요청을 놓칠 수 있음**

구현:

TraceID의 앞 8바이트 → unsigned int → threshold 비교

→ 항상 동일한 요청은 동일한 결정 (일관성)

Tail Sampling — 끝에서 결정

요청의 모든 span이 완료된 뒤 결정. Collector에서 처리.

장점:

- "에러 난 trace는 무조건 저장"

- "P99 지연 trace는 무조건 저장"

- "모든 결제 요청 저장"

단점:

- Collector가 **trace를 완료될 때까지 메모리에 보관** (보통 30초~1분)

- Gateway 모드 필수

구성 예 (tail_sampling processor):

policies:

- name: errors

type: status_code

status_code: { status_codes: [ERROR] }

- name: slow

type: latency

latency: { threshold_ms: 1000 }

- name: default

type: probabilistic

probabilistic: { sampling_percentage: 1 }

"에러, 1초 이상, 그리고 나머지 1%" → 대부분의 팀이 쓰는 기본 레시피.

Adaptive Sampling

트래픽이 폭증하면 비율을 자동 낮춤. 예산 기반 제어. 아직 OTel 표준엔 없고 벤더별 구현.

10. eBPF Auto-Instrumentation — 코드 수정 없이

기존 auto-instrumentation의 한계

- Java/Python: 에이전트 설치, JVM flag, 메모리 오버헤드

- Go: 에이전트 개념 약함 (바이너리 정적 링크)

- Rust: 매크로로 수동 계측 많음

eBPF의 접근

**Uprobe**로 사용자 공간 함수 진입점을 후킹. 함수가 실행되면 eBPF 프로그램이 **스택에서 인자 꺼내 event 생성**. 별도 사이드카나 코드 수정 없음.

대표 도구:

- **Grafana Beyla** (2023) — HTTP/gRPC를 eBPF로 자동 trace

- **Odigos** (2023) — Go, Java, Python 등 멀티 언어

- **Parca Agent** — 프로파일을 eBPF로

장점과 한계

장점:

- 코드 변경 0

- 언어 무관

- 매우 낮은 오버헤드

한계:

- 커널 권한 필요 (Pod에 `CAP_BPF`)

- 암호화된 트래픽은 TLS termination 지점까지만

- 비표준 라이브러리는 훅이 없을 수 있음

11. Semantic Conventions — 같은 것을 같은 이름으로

혼돈

앱 A는 `http.status`, 앱 B는 `http.response_status_code`, 앱 C는 `status`. 어떻게 같이 조회?

해결: 표준 속성 이름

OTel Semantic Conventions가 모든 속성 이름을 표준화한다.

http.request.method = "GET"

http.response.status_code = 200

url.path = "/api/users"

server.address = "api.example.com"

server.port = 443

db.system = "postgresql"

db.operation.name = "SELECT"

messaging.system = "kafka"

k8s.pod.name = "payment-api-x9k2"

k8s.container.name = "app"

service.name = "payment-api"

**Resource attributes** (Pod/Node/서비스 정보)와 **signal-level attributes** (요청 상세)가 나뉘어 있다. 쿼리할 때 `service.name="payment-api" AND http.response.status_code=500`처럼 통일된 이름으로 가능.

2024년 1.0 안정화

2024년에 HTTP, DB, messaging 등 주요 conventions가 1.0으로 안정화됐다. 그 이전 속성 이름(예: `http.status_code`)은 **이전 버전**으로 여전히 지원되지만, 새 코드는 1.0 스펙을 써야 한다.

12. 저장소 계층 — OTel 이후의 Jaeger, Tempo, Prometheus

OTel은 프로토콜이지 저장소가 아니다. 현장에서 쓰는 저장소들:

Trace

- **Jaeger** — Cassandra/Elasticsearch 백엔드

- **Grafana Tempo** — **S3 native**, 라벨 없이 trace ID만 인덱스 (저렴)

- **ClickHouse-based** — SigNoz, Uptrace

- **Honeycomb** — 칼럼형 저장소로 임의 쿼리

Metric

- **Prometheus** — remote_write로 OTel 받음

- **Mimir / Thanos / Cortex** — 장기 저장, 수평 확장

- **VictoriaMetrics** — Prometheus 호환 고성능

- **InfluxDB 3.0** — OTel 네이티브 지원

Log

- **Loki** — 라벨 인덱스만, 나머지는 전체 텍스트 스캔 (저렴)

- **Elasticsearch/OpenSearch** — 전통적, 풀텍스트 검색 강함

- **ClickHouse** — 수평 확장 + SQL

Profile

- **Pyroscope** (Grafana) — 컨티뉴어스 프로파일 특화

- **Parca** — 오픈소스, 단독 사용 가능

13. 실전 운영 — 6가지 교훈

교훈 1: Cardinality가 모든 걸 죽인다

메트릭 라벨에 `user_id`, `request_id`를 넣으면 시리즈 수가 폭발. 시리즈당 8KB 메모리라면 1000만 user_id = 80GB. **Prometheus/Cortex가 OOM**.

규칙: **라벨은 카디널리티가 bounded (수백 이하)인 것만**. 사용자 단위 지표는 trace attribute나 exemplar로.

교훈 2: Sampling을 일찍부터 설계

프로토타입에서 100% 샘플 → 프로덕션에서 비용 폭탄. 처음부터 **5~10% head + tail error 100%** 구성 권장.

교훈 3: Collector를 SPOF로 만들지 마라

Gateway Collector가 죽으면 텔레메트리 손실. 복제 + 큐 (Kafka/Kinesis)로 decoupling.

교훈 4: Semantic Conventions에 진심

팀마다 다른 속성 이름을 쓰면 쿼리가 지옥이 된다. **CI에 컨벤션 린터** 넣기. OTel Collector의 transform processor로 강제 rewrite도 가능.

교훈 5: Trace와 Log가 연결돼야 가치가 생긴다

로그에 trace_id/span_id가 꼭 있어야 한다. 로그 라이브러리의 MDC(Mapped Diagnostic Context) 또는 structured logging으로.

교훈 6: 보존 정책을 계층화

- Trace: 7일 hot, 30일 cold (S3)

- Metric: 15일 1m resolution, 1년 5m resolution

- Log: 30일, 정책은 feature-flag/PII 기준

통일된 30일 보관은 비용 낭비.

14. 함정과 안티패턴

안티패턴 1: span attribute에 PII를 넣기

이름, 이메일, 카드 번호가 trace에 들어가면 규제 위반. Redact processor나 앱 단에서 막아야.

안티패턴 2: 매 함수에 수동 span 추가

Span이 수백 개인 trace는 Jaeger UI에서 **보기 불가능**. 자동 계측 + 중요 경로만 수동.

안티패턴 3: log → metric → trace를 각각 다른 팀이 운영

사일로가 생김. **Platform team**이 하나로 운영해야 correlation이 생긴다.

안티패턴 4: "일단 전부 보내고 나중에 본다"

저장 비용은 선형이지만 쿼리 비용은 수퍼선형. 보내기 전에 필터하라.

안티패턴 5: Collector 없이 직접 백엔드로

초기엔 편하지만 백엔드 교체, 비밀 관리, 전처리가 불가능해진다. 처음부터 Collector 넣자.

안티패턴 6: histogram bucket을 기본값으로

`[0.005, 0.01, 0.025, ...]` 기본 bucket은 대부분의 도메인에 맞지 않는다. 실제 P99를 보고 **재조정**. bucket이 많으면 cardinality 폭발.

15. 실전 체크리스트 12가지

1. **서비스마다 `service.name`, `service.version` 필수 설정**

2. **W3C trace context 헤더 전파 확인** — 중간에 끊기는지 통합 테스트

3. **auto-instrumentation 먼저 → 수동은 최소화**

4. **샘플링 전략을 문서화** — Head/Tail/Adaptive를 조합해 결정

5. **Collector를 모든 경로에 두기** — 앱이 직접 백엔드 노출 X

6. **OTLP/gRPC + mTLS**로 Collector 보호

7. **Cardinality monitoring** — 상위 N개 라벨 값 카운트 대시보드

8. **Runbook에 trace URL 템플릿** — 알람에서 1클릭으로 trace로

9. **Semantic Conventions 1.x로 마이그레이션**

10. **Kubernetes Resource attributes 자동 주입** — k8sattributes processor

11. **retention 계층화 + 비용 알람**

12. **eBPF auto-instrumentation 파일럿** — Go/다언어 서비스에

다음 글 예고 — DNS 내부의 미스터리

Service Mesh와 OpenTelemetry를 배웠으니 이제 그 아래 계층의 기본, **DNS**를 제대로 볼 차례다. 다음 글에서는:

- **DNS 조회 한 번의 전체 경로** — 재귀 리졸버부터 권한 서버까지

- **EDNS0, DNSSEC, DoT, DoH, DoQ** — 보안과 사생활의 진화

- **CoreDNS** — Kubernetes의 숨은 심장

- DNS 기반 로드 밸런싱과 latency (ndots 5 함정!)

- **Happy Eyeballs** — IPv4/IPv6 중 빠른 쪽 선택

- DNS 기반 실패 사례 (AWS 2019, Akamai 2021)

- 퍼블릭 리졸버 대결 (1.1.1.1 vs 8.8.8.8 vs 9.9.9.9)

- Anycast와 DNS의 결합

"왜 Kubernetes에서 DNS가 느려요?", "CoreDNS를 왜 튜닝해요?" 같은 질문의 답이 거기 있다.

> **"모든 분산 장애의 절반은 DNS다."** 반 농담으로 쓰이는 이 말의 실체를 다음 글에서 확인해보자.

현재 단락 (1/269)

"Observability" — 한때 SRE들만 쓰던 단어가 지금은 모든 DevOps 면접의 필수 질문이다. 하지만 질문을 해보면 대답은 대부분 이렇다.

작성 글자: 0원문 글자: 10,320작성 단락: 0/269