🥁

Go WebサーバーへのOpenTelemetry計装から学んだ手動計装の関心事

2024/12/22に公開

こんにちは。sumirenです。

この記事は、OpenTelemetry Advent Calendar 2024 22日目の記事です。

イントロダクション

先日、筆者はSREingの技術顧問として、Go言語で書かれたアプリケーションにOpenTelemetryを導入するプロジェクトを担当しました。Webサーバーとバッチスクリプトを対象に計測を行ってオブザーバビリティを大幅に向上し、パフォーマンス改善を開始することができました。

筆者はオブザーバビリティとOpenTelemetryの専門家ですが、Goの経験は浅く、Goでの計装も初めてであったため、プロジェクト内のGoの専門家とコミュニケーションを取り、リサーチを重ねながら計装することになりました。自動計装を検討したもののGoでは選択肢が限られており、結果的に手動計装を採用しましたが、この過程で多くの学びを得ることができました。

  • (当然ながら)Javaなどの自動計装が手厚い言語やランタイムに比べ、手動計装では作業が多いこと
  • 一方で、手動計装にはいくつかのパターンがあり、パターン次第では作業コストが小さいこと

この記事では、これらの知見を基に、手動計装中心のOpenTelemetry導入で必要となる具体的な作業内容を整理します。手動計装を検討されている方が、作業を洗い出し、コストを見積もる際の実践的なヒントを得られることを目指しています。Goでの経験をもとに書いていますが、Goに限らず、さまざまな言語で役立つ内容を意図しています。

免責事項の合意

  • 本記事は、OpenTelemetryの手動計装における作業の全体像や詳細を示し、読者がOpenTelemetry導入を効率的に進める助けとなることを意図しています。Goによる計装を説明することは主題ではなく、またGoの実例を参考にされる場合には、読者が専門知識によりその是非を判断するものとします。
  • 掲載したコード例は、手動計装の背景や考え方を理解するための手助けとして提供しており、また必ずしも実プロジェクトのコードを抜粋してはいません。そのため、バージョンの整合性などは考慮しておらず、動作保証を行うものではない点にご注意ください。
  • 記事の誤りや表現の改善に関する指摘は歓迎しますが、お返事や修正の有無については、筆者が気分や余力を踏まえて恣意的に判断するものとします。

1. 背景:手動計装の経緯

1-1. 計装対象のフレームワーク・ライブラリ

筆者が計装したのは、Goで書かれたWebサーバーとバッチスクリプトでした。この記事では、Webサーバーを前提に記述します。

一般に手動計装と言ったときには、以下の2つの意図がありうると考えます。

  • 自動計装で行われることが多い、ライブラリやIOなど技術的なカットでの計装を、技術的制約や設計上の意思決定を理由に手動で代替する
  • ドメインロジックなどユーザーランドのコードを計装する

この記事で手動計装と言うときは上記の前者の意味合いとします。オブザーバビリティの導入時点では技術的なカットでの計装から始めることが多いでしょう。計装対象になりうるフレームワーク・ライブラリは以下のようなものでした。

  • Gin (Webフレームワーク)
  • GORM(RDBのドライバ)
  • mgo(MongoDBのドライバ)
  • net/http(HTTPクライアント)

1-2. 手動計装を選択した理由

JavaやNode.jsでは、javaagentを用いたり、nodeコマンドの--requireオプションでスクリプトを読み込むことで、この記事で行おうとしているような技術的なカットでの計装は自動的に行うことができます。

しかし、Goでは2024年12月現在、自動計装の選択肢が限られています。公式ドキュメントによると、自動計装(ゼロコード計装)の取り組みは開発中であり、現時点で実用性が十分ではありません。

eBPFを用いた自動計装も検証しましたが、以下の課題がありました:

  • 満足にスパン量が得られなかった
  • ドメインロジックの計装などユーザーランドの手動計装と組み合わせた場合、トレースやスパンが紐づかなかった

これらの理由から、手動計装を採用しました。このトピックの詳細は、以前筆者が寄稿したSRE Magazineの記事をご参照ください。

2. 手動計装作業の全体像

手動計装を選択する場合、実態としては以下のように大きく2つの実装作業に分かれます。

  1. OpenTelemetry SDKのセットアップ
  • エントリポイント直後での初期化処理や終了処理
  • Collectorなどにスパンを送信するための環境変数などの定義と読み込み
  1. フレームワーク・ライブラリなど、計装対象ごとの処理
    • Webフレームワーク、DBドライバ、HTTPクライアント等の計装が必要

2-1. OpenTelemetry SDK

SDKのセットアップ自体は、main()直後に初期化を行い、終了時にはリソースを正しく解放する処理を入れる程度で済むことが多いです。ただし、他の横断的な初期化処理との前後関係、アプリケーション終了時の確実なシャットダウンなど、丁寧な実装が求められます。以下はGoの例です。


  func main() {

    // 初期化処理...

+   // OpenTelemetry SDK 初期化
+   otelShutdown, err, _ := setupOTelSDK(ctx)
+   if err != nil {
+       log.Printf("Failed to set up OpenTelemetry SDK: %v", err)
+       return
+   }
+
+   defer func() {
+       if err := otelShutdown(context.Background()); err != nil {
+           log.Printf("Failed to shutdown OpenTelemetry SDK: %v", err)
+       }
+   }()

    router := getRouter()

    // 後続の初期化処理...

上記例は公式ドキュメントを参考にしています。setupOTelSDKの中でPropagatorの設定やTraceProviderの設定をしている想定です。本質的に難しい作業ではないため全体は割愛しますが、OpenTelemetry Collectorなどアプリケーションの外にスパンを送信するための設定、その送信先を環境ごとに受け取るための仕組みの検討と実装などが必要になります。以下はそのイメージです。

func setupOTelSDK() (func(ctx context.Context) error, error) {

+    endpoint := os.Getenv("OTEL_COLLECTOR_ENDPOINT")
+    if endpoint == "" {
+        return nil, fmt.Errorf("OTEL_COLLECTOR_ENDPOINT not set")
+    }

     traceExporter, err := otlptracegrpc.New(
         context.Background(),
         otlptracegrpc.WithEndpoint(endpoint),
         otlptracegrpc.WithInsecure(), // インフラ状況に応じてTLS化を検討
     )
     if err != nil {
        return nil, fmt.Errorf("failed to create trace exporter: %w", err)
     }

     res, err := resource.New(context.Background(),
         resource.WithAttributes(
             semconv.ServiceNameKey.String("my-service"),
         ),
     )
     if err != nil {
         return nil, fmt.Errorf("failed to create resource: %w", err)
     }

     traceProvider := trace.NewTracerProvider( 
         trace.WithBatcher(traceExporter),
         trace.WithResource(res),
     )

    // ...

関連する作業として、IaCやインフラ修正による環境変数の受け渡し、OpenTelemetry Collectorの設定とインフラ構築、インフラ上のネットワーク設定、なども想定されますが、計装という表現から連想される範囲には含まれないため割愛しています。

2-2. フレームワーク・ライブラリの計装

各ライブラリの計装は、以下のように個別の作業を必要とします:

Gin:リクエストごとのトレース生成。
GORM:クエリ実行時のcontextの引き回し。
net/http:HTTPクライアント呼び出しごとのトレース。

2.は、筆者がこの記事を通して洞察を伝えたい作業内容になります。そのため、この記事の以降の部分では、2.について詳しく解説していきます。

参考までに、ここまでに述べた作業の全体像を簡単に図解しておきます。

3. (本題)フレームワーク・ライブラリに対する手動計装の3つのパターン

この章では、前章で説明した「2. フレームワーク・ライブラリなど、計装対象ごとの処理」を深堀りします。特にアプリケーションが大きければ大きいほど作業全体のなかでも大きな作業コストを占め、再帰テストが重要になってくる箇所です。

今回の導入で筆者が学んだことは、ひとくちに手動計装といっても、計装対象の状況次第で作業コストは大きく変わってくるということです。

これを大きく分けて3つのパターンに分類してみました。計装したい対象のそれぞれがどのパターンに当てはまるかを調査することで、作業量を見積もることができると考えます。以降、簡単のため、フレームワーク・ライブラリという表現を、ライブラリに統一します。

以下はその3つのパターンです。対応の容易さの順番に記載しています。

    1. ライブラリがOpenTelemetryに対応しており、かつ少ない箇所の修正で対応できるケース
    • ライブラリの初期化処理の修正のみで良い場合
    • または、ライブラリの個別利用箇所に修正が必要だが、うまく共通化できる場合
    1. ライブラリがOpenTelemetryに対応しているものの、個別の利用箇所を修正しないといけないケース
    • ライブラリの個別処理の修正が必要で、特にコードベース上でラップされていない・できないケース
    1. ライブラリがOpenTelemetryに対応していないケース
    • 自分でスパンの生成処理やメタデータ付与から書く必要がある

それぞれのパターンについて、具体的にどのような作業が必要になるかを説明します。

3.1. ライブラリがOpenTelemetryに対応しており、かつ少ない箇所の修正で対応できるケース

最も容易なケースは、ライブラリがOpenTelemetryに対応しており、少ない箇所の修正で完結することができる場合です。

3.1.1. ライブラリの共通的な初期化処理のみの修正で良い場合

例えば、WebフレームワークであるGinはOpenTelemetryに対応しており、かつ、ミドルウェアを適用するだけでトレースのスパンを生成するようになります。(参考

import (
    "github.com/gin-gonic/gin"
    "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)

func main() {
    r := gin.New()
+   r.Use(otelgin.Middleware("your-service-name"))

    r.GET("/", func(c *gin.Context) {
        c.JSON(200, gin.H{"message": "hello"})
    })

    r.Run()
}

こうしたパターンでは、手動計装と表現されつつも、ほとんど自動に近い作業量で計装を完了することができます。

3.1.2. ライブラリの個別利用箇所に修正が必要だが、うまく共通化できる場合

次は工夫が必要になるパターンを考えてみましょう。例えば、GORMはOpenTelemetryに対応しています。まずは以下のような設定で初期化できます(参考

  db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
      Logger: newLogger,
  })

  if err != nil {
      return db, err
  }

+ err = db.Use(tracing.NewPlugin(tracing.WithoutMetrics()))
  return db, err

GORMの場合、これだけではトレースのスパンがつながりません。適切に親スパンの下に子スパンを生やすためには、個別のクエリ処理時にdb.WithContext(...)等でコンテキストを渡せている必要があります。単純に修正すると、全てのGORM利用箇所でWithContextを呼ぶように修正したり、そのために関数間でコンテキストを引き回す修正が必要となり、非常に範囲の広い改修となることが予想されます。

しかし、幸いなことに、WithContextDBを受け取りDBを返すインターフェースをしています。もし(Webサーバーの場合)各エンドポイントの呼び出しごとのDB生成処理が共通化されていれば、その箇所でContextを渡すようにするだけで対処することができるかもしれません。

func createDb(c *gin.Context) (*DB, error) {
     conn, err := sql.Open("mysql", "your-dsn-here")

     // ...

+    conn = conn.WithContext(c.Request.Context())
     return conn, nil
}

このように、個別利用箇所に影響を与えうる修正であっても、ライブラリやそのOpenTelemetry対応が提供するインターフェースや、既存コードベースの抽象化・共通化の状況次第では、局所的な修正のみで計装を実現することができます。

https://github.com/go-gorm/opentelemetry/issues/22
https://gorm.io/docs/context.html#Continuous-Session-Mode

3.2.ライブラリがOpenTelemetryに対応しているものの、個別の利用箇所を修正しないといけないケース

ライブラリがOpenTelemetryに対応しているものの、3.1.2.のようにうまく修正の共通化ができない場合には、個別のライブラリ利用箇所を修正する必要が出てきます。ひとつひとつの修正自体は容易なものの、広範囲の修正となることから、レビューやテストを含む作業コストとしては相対的に大きなものになるでしょう。

例えば上記のGORMに関して、全てのエンドポイントの個別ハンドラーにDBを生成するような処理がベタ書きされていたとしたらどうでしょうか。その全ての生成箇所でWithContextを呼ぶにせよ、共通的なDB生成の関数に抽出するにせよ、コードベース上の広範な修正が必要となります。

また、そもそもライブラリが提供しているインターフェース上、共通化が難しいパターンやまず既存コードベース上で共通化されていないパターンというのも考えられます。例えばnet/httpについて考えてみましょう。net/httpはOpenTelemetryに対応しています。以下のようにセットアップできます。

+ func CreateHttpClient() *http.Client {
+     return &http.Client{
+         Transport: otelhttp.NewTransport(http.DefaultTransport),
+     }
+}

GORM同様、これだけでは親スパンにつながりません。Contextを渡す必要があります。しかし、この渡し方が、GORMと事情が異なるポイントです。

-	req, err := http.NewRequest(
+	req, err := http.NewRequestWithContext(
+		c.Request.Context(),
		"GET",
		"https://graph.facebook.com/v2.12/debug_token?"+values.Encode(),
		nil)

DB値へのコンテキストの引き渡しと実際のクエリ実行が分離されていたGORMのインターフェースと異なり、net/httpではNewRequestWithContextいった形で、コンテキストの引き渡しと処理実行が一体化しています。

このインターフェースでは、コードベースの広い範囲の修正が必要になる場合が多くなると思われます。もちろんNewRequestがコードベースの様々なところから呼ばれていたら、その全てをNewRequestWithContextに修正したり、ラッパー関数に括りだすように修正する必要があります。加えて、もしNewRequestがたまたま既にラップされていたとしても、Contextがはじめからその関数に渡されているとは限りません。コードベース全体がContextをあまり引き回していないとしたら、ハンドラーからドメインロジックまで大規模な修正になりえます。

このように、このパターンでは広い範囲の修正が必要となる可能性が高いです。一方で、呼び出し方さえ修正してしまえばスパンが生成される点で、「手動計装」と聞いたときにイメージされる作業量に比べれば、楽といえるのではないでしょうか。この次の最終章では、ザ・手動計装的なパターンを取り扱います。

3.3. ライブラリがOpenTelemetryに対応していないケース

最後に取り扱うのは、計装対象のライブラリがOpenTelemetryに対応していないケースです。この場合、まずスパンを生成したり、スパンに属性を付与する作業が必要になります。

スパンの生成は以下のように行うことができます。

  var tracer = otel.Tracer("my-service")

  func MyHttpGetWrapper(ctx context.Context, url string) (*http.Response, 
  error) {
+     ctx, span := tracer.Start(ctx, "HTTP GET")
+     defer span.End()

      resp, err := http.Get(url)
      return resp, err
  }

このように、フレームワークではなくライブラリが計装対象の場合、コードベース全体からの呼び出しがたまたまラップされていればよいですが、そうでない場合も多いでしょう。ラッパーを作り全体を修正しなければならない可能性もありますが、ライブラリがフックを提供していれば共通的な修正で対応できるかもしれません。以下はそのイメージです。

func NewSomeClient() *SomeClient {
    client := &SomeClient{}

    client.BeforeHook = func(ctx context.Context, req *http.Request) context.Context {
        // リクエストに応じたスパン名を付与
        ctx, span := tracer.Start(ctx, "ExternalClient Request")
        return trace.ContextWithSpan(ctx, span)
    }

    client.AfterHook = func(ctx context.Context, resp *http.Response, err error) {
        span := trace.SpanFromContext(ctx)
        span.End()
    }

    return client
}

その他、計装対象がフレームワーク等であれば、ミドルウェアを差し込み、Chain of Responsibilityのnext()をスパンで囲むだけでスパン作成に関わる修正は完了するかもしれません。

このように、コードベースや計装対象次第で、スパン作成の時点で修正コストが大きく膨らんでいるかもしれません。ただ処理時間や処理順序を計測したいだけであればこれで十分ですが、トレースをさらに使いこなすためには、さらにスパン属性の設定を実装する必要があります。

例えば、以下はGORMのスパンをHoneycombというオブザーバビリティバックエンドで見たものです。もちろんスパンの長さで処理時間はわかりますが、それに加えて、右ペインに表示されている属性によって、具体的にどの処理が遅いか、どの程度の行に影響を与えているかといった情報が得られ、課題の特定のヒントになっています。

こうした属性は、以下のようにスパンに付与できます。

  func MyHttpGet(ctx context.Context, url string) (*http.Response, error) {
      // スパン開始
      ctx, span := tracer.Start(ctx, "HTTP GET")
      defer span.End()

      // スパンに属性を設定(例: HTTPメソッドとURL)
+     span.SetAttributes(
+         attribute.String("http.method", "GET"),
+         attribute.String("http.url", url),
+     )

      // HTTP GET 実行
      resp, err := http.Get(url)

      // スパンにリクエスト結果の情報を追加
+     if resp != nil {
+         span.SetAttributes(
+             attribute.Int("http.status_code", resp.StatusCode),
+         )
+     }

      if err != nil {
          span.SetAttributes(
              attribute.String("error", err.Error()),
          )
      }

      return resp, err
  }

この作業は、コードベースのなかで修正する箇所としては局所ですが、関数のインプットとアウトプットから設定すべき属性を洗い出したり、1つ1つの属性について値の型を決めたりnil判定するなど、質的な手間がかかる作業です。

今回、筆者の導入先では、最終的にこのパターンは避けました。mgoという少々古いMongoDBのドライバーを使っていたものの、メンテナンス終了状態であったため、手動計装するくらいならライブラリ移行を先にやろう、ということになりました。このように、計装コストを考慮して、OpenTelemetry対応済みかつ、既存コードベースとの相性がよく局所的な修正で済むライブラリを選ぶことも有効な戦略となりえます。

なお、便宜上このケースを最も大変なケースとしましたが、実際にはこのパターンよりも2のパターンのほうが大変になるケースもありえます。このパターンでは、スパン属性の付与作業は手間がかかるものの、コードベースの修正は局所的で済む可能性もあるためです。とはいえ、多くの場合には最も手間のかかるパターンになるでしょう。

まとめ

この記事では、Goを通じて手動計装の全体像を示し、計装対象のライブラリとその使用状況によって作業コストが大きく変わることを解説してきました。計装対象のそれぞれについてOpenTelemetryに対応しているか、共通化できそうか、あるいはスパン生成と属性付与から必要か、といったパターンを事前にリサーチすれば、概ね必要な手間を見積もることができます。

各論ですが、Goの場合には、既存コードベースにおけるContextの伝搬状況といったポイントも追加で確認しておくと、広範囲な修正に気づきやすくなります。巨大なPRやコンフリクトを避けるためにも、まずはフレームワークなどミドルウェアを一箇所差し込むだけで済むような計装コストの低い箇所からスモールスタートしても良いでしょう。

実際には、ライブラリがOpenTelemetry対応のはずなのにうまく動かない、非対応ライブラリの計装が想定以上に大変といった問題も起こりえます。その場合、ライブラリの移行や計測箇所を絞るなど、実務的なトレードオフ判断が必要になることもあります。アプリケーションのパフォーマンスを損なっている疑いの大きいIOやライブラリから優先的に計装するとよいでしょう。

最後に、余談になりますが、筆者は技術的なカットでの計装はあくまではじまりに過ぎないと考えています。ドメインロジックやアプリケーション固有の処理への手動計装があれば、リクエストパラメータやDBの内部状態、処理が通ったパスなどが観測可能になり、パフォーマンスの問題だけでなく振る舞いの問題にも対処できます。筆者は、こうした振る舞いへの計装こそがオブザーバビリティが最大の価値を生むポイントであると確信しています。

この記事を通じて、手動計装への解像度が少しでも上がり、OpenTelemetry導入の一助となれば幸いです。

追伸

よければTwitterのフォローもどうぞ。
オブザーバビリティの仕事や相談も募集しています。ご発注に至らなくても構わないので、お気軽にDMやメールでご相談ください。

Discussion