🗃️

[OpenTelemetry] Go のコンパイル時計装において、ctx 伝搬なしで span を紐づける仕組みについて調べた

に公開1件

Go の OpenTelemetry コンパイル時計装(opentelemetry-go-compile-instrumentation の otelc)について調べていると、context.Context を伝搬していなくても span の親子を繋げられる仕組みが入っていることがわかりました。これは GLS(goroutine-local storage) と呼ばれる仕組みです。

Go では span の親子関係を ctx で運ぶのが API の規約なので、ctx を引き回していないコードにそのまま計装を入れても span 同士は繋がりません。GLS はこの隙間を埋めるもので、ctx を全ての呼び出し境界で渡していないレガシーコードの計装にも使えます。

今回は otelc がこれをどう実現しているのかを、小さなアプリで動きを確かめながら、実装まで掘ってみます。

環境: otelc v1.1.0、otel SDK v1.46.0、Go 1.25

そもそも Go のコンパイル時計装とは

Java や Python のゼロコード計装は、実行時にエージェントがコードを書き換えることで実現されています。一方 Go はシングルバイナリにコンパイルされ、実行時に外から手を入れる場所がありません。そこで otelc が採っているのが、ビルド時にソースコードを書き換える方式です。

使い方はビルドコマンドの置き換えだけです。

go install go.opentelemetry.io/otelc/tool/cmd/otelc@latest
otelc go build

otelc は go build をラップし、コンパイラの呼び出しに割り込んで、各パッケージのソースにフックを注入してからコンパイルします。どこに何を注入するかは同梱のルール定義で決まっていて、注入対象はアプリのコードに限りません。net/http や database/sql といったライブラリ、さらには runtime パッケージそのものにも及びます。

計装済みのバイナリは、環境変数(OTEL_EXPORTER_* など)で設定したエクスポーターへ span やメトリクスを送ります。SDK の初期化コードも注入されるので、アプリ側に OTel 関連のコードは1行も要りません。

Goのトレースはctxで親子を繋ぐ

通常 span の親子関係は context.Context で運ばれます。tracer.Start(ctx, ...) は ctx の中の span を親として新しい span を作ります。これは SDK の実装詳細ではなく API の規約で、trace API の Tracer インターフェースの定義に「ctx に Span が含まれていれば新しい Span はその子になり、含まれていなければ root span になる」と明記されています。

なので、呼び出し先に ctx を渡さないと、呼び出し先で作られた span は呼び出し元の span と親子関係で繋がりません。たとえば database/sql の ctx なしメソッド(db.QueryRow など)は、内部で context.Background() を使います。ここに計装が入っても、親のいない root span がポツンとできるだけです。「1リクエストで何本SQLが走ったか」を見たいのに、HTTP の span と SQL の span が別々のトレースになってしまいます。

GLSとは何をするものか

GLS はこのギャップを埋めるために opentelemetry-go-compile-instrumentation に実装されている互換レイヤーです。ドキュメントいわく、context を全ての呼び出し境界で渡していないコードとの互換性のための仕組みになります。GLS がやっていることは単純です。「現在の goroutine でアクティブな span」を goroutine ローカルな場所に覚えておき、ctx に span がいないときのフォールバックするのが GLS の仕事です。

span が開始されると、その span を goroutine ごとのスタックに積みます。ctx を渡さない呼び出しの中で新しい span を作ろうとしたとき、ctx は空ですが、このスタックの先頭には呼び出し元の span がまだ載っています。それを親に使う、という流れです。

本当にそう動くのか、小さなアプリで確かめてみます。

実験

net/http のハンドラーが1つだけの小さなアプリを書きます。

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"net/http"

	_ "github.com/go-sql-driver/mysql"
	_ "go.opentelemetry.io/otel/sdk/trace" // GLSを有効にする
	"go.opentelemetry.io/otel/trace"
)

var db *sql.DB

func main() {
	var err error
	db, err = sql.Open("mysql", "user:pass@tcp(localhost:3306)/test")
	if err != nil {
		log.Fatal(err)
	}

	// ctxを渡さないDBクエリ + ctxなしでspanを探す
	http.HandleFunc("/noctx", func(w http.ResponseWriter, r *http.Request) {
		var one int
		db.QueryRow("SELECT 1").Scan(&one) // ctxなし
		gls := trace.SpanFromContext(context.Background()).SpanContext()
		req := trace.SpanFromContext(r.Context()).SpanContext()
		fmt.Fprintf(w, "gls_lookup: valid=%v span_id=%s\n", gls.IsValid(), gls.SpanID())
		fmt.Fprintf(w, "req_ctx:    valid=%v span_id=%s\n", req.IsValid(), req.SpanID())
	})

	log.Fatal(http.ListenAndServe(":8099", nil))
}

1箇所だけ補足しておきます。_ "go.opentelemetry.io/otel/sdk/trace" の blank import は、アプリの動作には何の意味もありませんが、現状(v1.1.0)では必要です。理由としては GLS へ span を積むフックのターゲットが go.opentelemetry.io/otel/sdk/trace パッケージで、otelc はユーザーのソースツリーから辿れる依存にしかルールをマッチさせないからです。SDK は otelc が注入してくれるのでアプリ側は普通 sdk/trace に依存しておらず、この import が無いと sdk/trace 向けのルールが適用されず GLS が無効になります。依存グラフに SDK を入れてやれば、ルールがマッチして動くため、blank import することで依存に追加しています。

ハンドラーの中身を1行ずつ見ていきます。

db.QueryRow("SELECT 1").Scan(&one) // ctxなし

まず、ctxを渡さずにDBクエリを1回打ちます。計装が入っていれば、ここでSQLのspanが1つ作られるはずです。このspanに親が付くかどうかが、本題です。

gls := trace.SpanFromContext(context.Background()).SpanContext()

次の行は、GLS が機能しているかどうかを確かめるために入れています。trace.SpanFromContext(ctx) は「ctx の中に入っている span を返す」APIなので、空の context.Background() を渡せば、本来は必ず無効な span が返ります。ところが otelc はこの API にフックを注入していて、ctx に span が無いときは GLS を読みにいくようにしています。GLS が機能していれば現在の HTTP span が返り、機能していなければ無効なspan が返ります。

req := trace.SpanFromContext(r.Context()).SpanContext()

比較用に、リクエストの ctx からも span を取っておきます。HTTP計装が動いていれば、ここには必ず HTTP span が入っています。gls と req が同じspanなら、GLS が「いまアクティブな span」を正しく覚えていることになります。最後にこの2つの結果をレスポンスに出力します。

ビルドと実行:

otelc go build -o app .
OTEL_TRACES_EXPORTER=console OTEL_METRICS_EXPORTER=none OTEL_LOGS_EXPORTER=none ./app

トレースは console エクスポーターで確認します。spanが標準出力にJSONで出るので、コレクターもバックエンドも要りません。各spanの Parent.SpanID を直接見れば親子が分かります。

ctxを渡していないSQLのspanに親が付く

/noctx を叩くと:

gls_lookup: valid=true span_id=16fd004600953745
req_ctx:    valid=true span_id=16fd004600953745

GLS 経由の読み出しが、リクエスト ctx の中の span と同じ span を返しました。空の ctx を渡したのに、現在の HTTP span が取れています。標準出力に出た2つのspanを見ると:

GET /noctx   TraceID=73a4c497…  Parent=(なし)
SELECT       TraceID=73a4c497…  Parent=16fd0046…(= GET /noctx の子)

ctx を渡していない SQL の span に、親が付いています。 HTTP の span と SQL の span が同じトレースに収まり、「1リクエストで何本SQLが走ったか」が見える形になりました。

もう1点、gls_lookup の結果にも注目してください。この読み出しは db.QueryRow の後、つまり SELECT の span がすでに終了した後に行っています。それでも SELECT ではなく HTTP span が返っているということは、span の終了時にスタックから正しく降ろされていることを意味します。

親が付く仕組み

GLS に関わるフックは3種類あります。

  • push: sdk/trace の span 開始処理に入り、作られた span をスタックに積む
  • pop: sdk/trace の span 終了処理に入り、スタックから降ろす
  • read: trace.SpanFromContext に入り、ctx に span が無ければスタックの先頭を返す

このうち読み出し側は trace.SpanFromContext の1箇所だけです。それだけで span の親付けまでされるのは、SDK が親 span を決めるときに必ず trace.SpanFromContext(ctx) を呼ぶからです。「ctx が空のとき GLS を見る」という差し込みをこの関数に入れておけば、SpanFromContext を直接呼ぶコードも、内側で親を解決する SDK も、otelc が仕込んだ同じフックを通ることになります。

上記の1リクエストで起きていることを順に追うと以下のようになります。

① HTTP 計装が GET /noctx の span を開始し、GLS に push する

② db.QueryRow の中で database/sql 計装が tracer.Start(context.Background(), ...) を呼ぶ。SDK は親を探すために SpanFromContext(ctx) を呼ぶが ctx は空。ここでフックが介入し、GLS の先頭にいる GET /noctx を返す

③ GET /noctx を親として SELECT の span が作られ、これも GLS に push される

GLSの実装を読む

GLS の実体を注入コードから整理します。Go には goroutine ローカルなストレージが標準にはありません。otelc はコンパイル時にコードを書き換えられることを活かして、これをruntimeパッケージを改造することで作り出しています。

1. g構造体へのフィールド追加
goroutineの実体であるruntimeの g 構造体に、フィールドを2つ注入します。ビルド成果物(.otelc-build/debug/runtime/runtime2.go)を見ると実際に増えています。

type g struct {
	// ...(本来のフィールド)
	otel_trace_context     interface{}
	otel_baggage_container interface{}
}

2. アクセッサの追加
現在のgoroutineの g からこのフィールドを読み書きする関数をruntimeに生やします。

func GetTraceContextFromGLS() interface{} {
	return getg().m.curg.otel_trace_context
}

3. goroutineを跨いだ伝搬
go 文で新しいgoroutineが作られる処理(runtime/proc.go)に、親goroutineの値を子へ引き継ぐコードを注入します。

// 注入後のgoroutine生成処理
newg.otel_trace_context = propagateOtelContext(callergp.otel_trace_context)

2つのフィールドのうち otel_trace_context に入っているのがspanのスタックです。スタックの実装は注入ソースの otel_trace_context.go にあり、こういう構造をしています。

type traceContext struct {
	sw  *spanWrapper // スタックの先頭(いまアクティブなspan)
	n   int          // 積まれている数
	lcs trace.Span   // このgoroutineにおける起点となるspan
}

type spanWrapper struct {
	span  trace.Span
	prev  *spanWrapper // 前の要素への片方向リンクだけを持つ
	ended *atomic.Bool
}

spanWrapper の単方向リストです。span開始のフックが呼ぶ add は、新しい要素の prev に現在の先頭を入れることで自分が先頭になるようにする処理です。

func (tc *traceContext) add(span trace.Span) bool {
	// ...
	wrapper := &spanWrapper{span, tc.sw, stateForSpan(span)}
	tc.sw = wrapper
	tc.n++
	// ...
}

スタックの先頭が「このgoroutineでいまアクティブなspan」で、trace.SpanFromContext のフックはctxにspanが無いときこれを返します。

まとめると、GLSは「ctxの代替になる特別な仕組み」というより、runtime を改造することで g に間借りした、spanスタックの置き場です。コンパイル時にランタイムごと書き換えられるからこそ可能な実装です。

まとめ

  • otelcのGLSは、ctxを引き回していないコードでもトレースの親子を繋げる互換レイヤーで、実際に機能する。ctxなしの db.QueryRow のspanが、HTTP spanの子として同じトレースに入る
  • 仕組みは「runtime 改造で g 構造体に間借りした span スタック」+「sdk/trace への push/pop フック」+「trace.SpanFromContext への read フック」。read フックが SDK の親 span 解決の途中に刺さっているので、読み出し側のフックによって span の親付けまでできる
  • Goに無いgoroutineローカルストレージをruntimeごと書き換えて作る、というコンパイル時計装ならではの実装
  • 現状(v1.1.0)は _ "go.opentelemetry.io/otel/sdk/trace" の blank import が無いと GLS 関連のルールがマッチしないため注意

TBD

今回は「ctx なしの呼び出しで親が付く」ことだけを確認しました。GLS には goroutine を跨ぐ伝搬の仕組みもありますが、worker pool のように事前に起動した goroutine に仕事を渡すケースや、親 span が終了した後に動く非同期処理でどう振る舞うかは検証していません。これらはまた別の機会に検証しようと思います。

参考

Discussion

Y.AY.A

ctxを渡さない呼び出しでもgoroutineローカルなスタックの先頭を親として使うGLSの仕組み、実験コードまで書いて確かめているのが説得力ありました。database/sqlの同期メソッドがcontext.Background()を内部で使っていてroot spanが浮いてしまうという具体例も、実際にハマりそうな場面としてイメージしやすいです。

1