Go에서 Distributed Tracing 구축: 서비스 간 요청 추적에 대한 완전 가이드
Source: Dev.to
위의 링크에 있는 전체 글 내용을 제공해 주시면, 해당 내용을 그대로 유지하면서 한국어로 번역해 드리겠습니다. (코드 블록, URL 및 기술 용어는 번역하지 않고 원본 그대로 유지합니다.)
📚 작가 홍보
베스트셀러 작가인 저는 여러분을 **Amazon**에서 제 책들을 살펴보시길 초대합니다.
잊지 말고 **Medium**에서 저를 팔로우하고 응원해 주세요. 감사합니다! 여러분의 응원은 큰 힘이 됩니다!
1. Tracer – 시스템의 핵심 요소
구현은 Tracer 구조체를 중심으로 이루어집니다. 이 구조체는 전체 추적 과정을 관리합니다. 트레이서를 생성할 때 다음을 제공해야 합니다:
- 서비스 이름 – 트레이스에서 서비스를 식별합니다.
- 샘플링 비율 – 실제로 기록하고자 하는 요청의 비율입니다 (고 트래픽 시스템에서 모든 요청을 기록하는 것은 비용이 과도하게 많이 듭니다).
tracer := NewTracer("order-service", 0.1) // Sample 10 % of traces
Source: …
2. Span – 작업 단위
Span 은 하나의 작업 단위(예: DB 쿼리, HTTP 핸들러)를 나타냅니다. StartSpan 메서드가 바로 그 마법이 시작되는 곳입니다. 이 메서드는:
- 전달된
context에서 부모 span을 찾습니다. 존재한다면, 새로운 span은 그 자식이 되어 트레이스 계층 구조를 형성합니다. - 샘플러에게 이 span을 기록할지 여부를 묻습니다.
func (t *Tracer) StartSpan(ctx context.Context, name string, opts ...SpanOption) (context.Context, *Span) {
var parentSpanContext trace.SpanContext
if parent := trace.SpanFromContext(ctx); parent != nil {
parentSpanContext = parent.SpanContext()
}
samplingResult := t.sampler.ShouldSample(SamplingParameters{
TraceID: generateTraceID(),
ParentContext: parentSpanContext,
Name: name,
Attributes: make(map[string]interface{}),
})
// ... create span based on the sampling decision
}
No‑op Span
샘플러가 기록하지 않기로 결정하면, no‑op span을 반환합니다. 이 span은 아무 작업도 하지 않으며, 오버헤드를 거의 0에 가깝게 유지하면서 동일한 코드 경로가 실행될 수 있게 합니다.
Real Span & Object Pool
샘플러가 기록을 허용하면, sync.Pool 에서 span을 가져옵니다. span 객체를 재사용하면 Go 가비지 컬렉터에 대한 부담을 줄일 수 있습니다.
span := t.spanPool.Get().(*Span)
// ... configure the span
return ctx, span
Span Lifecycle
span은 다음과 같은 정보를 저장합니다:
- 고유 ID
- 부모 ID
- 시작 및 종료 타임스탬프
- 속성(키‑값 쌍, 예:
http.method="GET"또는db.query="SELECT * FROM users")
작업이 끝나면 EndSpan을 호출합니다:
- 지속 시간 계산
- 최종 상태 설정(성공, 오류 등)
- span을 버퍼링된 채널에 보내어 내보내기 수행
- span을 초기화하고 풀에 반환
3. 컨텍스트 전파 – 서비스 간 트레이스 데이터 전달
전파는 트레이스 정보를 한 서비스에서 다른 서비스로 전달합니다. HTTP의 경우, 트레이스 ID와 스팬 ID가 헤더에 인코딩됩니다.
들어오는 컨텍스트 추출
ctx := tracer.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
나가는 컨텍스트 주입
tracer.Inject(ctx, propagation.HeaderCarrier(r.Header))
같은 패턴이 gRPC, 메시지 큐, 혹은 모든 전송 방식에서도 작동합니다—적절한 캐리어 타입만 사용하면 됩니다.
4. 샘플링 전략
4.1 확률 샘플링
가장 간단한 방법: 새로운 트레이스마다 주사위를 굴린다. 0.1 (10 %) 비율일 경우, 무작위 숫자가 0.1 이하이면 해당 트레이스가 샘플링된다.
- 장점: 이해하기 쉽고 예측 가능하다.
- 단점: 트래픽 급증 시, 거대한 양의 10 %만으로도 백엔드에 과부하를 일으킬 수 있다.
4.2 레이트 제한 샘플링
초당 스팬 수를 제한하는 보다 정교한 접근 방식이다.
- 크레딧 시스템 – 매초 샘플러는 고정된 크레딧 수(예:
100)를 획득한다. - 스팬이 생성될 때마다 한 개의 크레딧을 사용한다. 크레딧이 남아 있지 않으면 해당 스팬은 삭제된다.
이는 급격한 트래픽 급증 상황에서도 트레이싱 백엔드에 가해지는 부하를 제한한다.
5. 모두 합치기
func handler(w http.ResponseWriter, r *http.Request) {
// 1️⃣ Extract incoming trace context
ctx := tracer.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
// 2️⃣ Start a new span for this handler
ctx, span := tracer.StartSpan(ctx, "http.handler", trace.WithAttributes(
attribute.String("http.method", r.Method),
attribute.String("http.path", r.URL.Path),
))
defer span.EndSpan()
// 3️⃣ Do some work (e.g., DB query)
doDBWork(ctx)
// 4️⃣ Call downstream service, injecting trace context
req, _ := http.NewRequestWithContext(ctx, "GET", "http://service-b/api", nil)
tracer.Inject(ctx, propagation.HeaderCarrier(req.Header))
http.DefaultClient.Do(req)
// 5️⃣ Respond to the client
fmt.Fprintln(w, "OK")
}
TL;DR
- Tracer – 중앙 관리자, 서비스 이름 및 샘플링 비율을 보유합니다.
- Span – 작업 단위;
StartSpan으로 생성하고EndSpan으로 종료합니다. - Propagation – 들어오는 헤더를
Extract, 나가는 헤더를Inject. - Sampling – 데이터 양을 제어하기 위한 확률 vs. 레이트‑리밋.
이러한 구성 요소들을 사용하면 어떤 Go 서비스든 계측하고, 엔드‑투‑엔드 가시성을 확보하며, 오버헤드를 제어할 수 있습니다. 즐거운 트레이싱!
레이트‑리밋 샘플러
만약 크레딧이 남아 있지 않다면, 새로운 스팬은 더 많은 크레딧이 쌓일 때까지 샘플링되지 않습니다. 이는 데이터 양에 대한 엄격한 상한선을 제공합니다.
func (rls *RateLimitingSampler) ShouldSample(params SamplingParameters) SamplingResult {
rls.mu.Lock()
defer rls.mu.Unlock()
// Update credits based on time passed
now := time.Now()
elapsed := now.Sub(rls.lastCreditUpdate).Seconds()
rls.currentCredits += elapsed * rls.creditsPerSecond
// Spend a credit if we have one
if rls.currentCredits >= 1.0 {
rls.currentCredits -= 1.0
return SamplingResult{Decision: RecordAndSample}
}
return SamplingResult{Decision: Drop}
}
더 똑똑한 시스템은 적응형 샘플링을 사용할 수 있습니다. 이는 HTTP 오류 코드가 증가하는 것을 감지하면 자동으로 샘플링 비율을 높여, 장애 발생 시 가시성을 향상시킵니다. 샘플러 인터페이스는 이러한 다양한 전략을 손쉽게 플러그인할 수 있게 합니다.
스팬 내보내기
스팬을 수집하는 것은 한 가지 일이고, 이를 유용한 곳으로 보내는 것은 또 다른 일입니다. TraceExporter가 이를 처리합니다. 스팬은 버퍼링된 채널(exporterCh)에 전송됩니다. 별도의 고루틴이 이 채널에서 읽어 스팬을 배치로 묶습니다. 배치 처리는 효율성을 위해 매우 중요합니다—HTTP 요청당 하나의 스팬만 보내면 비효율적이기 때문입니다. 스팬을 그룹화함으로써 네트워크 오버헤드를 크게 줄일 수 있습니다.
배치 프로세서는 배치가 가득 찰 때까지(예: 100개의 스팬) 또는 타이머가 작동할 때까지(예: 매 5초) 기다립니다. 이렇게 하면 트래픽이 많을 때는 스팬이 빠르게 내보내지고, 트래픽이 적을 때는 부분 배치가 영원히 대기하지 않게 됩니다.
func (te *TraceExporter) processBatches() {
batch := make([]*SpanData, 0, te.batchSize)
for {
select {
case span := <-te.batchCh:
batch = append(batch, span)
if len(batch) >= te.batchSize {
te.sendBatch(batch)
batch = batch[:0]
}
case <-time.After(te.flushInterval):
if len(batch) > 0 {
te.sendBatch(batch)
batch = batch[:0]
}
}
}
}