TOP

このエントリーをはてなブックマークに追加

OpenTelemetry


はじめに

最近 OpenTelemetry を使ったシステム監視機能を構築する機会がありました。
ほぼ何も知らない状態から、いろいろ調べながら推進する過程で得られた知見をまとめたのがこの記事です。



OpenTelemetry とは

OpenTelemetry(OTel) は、テレメトリデータを生成・収集・処理・出力するためのベンダー非依存なオブザーバビリティフレームワークです。
Google が主導して作成した OpenCensus と、CNCF(Cloud Native Computing Foundation コンテナ技術の推進を目的とした組織)が主導していた OpenTracing を統一し、テレメトリ仕様の業界標準としたものです。
テレメトリとはサーバーの稼働状況(メトリクス)やエラーログを収集したもので、システムの安定稼働や障害の早期解決に利用されます。
テレメトリとして扱われる主なデータにはログ、トレース、メトリクス、プロファイルがあります。
また、これらを関連付けたりサービス間で伝播したりする仕組みとして、トレースコンテキストやバゲッジがあります。
ログ、トレース、メトリクス、プロファイルはシグナルとも呼ばれます。
シグナルとは、システムやアプリケーションの動作状況やパフォーマンスを把握するために外部へ出力されるデータの種類のことです。
各テレメトリの分類と概要・役割は以下の通り。

テレメトリの分類概要・役割
ログ
  • 概要: アプリケーションやシステムが吐き出す、タイムスタンプ付きのテキストレコードです。
  • 役割: 「その瞬間、具体的に何が起きたのか」という詳細な事実・デバッグ情報を提供します。
トレース
  • 概要: リクエストが分散システム(マイクロサービスなど)を通過する際の一連の流れ、経路を記録します。
  • 役割: 「どこで遅延が発生しているか」「どのサービス間でエラーが起きたか」という文脈(コンテキスト)を特定します。
トレースコンテキスト
  • 概要: トレースの標準仕様、およびそのデータ構造のことで、W3C Trace Context として標準化されています。
  • 役割: 異なるシステムやベンダー間でも一連の処理を正確につなぐ役割を持ちます。
メトリクス
  • 概要: CPU使用率、メモリ消費量、リクエスト数、エラー率など、一定時間ごとに集計された数値データです。
  • 役割: 「システムの調子は良いか悪いか」「システム全体で何が起きているか」という統計的な傾向や異常の検知に使います。
バゲッジ
  • 概要: トレースの「コンテキスト(文脈)」に含めて、サービス間で引き継ぎたい任意のキー・バリューのペア(メタデータ)です。
  • 役割: 例えば「ユーザーID」や「テナントID」をバゲッジに入れることで、リクエストがどれだけ多くのマイクロサービスを経由しても、
    下流のサービスまでその情報を伝播させることができます(ログやトレースの分析が格段に楽になります)。
    ただし、個人情報・認証情報・秘密情報などを安易に格納しないよう注意が必要です。
プロファイル
  • 概要: コードレベルでのCPU利用時間やメモリ割り当ての詳細な内訳(コールスタックなど)を定期的に収集します。
  • 役割: メトリクスやトレースで「この処理が遅い」と分かった後、「コードの何行目が原因か」まで
    ピンポイントで特定します(近年、OpenTelemetryでも標準化が急ピッチで進んでいます)。

これらの概念自体はOpenTelemetry登場前から存在していたが、ベンダー(DatadogやPrometheus, Jeagerなど)によって仕様が異なっていました。
それらをベンダーに依存しない統一フォーマットとして提供し、データの生成から転送までの方式を標準化しました。
さらに、ログやトレースなど、関連するテレメトリをコンテキスト情報によって関連付けて調査しやすくしました。
これまでは、例えば障害があった時に各シグナルを結びつけるためには、おおよその時間から各データの関連性を推測するというのが主な調査方法でしたが、各データをコンテキストで結びつけることで、明確な紐付けをすることができるようになりました。
(これまでも一部繋がってはいましたが、それはベンダーの仕様に依存していました。OTelはそれを全面的に標準化しました。)

ここまでがOpenTelemetryの概要です。


OpenTelemetry の全体構成

OpenTelemetry の構成を俯瞰して見ると以下の図のようになっています。
OpenTelemetryの全体構成

それぞれの役割は以下の通り。

機能役割
コレクター アプリケーションから送られてきたシグナルを受け取り、加工して、次の場所へ送り出す中継地点(プロキシ)。
コレクターの役割は大きく以下3つ
  • 受信: アプリケーションからデータを受け取る
  • 処理: データを扱いやすいようにフィルタリングしたり、個人情報をマスキングしたりする
  • 送信: 後続のストレージや、Datadog, Prometheus といった外部の監視ツールへ送信する
ストレージ 送信されたテレメトリーデータを蓄積する場所
バックエンド エンドユーザー(我々)に対してUIを通してデータを見えるようにする場所


コレクターには、公式が提供している OpenTelemetry Collector の他、各ベンダーが提供する Agent を利用する構成もあります。
具体的な構成例があったほうがイメージしやすいため、以下に構成例を示します。
なお、私が担当したシステムでは Datadog を利用していたため、Datadog Agent を利用した構成例も記載しています。

構成例コレクターストレージバックエンド(UI/クエリ)備考
Datadog を利用
する場合の一例
Datadog Agent Datadog Datadog Datadog はストレージとバックエンドを兼務できる
OSS を駆使した
自前運用
(無料でローカル
環境で構築できる
ということ)
OpenTelemetry Collector
LokiGrafanaLokiはログ専用ストレージ
ElasticSearchKibana / Grafanaログ・トレース両対応
Prometheus Grafana or
Prometheusコンソール
Prometheusはメトリクス特化。
ストレージとバックエンドを兼務可。
JaegerJaeger Jaegerはトレース特化。
ストレージとバックエンドを兼務可。
AWS構成 ADOT
(AWS Distro for OpenTelemetry)
X-RayX-RayX-Rayはトレース専用
CloudWatchCloudWatchメトリクス・ログ兼務
Prometheus Grafana or
Prometheusコンソール
Prometheusはメトリクス特化。
ストレージとバックエンドを兼務可。
Amazon Managed
Prometheus
Grafana メトリクス専用。
バックエンドはGrafanaが多い。
GCP構成
Cloud TraceCloud Traceトレース専用
Cloud LoggingCloud Loggingログ専用
Cloud MonitoringCloud Monitoringメトリクス専用


コレクター

コレクターは先にも書いた通り、アプリケーションから送られてきたシグナルを受け取り、加工して、次の場所へ送り出す中継地点(プロキシ)です。
アプリケーションからコレクタへデータを送信する際の通信プロトコルはgRPC / HTTPが使用できます。
送信するデータフォーマットは OTLP (OpenTelemetry Protocol) というプロトコルに準拠しています。
OTLP はアプリケーションからコレクター、またはコレクターからバックエンドへテレメトリデータを送信・転送するためのプロトコル/データ構造を定めたものです。
また、サービス間(HTTPヘッダーやgRPCメタデータ)でトレースの文脈( traceparent , tracestate )を伝播するための標準仕様として Trace Context が定義されています。
これは W3C (Web技術の標準化を進める国際組織)の仕様として定義されているものです。
今後このフォーマットが当たり前になっていくものと思われます。

通信手段と使用するポート番号は以下の通り。


プロトコルポート用途・特徴
OTLP/gRPC 4317 ほとんどの言語用SDKで標準的に使用される高速なバイナリ通信
OTLP/HTTP 4318 Webブラウザなどのフロントエンドやスマホアプリなどで使用されることが多い

コレクターへの通信手段の話はこれぐらいで、次はインフラ構成の話


k8s 環境下で OpenTelemetry コレクターはどこに置くのか

その前に Kubernetes の構成について一瞬おさらい

k8s は、Cluster の中に Node があって Node の中に Pod がいくつかあって Pod の中に1つ以上のコンテナが入ってるという構成になっている。
スケールイン、スケールアウトは Pod 単位(hpa の minReplicas/maxReplicas)。
Kubernetesの構成
図はリンク先のもの。


で、k8s 環境下で OpenTelemetry コレクターはどこに置くの

k8s 環境で OpenTelemetry Collector を構成する際の主要パターンは以下の通り。

パターン構成
サイドカー構成 アプリと同一 Pod に常駐させる構成。
アプリとコレクターが同一 Pod のため localhost でデータを送信。
アプリごとの独立性が高いが、ポッド数に比例してメモリ/CPU消費が増える。
サイドカー構成
デーモンセット構成
(ノードエージェントモデル)
ノードに1つだけ配置し、ノード上で稼働する複数のアプリ Pod が共同で利用する構成。
同一ノード上のコレクターへ Node IP 経由で送信。
ノードあたりのリソース効率が良いが、マルチテナントでの分離や設定変更の柔軟性に制限あり。
DaemonSet構成
ゲートウェイ構成 コレクター専用の Deployment を構成し、負荷状況に応じてオートスケール。
データ送信元はデーモンセット/サイドカー混合可。
ClusterIP Service 経由で Gateway Collector へ集約。
重い処理(Tail-based Sampling や集約処理)を担当させるのに向いている。
Gateway構成

デーモンセット構成の場合、アプリケーション Pod からコレクター Pod への通信は Node IP 経由で行うので、Node の IP アドレスを取得する必要がある。
k8s であれば deployment.yml に以下のような設定をすることで Node IP を環境変数に設定することができる。


k8s設定
env:
  - name: COLLECTOR_HOST
    valueFrom:
      fieldRef:
        fieldPath: status.hostIP

status.hostIP で Kubernetes の機能を使って、このアプリ Pod が今乗っているワーカーノードの物理 IP アドレスを環境変数 COLLECTOR_HOST に設定している。
アプリケーションでは COLLECTOR_HOST:4317 をデータの送信先とすることで、コレクターにデータを送信し、これを受けたコレクターがバックエンドにデータを転送する。


コレクターの話はこれぐらいにして次は実装の話


アプリケーションの話(Goの実装の話)

ここからは、各シグナルをアプリケーションで計測する際の実装方法の話をして行きます。
OpenTelemetry のログの実装については私はよくわかっていないので、トレースとメトリクスについて記載します。


TraceとSpan

トレース (Trace) は、リクエストが分散システム(マイクロサービスなど)を通過する際の一連の流れ、経路を記録するものです。
セットで覚えるべき概念としてスパン (Span) があります。
スパンは、システム内で行われた「単一の処理」の記録です。
例えば、「認証APIを呼び出す」「データベースからユーザー情報を取得する」「関数Aを実行する」といった個々の作業が1つのスパンになります。
ユーザーの1回の操作(リクエスト)から始まる処理は、どれだけ多くのサーバーやマイクロサービスをまたいでも Trace は複数の Span から構成され、それらが同じ Trace ID によって関連付けられます。
Datadog APM でトレースを確認すると以下画像のような画面になりますが、この1つ1つの横棒がスパンを表し、全体が1つのトレースを構成します。
トレースはスパンの集合とも言える。
Datadog APMの画面


どう実装するのか

呼び出し元から受け取ったコンテキストを元にSpanを生成すると、それは同一トレース内のスパンという扱いになります。
Goのサンプルコードは以下。
ctx, span := tracer.Start(ctx, "xxx") で Span を生成しています。
以下のコードがDatadog APMのスパン1行分にあたります。

サンプルコード(Go)
import "go.opentelemetry.io/otel"
var tracer = otel.Tracer("yyy")

func (a *aAdapter) GetAaa(ctx context.Context) error {
    ctx, span := tracer.Start(ctx, "xxx")
    defer span.End()
    ...
}

このような形で、受け取ったコンテキストを元にスパンを生成していきます。
引数で受け取ったコンテキストを無視して、新規にコンテキストを生成してSpanを開始したら、それは別のトレースになるし、そこがルートスパンになります。



プロパゲーター

プロパゲーターは、分散トレーシング用のデータ伝播形式です。
OpenTelemetry は TraceContext や Baggage という W3C の標準フォーマットに準拠しており、HTTP/gRPC のヘッダーなどを介してコンテキストを受け渡すことができます。

実装イメージ
package main
import (
    otelpropagation "go.opentelemetry.io/otel/propagation"
)

// propagatorの設定を返す。OTelのHTTPサーバー/クライアントのミドルウェアで使用
func newPropagation() otelpropagation.TextMapPropagator {
    return otelpropagation.NewCompositeTextMapPropagator(
        otelpropagation.TraceContext{},
        otelpropagation.Baggage{},
    )
}


追加で b3 などのベンダー固有のフォーマットにも、上記と合わせて複合的に対応できるようになっています。
OpenTelemetry ライブラリの NewCompositeTextMapPropagator を使って、複数の分散トレース用伝播形式(Propagator)を1つにまとめ、異なる規格(W3C TraceContextやBaggage、b3)を同時に扱えるように合成することもできます。

TextMapPropagator の概要

  • 役割: 渡された複数の TextMapPropagator を統合し、1つの統一されたPropagatorとして機能させる。
  • 用途: 異なるシステムやベンダー(Datadog、GCP Cloud Trace、標準のW3Cなど)が混在する環境で、トレース情報を正しく引き継ぐために使用する。

実装イメージ
package main
import (
    otelpropagation "go.opentelemetry.io/otel/propagation"
)

// b3形式で伝播されたトレース情報をコンテキストに読み込むためのPropagator
type propagationStruct struct {
    b3 otelpropagation.TextMapPropagator
}

// b3形式で伝播されたトレース情報をコンテキストに読み込む
func (p *propagationStruct) Extract(ctx context.Context, carrier otelpropagation.TextMapCarrier) context.Context {
    return p.b3.Extract(ctx, carrier)
}

// b3形式のトレース情報をキャリア(HTTPヘッダー等)へ書き込む
func (p *propagationStruct) Inject(ctx context.Context, carrier otelpropagation.TextMapCarrier) {
    p.b3.Inject(ctx, carrier)
}

// b3で利用するヘッダーキー一覧を返す
func (p *propagationStruct) Fields() []string {
    return p.b3.Fields()
}

// propagatorの設定を返す。OTelのHTTPサーバー/クライアントのミドルウェアで使用
func newPropagation() otelpropagation.TextMapPropagator {
    // OTelの標準伝播であるTraceContext/Baggageを優先しつつ、
    // OpenCensusなどで使用されているB3との互換性維持のためb3も受け付ける。
    return otelpropagation.NewCompositeTextMapPropagator(
        otelpropagation.TraceContext{},
        otelpropagation.Baggage{},
        &propagationStruct{b3: b3.New()},
    )
}


メトリクス

ランタイムのメトリクス自動収集

OpenTelemetryでは、アプリケーションの初期化時に go.opentelemetry.io/contrib/instrumentation/runtime パッケージを使用して、ランタイムメトリクスの収集を開始することができる。
GoのGCの状況とかgoroutineの数などを自動収集してくれる。

初期化コードの例
import (
    "go.opentelemetry.io/contrib/instrumentation/runtime"
    "go.opentelemetry.io/otel"
)
func initRuntimeMetrics() {
    // runtime.Start() を呼ぶだけで、バックグラウンドで
    // heap_alloc_bytes を含むランタイム統計の収集が始まります。
    err := runtime.Start(
      runtime.WithMinimumReadMemStatsInterval(time.Second * 10), // 収集間隔の設定(任意)
    )
    if err != nil {
      log.Fatalf("failed to start runtime metrics: %v", err)
    }
}

上記は収集間隔を10秒に設定しているが、省略した場合は15秒。
(リンク先のコードで15秒が設定されている)
単にGoのランタイム統計を取りたい場合は、この runtime.Start() への移行が最も推奨される方法です。



カスタムメトリクス

収集するメトリクスを独自実装してデータ収集することもできる。
メトリクスにもいくつか種類があるのでその話。
公式はリンク先参照。

メトリクスの計装の種類は以下の通り。

大分類概要interfaceの例初期化データ記録備考
Gauge
読み取った時点の状態をスナップショットとして記録する。
メモリ使用量や時間などを記録する。
種類:
  • Gauge
  • Asynchronous Gauge
Int64Gauge
Float64Gauge
latency, err := meter.Int64Gauge(
  "matrics_name",
  metric.WithDescription("Description"),
  metric.WithUnit("ms"),
)
どの名前、単位で記録するのかと説明文を定義
var (
  iDKey = attribute.Key("id")
  statusKey = attribute.Key("status")
)
latency.Record(ctx, latencyMs,
  metric.WithAttributes(
    iDKey.String(id),
    statusKey.String(status),
  ),
)
記録する値。 上記はID、ステータス毎の処理時間。
WithUnitで単位指定
指定する単位の形式はUCUM系式に準じている。
UCUM Specification
例:
  • 時間:
    • ms (ミリ秒)
    • s (秒)
  • データ量:
    • By (バイト)
    • KiBy (キビバイト)
  • 割合/比率:
    • 1 (単位なし/比率)
Counter
時間と共に蓄積される値。
Counterは増加のみ。
UpDownCounterは増減させることができる。
種類:
  • Counter
  • Asynchronous Counter
  • UpDownCounter
  • Asynchronous UpDownCounter

Int64Counter
Int64UpDownCounter
counter, err := meter.Int64Counter(
  "matrics_name",
  metric.WithDescription("Description"),
)
var (
  iDKey = attribute.Key("id")
  statusKey = attribute.Key("status")
)
counter.Add(ctx, 1,
  metric.WithAttributes(
    iDKey.String(id),
    statusKey.String(status),
  ),
)
Histogram 値の分布を表す。
例: レイテンシ、レスポンスサイズなど
リクエストのレイテンシーなどの値をクライアント側で集約したもの。
どれぐらいのリクエストが1秒未満かなどを知りたい場合に使う。
Int64Histogram
Float64Histogram
duration, err := meter.Float64Histogram(
  "matrics_name",
  metric.WithDescription("Description"),
  metric.WithUnit("ms"),
)
var (
  operationKey = attribute.Key("operationKey")
)
duration.Record(ctx, latencyMs,
  metric.WithAttributes(
    operationKey.String(operation),
  ),
)
特にOpenTelemetryなどの可観測性ライブラリや、
Go言語の内部ランタイムなどで広く活用されています。


テンポラリティ

OpenTelemetry にはテンポラリティ(時間性)というものがあります。
カウンターやヒストグラムなどのメトリクスが、時間経過に対してどのように集計・報告されるかを定義する概念です。
cumulative(累積)とdelta(差分)の2つがあり、Counter, Histogram が影響を受けます。

  • cumulative: アプリケーション起動時からの合計値を送信し続ける方式。途中でメトリクスがリセットされないため、長期的な傾向分析時に使用。
  • delta: 測定期間内に変化した値のみを送信する方式。データ送信毎に状態がリセットされる。

OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCEという環境変数の値を cumulativeまたはdeltaに設定することで処理方式を切り替えることができる。
OpenTelemetry のデフォルトは cumulative です。
注意点として、Prometheus や Datadog など、受ける側の監視ツールによって推奨・対応しているテンポラリティが異なります。
どのバックエンドでどちらが推奨されるかはバックエンドの仕様を確認する必要があります。

参考:OpenTelemetry テンポラリティとカーディナリティ制限


参考

書籍

サイト