Google CloudのCloud FunctionsをOpenTelemetryでトレースする方法をご紹介します。ローカル、Google Cloud両方で試せるサンプルも用意したので適宜試してみてください。
サマリ
Cloud Functionsの計装ではTracerProviderのShutdown処理を実施せず、毎回のFunction実行でSpanをForceFlushする。一方で低いレイテンシーが求められる場合はSpanの欠損を許容し、ForceFlushを実施せずにSpanをバックグラウンドで送信することなども検討する。
前提
OpenTelemetryの初期化
OpenTelemetryの初期化を実施します。
package greeting
import (
"context"
"os"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
sdk "go.opentelemetry.io/otel/sdk/trace"
)
func InitTracing() *sdk.TracerProvider {
var opts []otlptracehttp.Option
if localOnly := os.Getenv("LOCAL_ONLY"); localOnly == "true" {
opts = append(opts, otlptracehttp.WithInsecure())
}
client := otlptracehttp.NewClient(opts...)
exporter, err := otlptrace.New(context.Background(), client)
if err != nil {
panic(err)
}
resources, err := resource.New(
context.Background(),
resource.WithHost(),
resource.WithAttributes(
),
)
if err != nil {
panic(err)
}
tp := trace.NewTracerProvider(
trace.WithBatcher(exporter),
trace.WithResource(resources),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
return tp
}
通常は以下のようにcleanup用の関数を返し、適切にShutdownできるようにします。FunctionsではTracerProviderを返します。
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := tracerProvider.Shutdown(ctx); err != nil {
log.Printf("Failed to shutdown tracer provider: %v", err)
}
}
return cleanup, nil
Shutdown処理をしないというワイルドな使い方をするのがCloud Functionsの計装の特徴です。理由などについては後述します。
HttpHandlerの計装
計装対象のHttpHandlerをラップしてSpanを作成するようにします。ラップしたHttpHandlerを返すInstrumentedHandler関数を作成します。
package greeting
import (
"context"
"log"
"net/http"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
"go.opentelemetry.io/otel/trace"
)
type Flush interface {
ForceFlush(context.Context) error
}
type HttpHandler = func(w http.ResponseWriter, r *http.Request)
func InstrumentedHandler(functionName string, function HttpHandler, flusher Flush) HttpHandler {
opts := []trace.SpanStartOption{
trace.WithAttributes(semconv.FaaSTriggerHTTP),
}
handler := otelhttp.NewHandler(
http.HandlerFunc(function), functionName, otelhttp.WithSpanOptions(opts...),
)
return func(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r)
err := flusher.ForceFlush(r.Context())
if err != nil {
log.Printf("failed to flush spans: %v", err)
}
}
}
トレースコンテキストから親スパンの情報を抽出するためにはotelhttpパッケージを用います。ポイントはForceFlush関数でSpanの送信を明示的に実行している部分です。
上記の関数を利用して計装したCloud Functionsの実装が以下です。トレースがつながることを確認できるように NEXT_ENDPOINT
が設定されていた場合はotelhttpでトレースコンテキストを付与して次のエンドポイントにリクエストを送信しています。
package greeting
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/GoogleCloudPlatform/functions-framework-go/functions"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func init() {
tracerProvider := InitTracing()
handler := InstrumentedHandler("greeting", greeting, tracerProvider)
functions.HTTP("Greeting", handler)
}
func greeting(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hiya!")
time.Sleep(100 * time.Millisecond)
err := greetNext(r.Context())
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
}
}
func greetNext(ctx context.Context) error {
next := os.Getenv("NEXT_ENDPOINT")
if next == "" {
log.Println("I have no freinds :(")
return nil
}
res, err := otelhttp.Get(ctx, next)
if err != nil {
return err
}
err = res.Body.Close()
if err != nil {
return err
}
log.Println("I said hi to my friend :)")
return nil
}
一番のポイントはinit関数です。初期化したTracerProviderを直接利用することは普段ないと思います。ForceFlushを実行するために、InitTracing関数でTracerProviderを受け取り、InstrumentedHandlerに渡しています。*2
上記で実装の説明は以上になります。Cloud Tracesで確認すると以下のようなトレースが取得できます。
ForceFlushについて
Cloud Functions計装のポイント、ForceFlushについてもう少し詳しく説明します。
そもそもTracerProviderのShutdown処理は本来実施すべき処理です。リソースを解放し、SpanのFlushを実施します。しかし、Cloud Functionsはインスタンスの終了時にいい感じでクリーンアップ処理をする方法を提供していません。
さらに行儀よく使うならTracerProviderを毎回作成し、毎回Shutdown処理をするというのもできるかもしれません。ただ、さらにレイテンシーが高くなる可能性があります。
そのためTracerProviderはGlobalで共有し、Shutdown処理をしない。その代わりにSpanの送信を適切に制御するくらいがちょうど良いバランスなのかなと考えています。リソース解放漏れがあってもCloud Functionsのインスタンスが終了するのであればあまり問題ない気もしています。
Cloud Functionsはバックグラウンドでの処理を推奨していません。確実にSpanを送信したい場合はForceFlushが必要です。一方で性能要件を満たせない場合は、Spanの欠損を許容しバックグラウンドでのSpan送信に頼るのもありかと思います。
まとめ
Cloud Functions(Go、HTTPトリガー)の計装について紹介しました。ForceFlushの考え方は言語、トリガーの種類に関係なく共通です。
Cloud Functionsの計装ではTracerProviderのShutdown処理を実施せず、毎回のFunction実行でSpanをForceFlushするのが良さそうです。一方で低いレイテンシーが求められる場合はSpanの欠損を許容し、ForceFlushを実施せずにSpanをバックグラウンドで送信することなども検討してください。