この記事は、Gaudiyが実施している「Gaudiy Advent Calendar 2022」の24日目の記事です。
はじめまして。Gaudiyでエンジニアをしているあんどう(@Andoobomber)です。
GaudiyではKotlin, Go, Node.jsでのBE開発をしており、前職ではKotlin×Spring BootでC2Cサービスの開発をしていました。
先日、↓の記事でも伝えたように、GaudiyではマイクロサービスにおけるObservabilityの課題に対して、OpenTelemetryの導入を行いました。そこで自分は、Kotlin(Ktor)環境への導入を担当しました。
その中で、実際のアプリケーションでの導入事例や、特定のAPMへのExport方法に関してはまだ情報が少ないかなと感じたので、今回この記事を書くことにしました。
同じような技術スタックの方や、OpenTelemetryを実際に導入しようと考えている方の参考になればと思います。
- 1. OpenTelemetryって何?
- 1-2. 計装方法
- 2. Kotlin(Ktor)にOpenTelemetryを導入する
- 3. Google Cloud Traceに出力する
- 4. まとめ
- 5. 参考文献
1. OpenTelemetryって何?
OpenTelemetryとは、テレメトリーデータ(ログ、メトリクス、トレース等)の計装と送信の標準仕様、及びそのライブラリーとエージェントを提供するプロジェクトです。
これだけ聞いてもなんのこっちゃわからんと思いますが、具体的には以下のイメージです。
1-1-1. ログ
アプリケーションに発生したイベントを記録するテキスト。ロガーライブラリによって吐かれるわりとよく見るやつ。
1-1-2. メトリクス
一定期間に測定された数値。 例: アクセス数、レイテンシ、CPU使用率
1-1-3. トレース
依存関係のあるシステム間で、リクエストのフローをエンドツーエンドで表すもの。
OpenTelemetryはこれらを標準仕様化&SDKを言語毎に用意してくれるので、各マイクロサービスに導入することでアプリケーションの健全性を監視したり分析することが容易にできるようになります。
1-2. 計装方法
計装には、手動計装(Manual Instrumentation)と自動計装(Auto Instrumentation)の2パターンがあります。
手動計装
- 自前で実装する必要がある
- 柔軟な計装ができる
自動計装
- 自前で実装する必要がない
- 使用ライブラリに合わせてInstrumentationをinstallする
- https://opentelemetry.io/registry/
今回は両方の計装を試してみたので、その実装方法について書きたいと思います。
2. Kotlin(Ktor)にOpenTelemetryを導入する
前置きが長くなりましたが、ここからが本題です。
今回検証したのは主にTraceになるのですが、一応Metricsも導入しています。 また、前提情報としてGaudiyのKotlinサーバーではFWにKtor, DIコンテナにKoinを利用しています。
まず、手動計装(Manual Instrumentation)の実装からです。
2-1. 手動計装
2-1-1. 依存関係のダウンロード
// build.gradle implementation platform('io.opentelemetry:opentelemetry-bom:1.21.0') implementation 'io.opentelemetry:opentelemetry-api' implementation 'io.opentelemetry:opentelemetry-sdk' implementation 'io.opentelemetry:opentelemetry-exporter-jaeger' implementation 'io.opentelemetry.instrumentation:opentelemetry-ktor-2.0:1.21.0-alpha' implementation 'com.google.cloud.opentelemetry:exporter-trace:0.23.0' implementation 'com.google.cloud.opentelemetry:exporter-metrics:0.23.0' implementation 'com.google.cloud.opentelemetry:propagators-gcp:0.23.0-alpha'
GaudiyではFWとしてKtorを利用しているので、Ktorのinstrumentationを入れています。 jaegerを入れているのはローカル確認用になります。
2-1-2. jaeger用のdocker-compose.ymlを作成
// docker-compose.yml version: '3.2' services: jaeger: image: jaegertracing/all-in-one:latest ports: - "5775:5775/udp" - "6831:6831/udp" - "6832:6832/udp" - "5778:5778" - "16686:16686" - "14268:14268" - "14250:14250" - "9411:9411"
2-1-3. コード書く
OpenTelemetryをアプリケーション導入する処理を書きます。
// SetOpenTelemetry.kt fun Application.setOpenTelemetry(env: Env): OpenTelemetry { // Resourceの設定. // https://opentelemetry.io/docs/reference/specification/resource/sdk/ val resource: Resource = Resource.getDefault() .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "${SERVICE_NAME}"))) // PropagatorはW3C Trace ContextとGCP Propagator両方利用しています. val propagators = ContextPropagators.create( TextMapPropagator.composite( XCloudTraceContextPropagator(false), W3CTraceContextPropagator.getInstance(), ), ) // otel build. val openTelemetry = OpenTelemetrySdk.builder() .setTracerProvider(createTraceProvider(env, resource)) .apply { if (!env.isLocal) createMeterProvider(resource) } .setPropagators(propagators) .build() // ktor instrumentationのinstall. install(KtorServerTracing) { setOpenTelemetry(openTelemetry) } return openTelemetry } // TraceProviderの作成 fun createTraceProvider(env: Env, resource: Resource): SdkTracerProvider { // localはjaeger, その他環境ではGCPへExportするように設定 val traceExporter = if (env.isLocal) { JaegerGrpcSpanExporter.builder().setEndpoint("http://localhost:14250/api/traces").build() } else { TraceExporter.createWithDefaultConfiguration() } return SdkTracerProvider.builder() .addSpanProcessor(SimpleSpanProcessor.create(traceExporter)) .setResource(resource) .build() } // MeterProviderの作成 fun createMeterProvider(resource: Resource): SdkMeterProvider { val metricExporter = GoogleCloudMetricExporter.createWithDefaultConfiguration() return SdkMeterProvider.builder() .registerMetricReader( PeriodicMetricReader.builder(metricExporter) .setInterval(Duration.ofSeconds(30)) .build() ) .setResource(resource) .build() }
計装クラス(SampleService)にTracerを生成し、処理の間にspanを生成・記録します。
// SampleService.kt class SampleService(private val repository: SampleRepository, openTelemetry: OpenTelemetry) { private val tracer: Tracer = openTelemetry.getTracer("SampleService") fun execute() { // start span. val span = tracer.spanBuilder("execute").startSpan() val data = repository.get() println(data) // end span. span.end() } }
SampleServiceをエンドポイントを叩いて呼び出すようにします。
// SampleRouting.kt fun Route.sampleRouting(sampleService: SampleService) { route("/sample") { get { sampleService.execute() call.respondText(LocalDateTime.now().toString(), status = HttpStatusCode.OK) } } }
最後に、作成したOpenTelemetryやSampleService等をシングルトン化し必要なクラスに渡します。
// Application.kt fun Application.module() { ... // Env is original config class. val env = Env.of(environment) val openTelemetry = setOpenTelemetry(env) install(Koin) { module( org.koin.dsl.module(createdAtStart = true) { single { openTelemetry } single { SampleService(get(), get()) } // openTelemetry利用クラス } ) } ... val sampleService by inject<SampleService>() routing { sampleRouting(sampleService) } }
2-1-4. Jaeger起動&Application起動&エンドポイント叩く
$ docker compose up $ ./gradlew runShadow {"time":"2022-12-14T17:43:01.261Z","message":"Autoreload is disabled because the development mode is off.","logger_name":"Application","thread_name":"main","severity":"INFO"} {"time":"2022-12-14T17:43:04.317Z","message":"Application started in 3.156 seconds.","logger_name":"Application","thread_name":"main","severity":"INFO"} {"time":"2022-12-14T17:43:04.323Z","message":"Application started: io.ktor.server.application.Application@2eda4eeb","logger_name":"Application","thread_name":"main","severity":"INFO"} {"time":"2022-12-14T17:43:04.588Z","message":"Responding at http://0.0.0.0:8080","logger_name":"Application","thread_name":"DefaultDispatcher-worker-1","severity":"INFO"} $ curl http://localhost:8080/sample 2022-12-15T02:46:40.909022
2-1-5. Traceの確認
ブラウザでhttp://localhost:16686
にアクセスします。
今回はjaegerで確認しましたが、いい感じに記録されてます。
2-2. 自動計装
続いて自動計装(Auto Instrumentation)になります。
2-2-1. opentelemetry-javaagent.jarのダウンロード
JVM系で自動計装するには、opentelemetryのjavaagentをアプリケーションに入れる必要があります。 Releaseにて最新のopentelemetry-javaagent.jarをダウンロードすることができるのですが、今回はbuild時にgradleで自動ダウンロードするようにしてみました。
// build.gradle plugins { id "com.github.johnrengelman.shadow" version "7.1.2" id "de.undercouch.download" version "4.1.1" } def agentPath = project.buildDir.toString() + "/otel/opentelemetry-javaagent.jar" task downloadAgent(type: Download) { src "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.21.0/opentelemetry-javaagent.jar" dest agentPath overwrite true } shadowJar { dependsOn("downloadAgent") ... }
2-2-2. opentelemetry-javaagent.jarの適用
fat jar作成時に-javaagent:path/to/opentelemetry-javaagent.jar
のようにjavaagentのconfigを記載します。
また、exporterの指定などotelに必要なconfig達を環境に合わせて渡しておきます。
※configについて詳しくはagent-configを確認。
// build.gradle def isLocal = !project.ext.has("ENV") shadowJar { dependsOn("downloadAgent") ... def args = [ "-javaagent:${agentPath}", "-Dotel.service.name=sample-service", ] if (isLocal) { args += [ "-Dotel.traces.exporter=jaeger", "-Dotel.metrics.exporter=none", ] } else { args += [ "-Dotel.traces.exporter=google_cloud_trace", "-Dotel.metrics.exporter=google_cloud_monitoring", ] } applicationDefaultJvmArgs = args }
2-2-3. 実装
依存関係の追加
// build.gradle dependencies { ... implementation 'io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:1.21.0' ... }
手動計装の際にSpan作成した箇所(SampleService)を修正します。OpenTelemetryやTracerを削除し、Span作成するfunctionに対して@WithSpanアノテーションを付与します。
// SampleService.kt import io.opentelemetry.instrumentation.annotations.WithSpan class SampleService(private val repository: SampleRepository) { @WithSpan fun execute() { val data = repository.get() println(data) } }
2-2-4. Jaeger起動&Application起動&エンドポイント叩く
Jaegerのdockerファイル用意は手動計装と同じなので割愛します。
$ docker compose up $ ./gradlew runShadow [otel.javaagent 2022-12-15 03:45:35:551 +0900] [main] INFO io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 1.18.0 {"time":"2022-12-14T18:45:42.522Z","message":"Autoreload is disabled because the development mode is off.","logger_name":"Application","thread_name":"main","severity":"INFO"} {"time":"2022-12-14T18:45:45.724Z","message":"Application started in 3.875 seconds.","logger_name":"Application","thread_name":"main","severity":"INFO"} {"time":"2022-12-14T18:45:45.726Z","message":"Application started: io.ktor.server.application.Application@44e3f3e5","logger_name":"Application","thread_name":"main","severity":"INFO"} {"time":"2022-12-14T18:45:47.223Z","message":"Responding at http://0.0.0.0:8080","logger_name":"Application","thread_name":"DefaultDispatcher-worker-2","severity":"INFO"} $ curl http://localhost:8080/sample 2022-12-15T04:05:28.119532
2-2-5. Traceの確認
アノテーション付けただけでSpanが記録されました。とても簡単で便利ですね。
他にも@SpanAttribute
などでSpanに対して属性を追加できるみたいです。※詳しくはannotationsをご確認ください。
これでKotlinで手動計装・自動計装ができました。次にこのテレメトリーデータをGoogle Cloud Traceに出力してみたいと思います。
3. Google Cloud Traceに出力する
今まではサンプルコードを使ってテレメトリーデータを計装してみましたが、どうせなら色々Spanがみれた方が良いと思うので、Gaudiyのアプリケーションを用いてOpenTelemetryを実装・Google Cloud TraceにExportしてみようと思います。
3-1. 手動計装の場合
手動軽装は上記のSampleの実装で利用することができます。 一応おさらいしておくと、Metrics, Trace共にGCP用Exporterを作成し、MetricsProvider, TraceProviderを作成。それをOpenTelemetryに読み込ませるだけです。
// SetOpenTelemetry.kt fun Application.setOpenTelemetry(env: Env): OpenTelemetry { // Resourceの設定. // https://opentelemetry.io/docs/reference/specification/resource/sdk/ val resource: Resource = Resource.getDefault() .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "${SERVICE_NAME}"))) // PropagatorはW3C Trace ContextとGCP Propagator両方利用しています. val propagators = ContextPropagators.create( TextMapPropagator.composite( XCloudTraceContextPropagator(false), W3CTraceContextPropagator.getInstance(), ), ) // otel build. val openTelemetry = OpenTelemetrySdk.builder() .setTracerProvider(createTraceProvider(env, resource)) .apply { if (!env.isLocal) createMeterProvider(resource) } .setPropagators(propagators) .build() // ktor instrumentationのinstall. install(KtorServerTracing) { setOpenTelemetry(openTelemetry) } return openTelemetry } // TraceProviderの作成 fun createTraceProvider(env: Env, resource: Resource): SdkTracerProvider { // localはJaeger, その他環境ではGCPへExportするように設定 val traceExporter = if (env.isLocal) { JaegerGrpcSpanExporter.builder().setEndpoint("http://localhost:14250/api/traces").build() } else { TraceExporter.createWithDefaultConfiguration() } return SdkTracerProvider.builder() .addSpanProcessor(SimpleSpanProcessor.create(traceExporter)) .setResource(resource) .build() } // MeterProviderの作成 fun createMeterProvider(resource: Resource): SdkMeterProvider { val metricExporter = GoogleCloudMetricExporter.createWithDefaultConfiguration() return SdkMeterProvider.builder() .registerMetricReader( PeriodicMetricReader.builder(metricExporter) .setInterval(Duration.ofSeconds(30)) .build() ) .setResource(resource) .build() }
3-2. 自動計装の場合
自動計装はopentelemetry-javaagent.jarがexportを行うので、opentelemetry-javaagent.jarにgcp用exporterを指定する必要があります。
しかし、自動計装用のcustom exporterはPoC段階でありexporter-auto.jarファイルをmaven repositoryから取得、もしくは自分で生成しなければならなそうでした。(2022/12/15現在) ※詳しくはexporter-autoをご確認ください
3-2-1. gcp exporterの生成
GCP opentelemetry-eperations-javaをcloneして、fat jarをbuildし、~/exporters/auto/build/libs/exporter-auto(.*).jar
を生成します。
3-2-2. 生成したjarファイルをアプリケーション内に設置
$ cp ~/exporters/auto/build/libs/exporter-auto(.*).jar path/to/application/exporter-auto(.*).jar
3-2-3. exporterをopentelemetry-javaagent.jarのconfigに設定
GaudiyではプラットフォームとしてCloud Runを利用しているため、Dockerfileを書きます。
otel.javaagent.extensions
に先程のpath/to/application/exporter-auto(.*).jar
を指定します。
// Dockerfile FROM adoptopenjdk/openjdk11:alpine-slim EXPOSE 8080 ENTRYPOINT java \ -Dfile.encoding=UTF-8 \ -javaagent:path/to/otel/opentelemetry-javaagent.jar \ -Dotel.javaagent.extensions=path/to/application/exporter-auto(.*).jar \ -Dotel.service.name=sample-service \ -Dotel.metrics.exporter=google_cloud_monitoring \ -Dotel.traces.exporter=google_cloud_trace \ -jar ./build/libs/sample.jar
この時、exporterやservice.nameなどのopentelemetry configを渡します。
3-2-4. Docker build & Docker Push & Cloud Runデプロイ
$ docker image build -t $IMAGE_PATH . $ docker image push $IMAGE_PATH $ gcloud beta run deploy sample-service --image $IMAGE_PATH
3-2-5. Traceの確認
少しわかりにくいかもしれませんが、ServiceA → ServiceB → ServiceCとTraceが伝搬されていることがわかります。 自動計装によりGraphQL, Firestore, Spannerの処理も計装できていていい感じですね。
- ServiceA: Node.js
- ServiceB: Kotlin
- ServiceC: Kotlin
4. まとめ
Kotlin(Ktor)サーバーにOpenTelemetryの導入ができました。ただ、Google Cloud TraceへのExportがPoCであり若干の荒技感あるので、今後はOpenTelemetry Collector経由でテレメトリーデータを扱うようにしたいと考えています。
また、Metrics, Logについても本格的にアプリケーションに適用して、Observabilityの向上に努めたいです。
そういえば今日はクリスマスイブですね。Merry Xmas, Merry OpenTelemetry!