Cloud Functions(Kotlin/JVM)をOpenTelemetry手動計装する

目次

sumirenです。

VP of オブザーバビリティ(自称)をやっている組織で、いままでPub/Subから呼ばれるCloud Functions(第2世代)はOpenTelemetry計装していませんでした。

しかし、そのうちの1つで最近障害が多発しており、特に同じPub/Subに再度publishしなおすというアクロバティックな動きをしてる部分があることで、リトライが絡んでメッセージが無限増殖して爆散しかける事故などが起きていたため、計装することにしました。

課題:Cloud Functions x JVMだとOpenTelemetry自動計装が使いづらい

OpenTelemetry Javaは自動計装に対応しており、javaコマンド実行時に以下のようなコマンドでjavaagentを指定することで、アプリケーションコードベースへの影響を出さずに計装可能です(参考)。これが成熟していることから、公式でもJavaの場合にはjava agent利用が強く推奨されています。

exec java \
  -cp ... \
  -javaagent:/app/otel_agent.jar \
  ...

一方で、Cloud Functionsは以下のように–sourceにjarやソースコードを指定してデプロイします。このコマンドベースだと余分なファイルをworkspaceにデプロイしておく方法は、筆者が調べた限り見つかりませんでした。したがって、javaagentをオプションで指定したくても、そもそもエージェントのファイルをCloud Functions上に配置できず、実行時に参照できません(方法知ってる方がいたら後学のためにご教示ください)。

gcloud functions deploy jar-example \
    --gen2 \
    --entry-point=Example \
    --runtime=java21 \
    --region=REGION \
    --trigger-http \
    --source=target/all.jar

Kotlin(JVM)で手動計装する

以前、Goを例に手動計装全般について観念を体系化する記事を書きました。よければこちらも参照ください。

上記記事では、作業の全体像は大きく2つ、OpenTelemetry SDKのセットアップと個別の計装に分けられる説明しました。また、後者の計装については、一口に手動計装といっても3つのパターンがあると提唱しました。

  • 作業1. OpenTelemetry SDKのセットアップ
  • 作業2. (個別処理ごとに)計装
    • ライブラリがOpenTelemetry対応、少ない箇所の修正で対応できるパターン
    • ライブラリがOpenTelemetry対応、修正が広範囲になるパターン
    • ライブラリがOpenTelemetryに未対応パターン

この記事でも上記の順番に解説します。なお、記事の意図としてはあくまで指針を示す意図のため、コードは公式ドキュメントやGitHubをリファレンスとして示すにとどめます。

作業1. OpenTelemetry SDKのセットアップ

公式が自動計装を推しているため、ドキュメント上ではサンプルがぱっと見つかりませんでした。そのためGitHubベースで紹介していきます。

まず以下を参考に依存を追加します。このあたりは、後続の計装でライブラリ計装を利用する場合には付け足していく形になります。 https://github.com/open-telemetry/opentelemetry-java-examples/blob/main/http/build.gradle.kts

以下のコードを参考に、エントリポイント周辺でSDKをセットアップし、またシャットダウン処理を追加します。GlobalOpenTelemetryというのが便利に使えるので、SDKセットアップ時にbuildAndRegisterGlobal()しておくと良いと思います。GlobalOpenTelemetryに依存するよりDIでOpenTelemetryインスタンスを差し込みたい場合は、DIコンテナに登録しておきます。

https://github.com/open-telemetry/opentelemetry-java-examples/blob/main/http/src/main/java/io/opentelemetry/example/http/ExampleConfiguration.java

以下、Tips的によく使いそうなクラスを紹介しておきます。

  • Resource.getDefault().merge(Resource.create(Attributes.builder().put("service.name", serviceName)...build()))
  • OtlpHttpSpanExporter.builder().setCompression("gzip").setEndpoint(traceEndpoint).build()
    • javaagentにおけるPROTOCOL=http/protobufと同等
    • OtlpGrpcSpanExporterもいます
    • 同様にxxMetricsExporterもいます

筆者と同様にサーバレスのユースケースの方は、BatchProcessor類を使う場合にシャットダウン処理でforceFlushしておくと良いと思います。あえて検証したわけではありませんが、体感ではこれを抜いてた時期はスパンがロストしたように思います。以下はトレースのforceFlushの例です。

openTelemetry.sdkTracerProvider.forceFlush()
openTelemetry.shutdown()

作業2. 個別処理ごとに計装

この記事はあくまでOpenTelemetryの導入・Getting Startedが中心となるため、ドメインロジックやアプリケーション固有の情報の計装はスコープに含めません。IO中心で解説します。

javaagentによるバイトコードへの干渉と異なり、手動計装はライブラリなどの処理にDecoratorをかませるというシンプルな仕組みのため、IOパターンに対応すればするほど大変になります。例えばPub/Sub一つとっても、Pub/Subクライアントクラス専用のTelemetryラッパーを依存にインストールして噛ませたり、またはPub/Subクライアント生成時のフックでgRPCクライアントに対してTelemetryラッパーを噛ませるなどが必要になります。

そうした手間に加え、今回は導入後のドメインロジック手動計装が最重要であったことから、IO周りの手動計装は最小限としました。

1. rootスパンの生成

まず最も重要なのがrootスパンの生成です。Webサーバーのフレームワークを使っていたりする場合には、おそらくここは「ライブラリがOpenTelemetry対応、少ない箇所の修正で対応できるパターン」にあたりますが、今回はCloud Functionsランタイムから独自の形式で呼び出されるクラスのため、手動で生成する必要があります。これを忘れると、DBなどが生成したスパンが共通の親を持たずバラバラに散らばることになります。以下のコードが参考になります。

https://github.com/open-telemetry/opentelemetry-java-examples/blob/main/http/src/main/java/io/opentelemetry/example/http/HttpServer.java#L57

あとはCloud Functionsが受け取ったHTTPリクエストのURLやメソッド、Pub/Subなどクライアント固有のHTTPヘッダを適宜スパンに追加するとよいでしょう。

2. RDBの計装

ここから先は計装するIO次第ですので、あくまで雰囲気を掴むための参考としていただければと思います。事例として、RDBとPub/Subの例を紹介します。

ORMはExposedを使っており、DBの計装は、DataSourceJdbcTelemetryでラップするだけで、「ライブラリがOpenTelemetry対応、少ない箇所の修正で対応できるパターン」に該当していました。DataSourceの作成処理はDIコンテナに登録されていたため、そこを修正するだけで済みました。

io.opentelemetry.instrumentation:opentelemetry-jdbcを依存として追加します。その後、Koinであれば、まず以下のようにJdbcTelemetryを登録します(OpenTelemetryが登録されていることが前提です)。

single<JdbcTelemetry> {
    JdbcTelemetry.create(get())
}

その上で、DataSourceインスタンスを生成している箇所でJdbcTelemetryにラップさせます

val dataSource = get<JdbcTelemetry>().wrap(hikariDataSource)

3. Pub/Subクライアントの計装

Pub/Subクライアントについては、ライブラリ計装は行わず手動計装しました。

というのも、コードベース独自の都合で、Pub/SubクライアントをCloud Functionsで動くスクリプトとCloud Runで動くWebサーバーで共有しており、Pub/Subクライアントの生成処理にライブラリ計装を入れると、Cloud Run側のライブラリレイヤと通信レイヤで二重にスパンが出る懸念があったためです。

Pub/Subへのpublishに関しては、取りたいメタデータもむしろドメインロジック的な部分のほうが大きかったため、依存を足してまで広範囲を修正するより、シンプルにCloud Functions側にしかないPub/Subクライアントの呼び出し側で手動計装したほうが速いと判断しました。

内容はrootスパンを生成するときとほとんど同じため、コードのリファレンスは割愛します。OpenTelemetryインスタンスをDIするかGlobalOpenTelemetryを参照してpublish処理を囲む形でスパンを生成し、そのスパンに属性を追加するだけとなります。

まとめ

OpenTelemetry Javaは自動計装が強いがゆえに、あまり手動計装のリファレンスが充実していません。少し手こづりましたが、エコシステムにおけるDIの慣習なども幸いし、最終的には少ない差分で実現できました。

とはいえ、やはり手動計装ではIOのパターンごとに計装が必要になりハイコストです。今回も、例えばGoogle Cloud内でのIAM関連の通信などはスパンにあらわれていません。自動計装であれば通信レイヤで取れるためHTTP計装やgRPCを有効化して終わりですが、手動計装ではその手前のライブラリごとにひとつずつ独自ラッパーの適用や手動計装が必要になります。なるべく自動計装を使えるように粘りたいところです。

こうして振り返ると、昨年書いた手動計装整理した記事はかなり役にたったように思います。ぜひあわせて参照してみてください。

宣伝

オブザーバビリティのような横断的なエンジニアリング機能は、どのようなエンジニアリング企業においても必要です。 このたび、こうした支援に特化した「株式会社Reminus」を創業しました。 https://www.reminus.co.jp/

スタートアップ様中心に、ハイレベルな組織であればオブザーバビリティなど専門領域特化で飛躍させる支援を、未成熟な組織であれば社外CTOといった全方位的な支援を、状況に応じて柔軟に提供しています。 少しでも話を聞いていただけるようでしたら、ぜひお問い合わせください。

蛇足:せっかく問い合わせフォームをServer ActionsとuseActionStateで実装したのに、全てsumirenのXにDMで来ており複雑な気持ちになっているので、問い合わせフォームを試していただけたら10%お値引きさせていただきます。