OpenTelemetry ve Cloud Trace Kullanarak Servislerizi Dağıtık Olarak İzleyin
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.
> 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_account
Gayet iyi, ayrıca account_id=42
güzel bir Span özniteliği olabilirget_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:
- https://cloud.google.com/docs/authentication/production
- https://cloud.google.com/sdk/gcloud/reference/auth/application-default
Ö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.
Burada net bir şekilde yazdığımız kodların çalıştığını ve önümüze sonucun yazdırıldığını görüyoruz.
Ö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 json | parse json | write |
---|---|---|---|
434.984ms | 433.595ms | 1.18ms | 0.017ms |
53.129ms | 52.646ms | 0.374ms | 0.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.
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;
Label | Value |
---|---|
golang.go.repo.name | golang/go |
golang.go.repo.id | 23096959 |
golang.go.repo.star | 86023 |
service.version | 1.0.0 |
instance.id | foo12345 |
g.co/agent | opentelemetry-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ı.