opentelemetry-go-instrumentationというGoでOpenTelemetryの自動計装を実現するライブラリを知りました。eBPFを活用しているようです。このライブラリを実際に動かしてみながら簡単に調べてみます。

本記事はOpen Telemetry 2023 Advent Calender第5日目の記事です。第4日目の記事は@aerealさんの「OpenTelemetry Collectorのconfmap providerを実装してみる」でした。
本記事執筆時点のopentelemetry-go-instrumentationはWork in Progress(v0.8.0-alpha)となっています。その後大きく変わっている可能性にご注意を。

自動計装とは?

OpenTelemetryを中心とした分散トレーシングでは、Contextを伝播したりMetrics, Logs, Tracesを出力したりするための計装(Instrumentation)をアプリケーションに施す必要があります。この作業はアプリケーションに手を加える必要があり手間です。そこで計装を自動化する、つまりアプリケーションのコードに手を加えずに実現する方法がJavaなどで実現されています1

しかしGoはJavaやPythonなどとは違って、マシンコードにネイティブにコンパイルされます。そのため実行時にコードを追加することができません。少なくとも自分はGoでの自動計装は難しいと思っていました2

今回紹介するopentelemetry-go-instrumentationはeBPFを使ってGoでの自動計装を実現しようと試みています。

eBPFとは?

eBPFはカーネル空間のサンドボックス環境で安全にユーザー定義のプログラムを実行するLinuxの技術です。Networkingを中心としてObservabilityやTracingの分野での研究開発が盛んです。自分が読んだ論文をいくつか簡単に紹介しているのでよければご覧ください。

個人的にはCiliumを中心としてコンテナネットワーク関連でよく使われる印象を持っています。

opentelemetry-go-instrumentationでは、eBPFを用いて実行プロセスのコードと変数にattachしているようです。

動かしてみる

調べる前に動かしてみます。getting-started3があるのでそれに従います。

準備

kindでk8sクラスタ作成&imageをロード

$ kind create cluster --name=otel-go-inst
$ make docker-build
$ kind load docker-image otel-go-instrumentation --name=otel-go-inst

アプリケーションをデプロイ

$ kubectl apply -k docs/getting-started/emojivoto/
namespace/emojivoto created
serviceaccount/emoji created
serviceaccount/voting created
serviceaccount/web created
service/emoji-svc created
service/voting-svc created
service/web-svc created
deployment.apps/emoji created
deployment.apps/vote-bot created
deployment.apps/voting created
deployment.apps/web created

Jaegerをデプロイ

$ kubectl apply -f docs/getting-started/jaeger.yaml -n emojivoto
deployment.apps/jaeger created
service/jaeger created
$ kubectl port-forward svc/jaeger 16686:16686 -n emojivoto

計装前

計装前にどんなアプリケーションか見てみます。

$ kubectl port-forward svc/web-svc 8080:80 -n emojivoto

絵文字を投票するアプリケーションのようです。いくつかリクエストを送ってからJaegerを見てもTraceは見れません。

計装後

計装したバージョンのアプリケーションをデプロイ

$ kubectl apply -f docs/getting-started/emojivoto-instrumented.yaml -n emojivoto
deployment.apps/emoji configured
deployment.apps/voting configured
deployment.apps/web configured

計装用のコンテナ定義はこちら。特権を与えた上で計装対象のコンテナと同じPodで動作させる&プロセス名前空間を共有する4ことで実現しています。コンテナ定義を追加するだけでアプリケーションのコード・イメージには変更はありません。

再びアプリケーションを操作してからJaegerを見ます5。 ここでアプリケーションがJaegerからconnection refusedされていることに気づきました。

2023/11/30 15:41:05 traces export: Post "http://jaeger:4318/v1/traces": dial tcp 10.96.187.248:4318: connect: connection refused

imageをjaegertracing/opentelemetry-all-in-oneからjaegertracing/all-in-oneに変更したら動いたので、PRをupstreamに投げておきました。


これだけでかなり詳細なTraceを見ることができました。 ちなみになんとなくアプリケーションの動作が重くなった気がしますが気のせいでしょうか。ちゃんと計測してみないと分かりませんが。

仕組み

ドキュメントを読んでいきます6

計装が必要な処理は大きく以下の3つです。

  1. HTTP/gRPCのリクエスト・レスポンスのSpanContext7を読み書きする
  2. Spanを作成する
  3. SpanContextをeBPF Mapに格納する

eBPFプログラムはスタックとCPUレジスタを解析してユーザーコードと変数にアクセスします。このときhttp.Requestのような構造体のSpanContextを読み書きするためには、構造体内のそのフィールドのオフセットを知る必要があります。しかしオフセットは構造体定義が変更されるたびに変化します。そこでオフセットを解析し、その情報をバージョン・構造体ごとにJSONに保存してくれるのがoffsets-trackerです。

1ではoffsets-trackerを利用して、http.Requestやgrpc.ClientConnといった構造体のSpanContextを読み書きしているようです。

2では適切な位置で自動でSpanを作成します。例えばHTTP Serverのハンドラ内でgRPCのリクエストを送信するときにSpanを作成します。 またこのライブラリは手動で作成したスパンにも対応しています。その場合はSpanContextを更新します。

3ではSpanContextをeBPF Mapに格納することで、同じgoroutine内の他の場所でSpanContextを利用できるようにしています。例えば受け取ったHTTPリクエストのSpanContextをeBPF Mapに格納し、最後HTTPレスポンスを返す際に取り出して書き込むといった流れです。SpanContextの更新は、1で新たなSpanを受け取った際や2で現在のSpanが更新された際に必要です。

現在の実装ではeBPF MapのKeyをgoroutine IDに、ValueをSpanContextとしています。そのため複数goroutineに跨ったSpanContextの共有は難しいそうです。将来的にはgoroutineの木構造の依存関係をトラッキングすることも考えているようです。

他には、Spanの開始・終了時にタイムスタンプを取得する必要があります。関数の最後でeBPFコードを呼び出すuretprobesはGoとの相性が悪いらしく、代わりにreturn句を検出して直前にuprobesを置くことで終了タイムスタンプを収集するeBPFコードを呼び出しているそうです。

eBPFやOpenTelemetryについてまだまだ勉強中なので、本記事で間違っているところなどがあれば遠慮なくご指摘ください。

終わりに

ドキュメントが整備されており非常に調べやすかったです。今回は実装までちゃんと読めていないので次はもっと調べてみたいと思っています。

開発が進んでもっと色々な状況で使用できたら非常に便利だと感じました。一方で実現方法のゴリ押し感・動作が重い可能性・コンテナに特権を与えるセキュリティ上の問題が気になります。引き続き追っていきたいと思います。