GoプロジェクトへのOpenTelemetry計装でeBPF自動計装を採用しなかった話

目次

既存GoプロジェクトにOpenTelemetryを計装する機会がありました。eBPFによる自動計装ではなく、手動計装を選んだ理由を説明します。

GoアプリケーションへのOpenTelemetry計装手段

Goにおいては、OpenTelemetryの自動計装が公式で用意されていません。公式サイトにAutomaticの章がないことからわかります。おそらく、ランタイムの制約で実行時にアプリケーションの挙動を変えることが難しいのでしょう。

トレースに十分なスパンを含めるために、現状では以下の2つの計装手段があります。既存のGoアプリケーションに導入する手間や影響範囲をイメージいただくために、概要に絞って解説します。

  1. 手動計装
  2. eBPFによる自動計装(Work In Progres)

1. 手動計装

まず、OpenTelemetryのSDKをインストールし、セットアップをします。

func main() {
  // ...
  
  otelShutdown, err := setupOTelSDK(ctx)
  
  // ...
  
  srv := &http.Server{
    // ...
  }
}
func SetupOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err error, tracerProvider *trace.TracerProvider) {
  // ...
  
  prop := newPropagator()
  otel.SetTextMapPropagator(prop)
  
  tracerProvider, err = newTraceProvider()
  
  // ...	
  
  otel.SetTracerProvider(tracerProvider)
}

後はライブラリごとのプラグインを適用したり、カスタムでスパンを生成するだけです。OpenTelemetryのプラグインが用意されているライブラリであれば、多少手間は少なくなります。例えばGinであれば、以下のような計装でリクエスト全体に対するスパンが生成できます。

func CreateGinEngine() *gin.Engine {
  // ...
  
  engine.Use(otelgin.Middleware("service-name"))
  
  return engine
}

一方で、ルート以外のスパンを生成するためには個別処理でリクエストのコンテキストを渡す必要があるため、大半の場合、共通処理だけで計装完了とはいかないと思います。例えばGORMでは以下のようにWithContextを呼ぶ必要があります。

func Db() (db *gorm.DB, err error)
  // ... 
  
  err = db.Use(tracing.NewPlugin(tracing.WithoutMetrics()))
  
  return db
}

func Query(c *gin.Context, db *gorm.DB) {
  result := db.WithContext(c.Request.Context()).Raw("SHOW TABLES").Scan( ... )
  
  // ...
}

2. eBPF自動計装

一方で、eBPF自動計装では、アプリケーションレベルではなく、eBPFの仕組みを用いてスパンを取得します。例えばDocker Composeであれば、公式Docを参考に、以下のようにボリュームを共有し、動いているGoアプリケーションに対してeBPF計装のエージェントを別で起動するだけです。

version: '3.8'
services:
  go-web:
    build:
      context: ./go-web
    volumes:
      - go-web-volume:/app
    ports:
      - '8080:8080'
  # ...
  instrumentation:
    image: otel/autoinstrumentation-go
    privileged: true
    pid: "host"
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
      - OTEL_GO_AUTO_TARGET_EXE=/app/go-web
      # ...
    volumes:
      - go-web-volume:/app
      # ...
volumes:
  go-web-volume:

このように、インフラにあわせて導入方法を検討する必要はありますが、アプリケーションへの影響は非常に小さくなります。

技術選定でeBPFを選ばなかった理由(2024.03時点)

今回、技術顧問先で既存のGoプロジェクトを計装するにあたり、eBPF自動計装はとても魅力的でした。手動計装には既存コードベースへの理解が必要があり、リグレッションの懸念もあるためです。アルファではあるものの、動作が安定していれば採用の余地はあると考えて調査しましたが、最終的には手動計装を選定しました。その主な理由を以下に記載します。

1. 十分にスパンが取得できない

今回、サンプルのWebアプリを計装することでeBPF自動計装を検証しましたが、満足にスパンが生成されていませんでした。サーバレスDBへのSQLアクセスでは、スパンは生成されているものの、db.statementが空となっており正しく取得できていませんでした。WebサーバーからのアウトバウンドのHTTPリクエストについては、そもそもスパン自体が生成されていませんでした。

今回計装したいプロダクトでは、MongoDBへのアクセスなども含んでいます。HTTPのコールアウトさえ満足に取れていないように見えることから、こうした多様な技術的処理に対して現状でどれだけ対応できているか懸念がありました。

sre_magazine_sumiren01

2. 手動計装とのハイブリットができない

eBPF自動計装が十分にスパンを生成できないなら、足りない部分だけ手動計装で補うというアーキテクチャを取れないかと考えて検証しましたが、動作しませんでした。eBPF自動計装を動かしながらOpenTelemetry SDKをセットアップして手動計装しても、それぞれ別々にトレースIDやスパンIDを採番してしまいます。eBPF自動計装と手動計装が二者択一であることは、意思決定へのインパクトが大きかったです。

これは蛇足ですが、そもそもeBPFではハイブリットは技術的に困難にも思えます。Goアプリケーション上ではeBPFの自動計装が採番したトレースIDやスパンIDを知る由がありませんし、逆にeBPFの自動計装も、ランタイムでGoのメモリ内に含まれるコンテキストからトレースIDや親スパンIDを得る手段がないように思います。筆者はeBPFに詳しくないため、これはあくまで推測であり、ゆえに蛇足です。eBPFとその可能性に詳しい方がいましたら、ぜひ見解を教えてください。

3. 独自の認証HTTPヘッダ等をつけられない

HoneycombやDynatraceやNewRelicなど、一般的なOTLP対応のオブザーバビリティバックエンドを利用するためには、HTTPヘッダに認証のためのキーを入れる必要があります。現状のeBPF自動計装では環境変数でヘッダ等を指定できず、OpenTelemetry Collectorを必ず経由する必要が出てきます。これも、スモールスタートするには少し重たいと感じました。

まとめ

2024年3月時点では、eBPFの自動計装はWork In Progressであることもあり、まだ多くのユースケースにマッチするとは言い難いと感じました。最近ではeBPF以外にもOS/インフラレイヤでの計装を研究する動きが活発になっています。こうした技術の発展を祈りながら、目先のGoプロジェクトでは手動で計装していこうと思います。この記事が参考になれば幸いです。