Paper2 Blog

ともに、かける

Cloud FunctionsをOpenTelemetry Goでトレースする

🎄 OpenTelemetry Advent Calendar 2023 13日目の記事です。

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"
)

// InitTracing initializes tracing.
func InitTracing() *sdk.TracerProvider {
    var opts []otlptracehttp.Option
    if localOnly := os.Getenv("LOCAL_ONLY"); localOnly == "true" {
        // In local environment, TLS is not set up.
        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(
        // Custom attributes
        ),
    )
    if err != nil {
        panic(err)
    }

    tp := trace.NewTracerProvider(
        trace.WithBatcher(exporter),
        trace.WithResource(resources),
    )

    // Set the global TracerProvider to the SDK's TracerProvider.
    otel.SetTracerProvider(tp)

    // W3C Trace Context propagator
    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)

// InstrumentedHandler wraps the function with OpenTelemetry instrumentation.
func InstrumentedHandler(functionName string, function HttpHandler, flusher Flush) HttpHandler {
    opts := []trace.SpanStartOption{
        // customizable span attributes
        trace.WithAttributes(semconv.FaaSTriggerHTTP),
    }

    // create instrumented handler
    handler := otelhttp.NewHandler(
        http.HandlerFunc(function), functionName, otelhttp.WithSpanOptions(opts...),
    )

    return func(w http.ResponseWriter, r *http.Request) {
        // call the actual handler
        handler.ServeHTTP(w, r)

        // NOTE: ForceFlush() may extend the function's duration. It must be used carefully.
        //       If ForceFlush() is not called, spans are send on background.
        //       Backgraound tasks are not recommended in Cloud Functions. Span data sometimes get lost.
        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() {
    // NOTE: Usually TracerProvider should call Shutdown() at the end of the program.
    //       It is difficult to do so in Cloud Functions.
    //       This issue can be mitigated by using ForceFlush() to flush spans.
    tracerProvider := InitTracing()
    handler := InstrumentedHandler("greeting", greeting, tracerProvider)
    functions.HTTP("Greeting", handler)
}

// greeting is the function's core logic.
// It resoponses "Hiya!" and calls the next function if NEXT_ENDPOINT is set.
func greeting(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hiya!")

    // sleep for extending the span's duration
    time.Sleep(100 * time.Millisecond)

    err := greetNext(r.Context())
    if err != nil {
        log.Println(err)
        w.WriteHeader(http.StatusInternalServerError)
    }
}

// greetNext calls the next function.
func greetNext(ctx context.Context) error {
    next := os.Getenv("NEXT_ENDPOINT")
    if next == "" {
        log.Println("I have no freinds :(")
        return nil
    }

    // call the next function.
    // otelhttp sends a trace context to the next function.
    res, err := otelhttp.Get(ctx, next)
    if err != nil {
        return err
    }

    // Must close the response body to avoid leaking connections.
    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をバックグラウンドで送信することなども検討してください。

*1:Dynatraceの記事をベースに作成しています。Pub/Subトリガーを利用する場合も当記事とDynatraceの記事を参考にすれば作れると思います。

*2:otel.GetTracerProvider関数を使えばInitTracingで返す必要ないのでは?と思い試してみましたが、GetTracerProvider関数が返すtrace.TracerProviderインターフェースはForceFlush関数を含んでいませんでした。そのため型アサーションなどの処理が必要になり少し複雑になります。そのためInitTracing関数でTracerProviderを返す方がシンプルかなと思います。