👣

OpenTelemetry in Go

2021/12/02に公開

皆さんはアプリケーションの Tracing を行っているでしょうか。
巷には GCP の Cloud Trace, AWS の X-Ray, Azure の Application Insights など関連するサービスが溢れています。

ここでは、Tracing を導入する手段として OpenTelemetry と Go での実際の導入方法について、簡単に紹介させていただきます。

想定読者

  • Go で Tracing に興味がある方
  • OpenTracing, OpenCensus を既に使用していて OpenTelemetry への移行を検討している方
  • Tracing を導入したもののベンダーロックインされていて、課題を感じている方

OpenTelemetry 概要

実際に Tracing を導入した方は分かると思いますが、Instrumentation (計装) という作業を行う必要があり、具体的には Trace したい箇所のコードに Span を作成する処理や Attributes を付与する処理を追加する事になります。これは地味に骨の折れる作業です。

この計装に関しては、基本的にベンダーの提供する SDK を用いることになるのですが、これがベンダーロックインに繋がります。つまり、AWS X-Ray から GCP Cloud Trace に乗り換えようと思ったら、計装やり直しということになります。

そこで OpenTracing, OpenCensus といった、Trace の共通仕様を定めてベンダーに依存せずに計装できる仕組みが有用です。ベンダーに依存しないことで乗り換えやすいという利点以外に、利用者が増えることでエコシステムが育つという利点があります。

この OpenTracing, OpenCensus の後継が OpenTelemetry です。
OpenTelemetry では OpenTracing でサポートされている Traces, OpenCensus でサポートされている Metrics 以外に Logs にも対応する予定です。Observability 3本柱 すべて OpenTelemetry でサポートされるという事になりますね。

今年は OpenTelemetry の Trace の仕様が v1.0.0 になりました
詳細は OpenTelemetry の Blog 記事を読んでいただくとして、これにより今後 API, SDK の実装がより活発に進んでいくと期待されていました。

実際のところ、OpenTelemetry-Go の API, SDK の実装 は v1.2.0 に到達し Stable として提供されています。 (12/2 現在)

Go で計装する

環境

  • Go 1.17
  • OpenTelemetry-Go v1.2.0
module example.com/example-service

go 1.17

require (
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.27.0
	go.opentelemetry.io/otel v1.2.0
	go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.2.0
	go.opentelemetry.io/otel/sdk v1.2.0
	go.opentelemetry.io/otel/trace v1.2.0
)

require (
	github.com/felixge/httpsnoop v1.0.2 // indirect
	go.opentelemetry.io/otel/internal/metric v0.25.0 // indirect
	go.opentelemetry.io/otel/metric v0.25.0 // indirect
	golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 // indirect
)

計装

簡単のために https://example.com に GET リクエストするだけのアプリケーションに計装するケースを考えます。

先にサンプルコード全体を貼ります。
順に説明していきます。

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.7.0"
	"go.opentelemetry.io/otel/trace"
)

func NewExporter() (sdktrace.SpanExporter, error) {
	return stdouttrace.New(
		stdouttrace.WithPrettyPrint(),
		stdouttrace.WithWriter(os.Stderr),
	)
}

func NewResource(name, version string) *resource.Resource {
	return resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.ServiceNameKey.String(name),
		semconv.ServiceVersionKey.String(version),
	)
}

func SetupTraceProvider(shutdownTimeout time.Duration) (func(), error) {
	exporter, err := NewExporter()
	if err != nil {
		return nil, err
	}

	reource := NewResource("example-service", "1.0.0")
	tracerProvider := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithSampler(sdktrace.AlwaysSample()),
		sdktrace.WithResource(reource),
	)
	otel.SetTracerProvider(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
}

var tracer = otel.Tracer("example.com/example-service")

func httpRequest(ctx context.Context) error {
	var span trace.Span
	ctx, span = tracer.Start(ctx, "httpRequest")
	defer span.End()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", http.NoBody)
	if err != nil {
		return err
	}

	req.Header.Set("User-Agent", "example-service/1.0.0")
	cli := &http.Client{}
	resp, err := cli.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	cleanup, err := SetupTraceProvider(10 * time.Second)
	if err != nil {
		panic(err)
	}
	defer cleanup()

	if err := httpRequest(ctx); err != nil {
		panic(err)
	}
}

Exporter を作成します。
ここでは stdouttrace (go.opentelemetry.io/otel/exporters/stdout/stdouttrace) を使って標準エラー出力に export しますが、Exporter を変更することで Trace の送信先を自在に切り替えることができます。

func NewExporter() (sdktrace.SpanExporter, error) {
	return stdouttrace.New(
		stdouttrace.WithPrettyPrint(),
		stdouttrace.WithWriter(os.Stderr),
	)
}

Resource を作成します。
仕様には Resource は telemetry を生成する entity の属性と書かれていますが、言い換えるとアプリケーション(サービス)が稼働している環境の情報と言えるでしょう。
ここでは最低限、ServiceName と ServiceVersion を指定しています。

semconv は Semantic Conventions に関する package で、メタデータの名前と値に関しての標準を定義しているものになります。
go.opentelemetry.io/otel/semconv/v1.7.0 のように、OpenTelemetry Specification のバージョンが import path に含まれる形式になっています。

func NewResource(name, version string) *resource.Resource {
	return resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.ServiceNameKey.String(name),
		semconv.ServiceVersionKey.String(version),
	)
}

TraceProvider を作成します。
TraceProvider を作成しても返却していないのは、otel.SetTracerProvider で TraceProvider を登録することで otel.Tracer によってどこからでも呼び出せる為です。便利ですね。

ここで、先程用意した NewExporter, NewResource を使用しています。
NewResource には service 名と version をハードコーディングしていますが、実際には環境変数や build 時変数を使ってうまくやってください。

sdktrace.WithSampler(sdktrace.AlwaysSample()) の箇所は、特に大規模、高負荷なアプリケーションの場合は適切に見直す必要があります。Sampling によってエクスポートされるデータ量とオーバーヘッドを制御することができます。

cleanup 処理もこの関数の中に記述し、呼び出し元に対して隠蔽しています。

func SetupTraceProvider(shutdownTimeout time.Duration) (func(), error) {
	exporter, err := NewExporter()
	if err != nil {
		return nil, err
	}

	reource := NewResource("example-service", "1.0.0")
	tracerProvider := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithSampler(sdktrace.AlwaysSample()),
		sdktrace.WithResource(reource),
	)
	otel.SetTracerProvider(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
}

HTTP リクエストの実装部分です。ここで Span を生成します。
tracer (trace.Tracer) は goroutine safe なので、package 毎に1つ生成して使い回すと良いでしょう。引数はその package の import path で良いと思います。

tracer.Start で Span と共に返却される ctx (context.Context) は、後続の context を伴う関数呼び出しに使います。

var tracer = otel.Tracer("example.com/example-service")

func httpRequest(ctx context.Context) error {
	var span trace.Span
	ctx, span = tracer.Start(ctx, "httpRequest")
	defer span.End()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", http.NoBody)
	if err != nil {
		return err
	}

	req.Header.Set("User-Agent", "example-service/1.0.0")
	cli := &http.Client{}
	resp, err := cli.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}

最後に main 関数で SetupTraceProviderhttpRequest を呼び出して、終わりです。

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	cleanup, err := SetupTraceProvider(10 * time.Second)
	if err != nil {
		panic(err)
	}
	defer cleanup()

	if err := httpRequest(ctx); err != nil {
		panic(err)
	}
}

実行してみましょう。
以下のように Span が1つ作成されていたら OK 🙆 です。

$ go run main.go
{
        "Name": "httpRequest",
        "SpanContext": {
                "TraceID": "66ec8dcf44055538f81005cee8f9aa34",
                "SpanID": "3a319b25f3be9237",
                "TraceFlags": "01",
                "TraceState": "",
                "Remote": false
        },
        "Parent": {
                "TraceID": "00000000000000000000000000000000",
                "SpanID": "0000000000000000",
                "TraceFlags": "00",
                "TraceState": "",
                "Remote": false
        },
        "SpanKind": 1,
        "StartTime": "2021-12-02T05:23:26.7026+09:00",
        "EndTime": "2021-12-02T05:23:27.352808616+09:00",
        "Attributes": null,
        "Events": null,
        "Links": null,
        "Status": {
                "Code": "Unset",
                "Description": ""
        },
        "DroppedAttributes": 0,
        "DroppedEvents": 0,
        "DroppedLinks": 0,
        "ChildSpanCount": 0,
        "Resource": [
                {
                        "Key": "service.name",
                        "Value": {
                                "Type": "STRING",
                                "Value": "example-service"
                        }
                },
                {
                        "Key": "service.version",
                        "Value": {
                                "Type": "STRING",
                                "Value": "1.0.0"
                        }
                }
        ],
        "InstrumentationLibrary": {
                "Name": "example.com/example-service",
                "Version": "",
                "SchemaURL": ""
        }
}

楽に計装する

実は HTTP リクエストは、手動で Span を作成することなく計装できます。
otelhttp (go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp) を使って http.RoundTripper を wrap することで、自動で Trace されます。

先程の httpRequest に少しだけ手を加えます。
勿論 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp の import も忘れずに。

var tracer = otel.Tracer("example.com/example-service")

func httpRequest(ctx context.Context) error {
	var span trace.Span
	ctx, span = tracer.Start(ctx, "httpRequest")
	defer span.End()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", http.NoBody)
	if err != nil {
		return err
	}

	req.Header.Set("User-Agent", "example-service/1.0.0")
-	cli := &http.Client{}
+	cli := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
	resp, err := cli.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}

実行すると、先程の Span に加えて、もう一つ Span が出力されていると思います。

$ go run main.go
{
        "Name": "HTTP GET",
        "SpanContext": {
                "TraceID": "335a576d5d0616b0bdb43e2beaa40ad3",
                "SpanID": "2305e360995c44e8",
                "TraceFlags": "01",
                "TraceState": "",
                "Remote": false
        },
        "Parent": {
                "TraceID": "335a576d5d0616b0bdb43e2beaa40ad3",
                "SpanID": "440dab61069a6c47",
                "TraceFlags": "01",
                "TraceState": "",
                "Remote": false
        },
        "SpanKind": 3,
        "StartTime": "2021-12-02T05:35:26.836234+09:00",
        "EndTime": "2021-12-02T05:35:27.421921829+09:00",
        "Attributes": [
                {
                        "Key": "http.method",
                        "Value": {
                                "Type": "STRING",
                                "Value": "GET"
                        }
                },
                {
                        "Key": "http.url",
                        "Value": {
                                "Type": "STRING",
                                "Value": "https://example.com"
                        }
                },
                {
                        "Key": "http.user_agent",
                        "Value": {
                                "Type": "STRING",
                                "Value": "example-service/1.0.0"
                        }
                },
                {
                        "Key": "http.scheme",
                        "Value": {
                                "Type": "STRING",
                                "Value": "http"
                        }
                },
                {
                        "Key": "http.host",
                        "Value": {
                                "Type": "STRING",
                                "Value": "example.com"
                        }
                },
                {
                        "Key": "http.flavor",
                        "Value": {
                                "Type": "STRING",
                                "Value": "1.1"
                        }
                },
                {
                        "Key": "http.status_code",
                        "Value": {
                                "Type": "INT64",
                                "Value": 200
                        }
                }
        ],
        "Events": null,
        "Links": null,
        "Status": {
                "Code": "Unset",
                "Description": ""
        },
        "DroppedAttributes": 0,
        "DroppedEvents": 0,
        "DroppedLinks": 0,
        "ChildSpanCount": 0,
        "Resource": [
                {
                        "Key": "service.name",
                        "Value": {
                                "Type": "STRING",
                                "Value": "example-service"
                        }
                },
                {
                        "Key": "service.version",
                        "Value": {
                                "Type": "STRING",
                                "Value": "1.0.0"
                        }
                }
        ],
        "InstrumentationLibrary": {
                "Name": "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp",
                "Version": "semver:0.27.0",
                "SchemaURL": ""
        }
}
<以降省略>

このように Instrumentation library が用意されていると、計装が楽になります。

Instrumentation libraries

他にも次の Instrumentation library が提供されています。(一例)

Instrumented Library Instrumentation Library Description
google.golang.org/grpc go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc gRPC Server/Client
go.mongodb.org/mongo-driver/mongo go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo MongoDB Client
github.com/go-redis/redis/v8 github.com/go-redis/redis/extra/redisotel/v8 Redis Client
go.uber.org/zap github.com/uptrace/opentelemetry-go-extra/otelzap Logger

OpenTelemetry Registry に登録されているものは、こちらから検索できます。
https://opentelemetry.io/registry/?language=go&component=instrumentation

総括

OpenTelemetry 自体は以前から話題になってましたが、仕様、実装共に安定してきて導入の機運が高まってきたのではないでしょうか。
ベンダー各社も対応を表明しており、ドキュメントも増えてきているように感じます。

今後、Metrics, Logs に期待ですね。
※Metrics の Go 実装は Alpha ですが既に提供されています。

ベンダー各社の対応状況

Discussion