GoとOpenTelemetryによる包括的なマイクロサービスオブザーバビリティの実現
Wenhao Wang
Dev Intern · Leapcell

はじめに
現代のソフトウェア開発の急速に進化する状況において、マイクロサービスは、スケーラブルで、回復力があり、独立してデプロイ可能なアプリケーションを構築するための事実上のアーキテクチャスタイルとなっています。マイクロサービスは、俊敏性と保守性の点で否定できない利点を提供する一方で、特に分散システム全体のリクエストの流れを理解する上で、かなりの複雑さを伴います。単一のユーザーインタラクションが多数のサービスにわたる呼び出しの連鎖を引き起こす可能性があり、適切な可視性なしでは、パフォーマンスのボトルネックを診断したり、エラーを特定したり、システムの全体的な健全性を把握したりすることが非常に困難になります。
ここで分散トレーシングの概念が輝きます。分散トレーシングは、関連するすべてのサービスにわたるリクエストの完全なジャーニーを可視化することを可能にし、レイテンシ、エラー、およびサービス間依存関係についての貴重な洞察を提供します。高性能なマイクロサービスを構築する上でGoが prominent であることを考えると、堅牢なトレーシングソリューションの統合は不可欠です。業界標準のオープンソースオブザーバビリティフレームワークであるOpenTelemetryは、トレース、メトリクス、ログの収集に統一されたアプローチを提供します。この記事では、GoマイクロサービスへのOpenTelemetryの統合をガイドし、包括的なフルスタックトレーシングを可能にし、分散アプリケーションにおける比類なき可視性を備えたあなたを力づけます。
分散トレーシングのコアコンセプトの理解
実装に飛び込む前に、分散トレーシングとOpenTelemetryの中心となる主要な概念についての共通の理解を確立しましょう。
- トレース (Trace): トレースは、分散システム全体に伝播する単一のリクエストまたはトランザクションの完全な実行パスを表します。これは、順序付けられたスパンのコレクションです。
- スパン (Span): スパンは、トレース内の論理的な作業単位を表す、名前付きのタイムド操作です。各スパンには開始時間と終了時間、名前、および属性があります。スパンはネストすることができ、親子関係を形成します。たとえば、APIリクエストはトップレベルのスパンを生成し、それがデータベース呼び出し、外部サービス呼び出し、または内部ビジネスロジック実行の子スパンを持つことがあります。
- コンテキスト伝播 (Context Propagation): リクエストがシステムを通過する際に、トレース情報(
trace_id
やspan_id
など)がサービス間で渡されるメカニズムです。これは、スパンをリンクして完全なトレースを形成するために重要です。OpenTelemetryは、相互運用性を確保するために、合意されたコンテキスト形式(例:W3C Trace Context)を使用します。 - Tracer Provider:
Tracer
インスタンスを作成するためのエントリポイントです。エクスポート、サンプリング、リソース属性を設定します。 - Tracer:
Span
オブジェクトを作成するために使用されるインターフェースです。 - Exporter: 完成したスパンをバックエンドシステム(例:Jaeger、Zipkin、OTLPコレクタ)に送信して、保存および分析を担当します。
- Sampler: どのトレースを記録およびエクスポートするかを決定します。サンプリングは、特に高スループットシステムにおけるトレーシングデータの量を制御するために使用できます。
GoとOpenTelemetryによるフルスタックトレーシングの実装
GoマイクロサービスアーキテクチャへのOpenTelemetryの統合を説明しましょう。2つのサービス、注文作成を処理するOrder Service
と製品詳細を取得するProduct Service
を検討します。Order Service
はProduct Service
を呼び出します。
まず、両方のサービスでOpenTelemetryを設定する必要があります。
1. GoでのOpenTelemetryの初期化
Jaegerエクスポート(人気のあるオープンソース分散トレーシングシステム)でOpenTelemetryを初期化するユーティリティ関数を作成します。
// common/otel.go package common import ( "context" "fmt" "log" "os" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/jaeger" "go.opentelemetry.io/otel/sdk/resource" tracesdk "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.7.0" ) // InitTracerProvider はOpenTelemetry TracerProviderを初期化します func InitTracerProvider(serviceName string) (func(context.Context) error, error) { // Jaegerエクスポートを作成 url := "http://localhost:14268/api/traces" // デフォルトのJaegerコレクタエンドポイント exporter, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(url))) if err != nil { return nil, fmt.Errorf("Jaegerエクスポートの作成に失敗しました: %w", err) } // Jaegerエクスポートと、スパンを効率的に送信するためのBatchSpanProcessorを備えた新しいtracer providerを作成します。 tp := tracesdk.NewTracerProvider( tracesdk.WithBatchProcessor(tracesdk.NewBatchSpanProcessor(exporter)), // リソースはサービスとその属性を識別します。 tracesdk.WithResource(resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceNameKey.String(serviceName), attribute.String("environment", "development"), )), ) otel.SetTracerProvider(tp) otel.SetTextMapPropagator(otel.NewCompositeTextMapPropagator( // W3C Trace Context // およびB3(必要に応じて下位互換性のため)の標準コンテキストプロパゲーター。 // 主にW3C Trace Contextを使用します。 otel.GetTextMapPropagator(), // デフォルトでW3C Trace Contextが含まれます )) log.Printf("OpenTelemetry をサービス %s のために初期化しました", serviceName) return tp.Shutdown, nil }
このInitTracerProvider
関数は以下のことを行います。
- Jaegerエクスポートの設定: OpenTelemetryにローカルで実行されているJaegerコレクタにトレースを送信するように指示します。
TracerProvider
の作成: このプロバイダーはTracer
インスタンスを管理し、スパンがどのように処理されるか(例:効率のためにBatchSpanProcessor
を使用)を設定します。Resource
属性の設定: これらの属性は、サービス自体に関するメタデータ(例:サービス名、環境)を提供します。TextMapPropagator
の設定: これはコンテキスト伝播に重要です。トレースコンテキストがリクエストヘッダーにどのように注入され、抽出されるかを設定します。otel.GetTextMapPropagator()
はデフォルトでW3C Trace Context
を含みます。これは推奨される標準です。
2. Product Serviceの実装
Product Service
は製品リストを返すだけです。受信HTTPリクエストの自動スパン作成を計測します。
// product-service/main.go package main import ( "context" "fmt" "log" "net/http" "os" "time" "github.com/yourusername/app/common" // common/otel.go がここにあると仮定 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) func productsHandler(w http.ResponseWriter, r *http.Request) { // otelhttpによって自動的に作成されたリクエストコンテキストからスパンを取得します。 // このスパンにカスタム属性を追加したり、子スパンを作成したりできます。 span := trace.SpanFromContext(r.Context()) span.SetAttributes(attribute.String("product.category", "electronics")), // いくつかの処理をシミュレート time.Sleep(50 * time.Millisecond) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"products": [{"id": "prod1", "name": "Laptop"}, {"id": "prod2", "name": "Monitor"}]}`)) log.Println("Responded to /products request") } func main() { // OpenTelemetryを初期化 shutdown, err := common.InitTracerProvider("product-service") if err != nil { log.Fatalf("OpenTelemetryの初期化に失敗しました: %v", err) } defer func() { if err := shutdown(context.Background()); err != nil { log.Fatalf("TracerProviderのシャットダウンに失敗しました: %v", err) } }() // HTTPサーバーを計測するためにotelhttp.NewHandlerを使用します http.Handle("/products", otelhttp.NewHandler(http.HandlerFunc(productsHandler), "/products.")) port := ":8081" log.Printf("Product Service が %s でリッスンしています", port) if err := http.ListenAndServe(port, nil); err != nil { log.Fatalf("Product Service の起動に失敗しました: %v", err) } }
Product Service
の主なポイント:
common.InitTracerProvider
: OpenTelemetryを初期化します。otelhttp.NewHandler
: これはgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
からの便利なラッパーです。各リクエストを自動的にインターセプトし、各リクエストのスパンを作成します(ヘッダーに親コンテキストが存在する場合は抽出)、およびサーバーのHTTPハンドラーが計測されたコンテキストを使用するように設定します。trace.SpanFromContext(r.Context())
: リクエストコンテキストから現在のスパンを取得し、カスタム属性を追加できるようにし、操作に関するより詳細な情報を提供します。
3. Order Serviceの実装
Order Service
は注文を作成するためのエンドポイントを公開します。このエンドポイントは、製品詳細を取得するためにProduct Service
へのHTTP呼び出しを行います。
// order-service/main.go package main import ( "context" "fmt" "io" "log" "net/http" "os" "time" "github.com/yourusername/app/common" // common/otel.go がここにあると仮定 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" ) var tracer = otel.Tracer("order-service") func createOrderHandler(w http.ResponseWriter, r *http.Request) { // 全体の注文作成プロセスのための新しいスパンを作成します。 // 親コンテキストは、otelhttp.NewHandlerを介して受信HTTPリクエストから暗黙的に取得されます。 ctx, span := tracer.Start(r.Context(), "createOrder") defer span.End() span.SetAttributes(attribute.String("order.id", "order123")), log.Println("Order Service: 注文作成リクエストを受信しました") // いくつかの初期処理をシミュレート time.Sleep(10 * time.Millisecond) // Product ServiceへのHTTP呼び出しを実行 productSvcURL := "http://localhost:8081/products" req, err := http.NewRequestWithContext(ctx, "GET", productSvcURL, nil) if err != nil { span.RecordError(err) span.SetAttributes(attribute.Bool("error", true)) http.Error(w, fmt.Sprintf("リクエストの作成に失敗しました: %v", err), http.StatusInternalServerError) return } // HTTPクライアント呼び出しを計測 // otelhttp.Client は、トレースコンテキストを下流サービスに伝播するために重要です。 client := http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} log.Printf("Order Service: Product Service の %s を呼び出しています", productSvcURL) resp, err := client.Do(req) if err != nil { span.RecordError(err) span.SetAttributes(attribute.Bool("error", true)) http.Error(w, fmt.Sprintf("Product Service の呼び出しに失敗しました: %v", err), http.StatusInternalServerError) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { span.SetAttributes(attribute.Bool("error", true)) http.Error(w, fmt.Sprintf("Product Service が200以外のステータスを返しました: %d", resp.StatusCode), http.StatusInternalServerError) return } body, err := io.ReadAll(resp.Body) if err != nil { span.RecordError(err) span.SetAttributes(attribute.Bool("error", true)) http.Error(w, fmt.Sprintf("Product Service 応答の読み取りに失敗しました: %v", err), http.StatusInternalServerError) return } log.Printf("Order Service: 製品を受信しました: %s", string(body)) // 最終的な注文保存をシミュレート time.Sleep(20 * time.Millisecond) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) w.Write([]byte(fmt.Sprintf(`{"message": "注文が正常に作成されました。製品: %s"}`, string(body)))) log.Println("Order Service: 注文が正常に作成されました") } func main() { // OpenTelemetryを初期化 shutdown, err := common.InitTracerProvider("order-service") if err != nil { log.Fatalf("OpenTelemetryの初期化に失敗しました: %v", err) } defer func() { if err := shutdown(context.Background()); err != nil { log.Fatalf("TracerProviderのシャットダウンに失敗しました: %v", err) } }() // Order Serviceの受信HTTPサーバーを計測 http.Handle("/order", otelhttp.NewHandler(http.HandlerFunc(createOrderHandler), "/order")) port := ":8080" log.Printf("Order Service が %s でリッスンしています", port) if err := http.ListenAndServe(port, nil); err != nil { log.Fatalf("Order Service の起動に失敗しました: %v", err) } }
Order Service
の主なポイント:
tracer.Start(r.Context(), "createOrder")
:createOrder
操作のために新しいスパンを手動で作成します。重要なのは、r.Context()
を渡すことです。これは、受信リクエストのotelhttp.NewHandler
から伝播されたトレースコンテキストを含んでおり、createOrder
を受信リクエストのスパンの子スパンにします。http.NewRequestWithContext(ctx, "GET", productSvcURL, nil)
: outgoingリクエストを行う際、現在のトレーシングcontext.Context
(tracer.Start
からのctx
)を渡すことが重要です。これにより、トレースIDと親スパンIDがリクエストコンテキストに含まれることが保証されます。client := http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
:otelhttp.NewTransport
は、デフォルトのHTTPクライアントトランスポートをラップするために使用されます。このラッパーは、req.Context()
からのトレースコンテキストをoutgoing HTTPリクエストヘッダー(例:traceparent
ヘッダー)に自動的に挿入します。これが、サービス間のコンテキスト伝播を可能にする魔法です。span.RecordError(err)
およびspan.SetAttributes(attribute.Bool("error", true))
: エラーが発生したときにエラーを記録し、スパンをエラーとしてマークすることはベストプラクティスです。これにより、オブザーバビリティバックエンドで問題のあるトレースを簡単にフィルタリングできます。
例の実行
-
Jaegerの起動: Dockerを使用してJaegerを実行できます。
docker run -d --name jaeger -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 -p 6831:6831/udp -p 16686:16686 jaegertracing/all-in-one:latest
その後、Jaeger UIを
http://localhost:16686
でアクセスします。 -
サービスのビルドと実行:
# product-service ディレクトリ内 go mod init github.com/yourusername/app/product-service go mod tidy go run main.go # order-service ディレクトリ内 go mod init github.com/yourusername/app/order-service go mod tidy go run main.go
common/otel.go
がアクセス可能であることを確認してください。例えば、product-service
とorder-service
と同じレベルのcommon
ディレクトリに配置し、import
パスを調整します。 -
リクエストの送信:
curl http://localhost:8080/order
-
Jaeger UIでの観察:
http://localhost:16686
に移動し、サービスとしてorder-service
を選択して、トレースを見つけます。受信リクエスト、createOrder
スパン、およびoutgoing HTTPクライアント呼び出しのorder-service
のスパン、そしてproduct-service
での受信リクエストを表す子スパンを含むトレースが表示されるはずです。
利点と応用シナリオ
OpenTelemetryをフルスタックトレーシングに統合することは、数多くの利点をもたらします:
- 迅速なトラブルシューティング: リクエストフローを可視化することにより、レイテンシやエラーを引き起こしている正確なサービスまたはコンポーネントを迅速に特定します。
- パフォーマンス監視: 遅いデータベースクエリ、非効率的なAPI呼び出し、または高レイテンシの外部依存関係など、マイクロサービス全体にわたるパフォーマンスのボトルネックを特定します。
- 根本原因分析: どのサービスが関与し、それぞれの状態がどうであったかを含め、エラーのコンテキストを追跡し、効果的な根本原因の特定を支援します。
- サービス依存関係のマッピング: マイクロサービス間の依存関係を自動的に発見および可視化し、複雑なアーキテクチャの理解に役立ちます。
- オブザーバビリティの向上: テレメトリデータ(トレース、メトリクス、ログ)の収集とエクスポートに一貫性のある統一されたアプローチを提供し、包括的なオブザーバビリティ戦略へと移行します。
- ベンダーニュートラル: OpenTelemetryはオープンスタンダードであり、アプリケーションコードを変更することなく、さまざまなオブザーバビリティバックエンド(Jaeger、Zipkin、DataDog、New Relicなど)を切り替えることができます。
このセットアップは、eコマースプラットフォームから金融サービスまで、リアルタイムのシステム動作の理解が重要なGoマイクロサービスアプリケーションにとって不可欠です。
結論
フルスタックトレーシングは、分散アプリケーションの複雑なダンスへの深い可視性を提供する、マイクロサービスの分野における不可欠なツールです。GoでOpenTelemetryを活用することにより、開発者は標準化されたベンダーニュートラルなフレームワークを使用してサービスを計測できます。この統合は、不透明な分散システムを、透明でオブザーバブルなエンティティに変え、トラブルシューティング、パフォーマンス最適化、および全体的なシステム理解を劇的に単純化します。OpenTelemetryを採用することは、真に堅牢で保守可能なマイクロサービスアーキテクチャへの道を開きます。