Open Telemetry + Google Cloud Trace やってみた
モチベーション
Google Cloud Trace(以下Cloud Trace)がOpen Telemetryの対応をしているということで、更にドキュメントにはないけど(2021-06-14現在)Javaでもライブラリができたので、それを試してみる。
分散トレーシングしたいって言う場合、GKEで組んでいる場合、Cloud Traceのライブラリを使って直接送るっていうのもありだが、Open Telemetryを使うことで、他のツールにも送れるような仕組みができる。
前提
- 分散トレーシングについて知っている
- Nuxt.jsについて少し知っている
Open Telemetryってなに?
w3cのtrace contextに沿ってたりする(https://github.com/open-telemetry/opentelemetry-dotnet/issues/135)
まだ、ベータだけど分散トレーシングについて知りたい人は調べておく必要はあるかなぁと。
Google Cloud Traceってなに?
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
コードはこんな感じ
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
を使うのがちょうどよいと思います
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の設定
- トレースのデータを送るためにAPIを使うことを許可しておく
- 上記プログラムで利用するservice accountを用意しておく
の2点、ここでは各やり方は書かないし、そんなに難しくないので、調べてください。
実際に動かしてみる
例えば localhost:3000 で動かして、 /login にアクセスした場合は以下のように出てくるはず。なんのフィルタもしていないので、いろんなログが送られているのが見えるはず。
上記のような感じで出来ていれば、データが送られているはず。
備忘録
どういうトレースが流れているのかを確認したい場合
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に送る部分は実装する必要があるります。
まず、以下のものを依存関係に入れます。
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登録するのは明示だからつけてる的なもの)
@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を使う場合、こんな感じです。
@RestController
@RequestMapping("/")
class TopResource(
private val tracer: CloudTracer
) {
@GetMapping("/")
fun hello(): String {
return tracer.trace("/") {
"Hello World"
}
}
}
そうするとトレーサーに出てくると思います。
フロントと、バックエンドを連携させる
分散トレーシングなので、フロントサーバのトレーサーをバックエンドサーバのトレーサーに紐付けたいと、思います。
通信の仕様としてはW3Cに従います。
つまるところ、リクエストヘッダに
-
traceparent
で、ハイフンで以下のようにデータをつないだものを入れてください。 -
tracestate
で、カンマ区切りのkey=value
で入れてくださいということだそうです。
で、その実装を書くならこんな感じ(フロントサーバから、バックエンドサーバに送信する部分)
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とかのデータクラスを作って、こいつの中で計算させるのが良いかと思います。
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)
}
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部分では以下のように呼びましょう。
@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で例外が出ても問題ないように握りつぶしてます。許して
@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)
}
)
}
}
個人的にポイントカット式は難しいので、クラスとかを指定するぐらいなら、アノテーションのほうが良いとは思ったりしていますが、本筋じゃないので、こんな感じでいいと思います。
Discussion