株式会社ヘンリーでSREをしているsumirenです。
ヘンリーではオブザーバビリティバックエンドにHoneycombを採用しています。 Cloud Runでサービス間通信をしている場合、こうした外部オブザーバビリティバックエンドとOpenTelemetryを使うと、トレースが途切れてしまう課題があります。
解決してから1年弱経ってしまったのですが、対処事例を紹介します。
Cloud Run + OpenTelemetry + 外部バックエンドでトレースが途切れてしまう理由
途切れてしまう理由を解説するために図解を用意しました。ここでは2つのCloud Runアプリケーションがサービス間通信を行い、番号順に処理を行ってトレースを生成しています。便宜上各アプリケーションはスパンを1つしか生成しない形で図解していますが、スパン数が増えても問題の本質は変わりません。またトレースIDやスパンIDは簡易的なものにしています。
トレースにおいてスパン間がつながるのは番号2・4・6のトレースIDや親スパンIDの伝播のおかげです。この仕組みはContext Propagationと呼ばれ、HTTPによる通信であれば、そのヘッダに特定の形式でトレースIDや親スパンIDを埋め込むことで実現されます。Cloud RunはOpenTelemetryをサポートし、スパン生成に加え、こうしたスパンの伝播も行います。OpenTelemetryにおける標準的なContext Propagationの形式であるW3C Trace Contextをサポートし、自身がアプリケーションにプロキシする際にtraceparentヘッダを付与したり、逆に自身が受け取ったリクエストにそのヘッダが付与されていれば、そのスパンに紐づける形で子スパンを生成したりします。
さて、注目すべきは最終的にHoneycombとCloud Traceにどのようなスパンが送信されるかです。Cloud Runが生成したスパンはCloud Traceにしか送られません。一方で、アプリケーションでは正常にHoneycombにスパンを送信しています。そのため、ツリー構造を成すはずの1つのトレースのスパンが、HoneycombとCloud Traceに半分ずつ送られてしまっています。ツリー構造においては、当然ながら間のノードが抜けてしまうと構造が破綻します。これが、トレースが途中で途切れてしまう理由です。
Cloud Runが生成したスパンの送信先を変更したり、Cloud RunがアプリケーションのOpenTelemetryトレースに干渉することを無効化できればよいのですが、残念ながらこの挙動は変更も無効化もできないようです。少なくとも、2023年の10月から12月にかけてGoogle Cloud様に問い合わせ、技術者の方ともやりとりしましたが、解決には至りませんでした。
ちなみに、Honeycombのような外部オブザーバビリティバックエンドを使っていなくても、トレースが途切れる場合があります。Cloud RunはContext Propagationに含まれるサンプリングフラグを考慮する(Cloud Trace 用の計装 | Google Cloud)とされていますが、Google Cloud様のサポート曰く実際にはレートリミットのようなものがあり、割とすぐにサンプリングされてしまいます。ヘンリーではトレースをサンプリングなしですべて収集する戦略を取っているため、Cloud Trace運用時にも途切れた事例があります。
2024年4月頃まで改善していなかったことを確認していますが、他社のSREの方からも困っている旨を耳にするので、おそらく2025年1月現在も解消していないのではと推測しています。
途切れた場合のオブザーバビリティバックエンド上での見え方については以前に別の記事 事例から学ぶクラウドへのOpenTelemetry導入のハマりどころ - ヘンリー - 株式会社ヘンリー エンジニアブログ で紹介していますので、よければ参照ください。
Propagatorを自作するという方針
Cloud Run側でOpenTelemetryサポートを無効化できない以上、ユーザーランドで何らかのワークアラウンドをするしかありません。例えば、オブザーバビリティベンダの独自エージェントを使いOpenTelemetryを避けるといった手段があります。OpenTelemetryを継続しつつワークアラウンドを行う手段として、この記事ではアプリケーションのContext Propagationの振る舞いを変更するためにPropagatorを自作する方法を紹介します。ヘンリーではGraphQL BFFにNode.js、gRPCバックエンドにKotlin/JVMを使っているためそれぞれ紹介します。一部、ヘンリー固有の実装もあるため補足します。
自作するとはいっても、今回はCloud Runが知る由のないHTTPヘッダでやりとりできればそれでいいため、ヘッダ名のみ独自として、データ自体はW3C Trace Contextのフォーマットのままとしました。自作する以外にも、OpenTelemetryの各種言語のSDKではB3などW3C Trace Context以外の仕様も利用でき、Sansan様の技術ブログでもそうした異なる標準仕様を利用する方法が紹介されています(参考: Vol. 04 Cloud RunでCloud Trace以外のAPMを使う場合の一工夫 - Sansan Tech Blog)。筆者が試した環境ではこの方法でうまく解決しなかったため自作することにしたのですが、方針に関しては大変参考になりました。
以下は先程の図解について、Propagatorを自作して独自ヘッダでコンテキストを取り回した場合の変化を記述しています。Cloud Trace側には壊れたトレースが生成されていますが、外部オブザーバビリティバックエンド側にはアプリケーションで生成されたスパンのみから成るピュアなトレースが完成していることがわかるかと思います。
Node.jsの実装例
Node.jsでPropagatorをカスタマイズするには、サーバー起動時に実行するOpenTelemetryの計装スクリプトに以下のような修正を加えます。@opentelemetry/api
は1.9.0のバージョンです。
サーバーにリクエストが入ってきたときにはextract
メソッドで親スパンのIDを取り出します。ヘンリーにおいてはNode.jsはGraphQLのBFFで利用しており、OpenTelemetryトレースにおけるルートのサービスになります。そのため、my-traceparent
からの引き継ぎ処理はしていません。代わりにブラウザからトレースIDを指定することでテストを容易にしたいため、my-traceparent-traceid
という独自のヘッダを設けています。これは単純なトレースIDのみをブラウザから指定したいためデータフォーマット自体も独自です。アーキテクチャ次第では、injectの実装を普通のtraceparentのフォーマットからの取得処理にしたり、ルートとなるサービスでは省略したりすることになるでしょう。
逆に、下流サービスにリクエストするときには、inject
メソッドが呼ばれます。ここではmy-traceparent
をW3C Trace Contextのフォーマットで設定しています。下流サービスでは、extract
でこのHTTPヘッダにアクセスし、同じフォーマットでトレースIDや親スパンIDを取り出すことになります。
my-traceparent-traceid
にせよmy-traceparentにせよ
、x-
のプリフィックスをつけていない理由は特にないのですが、なんとなくCloud Runに触られる可能性をなるべく減らしたいという気持ちが表れています。
initializeOpenTelemetry(); function initializeOpenTelemetry() { const provider = new NodeTracerProvider({ resource: Resource.default().merge( // ... ), sampler: new AlwaysOnSampler(), }); const exporter = new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, }); const processor = new BatchSpanProcessor(exporter, { // ... }); provider.addSpanProcessor(processor); provider.register({ propagator: new MyTraceContextPropagator(), }); registerInstrumentations({ // ... }); } class MyTraceContextPropagator { inject(context: Context, carrier: any, setter: TextMapSetter = defaultTextMapSetter) { const spanContext = trace.getSpanContext(context); if (!spanContext) return; const traceparent = `${spanContext.traceId}-${spanContext.spanId}-${spanContext.traceFlags.toString(16).padStart(2, "0")}`; setter.set(carrier, "my-traceparent", traceparent); } extract(context: Context, carrier: any, getter: TextMapGetter = defaultTextMapGetter) { const traceparentTraceId = getter.get(carrier, "my-traceparent-traceid") as string; if (!traceparentTraceId) return context; const match = traceparentTraceId.match(/([0-9a-f]{32})/); if (!match) return context; const traceId = match[1]; const spanContext = { traceId, spanId: generateRandomSpanId(), traceFlags: 1, isRemote: false }; return trace.setSpanContext(context, spanContext); } fields() { return ["my-traceparent"]; } }
JVM/Kotlinの実装例
JVMでJava Agentを用いてOpenTelemetryの自動計装を行っている場合、Propagatorをカスタマイズするには、SPIを利用します(参考:Configure the SDK | OpenTelemetry)。io.opentelemetry:opentelemetry-api
のバージョン1.42.0時点のものです。
まずresources/META-INF.services
配下に以下のようなio.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider
のファイル名を配置し、利用するPropagatorを返すProviderを記載します。パッケージ名などは適宜読み替えてください。
jp.myapp.opentelemetry.agent.MyTraceContextPropagatorProvider
Providerは上記に指定したファイル名で以下のように実装します。
public class MyTraceContextPropagatorProvider : ConfigurablePropagatorProvider { override fun getPropagator(config: ConfigProperties): TextMapPropagator { return MyTraceContextPropagator() } override fun getName(): String { return "mypropagator" } }
Propagatorは以下のようになります。サーバーにリクエストが入ってきたときにはextract
メソッドで親スパンのIDを取り出します。ヘンリーにおいてはJVMはBFFからリクエストを受け取る下流サービスにあたるため、my-traceparent
からトレースIDと親スパンIDを引き継ぐこの処理が必須になります。フォーマットはW3C Trace Context同様で、ヘッダ名だけが独自となっています。
逆に、下流サービスにリクエストするときには、inject
メソッドが呼ばれます。ここはNode.js版で紹介したものと全く挙動で、my-traceparent
をW3C Trace Contextのフォーマットで設定しています。下流サービスでは、extract
でこのHTTPヘッダにアクセスし、同じフォーマットでトレースIDや親スパンIDを取り出します。
public class MyTraceContextPropagator : TextMapPropagator { private val traceparentRegex = """([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})""".toRegex() override fun fields(): MutableCollection<String> { return mutableListOf("my-traceparent") } override fun <C : Any?> inject(context: Context, carrier: C?, setter: TextMapSetter<C>) { val spanContext = Span.fromContext(context).spanContext if (!spanContext.isValid) return val traceparent = "${spanContext.traceId}-${spanContext.spanId}-${spanContext.traceFlags.toString().toInt().toString(16).padStart(2, '0')}" setter.set(carrier, "my-traceparent", traceparent) } override fun <C : Any?> extract(context: Context, carrier: C?, getter: TextMapGetter<C>): Context { val traceparent = getter.get(carrier, "my-traceparent") ?: return context val match = traceparentRegex.find(traceparent) if (match == null || match.groupValues.size != 4) { return context } val traceId = match.groupValues[1] val spanId = match.groupValues[2] val traceFlags = match.groupValues[3].toInt(16) val spanContext = SpanContext.createFromRemoteParent(traceId, spanId, TraceFlags.fromByte(traceFlags.toByte()), TraceState.getDefault()) return context.with(Span.wrap(spanContext)) } // ... }
ヘンリーではこれらを別のjarファイルにまとめているため、サーバーを起動する際にjavaagentのextensionとして読み込んでいます。
exec java \ -cp ... \ -javaagent:/app/otel_agent.jar \ -Dotel.javaagent.extensions=/app/otel-agent-extension.jar \ ...
設計上のトレードオフ
このアーキテクチャのトレードオフとして、個別のマイクロサービスでPropagatorを実装しなければならない点に注意が必要です。せっかくOpenTelemetry Collectorを導入してシグナルの加工やオブザーバビリティバックエンドの選択をアプリケーションから切り離しているのに個別マイクロサービスに横断的関心事を実装してしまっている、という見方もあります。
これを回避するために思いつく方法としては、例えばサイドカーに下流サービスへのリクエストをプロキシさせ、そこで一度HTTPSを終端してtraceparentヘッダを独自ヘッダに書き換えさせたり、受け取ったリクエストの独自ヘッダをtraceparentに書き換える、といったアーキテクチャが考えられます(筆者は試していないためあくまで仮説です)。
とはいえ、そもそもトレースが途切れてしまうこと自体がCloud Run特有の事情であり、せっかくCloud Runでクラスタ運用を省力化しているのに高い費用を払ってサイドカーを使う必要があるだろうかと考えた結果、ヘンリーではこのような構成にしています。結局のところ個別自動計装のオンオフやOpenTelemetry Collectorへの送信の設定などは全てのアプリケーションに対して行っており、言語もNode.jsとJVMの2つだけでマイクロサービス数も少ないため、総じて大きな負債にはならないと考えました。
まとめ
この記事ではCloud Run + OpenTelemetry + 外部オブザーバビリティバックエンドでトレースが途切れる原因を解説し、それを避ける手段の1つとしてアプリケーション上でPropagatorを自前実装する方法を紹介しました。
ぜひトレースが途切れる現象に心当たりがあれば参考にしてください。