Open Telemetry + Google Cloud Trace やってみた

12 min read

モチベーション

Google Cloud Trace(以下Cloud Trace)がOpen Telemetryの対応をしているということで、更にドキュメントにはないけど(2021-06-14現在)Javaでもライブラリができたので、それを試してみる。
分散トレーシングしたいって言う場合、GKEで組んでいる場合、Cloud Traceのライブラリを使って直接送るっていうのもありだが、Open Telemetryを使うことで、他のツールにも送れるような仕組みができる。

前提

  • 分散トレーシングについて知っている
  • Nuxt.jsについて少し知っている

Open Telemetryってなに?

https://opentelemetry.io/
歴史的経緯からOpen traceとOpen CensusをがっちゃんこしてできたCNCF(Cloud Native Computing Foundation)による分散トレーシングの仕組み
w3cのtrace contextに沿ってたりする(https://github.com/open-telemetry/opentelemetry-dotnet/issues/135)
まだ、ベータだけど分散トレーシングについて知りたい人は調べておく必要はあるかなぁと。

Google Cloud Traceってなに?

https://cloud.google.com/trace?hl=ja
Tracerとして使えるツールで、料金はまぁまぁ安いんじゃないかなぁと思ってます。(他のものときちんと比べてない)
GAEのスタンダード環境を使っている場合は何もしなくてもTraceにデータが入ってくる、はず
GCP上のツールなので、GKEとかで使うときが多いかなぁと(正直AWS、Azureからの通信だろうが使える
こっちの使い方自体はあんま解説しません。

環境

フロントサーバ: Nuxt.js(TypeScript) SSR
バックエンドサーバ: SpringBoot(Kotlin)
Nuxt.js→SpringBootという環境です。

まず、それぞれでCloud Traceにデータを流せるようにしましょう

フロントサーバに入れてみる

Nuxt.jsにトレースをしてくれるコードを入れる

必要なものをインストールする( GCPのドキュメントでの plugin-http はdeprecated なので下の通り instrumentation-http などを入れる )

npm i @google-cloud/opentelemetry-cloud-trace-exporter @opentelemetry/api @opentelemetry/instrumentation-express @opentelemetry/instrumentation-http @opentelemetry/node @opentelemetry/tracing

コードはこんな感じ

tracer.ts
import { NodeTracerProvider } from '@opentelemetry/node'
import { SimpleSpanProcessor } from '@opentelemetry/tracing'
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter'

import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'

let _initialized = false
export const initialized = () => _initialized
export const initialize = () => {
  if (initialized()) {
    console.error('already initilized')
    return
  }

  const provider = new NodeTracerProvider()

  const exporter = new TraceExporter({
    projectId: process.env.GCP_PROJECT_ID,
    credentials: JSON.parse(
      Buffer.from(
        process.env.SERVICE_ACCOUNT_CREDENTIAL_FILE || '',
        'base64',
      ).toString(),
    ),
  })

  provider.addSpanProcessor(new SimpleSpanProcessor(exporter))
  provider.register()

  registerInstrumentations({
    tracerProvider: provider,
    instrumentations: [
      new HttpInstrumentation(),
      new ExpressInstrumentation(),
    ],
  })
  _initialized = true
}

registerInstrumentations とするたけで http と express 周りを監視する。
本番環境では SimpleSpanProcessor を使わずに BatchSpanProcessor を使うパターンが多いかもしれない。それぞれの違いついてはここでは触れません。

本筋とずれるけど、今回は SERVICE_ACCOUNT_CREDENTIAL_FILE として、service accountのcredentialのjsonをbase64にして環境変数に入れているので、そのあたりの設定をしておくこと。

で、これをどこで実行するのかですが、
一番いいのはサーバが起動したタイミングで、1度だけ初期化するのが良いと思います。そのため、一番いいのはNuxtのhook機能 (https://nuxtjs.org/docs/2.x/configuration-glossary/configuration-hooks) の listen を使うのがちょうどよいと思います

nuxt.config.ts
import { initialized, initialize } from './tracer.ts'

export default {
  ssr: true,
  hooks: {
    listen(_nuxt) {
      if (!initialized()) {
        initialize()
        console.log('tracer initialize!')
      } else {
        console.log('tracer already initialized!')
      }
    },
  },
  // その他の設定
}

GCPの設定

  1. トレースのデータを送るためにAPIを使うことを許可しておく
  2. 上記プログラムで利用するservice accountを用意しておく

の2点、ここでは各やり方は書かないし、そんなに難しくないので、調べてください。

実際に動かしてみる

例えば localhost:3000 で動かして、 /login にアクセスした場合は以下のように出てくるはず。なんのフィルタもしていないので、いろんなログが送られているのが見えるはず。

イベントの見え方

そのイベントの詳細1
そのイベントの詳細2

上記のような感じで出来ていれば、データが送られているはず。

備忘録

どういうトレースが流れているのかを確認したい場合

ConsoleSpanExporter っていうのがあるので、それを追加してみると良いです

import { ConsoleSpanExporter } from '@opentelemetry/tracing'

provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()))

なんかGCPに送られていないっぽいを確認したい場合

TraceExporter#export をextendsしたclassを作って、それをProcessorに入れるのが良いです。

import { ConsoleSpanExporter } from '@opentelemetry/tracing'

provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()))

バックエンドに入れてみる

Java(Kotlin)の場合は、Tracerに送る部分は実装する必要があるります。
まず、以下のものを依存関係に入れます。

build.gradle.kts
implementation("io.opentelemetry:opentelemetry-api:1.2.0")
implementation("io.opentelemetry:opentelemetry-sdk:1.2.0")
implementation("io.opentelemetry:opentelemetry-extension-kotlin:1.2.0")
implementation("com.google.cloud.opentelemetry:exporter-trace:0.15.0")

その後以下のようなクラスを作成して、やるのが良いのではという気持ち。 ちなみに、 PreDestroy は、ここに入れなくても良い気がしている(@Componentがついているしbean登録するのは明示だからつけてる的なもの)

CloudTracer.kt
@Component
class CloudTracer(
        projectId: String,
        serviceAccountCredential: GoogleCredentials
) {
    final val ot: OpenTelemetrySdk
    final val tracer: Tracer
    init {
        val configuration: TraceConfiguration = TraceConfiguration
                .builder()
                .setProjectId(projectId)
                .setCredentials(serviceAccountCredential)
                .setDeadline(Duration.ofMillis(30000))
                .build()

        val traceExporter = TraceExporter.createWithConfiguration(configuration)
        ot = OpenTelemetrySdk.builder()
                .setTracerProvider(
                        SdkTracerProvider.builder()
                                .addSpanProcessor(BatchSpanProcessor.builder(traceExporter).build())
                                .build()
                )
                .buildAndRegisterGlobal()
        tracer = GlobalOpenTelemetry.getTracer(this.javaClass.canonicalName)
    }

    fun <T> trace(name: String, invoker: () -> T): T {
        return sendTrace(name, invoker)
    }

    private fun <T> sendTrace(name:String, invoker: () -> T, context: SpanContext? = null): T {
        val span = tracer.spanBuilder(name).setSpanKind(SpanKind.SERVER).also { builder ->
            context?.run(builder::addLink)
        }.startSpan()
        return runCatching {
            span.makeCurrent().use {
                invoker()
            }
        }.fold(onSuccess = {
            it
        }, onFailure = {
            span.recordException(it)
            throw it
        }).also {
            span.end()
        }
    }

    @PreDestroy
    fun end() {
        ot.sdkTracerProvider.shutdown()
    }
}

traceを使う場合、こんな感じです。

TopResource.kt
@RestController
@RequestMapping("/")
class TopResource(
        private val tracer: CloudTracer
) {
    @GetMapping("/")
    fun hello(): String {
        return tracer.trace("/") {
            "Hello World"
        }
    }
}

そうするとトレーサーに出てくると思います。

フロントと、バックエンドを連携させる

分散トレーシングなので、フロントサーバのトレーサーをバックエンドサーバのトレーサーに紐付けたいと、思います。
通信の仕様としてはW3Cに従います。

https://w3c.github.io/trace-context/#trace-context-http-request-headers-format

つまるところ、リクエストヘッダに

  • traceparentで、ハイフンで以下のようにデータをつないだものを入れてください。
  • tracestateで、カンマ区切りのkey=valueで入れてくださいということだそうです。

で、その実装を書くならこんな感じ(フロントサーバから、バックエンドサーバに送信する部分)

tracer.ts
  registerInstrumentations({
    tracerProvider: provider,
    instrumentations: [
      new HttpInstrumentation({
        requireParentforOutgoingSpans: true,
        requestHook: (span: Span, request: ClientRequest | IncomingMessage) => {
          if (request instanceof ClientRequest) {
            const version = Number(1).toString(16)
            const traceData = `${version}-${span.context().traceId}-${
              span.context().spanId
            }-${span.context().traceFlags}`
            request.setHeader('traceparent', traceData)
            const traceState = span.context().traceState
            if (traceState != null) {
              request.setHeader('tracestate', traceState?.serialize())
            }
          }
        },
      }),
      new ExpressInstrumentation(),
    ],
  })

で受け取る側の実装は以下のようになるのかなと思います。
TracerDataとかのデータクラスを作って、こいつの中で計算させるのが良いかと思います。

CloudTracer.kt
    fun <T> trace(name: String, data: TracerData, invoker: () -> T): T {
        val spancontext = SpanContext.create(data.traceId, data.spanId, data.convertFlag(), data.convertState() )
        return sendTrace(name, invoker, spancontext)
    }
TracerData.kt
data class TracerData (
        val traceId: String,
        val spanId: String,
        val traceFlags: Byte,
        val traceState: List<String>,
        val isRemote: Boolean?
) {
    companion object {
        fun from(traceParent: String, traceState: List<String>): TracerData {
            val traceData = traceParent.split("-")
            return TracerData(
                    traceData[1],
                    traceData[2],
                    traceData[3].toByte(16),
                    traceState,
                    false
            )
        }
    }

    fun convertFlag(): TraceFlags {
        return TraceFlags.fromByte(traceFlags)
    }

    fun convertState(): TraceState {
        return traceState.let { list ->
            TraceState.builder().also { builder ->
                list.forEach { state ->
                    val e = state.split("=")
                    builder.put(e[0], e[1])
                }
            }.build()
        }
    }
}

API部分では以下のように呼びましょう。

TopResource.kt
    @GetMapping("/")
    fun hello(@RequestHeader header: HttpHeaders): String {
        return tracer.trace("/", TracerData.from(
                header.getFirst("traceparent") ?: "",
                header.getOrEmpty("tracestate")
        )) {
            "Hello World"
        }
    }

これにより、一旦紐付いていること(Linkされていること)が確認できます。

まとめ

この方法であれば、一旦Open Telemetryを使ったGoogle Cloud Tracerの実装ができると思います。
本当は、Javaの方で、addLinkではなく、コンテキストの返してくれるものが、いじれればいいのですが、このあたり、ドキュメントなどがなく困っている次第、、、
一応、関連付けられるからまぁ、良しとするかぁという感じで諦めてしまっているので、誰か教えて下さい

補足

明示的に呼ぶのではなく、 AOPとして、実装するのありだと思います。SpringのAOPの機能を使ってやるのは一番使い勝手が良いかな。
(TracerData.fromで例外が出ても問題ないように握りつぶしてます。許して

TracerAspect
@Component
@Aspect
class TracerAspect(
        private val tracer: CloudTracer
) {
    @Around("execution(* api..*Resource.*(..))")
    fun around(joinPoint: ProceedingJoinPoint): Any {
        return (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request.runCatching {
            val traceparent = getHeader("traceparent")
            val tracestate = getHeaders("tracestate")
            TracerData.from(
                    traceparent ?: "",
                    tracestate?.toList() ?: emptyList()
            )
        }.fold(
                onSuccess = {
                    tracer.trace("default", it) {
                        joinPoint.proceed(joinPoint.args)
                    }
                },
                onFailure = {
                    joinPoint.proceed(joinPoint.args)
                }
        )
    }
}

個人的にポイントカット式は難しいので、クラスとかを指定するぐらいなら、アノテーションのほうが良いとは思ったりしていますが、本筋じゃないので、こんな感じでいいと思います。