kertenkerem_
· DevOps · 6 dk okuma

Grafana Tempo ve OpenTelemetry ile Distributed Tracing

Gece saat 03:00. Telefonunuz çalıyor, PagerDuty çığlık çığlığa. Prod ortamındaki kritik ödeme servisi yavaşlamış, hata oranları tavan yapmış. Hemen bilgisayarınızı açıp loglara bakıyorsunuz: Dev bir 500 Internal Server Error yığını. Ama bu hata o servisin kendisinden mi kaynaklanıyor, yoksa arkada çağırdığı 15 farklı microservice’ten birinin database sorgusunun takılmasından mı? Loglar burada yetersiz kalır, metrikler ise dilsizdir. İhtiyacımız olan şey, bir isteğin sistemdeki tüm yolculuğunu uçtan uca görebilmek.

Bu yazıda modern gözlemlenebilirlik (observability) yaklaşımının temel taşlarından biri olan distributed tracing konusunu ele alıyoruz. Yönetmesi ve ölçeklemesi tam bir operasyonel kabus olan eski nesil tracing çözümlerini bir kenara bırakıp Grafana, Tempo ve OpenTelemetry (OTel) ile production-ready, maliyet dostu ve yüksek performanslı bir tracing hattını adım adım kuruyoruz. Böylece microservices mimarimizdeki performans darboğazlarını saniyeler içinde tespit edeceğiz.

Neden Grafana Tempo? Elasticsearch’e RAM yetiştiremeyenler kulübü

Daha önce Jaeger veya Zipkin ile bir distributed tracing altyapısı kurduysanız, arka planda devasa bir Elasticsearch ya da Cassandra cluster’ı yönetmenin ne kadar sancılı olduğunu bilirsiniz. Yalnızca trace datalarını indekslemek için harcanan RAM ve CPU miktarı, bazen uygulamanın kendisinden fazla kaynak tüketebilir.

Grafana Tempo bu probleme radikal bir mühendislik yaklaşımıyla çözüm getiriyor: “No-index” architecture. Tempo trace datalarını indekslemez. Trace’leri yalnızca Trace ID’lerine göre anahtarlayıp nesne depolama servislerinde (Object Storage: AWS S3, Google Cloud Storage veya lokalde MinIO) bloklar halinde saklar. İndeksleme yoksa arama nasıl yapılıyor? Tempo, metrikler (Prometheus) ve loglar (Loki) arasındaki korelasyonu kullanır. Logunuzda bir Trace ID bulursunuz ve Tempo bu ID’yi doğrudan nesne deposundan milisaniyeler içinde çeker. Ortaya sıfıra yakın operasyonel maliyet ve çok ucuz storage faturaları çıkar.

Mimarinin tasarımı: veri nasıl akacak?

Uygulamamızdan çıkan trace verilerinin Tempo’ya ulaşması için endüstri standardı olan OpenTelemetry protokolünü (OTLP) kullanacağız. Uygulamadan doğrudan Tempo’ya yazmak yerine araya bir OpenTelemetry Collector koyacağız. Çünkü collector uygulamalarımızın üzerindeki yükü alır, verileri buffer’lar, batch halinde gönderir ve gerekirse hassas verileri (PII) maskeler. Akış şöyle olacak:

Uygulama (Go SDK) --[OTLP/gRPC]--> OTel Collector --[OTLP/gRPC]--> Grafana Tempo <-- Grafana (UI)

Adım 1: Altyapıyı ayağa kaldıralım (Docker Compose)

Lafı uzatmadan pratik tarafa geçelim. Lokalinizde bu yapıyı test edebilmeniz için hazırladığım, içinde Tempo, OTel Collector ve Grafana bulunan docker-compose.yml dosyamızı oluşturalım.

version: '3.8'

services:
  # Grafana Tempo (Trace Deposu)
  tempo:
    image: grafana/tempo:2.3.0
    command: [ "-config.file=/etc/tempo.yaml" ]
    volumes:
      - ./tempo.yaml:/etc/tempo.yaml
    ports:
      - "3200:3200"   # HTTP API
      - "4317:4317"   # OTLP gRPC receiver

  # OpenTelemetry Collector (Veri Toplayıcı ve Dağıtıcı)
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.90.0
    command: [ "--config=/etc/otel-collector-config.yaml" ]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4318:4318"   # OTLP HTTP receiver
    depends_on:
      - tempo

  # Grafana (Görselleştirme)
  grafana:
    image: grafana/grafana:10.2.0
    volumes:
      - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
    ports:
      - "3000:3000"
    depends_on:
      - tempo

Şimdi Tempo’nun konfigürasyon dosyası olan tempo.yaml dosyasını oluşturalım. Burada lokal diskimizi bir object storage gibi simüle edeceğiz:

stream_over_http: true
server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317

ingester:
  lifecycler:
    ring:
      kvstore:
        store: inmemory
      replication_factor: 1

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/traces
    wal:
      path: /tmp/tempo/wal

Sırada OTel Collector konfigürasyonumuz (otel-collector-config.yaml) var. Burası gelen trace verisini karşılayıp Tempo’ya paslayacak ana santralimiz:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 1s
    send_batch_size: 256

exporters:
  otlp:
    endpoint: tempo:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp]

Son olarak Grafana’nın ayağa kalktığında Tempo veri kaynağını otomatik tanıması için grafana-datasources.yaml dosyasını hazırlayalım:

apiVersion: 1

datasources:
  - name: Tempo
    type: tempo
    access: proxy
    orgId: 1
    url: http://tempo:3200
    basicAuth: false
    isDefault: true
    uid: tempo-datasource

Adım 2: Uygulama enstrümantasyonu (Go ve OpenTelemetry)

Altyapımız hazır, artık kod seviyesine inebiliriz. Distributed tracing’in en kritik kavramı Context Propagation’dır. Bir istek dışarıdan geldiğinde oluşan Trace ID’nin, alt servislere yapılan HTTP veya gRPC çağrılarına taşınması gerekir. Bu zinciri kırarsanız trace bütünlüğünüz kaybolur.

Aşağıdaki Go örneğinde hem bir OpenTelemetry Tracer’ı nasıl initialize edeceğimizi hem de manuel olarak nasıl span üreteceğimizi göreceğiz. Bu örnek prod ortamındaki mikroservisleriniz için iyi bir şablon olacaktır.

package main

import (
	"context"
	"log"
	"net/http"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
	"go.opentelemetry.io/otel/trace"
	"google.golang.org/grpc"
)

const (
	serviceName = "payment-gateway"
	collectorAddr = "localhost:4317"
)

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
	// gRPC üzerinden OTel Collector'a bağlanacak exporter
	exporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithInsecure(),
		otlptracegrpc.WithEndpoint(collectorAddr),
		otlptracegrpc.WithDialOption(grpc.WithBlock()),
	)
	if err != nil {
		return nil, err
	}

	// Servis kimlik bilgileri
	res, err := resource.New(ctx,
		resource.WithAttributes(
			semconv.ServiceNameKey.String(serviceName),
			semconv.ServiceVersionKey.String("1.0.0"),
		),
	)
	if err != nil {
		return nil, err
	}

	// Tracer Provider tanımı (Samplers burada belirlenebilir)
	tp := sdktrace.NewTracerProvider(
		sdktrace.WithSampler(sdktrace.AlwaysSample()), // Prod için ParentBased(AlwaysSample) önerilir
		sdktrace.WithBatcher(exporter),
		sdktrace.WithResource(res),
	)

	otel.SetTracerProvider(tp)
	return tp, nil
}

func main() {
	ctx := context.Background()
	tp, err := initTracer(ctx)
	if err != nil {
		log.Fatalf("Tracer başlatılamadı: %v", err)
	}
	defer func() {
		if err := tp.Shutdown(ctx); err != nil {
			log.Printf("Tracer kapatılırken hata: %v", err)
		}
	}()

	tracer := otel.Tracer("http-server")

	http.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
		// HTTP isteğinden gelen context'i ve trace parent bilgisini alıyoruz
		ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))
		
		ctx, span := tracer.Start(ctx, "CheckoutProcess", trace.WithSpanKind(trace.SpanKindServer))
		defer span.End()

		// DB Sorgusunu simüle eden bir alt span oluşturalım
		_, dbSpan := tracer.Start(ctx, "QueryUserBalance")
		time.Sleep(120 * time.Millisecond) // Veritabanı gecikmesi
		dbSpan.SetAttributes(semconv.DBSystemPostgreSQL)
		dbSpan.End()

		// Üçüncü parti API çağrısını simüle edelim
		_, apiSpan := tracer.Start(ctx, "CallStripeAPI")
		time.Sleep(350 * time.Millisecond) // Dış servis gecikmesi
		apiSpan.End()

		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status": "success"}`))
	})

	log.Println("Server 8080 portunda çalışıyor...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Adım 3: Grafana Explore’da trace görselleştirme

Her şey hazır. Sistemimizi ayağa kaldırmak için terminalden şu komutu çalıştıralım:

docker compose up -d

Ardından Go uygulamamızı çalıştıralım ve test isteği gönderelim:

go run main.go
curl -i http://localhost:8080/checkout

Şimdi tarayıcınızdan http://localhost:3000 adresine giderek Grafana’ya giriş yapın (varsayılan kullanıcı adı/şifre: admin/admin). Sol menüden Explore sekmesine tıklayın ve veri kaynağı olarak Tempo’yu seçin.

Search sekmesine gelip “payment-gateway” servisimizi seçtiğimizde az önce attığımız isteğin trace verisini göreceğiz. Trace’e tıkladığınızda karşınıza çıkan Gantt şemasına benzer görselleştirme size şu bilgileri veriyor:

  • İsteğin toplamda ne kadar sürdüğünü (örn: 470ms)
  • Hangi alt işlemin (QueryUserBalance vs CallStripeAPI) bu sürenin ne kadarını yediğini (Darboğaz tespiti!)
  • Eğer işlem sırasında bir hata oluştuysa, o hatanın tam olarak hangi span üzerinde patladığını ve hata detaylarını (Exception stack trace)

Pro tip: Logları ve trace’leri birleştirmek (derived fields)

Fark yaratacağınız yer burası. Yalnızca trace izlemek yetmez, loglar ile trace’ler arasında köprü kurmalısınız. Grafana Loki konfigürasyonunuza ekleyeceğiniz bir derived fields kuralıyla log satırındaki trace_id değerini otomatik olarak Tempo’ya yönlendiren tıklanabilir linklere dönüştürebilirsiniz. Hata loguna bakan bir yazılımcı da tek bir tıkla o hatanın oluştuğu trace’e sıçrayabilir. Bu yaklaşıma “correlating signals” deniyor.

Production tavsiyeleri

Uygulamalarınız büyüdükçe ve microservices sayınız arttıkça distributed tracing lüks olmaktan çıkıp bir hayatta kalma aracına dönüşür. Grafana Tempo ve OpenTelemetry ikilisi size hem açık standartlara dayalı (vendor lock-in olmadan) hem de düşük maliyetli bir gözlemlenebilirlik platformu sunar.

Üretim ortamına geçerken şu noktalara dikkat etmenizi öneririm:

  • Sampling (Örnekleme): Her isteğin trace’ini saklamak disk alanınızı hızla tüketebilir. OTel Collector üzerinde tail-based sampling kullanarak yalnızca hatalı (5xx) veya uzun süren (latency > 2s) istekleri %100 saklayıp başarılı isteklerin sadece %1’ini örnekleyebilirsiniz.
  • Resource Constraints: OTel Collector için mutlaka memory_limiter processor’ını aktif edin. Böylece yoğun yük altında collector’ın memory sızıntısı yapmasını engellersiniz.

Sistemlerinizi izlenebilir kılın, geceleri rahat uyuyun!