Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Advent Calendar: OpenTelemetryの計装をやってみた話

こんにちは。技術本部 Bill One Engineering Unit の前田です。現在はSREチームに所属しており、アプリケーションに強いSREといった立ち位置になっています。今回は、私がSREチームに異動してから少しずつ実施した、OpenTelemetryを用いた計装について説明します。

なお、本記事はSansan Advent Calendar 2023の5日目の記事です。

はじめに

OpenTelemetryとの出会い

私がSREチームに異動したのは2023年4月で、それまではWebアプリケーション開発をやっていました。当時のSREチームはAPMツールの導入に動いているタイミングであり、チームに入る際、前提知識として『オブザーバビリティ・エンジニアリング』(以降「書籍」と呼称)をお勧めされたことが出会いでした。

www.oreilly.co.jp

一言で言うなら、書籍を読んで私は衝撃を受けました。コードを読まなくてもシステムの内部状態が理解できる状況は本当に理想だと感じたし、そういう世界を実現したいと強く思いました。

「第Ⅰ部 オブザーバビリティへの道」は非常によくまとまっているし、オブザーバビリティは必要なんだ!と強く感じられる部分なので、ぜひ読んでほしいです。また、オブザーバビリティ確保のために実施するデータ取得のための実装(計装(instrumentation)という)に関しても、OpenTelemetryというオープンソースの標準ライブラリの実装があることを知りました。

オブザーバビリティを確保していくには、OpenTelemetryで自動計装できるデータ以外にプロダクトの中で利用するデータも計装する必要があるため、アプリケーション開発をやっていた私の知見が活かせると思いました。

opentelemetry.io

この記事で書くこと

今回の記事はOpenTelemetryのライブラリを使用した事例の紹介になります。下記の記事にOpenTelemetryのヘッダー伝播やインフラ周りの話がまとまっています。合わせて見ていただくとより理解が深まるかもしれません。

buildersbox.corp-sansan.com

オブザーバビリティ自体の説明は、記事の主題から外れるため割愛します。ぜひ、書籍を読んでいただきたいです。

また、下記の図はOpenTelemetryのページにあるものですが、現状Bill Oneではデータを直接 3rd party service の部分に送信しています。そのため、OTel Collector 関連については知見がありませんのでこの記事からは割愛しています。

OpenTelemetryの全体像(https://opentelemetry.io/docs/ から引用)

この記事で使用しているOpenTelemetryのライブラリバージョンは下記のとおりです。

ライブラリ 言語 バージョン
io.opentelemetry Java 1.31.0
@opentelemetry/instrumentation JavaScript 0.40.0
@opentelemetry/api JavaScript 1.4.1
@opentelemetry/core JavaScript 1.18.1

OpenTelemetryでの計装について実施したこと

OpenTelemetryのライブラリは、プログラミング言語ごとに作成されています。下記のリンクから各言語の実装状況が確認できます。

opentelemetry.io

Bill OneではJava(Kotlin), JavaScript(Node.js), GoについてOpenTelemetryのライブラリを導入しています。このうち、JavaとJavaScriptではOpenTelemetryに用意されている 自動計装 という仕組みを用いて計装しています。自動計装についてはドキュメントを読んでいただく方が良いですが、すごくざっくり説明すると、httpやgrpcなどの外部通信、DBに発行したクエリ等について自動的に計装してくれる便利な機能です。自動計装のセットアップ等はOpenTelemetryのページをなぞるだけになるので省略して、自動計装に加えて実施したことを紹介します。

その前に、OpenTelemetryのTraceデータについて説明します。

OpenTelemetryのTraceデータ構造

opentelemetry.io

詳細は上記を読んでいただく方が良いです。ここでは TraceID, SpanID, Span Attributes について簡単に説明します。値の例は こちら にJSON形式で書いてあります。

種類 説明
TraceID 起点となるリクエストごとに一意の値。Cloud Pub/SubやAmazonSQSなどの非同期Messagingサービスを用いる場合には同じTraceIDを付与することで、一連の処理であることを表現できる
SpanID トレースの中の個々の処理を識別するための一意の値。スパン同士で親子関係があり、スパンには0もしくは1の親スパン、0または1以上の小スパンが紐づく。例えばHTTPリクエストの自動計装の場合、リクエストを受け付けた時点で最初のスパン(親スパン)を生成し、その中で行われる処理は親スパンの子として生成される
Span Attributes そのスパンの詳細情報。単純なKeyValue形式であり、自由に追加できる。規約 があるため、意味的に合致する属性なら規約に従う方が良い

やったこと

ユーザ情報の計装

Bill Oneでは、まずBFF (backend for frontend) *1でリクエストを受けており、その後それぞれのマイクロサービスにリクエストを送信しています。そして、ユーザ情報を取得する処理もBFFの中で行われています。そのため、ユーザ情報等は各マイクロサービスで計装するのではなく、BFFで計装しています。現在のスパンにスパン属性を付与するだけなのでシンプルに書けます。実装例は下記のとおりです。

// typescript
import api from "@opentelemetry/api"

export const instrumentUser =(user) => {
  const span = api.trace.getActiveSpan()
  if (span !== undefined) {
    // テナントに属するユーザか、個人ユーザか、もしくは匿名のユーザか等
    span.setAttribute("app.user_kind", getUesrKind(user))
    // ユーザ自身の内部ID
    span.setAttribute("app.user_id", user.id)
    // テナント所属である場合、どのテナントであるか
    if(user.tenantUser !== null) {
      span.setAttribute("app.tenant_id", user.tenantUser.tenantId)
// ...以下省略

関連する処理を一つのスパンにまとめる

例えば、データベースの接続に関して「コネクションを取得してトランザクションを開始して終了するまで」という流れを1つのスパンにまとめるようなケースです。自動計装では、データベースに発行した個々のクエリはスパンを作ってくれるものの、スパンを区切るような機能はありません。自動計装のみで作成されるスパンのイメージをリスト形式で書くと、下記のように平坦な表現になります。

  • HTTP Get /api/...
    • Database BEGIN (トランザクション開始)
    • Database SELECT aaa
    • Database INSERT bbb
    • Database COMMIT (トランザクション終了)
    • Database SELECT ccc (トランザクション外のSQL1)
    • Database SELECT ddd (トランザクション外のSQL2)

これを、それぞれのトランザクションでスパンをネストさせて下記のように表現したい場合、追加で計装が必要になります。

  • HTTP Get /api/...
    • Transaction Start (トランザクションの処理をまとめた親スパン)
      • Database BEGIN
      • Database SELECT aaa
      • Database INSERT bbb
      • Database COMMIT
    • Connect Database (トランザクション外の処理)
      • Database SELECT ccc
      • Database SELECT ddd

Javaの場合は WithSpan アノテーション という便利なメソッドがライブラリに用意されていますが、用意されていない言語もあります。JavaScriptのライブラリには WithSpan ほど便利なメソッドはないですが、下記のような感じで簡単に実装できます。実装は これ を参考*2にしています。

// TypeScript
import api from "@opentelemetry/api"

export async function withSpan<T>(spanName: string, process: () => Promise<T>): Promise<T> {
  // tracerの名前は、計装のライブラリ名などにする
  const tracer = api.trace.getTracer("sample-app")
  const span = tracer.startSpan(spanName)
  const v = await api.context.with(api.trace.setSpan(api.context.active(), span), async () => {
    return await process()
  })
  span.end()
  return v
}

利用する場合はこうなります。例として pg を用いたDB接続の共通処理を利用します。

// TypeScript
import { Pool, PoolClient } from "pg"

const pool = new Pool({...})
type BlockFunction<T> = (client: PoolClient) => Promise<T>

export const runInTransaction = async <T>(block: BlockFunction<T>): Promise<T> => {
  return withSpan("runInTransaction", async () => {
    const client = await pool.connect()
    try {
      await client.query("BEGIN")
      const result = await block(client)
      await client.query("COMMIT")
      return result
    } catch (ex) {
      await client.query("ROLLBACK")
      throw ex
    } finally {
      client.release()
    }
  })
}

自動的にTraceID等が伝播しない処理に伝播処理を書く

突然「伝播(Propagation)」という言葉を使いましたが、OpenTelemetryのTracesのドキュメント中に Context Propagation について書いてあります。これを適切にやらないと、処理の流れの中で突然TraceIDが変わってトレースが行方不明になったり、親スパンが特定できず処理の流れがわからなくなったりするため、分散トレーシングを行う上ではとても重要です。ドキュメントに記載の通り、ある程度はOpenTelemetryのライブラリで自動的にこの伝播処理が行われます。例えばHTTP呼び出しは特段意識することなく、伝播処理を自動的にやってくれます。自動計装でのコンテキスト伝播が用意されていない場合、自力で書く必要があります。もっとも、OpenTelemetryにはPropagation APIという、伝播のためのAPIが用意してあります。すごいですね。

例えば、JavaからGoogle Cloud の Cloud Tasks のタスク作成を呼び出す場合は、自力でコンテキスト伝播する必要があります。実装例は下記のようになります。

// Kotlin
import com.google.cloud.tasks.v2.HttpRequest
import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.context.Context
import io.opentelemetry.context.propagation.TextMapSetter

fun HttpRequest.Builder.injectPropagationTraceHeaders(): HttpRequest.Builder {
    val propagator = GlobalOpenTelemetry.get().propagators.textMapPropagator
    val context = Context.current()
    propagator.inject(
        context,
        this,
    ) { carrier, key, value ->
        // 補足: TextMapSetter<C> setter というパラメータだけど、Kotlinの場合はラムダで書けるためこうなる
        carrier?.putAttributes(key, value)
    }
    return this
}

Messaging処理に追加のスパン属性を付与する

上記と同じくCloud Tasksの話です。Bill Oneにおいて、Cloud TasksはMessagingにおけるPublisherの役割を果たしています*3。そして、OpenTelemetryには Messaging System に関するスパン名などの規約 があります。なので、その規約に合わせてスパン属性を追加しています。

// Kotlin
import io.opentelemetry.api.trace.Span
import io.opentelemetry.semconv.SemanticAttributes

// Cloud Tasks を作る処理
fun deployTasks(eventName: String) {
    // 省略
    with(Span.current()) {
        // naming conventions: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/messaging/messaging-spans.md#span-name
        updateName("$eventName publish")
        setAttribute(SemanticAttributes.MESSAGING_SYSTEM, "CloudTasks") // 利用しているシステムに応じて変更する
        setAttribute(SemanticAttributes.MESSAGING_OPERATION, "publish")
    }
    // 省略
}

例では省略しましたが、もっと多くの情報をスパン属性に付与するとさらに情報が拡充されます。例えば、どのキュー(Cloud Pub/Subならトピック)にpublishしたのか等の情報も計装しておく方が良いです。

ちょっとしたQ&A

単体テストに影響はないのか

OpenTelemetryに関する環境変数等をセットしなければ、単体テストに影響はありません。OpenTelemetryの実装を見ると、必要な設定が足りていない場合は Noop~~~ という何もしないインスタンスを返すようになっています。参考

Bill Oneの場合は下記のようにしてテストに影響が無いようにしています。

  • JavaScript(Node.js): トレース用スクリプトをテスト時は指定しない*4
  • Java: javaagent-gradle-plugin を用いて、テスト時は計装で利用するJava Agentをセットアップしない

ローカルではどうやって検証しているのか

一応、ローカルからAPM等のツールにテレメトリーデータを送信できますが、そこまでは実施していません。現状はローカルの開発環境を起動する際に、一緒に Jaeger All in One というDockerイメージも起動しており、そこにトレースのデータを送信するように設定しています*5

ローカルでJaegerを起動するとこういった画面になります。

Jaeger

ローカルにあるJaegerは非常に便利で、例えば「プロダクト上でユーザを登録したらどういった流れでどういう処理が呼び出されるんだろう?」ということを知りたい時は、コードを地道に追うよりローカルで操作した後にJaegerを見た方が圧倒的に楽です。詳しくない領域の全く知らない処理を追うのにとても重宝しますし、新しく入った人が処理の流れを理解するのにも役立つ可能性があります。

意図通りに計装されているか確認する単体テストを書くべきか悩みますが、上記の通り、単体テストでは計装が無視されるように設定しているため、現時点では単体テストは用意していません。

計装はどうやって進めているのか

理想としては、SRE以外のチームも計装を進めていくほうが良いのですが、繋がっていないトレースをつなげるなどの基本的な部分が不足しているため、SREチームが実装しているのが現状です。一応、各サービスで独自の計装ができるように、スパン属性の簡単な命名規則等は定めています。しかし、現状はアプリケーション独自の計装はあまり進んでいません。下記のような表をNotionで作って、どのサービスがどの程度計装を行なっているのか分かるようにしています。一覧では15個対象がありますが、今後も増えていく想定です。

計装の実施状況一覧表

おわりに

今後やっていきたいこと

やりたいことはたくさんあるのですが、例を挙げるとこの辺りは向き合いたいです。

  • 機能開発を進めているチームが、独自に計装を進めていける状況を作る
  • Cloud Functions のような FaaS の計装
  • OTel Collector を使って Messaging System における Subscriber の計装を楽にしたり余計なスパン属性を削ったりしたい
  • Exemplars を試す
  • 書籍の事例に書いてあるCIへのオブザーバビリティ導入のように、APM以外への展開

感想など

今回は、OpenTelemetryを導入した後に実施した計装について書いてみました。OpenTelemetryやオブザーバビリティの説明を省いてしまったので、興味がある方はぜひご自身で調べてみてください。私見ですが、オブザーバビリティへの第一歩として、とりあえずOpenTelemetryを導入して自動計装を有効にするだけでも効果は実感できると思います。自動計装だけで物足りない部分は、今回の記事のようにある程度拡張できます。

書籍が発売されたのが1月、私がSREチームに入ってオブザーバビリティに向き合い始めたのが4月で、新しい考え方に触れて実践できる環境は貴重だなーと記事を書いていて改めて思いました。

OpenTelemetryを導入して計装を実施した例はあまり見たことがなかったので、この記事を書いてみました。これから計装を始める方々の参考になれば幸いです。

*1:https://buildersbox.corp-sansan.com/entry/2020/08/21/110000 を参照

*2:startActiveSpanメソッドでも良いと思うが、使い分けがわかっていない

*3:最近はCloud Pub/Subも併用しており、そちらも似たような計装を実施している状況

*4:https://opentelemetry.io/docs/instrumentation/js/getting-started/nodejs/#run-the-instrumented-app

*5:実際は特に設定を入れておらず、OpenTelemetryのライブラリをデフォルトの設定にしておくだけでいい感じの設定になる

© Sansan, Inc.