OpenTelemetry Instrumentation for Javaの自動トレースの仕組みの調査

執筆者 : 山下雅喜


はじめに

分散トレーシングを行うためのソフトウェアとして、最近は OpenTelemetry が流行してきているように感じます。 OpenTelemetry は CNCF の Incubating プロジェクトであり、OpenTracing や OpenCensus を統合したものとして活発に仕様策定や開発が行われています。

一方、実際に分散トレーシングを行うためには、トレースを出力する仕組みをアプリケーション自体に埋め込む必要があります。 Java アプリケーションの場合は 公式サイトの例 のようにコードを書く必要があり、既存のアプリケーションに埋め込むのは手間がかかるためできれば避けたい作業です。

そういった手間をかけなくても自動でトレースを出力できるようにするものとして、OpenTelemetry Instrumentation for Java というライブラリが用意されています。 このライブラリを利用すると、Java アプリケーションの実行時にこのライブラリと設定を指定するだけでトレースを出力できるようになるため、既存のアプリケーションを変更しなくて済むという大きな利点があります。

しかし、このライブラリのドキュメントには利用方法は書かれているものの、このライブラリがどのような仕組みでいつどんな内容のトレースを出力するのかについては詳しく説明されていません。 そこで、このライブラリを安心して利用するために、ソースコードを読み解いて調査を行ってみました。

前提事項

今回調査を行った対象は、OpenTelemetry Instrumentation for Java v1.7.2 (2021-11-04リリース) です。 本記事内では、基本的にこのバージョンのソースコードやドキュメントに対してリンクを張っています。

また、OpenTelemetry 自体の仕様については特に解説を行っていませんので、不明な点があれば OpenTelemetry Specification も併せてご参照ください。

お試し動作確認

調査に先立って、まずは OpenTelemetry Instrumentation for Java を利用すると分散トレーシングをどのように行えるか試してみました。

まず、分散トレーシング用のサーバとして、Jaeger 公式ドキュメントの Getting Started の記載に従い、Docker 上で All in One の Jaeger 環境を構築しました。

docker run -d --name jaeger \
  -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 \
  -p 5775:5775/udp \
  -p 6831:6831/udp \
  -p 6832:6832/udp \
  -p 5778:5778 \
  -p 16686:16686 \
  -p 14268:14268 \
  -p 14250:14250 \
  -p 9411:9411 \
  jaegertracing/all-in-one:1.28

次に、分散トレーシング対象のアプリケーションを用意します。 ここでは、次の内容の非常にシンプルな Spring Boot アプリケーションを用意し、ビルドして app.jar ファイルを作成しました。

package app;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

@RestController
class Controller {
    @GetMapping("/")
    public String index() {
        return "app";
    }
}

その後、OpenTelemetry Instrumentation for Java の README の Getting Started に記載されている方法を参考に、次のコマンドでアプリケーションを実行してみます。

java -javaagent:opentelemetry-javaagent-all.jar \
     -Dotel.traces.exporter=jaeger \
     -jar app.jar

上記コマンドでは、出力方法 (exporter) をデフォルトの OpenTelemetry Protocol から Jaeger に変更しています。 また、Jaeger exporter を利用する場合、出力先エンドポイントのデフォルト値は http://localhost:14250 となりますが、今回は Jaeger を動作させているマシン上でアプリケーションも実行しているため、エンドポイントを変更していません。

アプリケーションが無事に起動したことが確認できたら、curl コマンドで http://localhost:8080 にアクセスしてみます。

すると、Jaeger UI 上では2つの Span から成るトレースを表示できました。

f:id:ymstmsys:20211125171152p:plain

Span には OpenTelemetry の Semantic Convention に従った多数のタグも付与されていました。

f:id:ymstmsys:20211125171201p:plain

このように、アプリケーションにトレースを出力するコードを記述しなくても、OpenTelemetry Instrumentation for Java を利用すれば自動でトレースを出力でき、分散トレーシングを行えることが確認できました。 コマンドのパラメータは Agent Configuration に詳しく記載されていますので、利用に困ることもないと思います。

以降では、OpenTelemetry Instrumentation for Java がどういう仕組みでトレースを出力しているか調査・解説していきます。

仕組みの調査

javaagentとは

OpenTelemetry Instrumentation for Java を組み込んでアプリケーションを実行する際、java コマンドに -javaagent というパラメータを付けていました。

javaagent とは、java.lang.instrument パッケージで用意されている Java SE 標準の機能であり、JVM がクラスファイルをロードする前に動的にバイトコードを変更できるようにする仕組みです。 この機能は Java SE 認定資格試験におそらく出ていない内容なので知る人も少ないかもしれませんが、Eclipse 日本語化プラグインである Pleiades を手動インストールしたことがある人なら一度は目にしているかと思います。

OpenTelemetry Instrumentation for Java はバイトコード変更を用いることで、アプリケーションのコードや設定を何も変更することなく、トレースを出力できるようにしています。 java コマンドにパラメータを付けるだけで利用できるため非常に便利ではありますが、java.lang.instrument パッケージの説明に下記の注意事項が記載されているように、利用する javaagent のライブラリの内容や構成の信頼性を検証する必要があります。

Note: developers/admininstrators are responsible for verifying the trustworthiness of content and structure of the Java Agents they deploy, since those are able to arbitrarily transform the bytecode from other JAR files. Since that happens after the Jars containing the bytecode have been verified as trusted, the trustworthiness of a Java Agent can determine the trust towards the entire program.

そこで、ここから先は OpenTelemetry Instrumentation for Java のソースコードを読み解いて理解を深めることで、安心して利用できるようにしていきます。

バイトコード変更が行われている箇所

javaagent 機能により初めに呼び出される premain メソッドは OpenTelemetryAgent クラス内にあります。

premain メソッドから Java の ServiceLoader やリフレクションも使いながら、次のコールスタックの順にメソッドが実行されていきます。

  1. OpenTelemetryAgent#premain
  2. OpenTelemetryAgent#agentmain
  3. AgentInitializer#initialize
  4. AgentInstaller#installBytebuddyAgent
  5. InstrumentationLoader#extend
  6. InstrumentationModuleInstaller#install
  7. TypeInstrumentation#transform

上記のうち、特に5以降が重要です。

5のメソッドの28~42行目では、ServiceLoader で抽象クラス InstrumentationModule に合致する具象クラスのインスタンスのリストを取得し、そのインスタンスごとに for ループで処理を行っています。 具象クラスの一覧は opentelemetry-javaagent-all.jar ファイル内の inst/META-INF/services/io.opentelemetry.javaagent.extension.instrumentation.Instrumentation ファイルに記載されており、OpenTelemetry Instrumentation for Java がサポートするライブラリやフレームワークなど に応じて合計158個のクラスが用意されています。 この具象クラスが具体的にどのような実装となっているかは後述します。

また、6のメソッドの65~113行目では、先ほどの具象クラスのインスタンスの typeInstrumentations メソッドを呼び出し、インタフェースTypeInstrumentation のインスタンスのリストを取得し、そのインスタンスごとに for ループで transform メソッドを呼び出しています。 transform メソッドの内容はクラスごとに異なりますが、いずれも特定のクラス/メソッドに何らかの処理を追加するというバイトコードの変更に関する内容が実装されています。 この実装の内容についても後述します。

なお、バイトコードを変更するためのライブラリとして Byte Buddy が利用されており、このライブラリの存在を今回初めて知りました。 バイトコード変更用のライブラリとしては、Spring Aop で利用されている AspectJ や、JBoss に取り込まれた Javassist が昔からよく利用されていましたが、後発である Byte Buddy が採用されている理由は少し気になるところです。

バイトコードの変更内容

個々のライブラリやフレームワークなどのバイトコードをどのように変更するかは、InstrumentationModule インタフェースの個々の具象クラスおよびその周辺のクラスのソースコードを読み解く必要があります。 それらのソースコードは、GitHub上の /instrumentation ディレクトリ以下にあります。

ここでは、先述の Spring Boot アプリケーションで試した際に機能した、Tomcat7InstrumentationModule クラスを例に解説します。 なお、Contributor 向けの説明ページ Writing an InstrumentationModule step by step も読むと理解が早まります。

Tomcat7InstrumentationModuletypeInstrumentations メソッド(32~41行目)を見ると、TypeInstrumentation として TomcatServerHandlerInstrumentation のインスタンスを1つだけ返しています。

TomcatServerHandlerInstrumentation クラスの typeMatcher メソッド(29~32行目)は、Tomcat の CoyoteAdapter という名前のクラスに対してバイトコード変更を行うということを表しています。 また、同クラスの transform メソッド(34~51行目)は、次の2つのメソッドに対してアドバイスを適用するようなバイトコード変更を行うということを表しています。

  • 1つ目(36~42行目)
    • 次の条件を満たすメソッド
      • スコープが public
      • 名前が service
      • 0番目の引数の型が org.apache.coyote.Request
      • 1番目の引数の型が org.apache.coyote.Response
    • 適用するアドバイスは Tomcat7ServerHandlerAdvice クラス
  • 2つ目(44~50行目)
    • 次の条件を満たすメソッド
      • 名前が postParseRequest
      • 0番目の引数の型が org.apache.coyote.Request
      • 2番目の引数の型が org.apache.coyote.Response
      • 戻り値の型が boolean
    • 適用するアドバイスは Tomcat7AttachResponseAdvice クラス

CoyoteAdapter クラスの service メソッドは、Tomcat に到達したリクエストごとに実行されるメソッドです。 また、postParseRequest メソッドは、Tomcat がリクエストのHTTPヘッダの解析が終わった後に実行されるメソッドです。

さらにアドバイスクラス Tomcat7ServerHandlerAdvice を見てみると2つのメソッドがあり、各メソッドには Byte Buddy の次のアノテーションが付けられています。

  • @Advice.OnMethodEnter
    • バイトコード変更対象のメソッドの処理の直前に追加したいメソッドに付けるアノテーション
  • @Advice.OnMethodExit
    • バイトコード変更対象のメソッドの処理の直後に追加したいメソッドに付けるアノテーション

onEnter メソッドの32行目の内部を読み解いていくと、最終的に Instrumenter クラスの start メソッド内で Span が開始されています。 また、stopSpan の45行目の内部を読み解いていくと、同様に最終的に Instrumenter クラスの end メソッド内で Span が終了されています。

アドバイスクラス Tomcat7AttachResponseAdvice には1つだけメソッドがありますが、ServletRequest の Attribute に ServletResponse を格納するという処理が記述されているだけであり、分散トレーシングとは関係ない内容であると思います。

これらのことを総合すると、Tomcat に到達した全リクエストに対して1つの Span が自動的に作られることがわかりました。 また、OpenTelemetry Instrumentation for Java がサポートするライブラリやフレームワークのどのクラス・メソッドを利用したときにトレースが出力されるのかについて、各 InstrumentationModule のソースコードを見れば把握できることもわかりました。

アプリケーション間のコンテキスト伝播

分散トレーシングを行う上で、複数のアプリケーションの Span を関連付けるためにコンテキスト伝播 (Context Propagation) を適切に行う必要があります。 通常コンテキスト伝播は、伝播元アプリケーションがHTTPリクエストのヘッダにコンテキストを注入し、伝播先アプリケーションがヘッダからコンテキストを抽出することにより実現されます。 このようなことも、OpenTelemetry Instrumentation for Java により自動的に行えます。

前項の通り各 InstrumentationModule は Instrumener クラスを用いて Span の開始や終了を行いますが、Instrumenter クラスには次の2つのサブクラスが用意されています。

  • ClientInstrumenter クラス
    • Span を開始した後、リクエストヘッダにコンテキストを注入する。
    • SpanKind は Client または Producer に相当
  • ServerInstrumenter クラス
    • リクエストヘッダからコンテキストを抽出した後、そのコンテキストで Span を開始する。
    • SpanKind は Server または Consumer に相当

各 InstrumentationModule がどの Instrumenter を利用するかは、InstrumentationModule ごとに異なります。

Tomcat7InstrumentationModule の場合、TomcatInstrumenterFactory クラスにて ServerInstrumenter が作られており、コンテキストを受け取れるようになっていることがわかります。

また、Java SE 標準のHTTPクライアントである HttpURLConnection クラス用の HttpUrlConnectionInstrumentationModule の場合、HttpUrlConnectionSingletons クラスにて ClientInstrumenter が作られており、リクエストヘッダにコンテキストを自動的に含めるようになっていることがわかります。

冒頭の Spring Boot アプリケーションでも実は利用されていた SpringWebMvcInstrumentationModule の場合、SpringWebMvcSingletons クラスにて単なる Instrumenter (SpanKind は Internal に相当) が作られており、コンテキスト伝播に関与しないことがわかります。

OpenTelemetry Instrumentation for Java がサポートするライブラリやフレームワークごとにコンテキスト伝播が行われるのかどうかについて、各 InstrumentationModule のソースコードを見れば容易に把握できることがわかりました。

Spanに設定される名前

Jaeger などのWeb画面上でトレースデータを参照する際、Span の名前を見ることで何のトレースデータなのか識別するかと思います。 冒頭の分散トレーシングの画面例の場合だと、"/" や "Controller.index" と表示されている値です。

Span 名を生成するための仕組みとして SpanNameExtractor インタフェースがあり、Instrumenter クラスの130行目で Span を生成する際にこのインタフェースの extract メソッドが呼び出されて Span 名が生成されています。 SpanNameExtractor のどの実装クラスを用いるのかは InstrumentationModule ごとに異なります。

Tomcat7InstrumentationModule の場合、TomcatInstrumenterFactory クラスにて、HttpSpanNameExtractor クラスが利用されています。 このクラスのソースコードを読むと、extract メソッドは "HTTP GET" (GETリクエストの場合) のような文字列が返り、これが Span 名となるはずです。 しかし、実際はリクエストのパスが Span 名となっているため、どうもソースコードを読み違えているようです…。

冒頭の Spring Boot アプリケーションで利用されていた SpringWebMvcInstrumentationModule の場合、SpringWebMvcSingletons の23行目にて HandlerSpanNameExtractor クラスが生成されて利用されています。 このクラスのソースコードを読み解くと、Span 名は "{Controllerクラス名}.{メソッド名}" という形式の文字列となり、こちらは冒頭の例と合っていました。

自動的に作られる Span の名前がどのように生成されるのかについて、各 InstrumentationModule のソースコードを読み解けば把握できそうですが、残念ながら読んでもわからないものもありました…。 リモートデバッグも上手く行えず、諦めてしまいました。

Spanに追加されるタグ

冒頭の分散トレーシングの例で見た通り、自動的に生成された Span には多数のタグが付与されていました。 OpenTelemetry SDK 標準の仕組み (ResourceProvider インタフェースおよびその実装クラス) により、ホスト名やアーキテクチャ名、OS種別、プロセス情報などがタグとして付与されています。 それらのソースコードは OpenTelemetry SDK のGitHub上の /sdk-extensions/resources ディレクトリ以下にあり、具体的なタグ名や値を把握できます。

これ以外にも、OpenTelemetry Instrumentation for Java には、Span にタグを自動的に付与するための仕組みとして、AttributesExtractor というインタフェースと数十個の実装クラスが用意されています。

どの AttributesExtractor を用いて Span にタグを付与するのかは、やはり InstrumentationModule ごとに異なります。

Tomcat7InstrumentationModule の場合、TomcatInstrumenterFactory クラスにて、次の3つの AttributesExtractor の実装クラスのインスタンスが生成され、Instrumenter で利用されています。

AttributesExtractor の実装のほとんどはGitHub上の /instrumentation-api ディレクトリ以下にあり、タグ名の定数クラス SemanticAttributes と併せて読むと、各 AttributesExtractor の実装クラスがどのようなタグを付与するものなのか把握できます。

未調査項目

OpenTelemetry Instrumentation for Java のカスタマイズ

自動で生成される Span の項目を少し変更したり、独自のライブラリやフレームワークに対する InstrumentationModule を追加したり、といったカスタマイズをしたい場合があるかと思います。

そのような場合、OpenTelemetry Instrumentation for Java のソースコードを変更・ビルドして opentelemetry-javaagent-all.jar ファイルを生成すれば、おそらく実現できそうです。 特に、新しく InstrumentationModule を作るためのドキュメント Writing an InstrumentationModule step by step と既に多数実装されているソースコード類を一緒に見れば、十分可能であると考えられます。

WARやEARアプリケーションへの適用

冒頭の例では Spring Boot によるJARアプリケーションに OpenTelemetry Instrumentation for Java を適用していましたが、Jakarta EE (旧Java EE) サーバにデプロイするWARやEARアプリケーションにも適用したい場合があるかと思います。

Jakarta EE サーバ自体もJavaで動作しているでしょうから、その起動パラメータに javaagent の設定を加えれば、Jakarta EE サーバやその上で動作するWARやEARアプリケーションにも適用されるのではないでしょうか。 ただし、javaagent の仕組み上、特定のWARやEARアプリケーションにのみ適用するといったことはおそらくできないものと推測しています。

まとめ

本記事では OpenTelemetry Instrumentation for Java のソースコードを読み解いて調査を行いました。 その結果、このライブラリはJavaのバイトコード変更の仕組みを用いてトレースを出力していることや、その仕組みについて概ね把握できました。 また、いつどんな内容のトレースを出力するのかについては、サポートするライブラリやフレームワークごとに用意されたクラスを起点として、どのソースコードをどう読めば把握できそうか明らかにしました。 実際のアプリケーションに適用してトレースを出力した後、実際に出力された内容をもとにソースコードを読めば、効率良く理解を行えそうです。

ただし、本記事の冒頭で掲げた OpenTelemetry Instrumentation for Java を 『安心』 して利用できるようにするという目標については、実はまだ達成できていないと考えています。 このライブラリを完全に理解したわけではないため、サードパーティのライブラリによってバイトコード変更が行われることによって、アプリケーションが正しく動作しなくなってしまわないか不安が残っています。

自身のアプリケーションに分散トレーシングを組み込むのであれば、OpenTelemetry SDK を用いて明示的に実装するほうが安心です。 また、アプリケーションのフレームワークに分散トレーシングを行う仕組みが備わっているならそれを用いる手もあり、例に挙げた Spring Boot アプリケーションに組み込むのであれば Spring 公式の Spring Cloud Sleuth を用いるのが良いでしょう。

最後に個人的な感想ですが、このライブラリのソースコードは過度な抽象化と分割が行われており、読み解くのが難解でした。 デバッグツールを使って動かしながら解析できれば理解しやすくなりそうなので、Contributor向けドキュメントの デバッグ方法 を上手く使いこなせるようになりたいです。