はじめに
OpenCensus APIとJavaで分散トレーシングを実装してみました。OpenCensusはGoogleのCloud Trace(旧 StackDriver Trace)をベースとした仕様で分散トレースとメトリック収集の仕様です。
こちらを使うことでPG側は統一したAPIを利用しながらバックエンドをJeagerやOpenZipkinあるいはCloud Traceなどに連携できます。
実はOpenCensusは既に古く、似た様な仕様のOpenTracingと統合する形で現在はOpenTelemetryとして標準化されています。こちらはDatadog, NewRelic, Dynatrace, Instanaといった商用のAPMも含めて対応しているので今後はこちらを使うべきです。
なので、最初はOpenTelemetryを弄ってたのですがJeagerには連携できたもののCloud TraceではJava版はまだ未対応だったので、とりあえずドキュメントの多そうなOpenCensusを試してみました。
ただ、思ったほどドキュメントが整理されてなかったので備忘をかねてまとめてみます。
今回、作成したコードは以下にあります。
https://github.com/koduki/miniban/tree/example/opensensus
システム構成
システム構成としてはシンプルにapi-endpoint
でリクエストを受け取り、ビジネスロジックを持つバックエンドのapi-core
に処理をREST/JSONで投げています。
マイクロサービスというほど細分化はされてないけど、これはこれで良くある構成だし分散トレーシングの検証としては十分かと思います。
各APIはQuarkusを使ってJAX-RSで実装しています。MicroProfile特有のAPIも基本的には使ってないのでJavaEE環境ならどれでもそのまま動くはずです。
実装
依存ライブラリ
依存ライブラリとしてpom.xmlに以下を追加します。
<dependency>
<groupId>io.opencensus</groupId>
<artifactId>opencensus-api</artifactId>
<version>0.26.0</version>
</dependency>
<dependency>
<groupId>io.opencensus</groupId>
<artifactId>opencensus-impl</artifactId>
<version>0.26.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.opencensus</groupId>
<artifactId>opencensus-exporter-trace-stackdriver</artifactId>
<version>0.26.0</version>
</dependency>
<dependency>
<groupId>io.opencensus</groupId>
<artifactId>opencensus-exporter-trace-jaeger</artifactId>
<version>0.26.0</version>
</dependency>
<dependency>
<groupId>io.opencensus</groupId>
<artifactId>opencensus-contrib-http-jaxrs</artifactId>
<version>0.26.0</version>
</dependency>
APIとして必要なのはopencensus-apiとopencensus-impl。opencensus-exporter-xxxはトレース情報を送信するexpoter各種のライブラリ、opencensus-contrib-http-jaxrs
はJAX-RS向けの便利ライブラリです。
今回は、stackdriverとjaegerの2つのエクスポーターを指定していますが普通はどれか一つになります。
Exporterの初期化
まずはExporterの初期化と登録を行います。これは一度だけ行えば良いので@Initialized(ApplicationScoped.class)
を使って起動時に読み込んでしまいます。
@ApplicationScoped
public class Bootstrap {
public void handle(@Observes @Initialized(ApplicationScoped.class) Object event) {
JaegerTraceExporter.createAndRegister(
JaegerExporterConfiguration.builder()
.setThriftEndpoint("http://localhost:14268/api/traces")
.setServiceName("api-endpoint")
.build());
}
}
Quick Startでは直接URLをcreateAndRegister
の引数にとっていますがこれは既に非推奨コードのようなのでJaegerExporterConfiguration
を使います。
あとは見ての通りですがsetThriftEndpoint
で連携先のURLを指定し、setServiceName
でJaeger上のサービス名となります。
このExporterの登録部分さえ変更してしまえば、バックエンドを任意に切り替えれます。たとえば、Cloud Traceを使いたい場合は以下の様に変更します。
@ApplicationScoped
public class Bootstrap {
public void handle(@Observes @Initialized(ApplicationScoped.class) Object event) {
String gcpProjectId = "your GCP project ID";
StackdriverTraceExporter.createAndRegister(
StackdriverTraceConfiguration.builder()
.setProjectId(gcpProjectId)
.build());
}
}
Trace Spanの作成
つづいてSpanの作成です。
try (Scope ss = Tracing.getTracer()
.spanBuilder("Span Name")
.setRecordEvents(true)
.setSampler(Samplers.alwaysSample())
.startScopedSpan()) {
// do somthing.
}
Tracing.getTracer()
はシングルトンつまりグローバルからトレーサーを取得します。そこからspanBuilder
を使ってSpanを組み立てます。
ポイントはsetSampler
です。サンプラはトレースを取得する頻度を指定します。ここでSamplers.alwaysSample()
を指定しないとトレース情報が常に書き込まれません。本番では適切な閾値を費用や負荷の面から付けるケースもあると思いますが、テスト時は負荷テスト以外ではリクエストが少な過ぎるので必ずalwaysを指定しましょう。
そのほかのサンプラの詳細はこちらにあります。
ちなみに毎回上記を書くのは面倒なので以下の様なヘルパーソメッドを定義してみました。
public static <R> R trace(Supplier<R> callback) {
var depth = 2;
var className = Thread.currentThread().getStackTrace()[depth].getClassName();
var methodName = Thread.currentThread().getStackTrace()[depth].getMethodName();
try (var ss = Tracing.getTracer()
.spanBuilder(className + "$" + methodName)
.setRecordEvents(true)
.setSampler(Samplers.alwaysSample())
.startScopedSpan()) {
return callback.get();
}
}
クラス名とメソッド名を取得してSpan名に自動で指定しています。利用する時は下記の様な感じです。
@GET
@Path("/{userId}/balance")
public Map<String, Long> getBalance(@PathParam("userId") String userId) {
return trace(() -> {
var balance = service.getBalance(userId);
return balance;
});
}
Trace Contextの伝播
同じアプリケーション内であれば上記のようにstartScopedSpan
をするだけで勝手に入れ子のSpanが作成されます。
ただし、分散トレーシングの肝はシステム間連携です。そのためリモートのコンテキストを連携してやる必要があります。ただ、このあたりからドキュメントが古かったりちゃんと書かれてなくて試行錯誤での取り組みになりました。
traceparent
OpenCensusでは元々はX-B3-TraceId/X-B3-ParentSpanIdといったヘッダー情報を使ってコンテキストを渡してたようなのですが、現在はW3Cで標準化されたTrace Contextが使われています。
これはtraceparent
というパラメータをHTTPヘッダに埋め込んでそれを元に「Trace ID」「Span ID」「Trace Options(trace-flags)」を組み立てます。
traceparentは以下の様な値です。
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
OpenCensusでtraceparentを解析してSpanContextを作成するのは以下の様なコードになります。
int VERSION_SIZE = 2;
int TRACEPARENT_DELIMITER_SIZE = 1;
int TRACE_ID_HEX_SIZE = 2 * TraceId.SIZE;
int SPAN_ID_HEX_SIZE = 2 * SpanId.SIZE;
int TRACE_ID_OFFSET = VERSION_SIZE + TRACEPARENT_DELIMITER_SIZE;
int SPAN_ID_OFFSET = TRACE_ID_OFFSET + TRACE_ID_HEX_SIZE + TRACEPARENT_DELIMITER_SIZE;
int TRACE_OPTION_OFFSET = SPAN_ID_OFFSET + SPAN_ID_HEX_SIZE + TRACEPARENT_DELIMITER_SIZE;
// Get tranceparent
String traceparent = headers.getRequestHeaders().getFirst("traceparent");
// Parse traceparent
TraceId traceId = TraceId.fromLowerBase16(traceparent, TRACE_ID_OFFSET);
SpanId spanId = SpanId.fromLowerBase16(traceparent, SPAN_ID_OFFSET);
TraceOptions traceOptions = TraceOptions.fromLowerBase16(traceparent, TRACE_OPTION_OFFSET);
// traceparentからSpanContextの作成
SpanContext spanContext = SpanContext.create(traceId, spanId, traceOptions);
JAX-RSクライアント(REST Call時にtraceparentを付与)
コンテキストをリモートのAPIに伝えるにはREST Call時にtraceparentをHTTPヘッダ等に付与してやる必要があります。
手動でやっても良いですがJAX-RS向けのOpenCensusライブラリの「opencensus-contrib-http-jaxrs」を利用します。
var url = 'http://localhost:5000';
var target = ClientBuilder.newClient()
.target(url)
.path("/account/" + userId + "/balance");
target.register(JaxrsClientFilter.class);
return target
.request(MediaType.APPLICATION_JSON)
.get(new GenericType<Map<String, Long>>() {});
target.register(JaxrsClientFilter.class)
でJaxrsClientFilterを登録することで自動的にリクエストヘッダーに現状のSpanからtraceparentを生成して付与してくれます。
JAX-RSクライアント(traceparentからSpanの作成)
JAX-RSのサーバ側、今回でいうapi-coreの実装です。
opencensus-contrib-http-jaxrsのContainer Filterを使うことで自動で設定できる様なのですが、なぜかうまく動かなかったので自前で実装します。
private static final TextFormat textFormat = Tracing.getPropagationComponent().getTraceContextFormat();
private static final TextFormat.Getter<HttpServletRequest> getter = new TextFormat.Getter<HttpServletRequest>() {
@Override
public String get(HttpServletRequest httpRequest, String s) {
return httpRequest.getHeader(s);
}
};
@GET
@Path("/{userId}/balance")
public Map<String, Long> getBalance(@Context HttpServletRequest request, @PathParam("userId") String userId) throws SpanContextParseException {
var spanContext = textFormat.extract(request, getter);
var depth = 1;
var className = Thread.currentThread().getStackTrace()[depth].getClassName();
var methodName = Thread.currentThread().getStackTrace()[depth].getMethodName();
try (var ss = Tracing.getTracer()
.spanBuilderWithRemoteParent(className + "$" + methodName, spanContext)
.setRecordEvents(true)
.setSampler(Samplers.alwaysSample())
.startScopedSpan()) {
var balance = service.getBalance(userId);
return balance;
}
}
TextFormat
クラスを活用することで先ほどの面倒なパースを自前でせずにSpanContext
を作成できます。
また、通常のSpan作成とは違いリモートのコンテキストからスパンを作る場合はspanBuilderWithRemoteParent
を使用します。
以降の子Spanを作る時はspanBuilder
で問題ありません。
以上で、JavaでOpenCensusを使った分散トレーシングの設定は完了なので、実際に動かしてJaegerやCloud Traceに連携されているか確認してください。
まとめ
JavaでOpenCensusを使った分散トレーシングを実装してみました。
正直、パッと見ドキュメントあるし有名な仕様だからブログ記事とかも多いだろうから1時間と少しくらいで楽勝だろっと思ってたら沼にハマり1日くらい掛かってしまいました。
ドキュメント不足とか不備をなんとかしたい気もしますが、きっとその労力はOpenTelemetryに使われるべきなので、
あと、細かいところが書いてないのは基本的にはそもそも分散トレースに詳しい一部の人がライブラリやFWを設計してあとは使うだけってコンセプトだからな気はします。MPのOpenTracingとかOpenTelemetryのOpenTelemetry Auto-Instrumentation for Javaは良く知らなくても触れますしね。
まあ、結果として今回いろいろ詳しくなれたと思うのでそれはそれで良かったかな。OpenTelemetryもそのうち再チャレンジしないと。
それではHappy Hacking!