これは、OpenTelemetry Advent Calendar 2024 16日目の記事です。
Table of Contents
はじめに
これまでGoで書かれたアプリケーションをOpenTelemetryで計装するには、net/http
やredigo
、database/sql
など各ライブラリ毎に対応する計装ライブラリを導入し差し替える必要がありました。これは、JavaやNodeJS、PHPといったzero-code計装が可能な言語に比べると導入ハードルが高くなる要因になり得ます。
zero-code計装が難しい要因として、Golangがコンパイル言語であり実行時に計装用コードを差し込むことが困難なことが挙げられます。
しかし最近Golangのzero-code計装も盛り上がりを見せており、zero-code計装のための仕組みが生まれてきています。この記事では下記の2プロダクトを取り上げ、実際に使い心地を確かめてみます。
- alibaba/opentelemetry-go-auto-instrumentation(ビルド時に計装コードを差し込む)
- open-telemetry/opentelemetry-go-instrumentation(eBPFの仕組みを利用)
計装するサンプルアプリ
ymtdzzz/go-auto-instrumentation-test
server_a
とserver_b
という2つのアプリケーションを用意しました。server_a
の/call-b
エンドポイントにGETでアクセスすると、それぞれMySQLとRedisに適当なリクエストを投げつつserver_b
の/data
エンドポイントに内部的に通信が行われます。
また、server_a
とserver_b
はそれぞれGinとEcho、環境変数を設定するとnet/httpでサーバーが起動するようにしています。
動作環境
動作確認は下記の環境で行いました。そのため、MacOSなど異なる環境では動作しない可能性があります。
alibaba/opentelemetry-go-auto-instrumentation
1つ目はalibaba/opentelemetry-go-auto-instrumentationです。これはビルド時に計装用のコードを差し込むことでzero-code計装を可能にしています。
利用方法は簡単で、アプリケーションのビルド時にgo build
を叩く変わりにotel go build
に差し替えてビルドを行うことでOpenTelemetry関連のコードを意識せずに計装を行うことができるようになります。
# Dockerfile.a_alibaba
# Install alibaba's auto instrumentation command
# NOTE: Quickly install sudo command because it's used in install.sh
RUN apt update \
&& apt install -y sudo \
&& curl -fsSL https://cdn.jsdelivr.net/gh/alibaba/opentelemetry-go-auto-instrumentation@main/install.sh | bash
RUN otel go build -o main ./server_a
ビルドされたバイナリの実行方法は特に変更は無く、また、サイドカーなども不要でスタンドアローンで動作します。
OpenTelemetry用の環境変数を設定して起動すれば、テレメトリが送信されます(今回のOTel Collectorはデバッグ用にotel-tuiを利用します)。
# docker-compose.yml
server_a_alibaba:
build:
context: .
dockerfile: ./Dockerfile.a_alibaba
ports:
- "8080:8080"
environment:
# ...
# OTel用の設定
OTEL_EXPORTER_OTLP_ENDPOINT: "http://oteltui:4318"
OTEL_EXPORTER_OTLP_INSECURE: true
OTEL_SERVICE_NAME: server_a_alibaba
oteltui:
image: ymtdzzz/otel-tui:latest
container_name: otel-tui
stdin_open: true
tty: true
docker compose up
で環境を起動し、http://localhost:8080/call-b
にアクセスすると、TraceやMetricが送信され始めます。
attributesの出力内容なども申し分ないように思えますし、特にcontextを受け渡したりしていないのにも関わらずトレースがきちんと繋がっていることに驚きました(redisのdurationがマイナスになってるのはPINGのような一瞬で終わる処理だから?)。
// 元コードではcontextを渡していないが、きちんと繋がっている
_, err := rdb.Do("PING") // this works even if we don't pass the context, wow!
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
serverbURL := os.Getenv("SERVER_B_DATA_URL")
resp, err := http.Get(serverbURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
また、ログについても出力内容にtrace_id
とspan_id
がappendされていることがわかります。
テレメトリとしてLogを出すのは未対応っぽいですが、そこはマッピングできるので十分許容範囲な気がします。
簡単に実装を覗いてみる
詳細はリポジトリのhow-it-works.mdを読むのが良さそうですが、簡単にご紹介します。
alibaba/opentelemetry-go-auto-instrumentationでは、Golangのtoolexecというビルド時に任意のコードを差し込むことでビルドプロセスを拡張する仕組みを利用し、計装用コードの差し込みをビルド時に行うようにしています。
依存パッケージやバージョンの解決など色々と複雑なことやっているように見えますので、そこはスルーしてまずは各パッケージ毎に用意されたruleを見てみます。
先程の動作確認時、net/httpのclientでcontextを渡していないのにきちんとトレースが繋がっているのが不思議だったので、net/httpのruleを見てみます。
まずはruleの設定ファイルを確認します。
https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/32af42919579fef40a724bf5fe6bb2a53455003e/pkg/data/default.json#L388-L395
{
"ImportPath": "net/http",
"Function": "RoundTrip",
"ReceiverType": "*Transport",
"OnEnter": "clientOnEnter",
"OnExit": "clientOnExit",
"Path": "github.com/alibaba/opentelemetry-go-auto-instrumentation/pkg/rules/http"
},
パッケージのimport pathやhookしたい対象のfunctionを指定しているようです。また、OnEnter
とOnExit
で指定されている関数が計装を差し込む処理っぽいので、そこを探してみます。
// https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/32af42919579fef40a724bf5fe6bb2a53455003e/pkg/rules/http/client_setup.go#L29-L48
func clientOnEnter(call api.CallContext, t *http.Transport, req *http.Request) {
if netHttpFilter.FilterUrl(req.URL) {
return
}
netHttpRequest := &netHttpRequest{
method: req.Method,
url: req.URL,
header: req.Header,
host: req.Host,
isTls: req.TLS != nil,
}
netHttpRequest.version = getProtocolVersion(req.ProtoMajor, req.ProtoMinor)
ctx := netHttpClientInstrumenter.Start(req.Context(), netHttpRequest)
req = req.WithContext(ctx)
call.SetParam(1, req)
data := make(map[string]interface{}, 1)
data["ctx"] = ctx
call.SetData(data)
return
}
ざっと読んだ感じ、Request内容をnetHttpClientInstrumenter
に渡し、戻ってきたctx
をreq
に詰めているようです。恐らくTrace Contextの生成はnetHttpClientInstrumenter
でやってそうなので、そちらも覗いてみます。
// https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/32af42919579fef40a724bf5fe6bb2a53455003e/pkg/rules/http/net_http_otel_instrumenter.go#L201C1-L219C2
func BuildNetHttpClientOtelInstrumenter() *instrumenter.PropagatingToDownstreamInstrumenter[*netHttpRequest, *netHttpResponse] {
builder := &instrumenter.Builder[*netHttpRequest, *netHttpResponse]{}
clientGetter := netHttpClientAttrsGetter{}
commonExtractor := http.HttpCommonAttrsExtractor[*netHttpRequest, *netHttpResponse, http.HttpClientAttrsGetter[*netHttpRequest, *netHttpResponse], net.NetworkAttrsGetter[*netHttpRequest, *netHttpResponse]]{HttpGetter: clientGetter, NetGetter: clientGetter}
networkExtractor := net.NetworkAttrsExtractor[*netHttpRequest, *netHttpResponse, net.NetworkAttrsGetter[*netHttpRequest, *netHttpResponse]]{Getter: clientGetter}
return builder.Init().SetSpanStatusExtractor(http.HttpClientSpanStatusExtractor[*netHttpRequest, *netHttpResponse]{Getter: clientGetter}).SetSpanNameExtractor(&http.HttpClientSpanNameExtractor[*netHttpRequest, *netHttpResponse]{Getter: clientGetter}).
SetSpanKindExtractor(&instrumenter.AlwaysClientExtractor[*netHttpRequest]{}).
AddOperationListeners(http.HttpClientMetrics(), http.HttpClientMetrics()).
SetInstrumentationScope(instrumentation.Scope{
Name: utils.NET_HTTP_CLIENT_SCOPE_NAME,
Version: version.Tag,
}).
AddAttributesExtractor(&http.HttpClientAttrsExtractor[*netHttpRequest, *netHttpResponse, http.HttpClientAttrsGetter[*netHttpRequest, *netHttpResponse], net.NetworkAttrsGetter[*netHttpRequest, *netHttpResponse]]{Base: commonExtractor, NetworkExtractor: networkExtractor}).BuildPropagatingToDownstreamInstrumenter(func(n *netHttpRequest) propagation.TextMapCarrier {
if n.header == nil {
return nil
}
return propagation.HeaderCarrier(n.header)
}, otel.GetTextMapPropagator())
}
RequestからテレメトリのAttributeにセットするための情報を取得するExtractorが定義されています。処理の実体は*instrumenter.PropagatingToDownstreamInstrumenter
みたいなので、もうちょい掘ってみます。
// https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/32af42919579fef40a724bf5fe6bb2a53455003e/pkg/inst-api/instrumenter/instrumenter.go#L48-L52
type PropagatingToDownstreamInstrumenter[REQUEST any, RESPONSE any] struct {
carrierGetter func(REQUEST) propagation.TextMapCarrier
prop propagation.TextMapPropagator
base InternalInstrumenter[REQUEST, RESPONSE]
}
さらにInternalInstrumenter
を確認します。
// https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/32af42919579fef40a724bf5fe6bb2a53455003e/pkg/inst-api/instrumenter/instrumenter.go#L89-L115
func (i *InternalInstrumenter[REQUEST, RESPONSE]) doStart(parentContext context.Context, request REQUEST, timestamp time.Time, options ...trace.SpanStartOption) context.Context {
if i.enabler != nil && !i.enabler.Enable() {
return parentContext
}
for _, listener := range i.operationListeners {
parentContext = listener.OnBeforeStart(parentContext, timestamp)
}
// extract span name
spanName := i.spanNameExtractor.Extract(request)
spanKind := i.spanKindExtractor.Extract(request)
options = append(options, trace.WithSpanKind(spanKind))
newCtx, span := i.tracer.Start(parentContext, spanName, options...)
attrs := make([]attribute.KeyValue, 0, 20)
// extract span attrs
for _, extractor := range i.attributesExtractors {
attrs, newCtx = extractor.OnStart(attrs, newCtx, request)
}
// execute context customizer hook
for _, customizer := range i.contextCustomizers {
newCtx = customizer.OnStart(newCtx, request, attrs)
}
for _, listener := range i.operationListeners {
newCtx = listener.OnBeforeEnd(newCtx, attrs, timestamp)
}
span.SetAttributes(attrs...)
return i.spanSuppressor.StoreInContext(newCtx, spanKind, span)
}
どうやらここが計装処理の実体のようです。ここでSpanをスタートし、先程セットしたExtractorを呼び出してAttributeにセットしているようです。そして、最後に新たなcontextを返却しています。
ruleの設定ファイルを作成し、それに合わせて必要なExtractorを定義してあげることで、他のパッケージでも自由にzero-codeすることができそうですね。
ドキュメントではos.Getenv()
のruleを作成する簡単な事例も紹介されていますので、興味のある方はご参照ください。
open-telemetry/opentelemetry-go-instrumentation
続いてopen-telemetry/opentelemetry-go-instrumentationです。こちらは先程とは異なり、eBPFの仕組みを利用したzero-code計装の試みとなります。eBPFについては私自身あまり詳しくないですが、ユーザー領域で実行中のプログラム(プロセス)に対して特定のイベントにフックして任意の処理をカーネルのサンドボックス化されたメモリ上で実行する仕組みです。
では早速使ってみます。ただし、現状対応パッケージが少なく(後述)、HTTPサーバーはnet/httpのみ対応しているため、サンプルアプリケーションでは実装をnet/httpを利用したものに切り替えています(といっても環境変数で処理を分岐してるだけですが)。
# docker-compose.yml
server_a_otel:
build:
context: .
dockerfile: ./Dockerfile.a_otel
ports:
- "8082:8080"
environment:
# ...
SERVER_MODE: "net/http" # ここで切り替え
depends_on:
- redis
- mysql
volumes:
- server_a_otel_binary:/app
また、eBPFプログラムをサイドカーとして動かす必要があります。そのため、docker composeで計装するためには実行ファイルが配置されたvolumeと、プロセス情報取得用に/proc
をマウントする必要があります。
# docker-compose.yml
server_a_otel_agent:
image: otel/autoinstrumentation-go
privileged: true
pid: "host"
environment:
OTEL_EXPORTER_OTLP_ENDPOINT: "http://oteltui:4318"
OTEL_EXPORTER_OTLP_INSECURE: true
OTEL_GO_AUTO_TARGET_EXE: /app/main_a # マウントした実行ファイルを指定
OTEL_SERVICE_NAME: server_a_otel
OTEL_PROPAGATORS: tracecontext,baggage
OTEL_GO_AUTO_INCLUDE_DB_STATEMENT: true
OTEL_GO_AUTO_PARSE_DB_STATEMENT: true
volumes:
- server_a_otel_binary:/app # 計装対象のコンテナとシェアしている実行ファイルのvolume
- /proc:/host/proc
depends_on:
- server_a_otel
volumes:
server_a_otel_binary:
先程と同様にdocker composeで起動後http://localhost:8082/call-b
にアクセスするとトレースが収集できます。
こちらもきちんとトレースが繋がっていますね。なお、Redisクライアントについてはサポートされていないためトレースは出力されません。
この方法の注意点としては、コード内できちんとcontextを引き回す実装になっていないとトレースが途切れてしまう点です。
なお、仕組みや実装については時間の都合&私のeBPFに対する知識不足のため省略します!詳細はHow it worksをご参照ください。
両者の比較
軽く触っただけではありますが、両者を簡単に比較してみます。
対応パッケージ
対応パッケージはalibabaの方が充実してますね。また、logやslog, zapなどのLoggerにも対応しているのもありがたいです。
ライブラリ | alibaba | otel |
---|---|---|
database/sql | ○ | ○ |
echo | ○ | ☓ |
elasticsearch | ○ | ☓ |
fasthttp | ○ | ☓ |
gin | ○ | ☓ |
go-redis | ○ | ☓ |
gorm | ○ | ☓ |
grpc | ○ | ○ |
hertz | ○ | ☓ |
kratos | ○ | ☓ |
log | ○ | ☓ |
logrus | ○ | ☓ |
mongodb | ○ | ☓ |
mux | ○ | ☓ |
net/http | ○ | ○ |
redigo | ○ | ☓ |
slog | ○ | ☓ |
zap | ○ | ☓ |
fiber | ○ | ☓ |
kafka-go | ☓ | ○ |
拡張性
alibabaのビルド時に差し込む方式についてはJSONとGolangでRuleを定義することで拡張が可能です。また、ビルドプロセスでコードの差し替えを行なっているため、contextの差し込みなど柔軟性も高そうです。今後色々なパッケージが対応されそうな気配を感じます(すでにかなり充実していますが、)。
対してOTelのeBPF方式は、probeの実装にC言語のコードが存在するためeBPFそれ自体へのキャッチアップも含めてハードルは高そうに思われました。(逆にその辺りに詳しい人だったらどんどん拡張できるのかなぁ?)
あくまでもGolangの土俵で と考えるとalibabaのビルド方式に軍配が上がるかもしれません。
利用しやすさ
どちらも導入は楽でした。alibabaのビルド方式はビルドコマンドの差し替え、otelのeBPF方式はサイドカーで実行ファイルとプロセスの共有できればシュッと導入できます。
ただ、eBPFの場合強めの権限を割り当てる必要があったり、共有ボリュームのマウントなどはハードルになるケースがあるかもしれません。
事故りにくさ
実行ファイルへの影響度、実行時エラーの起こりにくさについてはプロセスが分離しているeBPFの方が良さそうです。
ビルド方式でもエラーハンドリングは丁寧に行っておりメイン処理に影響を与えないような配慮は実装上見受けられますが、その柔軟性の高さ故に実行時エラーを引き起こすリスクはあるように思えます(それを言うなら計装ライブラリみんなそうですが)。
パフォーマンス
ベンチマークはとってないので今回は言及できません。構成も全然違うので何もわからない!誰か〜!
さいごに
どちらも実験的なフェーズかと思いますが何もコードいじってないのにちゃんとテレメトリ出て感動しました。「ついにGolangにもzero-code計装の時代がやってきたか・・・!」と思いました。
個人的にalibabaのビルド方式はtoolexecというGolangの仕組みに上手に乗っかっている感じがしてとても好きです。eBPFの仕組みもとても可能性を感じられました。
引き続きzero-code計装界隈はwatchしていこうと思います!現場からは以上です!