TOP

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

OpenTelemetry



はじめに

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


OpenTelemetryとは

OpenTelemetry(OTel)とは、Googleが主導して作成したOpenCensusと、CNCF(Cloud Native Computing Foundation コンテナ技術の推進を目的とした組織)が主導していたOpenTracingを統一し、テレメトリ仕様の業界標準としたもの。
(テレメトリ:サーバーの稼働状況(メトリクス)やエラーログを収集し、システムの安定稼働や障害の早期解決(オブザーバビリティ)に利用されます)
テレメトリの三本柱と呼ばれているログ、トレース、メトリクスに加え、バゲッジ、プロファイルというデータの標準仕様をOTLP(OpenTelemetry Protocol)というプロトコルとして定めた。
ログ、トレース、メトリクスはシグナルとも呼ばれる。システムやアプリケーションの動作状況やパフォーマンスを把握するために外部へ出力されるデータの種類のこと。
プロファイルもシグナルだが、現在(2026/7/13)開発中のステータス。
バゲッジは補助的なデータを伝播させる仕組み。
各データ分類の概要と役割は以下の通り。

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

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

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


OpenTelemetryの全体構成

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

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

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


具体的な構成例があったほうがよりピンと来る気がするので以下構成例

構成例コレクターストレージバックエンド(UI/クエリ)備考
SaaS活用Datadog AgentDatadogDatadogDatadogはストレージとバックエンドを兼務できる
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メトリクス専用


コレクター

コレクターとの通信について整理します。
ストレージとバックエンドはDatadogのことだと思えば大体わかると思われる。
コレクターはOpenTelemetry固有の概念なのでここを深掘る。

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

アプリケーションからコレクターへの通信は基本的にはgRPCを使い、gRPCが使用できない環境下ではHTTPを使用する。 通信手段と使用するポート番号は以下の通り。


プロトコルポート用途・特徴
OTLP/gRPC4317ほとんどの言語用SDKで標準的に使用される高速なバイナリ通信
OTLP/HTTP 4318 gRPCに未対応の環境向け
(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: DD_AGENT_HOST
    valueFrom:
      fieldRef:
        fieldPath: status.hostIP

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


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


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

各シグナルをアプリケーションで計測する際の実装方法の話。
以下にトレースとメトリクスについて記載する。
OTel宛のログの実装についてはよくわかっていない。


TraceとSpan

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


どう実装するのか

呼び出し元から受け取ったコンテキストを元にSpanを生成すると、それは同一トレース内のスパンという扱いになる。
サンプルコードを以下に示す。
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()
    ...
}

他にも以下のようなコードのパターンもある。
(コンテキストの取り出し方が違うだけではある)
usecase層では、paramsに格納されたコンテキストをparams.HTTPRequest.Context()で取り出し(nilなら新規生成)、それを元にSpanを生成。
ctx, span := tracer.Start(ctx, "xxx")の部分。

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

func (s *service) GetAaa(params aaa.PostXxxParams) aaa {
    ctx := params.HTTPRequest.Context()
    ctx, span := tracer.Start(ctx, "xxx")
    defer span.End()
    ...
}

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



プロパゲーター

プロパゲーターは、分散トレーシング用のデータ伝播形式。
OpenTelemetryはTraceContextBaggageというW3Cの標準フォーマットに準拠しており、HTTP/gRPCのヘッダーなどを介してコンテキストを受け渡すことができる。
追加でb3などのベンダー固有のフォーマットにも、上記と合わせて複合的に対応できるようになっている。
OpenTelemetryライブラリのNewCompositeTextMapPropagatorを使って、複数の分散トレース用伝播形式(Propagator)を1つにまとめ、異なる規格(W3C TraceContextやBaggage、b3)を同時に扱えるように合成することもできる。

TextMapPropagator の概要

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

実装イメージ
package main
import (
    "go.opentelemetry.io/otel"
    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も受け付ける。
    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() への移行が最も推奨される方法。
特定のメトリクスだけ取りたい場合や、上記で取得できないメトリクスを収集したい場合は、メトリクス名を指定して収集する。
メトリクス名はこちら:

以下サンプルコード。

サンプルコード
// 1. Goアプリのアロケートヒープメモリ量
// OTel標準名: process.runtime.go.mem.heap_alloc
goMemstatsHeapAllocBytes, err = meter.Float64ObservableGauge(
    "process.runtime.go.mem.heap_alloc",
    metric.WithDescription("Go application allocated heap memory"),
    metric.WithUnit("by"), // OTelのバイト単位の標準表記は小文字の "by" です
)
if err != nil {
    return err
}
// 2. GoアプリのGC所要時間
// OTel標準名: process.runtime.go.gc.duration
// ※ 累積時間(Sum)として扱う場合は、Gaugeではなく「Counter (Sum)」として定義するのがOTelの標準的なアプローチですが、
// 既存の非同期(Observable)な値をそのまま移送する場合は ObservableCounter または ObservableGauge を使用します。
// ここでは単純な名前と単位の置換を行っています。
goGcDurationSecondsSum, err = meter.Float64ObservableGauge(
    "process.runtime.go.gc.duration",
    metric.WithDescription("Go application total duration time of GC"),
    metric.WithUnit("s"),
)
if err != nil {
    return err
}
// 3. Goアプリのgoroutine数
// OTel標準名: process.runtime.go.goroutines
goGoroutines, err = meter.Int64ObservableGauge(
    "process.runtime.go.goroutines",
    metric.WithDescription("Go number of goroutines"),
)
if err != nil {
    return err
}


カスタムメトリクス

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

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

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

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


テンポラリティ

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

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

OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCEという環境変数の値を cumulativeまたはdeltaに設定することで処理方式を切り替えることができる。
OpenTelemetry のデフォルトは cumulative です。
注意点として、Prometheus や Datadog など、受ける側の監視ツールによって推奨・対応しているテンポラリティが異なります。
具体的には、Prometheus は Cumulative 選好、Datadog 等のメトリクスAPIは Delta が扱いやすいケースがあるようです。
システムの要件や監視ツールの仕様に応じて、適切なテンポラリティを選択することが重要です。

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


参考

書籍

サイト