Gaudiy Tech Blog

Gaudiyの技術、開発組織、カルチャーについてお伝えするブログです

Kotlin(Ktor)にOpenTelemetryを導入し、Google Cloud TraceにExportした話

この記事は、Gaudiyが実施している「Gaudiy Advent Calendar 2022」の24日目の記事です。

はじめまして。Gaudiyでエンジニアをしているあんどう(@Andoobomber)です。

GaudiyではKotlin, Go, Node.jsでのBE開発をしており、前職ではKotlin×Spring BootでC2Cサービスの開発をしていました。

先日、↓の記事でも伝えたように、GaudiyではマイクロサービスにおけるObservabilityの課題に対して、OpenTelemetryの導入を行いました。そこで自分は、Kotlin(Ktor)環境への導入を担当しました。

techblog.gaudiy.com

その中で、実際のアプリケーションでの導入事例や、特定のAPMへのExport方法に関してはまだ情報が少ないかなと感じたので、今回この記事を書くことにしました。

同じような技術スタックの方や、OpenTelemetryを実際に導入しようと考えている方の参考になればと思います。

1. OpenTelemetryって何?

OpenTelemetryとは、テレメトリーデータ(ログ、メトリクス、トレース等)の計装と送信の標準仕様、及びそのライブラリーとエージェントを提供するプロジェクトです。

これだけ聞いてもなんのこっちゃわからんと思いますが、具体的には以下のイメージです。

1-1-1. ログ

アプリケーションに発生したイベントを記録するテキスト。ロガーライブラリによって吐かれるわりとよく見るやつ。

Log_sample

1-1-2. メトリクス

一定期間に測定された数値。 例: アクセス数、レイテンシ、CPU使用率

Metrics_sample

1-1-3. トレース

依存関係のあるシステム間で、リクエストのフローをエンドツーエンドで表すもの。

Trace_sample

OpenTelemetryはこれらを標準仕様化&SDKを言語毎に用意してくれるので、各マイクロサービスに導入することでアプリケーションの健全性を監視したり分析することが容易にできるようになります。

1-2. 計装方法

計装には、手動計装(Manual Instrumentation)と自動計装(Auto Instrumentation)の2パターンがあります。

手動計装

  • 自前で実装する必要がある
  • 柔軟な計装ができる

自動計装

今回は両方の計装を試してみたので、その実装方法について書きたいと思います。

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にアクセスします。 Trace_manual_local 今回は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をご確認ください。

Trace_auto_local

これで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を生成します。

参考: opentelemetry-operations-java/build.gradle at main · GoogleCloudPlatform/opentelemetry-operations-java · GitHub

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の処理も計装できていていい感じですね。

Trace

  • 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!

recruit.gaudiy.com

5. 参考文献

opentelemetry.io

cloud.google.com

github.com

github.com

opencensus.io