Dağıtılmış izleme, çok katmanlı bir mikro servis mimarisine ilişkin öngörü ve gözlemlenebilirlik elde etmek için önemlidir. Servis A’dan servis B’ye servis çağrılarına, servis çağrılarına zincirleme yaptığınızda, çağrıların başarılı olduğunu ve ayrıca her adımdaki gecikmeyi anlamak önemlidir.

İşte tam da bu noktada karşımıza Cloud Trace çıkıyor. Cloud Trace servislerimizi izlemek için dağıtık bir Cloud çözümüdür. Bu yazıda Cloud Trace’i anlamak için basit bir Go uygulaması yazıcaz ve OpenTelemetry Go SDK’i kullarak uygulamamızı izleyeceğiz.


Kısaca OpenTelemetry Nedir?

OpenTelemetry 2019 yılında OpenCensus ve OpenTracing projelerinin birleşmesiyle oluşmuş, uygulamanızı dağıtık biçimde izleme ve ölçüm yapmak için tek bir API seti sağlayan, kitaplık ve toplayıcı hizmetidir.

Dikkat: OpenTelemetry tracing özelliği 2021 Şubat ayı itibariyle 1.0 sürümüne ulaşmıştır. Go SDK’i ise henüz Pre-GA aşamasında olduğundan, bu %100 kararlı genel kullanılabilir hale gelene kadar bazı şeylerin değişebileceği anlamına gelir.


Genel Bakış

Uygulamaya geçmeden önce kısa bir ön bilgi ile bazı OpenTelemetry terimlerini tanıyalım.

open telemetry diagram

> Span

“Span”, dağıtık bir sistemde yapılan bağımsız iş birimini temsil eden, dağıtılmış bir sistemi izlemenin birincil yapı taşıdır.

Dağıtılmış sistemin her bileşeni, iş akışının bir parçasını temsil eden, adlandırılmış ve zamanlanmış bir işlem olan bir Span’a katkıda bulunur.

Span’ler, diğer Span’ler için “Referanslar” içerebilirler, bu da birden fazla Span’in tek bir yerde birleştirilmesine olanak sağlar ve bir talebin ömrünün, dağıtık bir sistemde hareket ederken görselleştirilmesi sağlanır.

Her Span, OpenTelemetry spesifikasyonuna göre aşağıdaki maddeleri içerisinde barındırır:

  • Span adı
  • Durum
  • Bir başlangıç ve bitiş zaman damgası
  • Span’ı benzersiz şekilde tanımlayan değişmez bir SpanContext
  • Zaman damgalı olarak Event‘lerin listesi
  • Span, SpanContext veya null biçimindeki bir üst yayılma alanı
  • Span’in Öznitelikleri
  • Span’in Türü

Dikkat: Span’ler isimlendirilirken insanların anlıyabileceği şekilde isimlendirmek önemlidir.

Span AdıKılavuz
getÇok genel
get_account/42Çok spesifik
get_accountGayet iyi, ayrıca account_id=42 güzel bir Span özniteliği olabilir
get_account/{account_id}Daha da iyi (HTTP endpoint’ini gösteriyor)

> SpanContext

SpanContext, bir Span’in içeriğini tutan değiştirilemez yapıdır. OpenTelemetry spesifikasyonuna göre içerisinde şunları barındırır:

  • TraceId (16 byte)
  • SpanId (8 byte)
  • TraceFlags
  • TrackState

> Tracer & TracerProvider

Tracer, Span oluşturmaktan sorumlu birimdir. TracerProvider ise oluşturduğumuz Tracer’lara ulaşmamızı sağlar.


Uygulamalı Olarak Öğrenelim

Bunun için basit bir kurgu oluşturalım. Örneğin bir HTTP sunucumuz olsun ve /run endpoint’ine istek geldiği zaman golang/go projesinin GitHub’daki bazı istatistiklerini kullanıcının önündeki sayfaya yazdırsın ve bunu yaparkende hangi işlemin ne kadar sürdüğünü göstersin.

Hazırlık

Başlamadan önce OpenTelemetry’nin ve Google Cloud’un Go paketlerini kurmamız gerekiyor.

go get -u go.opentelemetry.io/otel
go get -u github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace

Main

// GitHub API dökümanlarına göre oluşturduğumuz Go veri yapımız
// ref: https://docs.github.com/en/rest/reference/repos#get-a-repository
type Repository struct {
	Id              int    `json:"id"`
	StargazersCount int    `json:"stargazers_count"`
	Forks           int    `json:"forks"`
	Name            string `json:"name"`
	FullName        string `json:"full_name"`
}

var (
	httpClient = &http.Client{Timeout: 5 * time.Second}
	tracer     trace.Tracer
)

func main() {
	initTracer() // Tracer'ımızı başlatıyoruz

	serveHTTP() // HTTP sunucumuzu başlatıyoruz
}

Önce GitHub API’ndan gelecek yanıt için bir veri yapısı oluşturduk, daha sonra HTTP Client’ımızı ve ileride initialize edeceğimiz Tracer’ımızı belirledik. En sonda ise servisimiz çalışmaya başladığında otomatik olarak çalıştırılacak olan main fonksiyonunu yazıp, içinde initTracer ve serveHTTP adında iki farklı fonksiyon çağırdık. Bu fonksiyonları sonraki adımlarda tanımlayacağız.

HTTP Server

func serveHTTP() {
	port := os.Getenv("PORT") // ortam değişkenlerinden `PORT` değerini al
	if port == "" {
		port = "8080" // eğer yoksa varsayılan olarak olarak `8080` portunu kullan 
		log.Printf("defaulting to port %s", port)
	}

	http.HandleFunc("/run", handler) // run endpoint'i için bir handler ayarla

	log.Printf("server starting at: %s", port)
	log.Fatal(http.ListenAndServe(":"+port, nil)) // sunucuya başlat
}

Go içerisinde built-in bir şekilde gelen net/http paketi ile oldukça kolay bir şekilde HTTP server yaratabilirsiniz. Üstteki kod parçamızda da serverHTTP metodunda bir HTTP server yarattık ve /run endpoint’i için bir handler fonksiyonu belirledik. Bu fonksiyonu da ileriki adımlarda tanımlayacağız.

Initialize Tracer

func initTracer() {
  // Cloud Trace Exporter'ımız 
	exporter, err := texporter.NewExporter(texporter.WithTraceClientOptions([]option.ClientOption{
		option.WithTelemetryDisabled(), // exporter'ın kendinide izlemesini kapattık
	}))
	if err != nil {
		log.Fatalf("texporter.NewExporter: %v", err)
	}
    
  // Yeni bir TracerProvider oluşturuyoruz
	tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter,
	  // yığını her saniyede bir gönder
		sdktrace.WithBatchTimeout(time.Second),
		// maksimum yığın büyüklüğü
		sdktrace.WithMaxExportBatchSize(16)),
		// bu sadece örnek öznitelikler eklemesi için varolan bir ayar
		// bunu production ortamında yazmanıza gerek yok
		sdktrace.WithSampler(sdktrace.AlwaysSample()),
		// tüm Span'lere eklenicek attribute'ler
		sdktrace.WithResource(resource.NewWithAttributes(
			attribute.String("service.name", "sample-service"),
			attribute.String("service.version", "1.0.0"),
			attribute.String("instance.id", "foo12345"),
		)),
	)
	// Global olarak TracerProvider'ımızı belirtiyoruz
	otel.SetTracerProvider(tp)

  // Global olarak belirttiğimiz TracerProvider'ımızından Tracer'ımızı alıyoruz
	tracer = otel.GetTracerProvider().Tracer("company.com/trace")
	defer func() {
		if err := tp.ForceFlush(context.Background()); err != nil {
			log.Println("failed to flush tracer")
		}
	}()
}

Burada ise Span’lerimizi Cloud Trace‘e göndermek için bir Exporter oluşturduk ve bazı ayarlar ile yeni bir TracerProvider yaratıp, bunu global olarak belirttik.

Yukarıdaki örnekte Google Cloud ile authenticate yaparken ben Application Default credentials’ları kullandım. Bu kodları local’de çalıştırmaya çalıştırdığınızda Permission Denied hatası almanız olasıdır. Google Cloud ortamlarında bu credentials’lar zaten hali hazırda bulunur ve sizin ek olarak bir şey yapmanıza gerek kalmaz. Fakat local development için bunu sizin sağlamanız gerekir. Bu ayrı bir makale konusu olduğu için buna şu an burada değinmiyorum.

Fakat bunu nasıl yapıcağınızı öğrenmek için bir kaç yardımcı kaynağı buraya bırakıyorum:

Önemli: Genelde örneklerde TelemetryProvider şu şekilde tanımlanır:

tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))

Fakat bu şekilde tanımlarsanız, her Span bittiği zaman veriyi Google Cloud’a göndermeye çalışır. Bu da sizin 100ms sürecek işleminizi kat ve kat uzatabilir.

Örneklerde Span’in olası bir durumda kaybolmaması için böyle yapılmasını öneriyor olabilir fakat bu yanlış bir yaklaşım diyebiliriz. Bu durum kullanıcıyı etkiler, ekranda daha uzun süre beklemesine yol açar.

Bunun yerine yukarıdaki örnekteki gibi arka planda, belli zaman aralıkları ile (bizim yaptığımız örnek için bu süre 1 saniye) toplu bir şekilde Span’leri göndermek bizim için daha verimli olur.

Bu sayede işlem olması gerektiği sürede tamamlanır. Olası bir durumda ise en fazla 1 saniye aralıktaki Span’leri kaybedersiniz, ki bu kabul edilebilir bir değer.

Handler & Tracing

func handler(w http.ResponseWriter, r *http.Request) {
  // `/run` adında yeni bir Span oluşturuyoruz ve start timestamp'ini başlatıyoruz
  // subCtx = sub context
	subCtx, span := tracer.Start(r.Context(), "/run")

  // event ekliyoruz ve ne olduğunu yazıyoruz
  // burası için event 'handling request'
	span.AddEvent("handling request")
    
  // en başta tanımladığımız Repository modelinden yeni bir tane oluşturuyoruz
	repo := new(Repository)

  // sub context kullanarak GitHub API'ından `golang/go` projesinin
  // istatistiklerini çekiyoruz. 
	err := fetchJSON(subCtx, "https://api.github.com/repos/golang/go", repo)

  // burada sub context ile yeni bir Span oluşturuyoruz ve adını 'write' koyuyoruz
  // yani bu durumda ilk oluşturduğumuz Span bizim root Span'imiz olurken
  // burada oluşturduğumuz Span, Child oluyor
	_, wSpan := tracer.Start(subCtx, "write")

  // en son gelin oluşturduğumuz Span'i bitirmesini yani end timestamp'ini yazmasını istiyoruz
	defer wSpan.End()

  // eğer hata varsa diye hatayı kontrol ediyoruz
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = fmt.Fprintf(io.MultiWriter(os.Stderr, w), "error: %v\n", err)
		return
	}

  // burada hata olmadığını doğruladığımız için root Span'imize dönen veriye göre bazı
  // attribute'ler yani öznitelikler atıyoruz
	span.SetAttributes(
		attribute.String("golang.go.repo.name", repo.FullName),
		attribute.Int("golang.go.repo.id", repo.Id),
		attribute.Int("golang.go.repo.stars", repo.StargazersCount),
	)

  // sonucu ekrana yazdırıyoruz
	_, _ = fmt.Fprintf(w,
		"===== %s =====\nRepository: %s (ID: %d)\nStar Count: %d\nFork Count: %d\nURL: https://github.com/%s",
		repo.FullName, repo.Name, repo.Id, repo.StargazersCount, repo.Forks, repo.FullName,
	)
  // root Span'imizi sonlandırıyoruz
	span.End()
}

Burada kısaca GitHub API’ndan go/golang projesinin bilgisini çekip ekrana yazdırdık ve bunu yaparken gelen yanıtın ekrana ne kadar sürede yazdırıldığını ve tüm işlemin ne kadar sürdüğünü görmek için 1 root 1 child olmak üzere 2 adet Span yaratıp bir kaç attribute ve event ekledik.

func fetchJSON(ctx context.Context, url string, target interface{}) error {
  // yine bir Span yarattık, bu Span'in amacı adındanda anlaşılacağı
  // üzere GitHub API'ndan verinin gelme süresini bize göstericek
  // unutmayın: gelen context, sub context olduğu için bu Span'de child oluyor
	subCtx, span := tracer.Start(ctx, "fetch json")

  // bu sefer yolladığımız request'de olağandışı bir durum olursa oluşturduğumuz
  // Span'in sonsuza kadar devam etmemesi için Span Context'ine bir timeout koyduk  
	_, cancel := context.WithTimeout(subCtx, time.Second * 3)

  // eğer her şey yolunda giderse en son gelip bu timeout'u iptal etmesini söyledik
	defer cancel()
	
  // Tam bu anda GitHub API'ına request gönderildiğini bildirmek için bir event ekledik 
	span.AddEvent("fetching repo info from github")
	r, err := httpClient.Get(url)

	if err != nil {
		return err
	}

  // requst'den dönen cevabın durum kodunu 'attribute' olarak ekledik 
	span.SetAttributes(attribute.KeyValue{
		Key:   "github.resp.status.code",
		Value: attribute.IntValue(r.StatusCode),
	})
  // en başta oluşturduğumuz Span'i sonlandırdık
	span.End()

	defer func(Body io.ReadCloser) {
		_ = Body.Close()
	}(r.Body)
  
  // JSON parse süremizi hesaplamak için bir child Span daha oluşturduk
	_, span = tracer.Start(ctx, "parse json")
  // burada da en son gelip bu Span'i bitirmesini söyledik 
	defer span.End()
	
	return json.NewDecoder(r.Body).Decode(target)
}

Son olarak burada üstteki Span’imiz için 2 child Span daha oluşturduk, birinde GitHub API’ndan verinin bize ne kadar sürede geldiğini, diğerinde ise gelen veriyi ne kadar sürede JSON’a parse ettiğimizi tuttuk ve yine bir kaç attribute ve event ekledik.

Günün sonunda toplam 4 farklı işlemin kaydını tutmuş olduk, bunları listelemek gerekirse;

  • Tüm işlemin ne kadar sürdüğü (root)
    • GitHub API’ndan verinin bize ne kadar sürede geldiği (child)
    • Verinin ne kadar sürede JSON’a parse ettiğimiz (child)
    • İşlenmiş veriyi ekrana ne kadar sürede yazdırdığımız (child)

Sonuç

go run . yazarak kodlarımızı çalıştırıp, /run endpoint’ine request attıktan sonra Cloud Trace’e girip bakıyoruz.

run endpoint

Burada net bir şekilde yazdığımız kodların çalıştığını ve önümüze sonucun yazdırıldığını görüyoruz.

Cloud Trace 1

Örnek olarak yaptığımız çağrıları Scatter Chart’da ve sağdaki listede açıkça görebiliyoruz. Aslında buradan bile basit bir çıkarım yapabiliriz. İlk yaptığımız çağrı 435ms sürerken, daha sonra ki çağrıların 53ms‘e kadar düştüğünü görüyoruz. Buradan ilk attığımız istekte, istediğimiz verinin veya bizim credentials’larımızın GitHub’ın önbelleğinde olmadığının çıkarımını yapıyoruz ve bunu doğrulamak için Span’leri birbirleri ile karşılaştırıyoruz.

Sonra karşımıza şöyle bir tablo çıkıyor;

request total (/run)fetch jsonparse jsonwrite
434.984ms433.595ms1.18ms0.017ms
53.129ms52.646ms0.374ms0.018ms

Bu tablodanda anlıyacağınız üzere, fetch json işlemi üstte dediğimiz sebepten ötürü yaklaşık 8 kat daha uzun sürüyor. Daha sonraki request’lerde bu süre düşüyor. parse json ve write işlemleri kısmen yakın görünüyor. Buda gecikmenin fetch json işleminden kaynaklandığını tekrardan doğruluyor.

Cloud Trace 2

Evet yine burada görebileceğiniz üzere, root Span’e bastığımız zaman bize hangi işlemin ne kadar sürdüğünü, eventleri ile birlikte gösteriyor. Ayrıca kodumuzda belirttiğimiz tüm attribute’ler sağ altta bulunan Labels kısmında da bu tablodaki gibi gözüküyor;

LabelValue
golang.go.repo.namegolang/go
golang.go.repo.id23096959
golang.go.repo.star86023
service.version1.0.0
instance.idfoo12345
g.co/agentopentelemetry-go 0.20.0; google-cloud-trace-exporter 0.20.0

Böylece Span hakkında daha fazla bilgi edinip, servisimizin olağındışı çalışması durumunda olayı daha kolay anlayabiliyoruz.

Bu yapıyı biraz daha geliştirip, HTTP, gRPC gibi server’larımıza middleware exporter’lar koyup bu veriyi daha da detaylandırabilir, yine distributed bir şekilde Span’lerle loglarımızı ilişkilendirebiliriz.


Son

Günün sonunda dağıtılmış bir sistemi Cloud Trace ve OpenTelemetry ile Tracer, Span gibi yapılar kullanarak adım adım izleyebildiğimizi ve bunları görselleştirip, yapılan çağrıların ve işlemlerin ne kadar sürdüğünü görebildiğimizi öğrendik. Ayrıca Span’leri birbirleri ile karşılaştırıp servisimizin yavaş kaldığı noktaları bulup buraları optimize edebileceğimizi de görmüş olduk. OpenTelemetry’nin mantığını anladıktan sonra bunu sadece Go ile değil tüm dillerde rahatça kullanabileceğinizi unutmayın.

Konuyu elimden geldiğince açık ve net anlatmaya çalıştım. Umarım yazıyı beğenmiş ve bir şeyler öğrenebilmişsinizdir. Bir sonraki yazılarda görüşmek dileğiyle.

Yazdığımız kodlara ulaşmak için: Github


Öğrenmeye Devam Edin

Öğrenmeye devam etmek isteyenler için konu ile ilgili bir kaç bağlantı.