
オブザーバビリティ入門 - ログ・メトリクス・トレースの三本柱を実務でどう設計するか
三本柱の先にある考え方を扱う定番書。
監視設計の基本を最短で押さえられる。
SLOとエラーバジェットの原典にあたる一冊。
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
「ダッシュボードは全部グリーンなのに、ユーザーからは遅いと言われる」「サービスを10個に分割したら、障害のたびに全チームがSlackで犯人探しを始めるようになった」。この2つは、システムがある規模を超えたときに必ず出てくる症状です。
原因は監視の量が足りないことではなく、用意した質問にしか答えられない仕組みしか持っていないことです。CPU使用率のグラフは「CPUは高いか」には答えますが、「特定キャンペーンのクーポンを持つiOSユーザーだけが決済でタイムアウトしている」には答えません。
この記事では、オブザーバビリティ(可観測性)の三本柱と呼ばれるログ・メトリクス・トレースについて、それぞれ何が得意で何が苦手か、どう設計すれば10年後も使える資産になるかを整理します。ツールの使い方ではなく、ツールが変わっても残る原理のほうを扱います。
NOTE
記事中のspan出力・traceparentヘッダ・Prometheus exposition形式・JSONログは、macOS arm64上のNode.js 18.20.8で実際に生成した出力です。使用パッケージは @opentelemetry/sdk-node 0.222.0、@opentelemetry/sdk-trace-node 2.11.0、@opentelemetry/auto-instrumentations-node 0.80.0、@opentelemetry/api 1.9.1、prom-client 15.1.3、pino 10.3.1です。OpenTelemetryは変化の速いプロジェクトなので、各シグナルの安定状況は2026年9月時点のものとして読んでください。
監視とオブザーバビリティは何が違うのか
言葉の定義から入ります。OpenTelemetry公式ドキュメントは、オブザーバビリティを次のように説明しています。
Observability lets you understand a system from the outside, by letting you ask questions about that system without knowing its inner workings.
(オブザーバビリティとは、内部構造を知らなくてもシステムに対して質問を投げかけられるようにすることで、外側からシステムを理解できるようにするものである)
そして続けて、その目的は "novel problems, that is, 'unknown unknowns'"(新規の問題、すなわち「未知の未知」)のトラブルシューティングにあると書いています。ここが監視との分岐点です。
- 監視(モニタリング) - 「既知の未知」を扱う。壊れ方が事前に分かっているので、それを測る指標とアラートをあらかじめ用意する
- オブザーバビリティ - 「未知の未知」を扱う。何が起きるか分からないので、後から任意の切り口で問い直せるだけの情報を残しておく
Google SRE Bookは監視の目的をもっと直接的に書いています。「Your monitoring system should address two questions: what's broken, and why?」(監視システムは2つの問いに答えるべきだ。何が壊れたか、そしてなぜか)。前半(症状)はアラートの仕事で、後半(原因)はオブザーバビリティの仕事です。実務上の判定基準は単純で、新しいコードをデプロイせずにその質問に答えられるか。答えられないなら、その質問に対してはオブザーバビリティが無いということになります。
三本柱とその役割分担
OpenTelemetryが「シグナル」として定義しているのはトレース・メトリクス・ログ・バゲージの4つで、プロファイルとイベントは開発中という位置づけです(2026年9月時点)。このうち中核の3つが、いわゆる三本柱です。
| シグナル | 公式の定義 | 答えられる質問 | コスト特性 |
|---|---|---|---|
| メトリクス | A measurement captured at runtime(実行時に取得された測定値) | 全体として今どうなっているか | 時系列数に比例。事前集計済みで安い |
| トレース | The path of a request through your application(リクエストがアプリケーションを通る経路) | この1件のリクエストはどこで時間を使ったか | span数に比例。サンプリング前提 |
| ログ | A recording of an event(イベントの記録) | その瞬間、具体的に何が起きたか | 行数×行サイズ。放置すると最も高い |
役割分担の定石は次の順序です。
- メトリクスで気づく - エラー率やレイテンシのSLOが割れたことをアラートで知る
- トレースで絞る - 遅い・失敗したリクエストの経路をたどり、どのサービスのどの処理が原因かを特定する
- ログで確定する - 該当spanの
trace_idでログを引き、具体的なパラメータや例外を読む
この「メトリクス -> トレース -> ログ」の導線が繋がっていないと、三本柱は3つの別々のツールでしかありません。逆に言えば三本柱の価値の大半は柱そのものではなく柱の間の接続にあります。
構造化ログの設計
なぜ構造化するのか
人間が読むために整形されたログと、機械が検索・集計するためのログは別物です。
2026-09-02 05:07:23 ERROR payment authorization failed for order ord_9f2c (user u_01JQ8Z) after 3021ms目で読むぶんには快適ですが、「直近1時間でPSP-Aへの決済が3秒以上かかった件数をユーザー単位で集計する」に答えるには正規表現を書くしかなく、メッセージ文言を変えた瞬間に壊れます。JSON Lines形式にすると同じ情報がそのままクエリ可能になります。以下は pino で実際に生成した出力です(読みやすさのため整形。実体は1イベント1行)。
{
"level": "error",
"time": "2026-09-01T20:07:23.275Z",
"service": "checkout-api",
"env": "production",
"version": "2026.9.1",
"trace_id": "46a86b2e3e53528a1653f0ddbc600728",
"span_id": "ec69ad363d986ad1",
"user_id": "u_01JQ8Z",
"event": "payment.failed",
"order_id": "ord_9f2c",
"err": { "type": "GatewayTimeout", "retryable": true },
"upstream": "psp-a",
"duration_ms": 3021,
"msg": "payment authorization failed"
}設計の要点を分解します。
eventに安定した識別子を持たせる -msgは人間向けの文章で、変わる前提です。集計とアラートはpayment.failedのような不変の名前に対して行います- 数値は数値型で出す -
"duration_ms": 3021であって"duration": "3021ms"ではありません。文字列にすると集計時にパースが必要になります - 共通フィールドをbaseで固定する -
serviceenvversionは全行に入れます。特にversionがあると「このデプロイ以降だけエラーが出ている」が一発で分かります - 時刻はISO 8601のUTCで - ローカルタイムでログを吐くと、複数リージョンのログをマージした瞬間に時系列が壊れます。タイムゾーンの扱いはタイムゾーンと日時処理入門でまとめています
ログレベルは「誰が起きるか」で決める
レベル設計が曖昧だと、WARNが毎分数千件出て誰も見なくなります。判断基準を「深刻さ」ではなく「行動」に置き換えると安定します。
| レベル | 判断基準 | 例 |
|---|---|---|
| ERROR | 人間の対応が必要。放置すると顧客影響が続く | リトライを使い切った外部API失敗、DB接続不能 |
| WARN | 今は自動回復しているが、増えたら危険 | リトライ1回目の失敗、フォールバック発動、レート制限に接近 |
| INFO | 正常系の重要な状態遷移。あとで経緯をたどるため | 注文確定、デプロイ、バッチ開始終了 |
| DEBUG | 開発時のみ。本番では既定でオフ | 中間計算値、外部リクエストの全文 |
ここで効いてくるのが「リトライ1回目はWARN、使い切ったらERROR」という区別です。タイムアウトとリトライの設計そのものはレジリエンスパターン入門で扱っていますが、リトライで吸収できた失敗をすべてERRORにするとアラートが崩壊します。
相関IDとPII
分散システムでログを追うにはリクエストを一意に識別するIDが要ります。かつては独自の X-Request-Id を採番するのが定番でしたが、全サービスを横断して伝播する仕組みが標準化された現在は、トレースの trace_id をそのまま使うのが素直です。独自IDを別途持つなら、時系列でソート可能なULIDのような形式が扱いやすくなります(UUIDとULID入門)。
PII(個人情報)は、原則を1つ決めておけば事故が激減します。ログに入れてよいのは識別子であって値ではない。
{ "user_id": "u_01JQ8Z", "email_domain": "example.com", "card_last4": "4242" }メールアドレス全体、氏名、住所、カード番号、認証トークン、リクエストボディ全文はログに落としません。どうしても必要なら、出力直前にキー名ベースでマスクするシリアライザを1か所に置きます。アプリの各所で redact を呼ぶ設計にすると、必ずどこかで漏れます。
ログのサンプリング
ログはコストが最も暴れるシグナルです。1リクエストあたり平均10行、1行500バイト、毎秒2000リクエストなら、生ログは毎秒10MB、1日で約864GBになります。この規模になると全量保存は現実的ではありません。
現実的な方針は3段です。ERROR/WARNは数が少なく価値が高いので全量残す。INFOの正常系はサンプリングするが、判定は必ず trace_id のハッシュで行い1トレースぶんのログは全部残すか全部捨てるかに揃える(行単位のランダムサンプリングは追跡途中でログが欠けて役に立ちません)。DEBUGは特定ユーザーやフラグに対してだけ動的にオンにする。
メトリクスの4つの型
Prometheus公式ドキュメントの定義に沿って整理します。
| 型 | 定義(公式より要約) | 典型例 |
|---|---|---|
| Counter | 単調増加のみ。再起動時に0へリセットされる以外は減らない累積値 | リクエスト総数、エラー総数、送信バイト数 |
| Gauge | 任意に増減する単一の数値 | メモリ使用量、キュー長、処理中リクエスト数 |
| Histogram | 観測値を設定可能なバケットに数えて記録する | レイテンシ分布、レスポンスサイズ分布 |
| Summary | 観測値をサンプリングし、スライディングウィンドウ上で分位数を計算する | クライアント側で分位数が必要な場合 |
公式ドキュメントはCounterについて「減少しうる値、たとえば実行中のプロセス数にはカウンタを使わないこと」と明示的に警告しています。
実際のexposition形式
prom-client で3つの型を定義し、1000リクエスト分(うち20件が500エラー)を記録して出力したものです。
# HELP http_requests_total Total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/orders/:id",status="500"} 20
http_requests_total{method="GET",route="/orders/:id",status="200"} 980
# HELP http_request_duration_seconds A histogram of the request duration.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.005",method="GET",route="/orders/:id"} 0
http_request_duration_seconds_bucket{le="0.025",method="GET",route="/orders/:id"} 280
http_request_duration_seconds_bucket{le="0.1",method="GET",route="/orders/:id"} 980
http_request_duration_seconds_bucket{le="0.5",method="GET",route="/orders/:id"} 980
http_request_duration_seconds_bucket{le="1",method="GET",route="/orders/:id"} 980
http_request_duration_seconds_bucket{le="5",method="GET",route="/orders/:id"} 1000
http_request_duration_seconds_bucket{le="+Inf",method="GET",route="/orders/:id"} 1000
http_request_duration_seconds_sum{method="GET",route="/orders/:id"} 73.08000000000006
http_request_duration_seconds_count{method="GET",route="/orders/:id"} 1000
# HELP http_requests_in_flight Number of HTTP requests currently being served.
# TYPE http_requests_in_flight gauge
http_requests_in_flight 3読みどころが3つあります。ヒストグラムは1つの定義から複数の時系列になること。公式ドキュメントのとおりバケットは <basename>_bucket{le="<upper inclusive bound>"}、合計は <basename>_sum、件数は <basename>_count として露出され、上の例では1つのヒストグラムが9本の時系列を生んでいます。次にバケットが累積であること。le="0.1" の980は le="0.025" の280を含み、le="+Inf" の値は必ず _count と一致します。最後に名前と単位の規約。Counterには _total、時間には _seconds(ミリ秒ではなく秒)を付けます。エラー率の分子に何を数えるかはHTTPステータスコードの意味づけに直結します(HTTPステータスコード入門)。
HistogramとSummaryはどちらを選ぶか
初学者が最も間違えるのがここです。結論から言うと、迷ったらHistogramです。理由は集約可能性にあります。
Prometheus公式ドキュメントは、Summaryについてこう書いています。
Aggregating the precomputed quantiles from a summary rarely makes sense. In this particular case, averaging the quantiles yields statistically nonsensical values.
(サマリの事前計算済み分位数を集約することは、ほとんど意味をなさない。特にこのケースでは、分位数を平均すると統計的に無意味な値になる)
10台のサーバがそれぞれ「私のp99は300msです」と報告してきたとき、その10個を平均してもクラスタ全体のp99にはなりません。Histogramは各サーバがバケットのカウンタを報告するだけなので、境界さえ揃っていればサーバをまたいで足し算でき、そこから全体のp99を計算できます。
| 観点 | Histogram | Summary |
|---|---|---|
| インスタンス横断の集約 | できる(バケットが揃っていれば) | できない |
| 分位数の後から変更 | できる(クエリ時に指定) | できない(事前設定のみ) |
| 計測側のコスト | 低い(カウンタの加算) | 高い(ストリーミング分位数計算) |
| 精度の性質 | バケット境界の粗さに依存 | 指定した分位数では正確 |
Histogramの弱点は、バケット境界の外挿誤差です。公式ドキュメントは、リクエスト時間が220ms付近に集中していて境界が200msと300msにある場合、p95が295msと推定されてしまう例を挙げています(真値は220ms付近)。バケット境界は自分のSLOのしきい値の周辺に密に置くのが基本です。300msのSLOを持つなら、0.1/0.2/0.25/0.3/0.4/0.5のように、300ms付近を細かく刻みます。
カーディナリティ爆発
メトリクス設計で最大の事故がこれです。Prometheus公式ドキュメントの一文が本質を突いています。
Each labelset is an additional time series that has RAM, CPU, disk, and network costs.
(ラベルの組み合わせ1つ1つが追加の時系列であり、RAM・CPU・ディスク・ネットワークのコストを持つ)
つまり時系列の数は、ラベル値の種類の掛け算で増えます。
http_requests_total{method, route, status}
method 5種 (GET, POST, PUT, PATCH, DELETE)
route 40種
status 8種
-> 5 x 40 x 8 = 1,600 時系列ここまでは健全です。ところが「調査しやすいように」とラベルを1つ足すと崩壊します。
http_requests_total{method, route, status, user_id}
user_id 500,000種
-> 1,600 x 500,000 = 8億 時系列ラベルを1つ足しただけで50万倍です。これがカーディナリティ爆発です。公式ドキュメントの推奨は明確で、「メトリクスのカーディナリティは10未満に保つよう努め、それを超えるものはシステム全体で数個に限るよう目指すこと」、そして「カーディナリティが100を超える、あるいは100に達しうるメトリクスがあるなら、次元数を減らすか、分析を監視の外へ移すといった代替案を検討すること」とされています。
ラベルに入れてはいけない値の典型は、ユーザーID・セッションID・リクエストID・trace_id、生のURLパス(/orders/12345 のようにIDが埋まったもの)、メールアドレスやIPアドレス、タイムスタンプやエラーメッセージ全文、そして自動生成のPod名やコンテナID(Podが入れ替わるたびに新しい時系列になります)です。
生のURLパスの問題は、OpenTelemetryのセマンティック規約でもきちんと分離されています。url.path は実際のパス(/orders/12345)、http.route はマッチしたルートテンプレート(/orders/:id)で、規約は http.route を明確に「low-cardinality」と位置づけています。メトリクスのラベルには http.route を、トレースの属性には url.path をというのが正しい使い分けです。
高カーディナリティの情報を捨てる必要はありません。捨てるのではなく、置き場所を変える。ユーザー単位の調査はメトリクスではなくトレースかログでやる、というのが原則です。
分散トレースの仕組み
spanとは何か
OpenTelemetryは、トレースを「リクエストがアプリケーションを通る経路」、spanを「作業や操作の単位。spanはトレースを構成する部品である」と定義しています。1つのspanが持つ主な要素は、名前、親span ID(ルートspanでは空)、開始・終了タイムスタンプ、span context(trace ID、span ID、trace flags、trace state)、属性、span events、span links、ステータスです。
そして「span kind」という分類があります。これがトレースの読み方を大きく左右します。
| kind | 意味 |
|---|---|
| SERVER | 同期的な着信リモート呼び出し。HTTPリクエストの受信など |
| CLIENT | 同期的な発信リモート呼び出し。外部HTTP呼び出しやDBアクセスなど |
| INTERNAL | プロセス境界をまたがない内部処理 |
| PRODUCER | 後で非同期に処理されるジョブの生成 |
| CONSUMER | producerが作ったジョブの処理。producerの終了よりずっと後に始まりうる |
PRODUCER/CONSUMERの存在が重要です。メッセージキューを挟むと、CONSUMERのspanはPRODUCERのspanが終わったあとに始まります。つまり非同期の経路では、トレースは1本の連続した時間軸にはなりません。キューを介した処理の設計はメッセージキュー入門で扱っています。
実際のspan出力
自動計装を有効にしたNode.jsのHTTPサーバに1リクエスト送り、ConsoleSpanExporter で出力させたものです(リソース属性と一部の属性は省略)。
{
instrumentationScope: { name: '@opentelemetry/instrumentation-http', version: '0.222.0' },
traceId: '74a3d758eb41fb6a24f4317684b9c696',
parentSpanContext: {
traceId: '74a3d758eb41fb6a24f4317684b9c696',
spanId: 'f2102b962ae30f06',
traceFlags: 1,
isRemote: true
},
name: 'GET',
id: '28af1f119f8ede67',
kind: 1,
timestamp: 1788293210983000,
duration: 19160.375,
attributes: {
'http.request.method': 'GET',
'url.scheme': 'http',
'server.address': 'localhost',
'server.port': 3999,
'url.path': '/orders/42',
'network.protocol.version': '1.1',
'user_agent.original': 'demo/1.0',
'http.response.status_code': 200
},
status: { code: 0 },
events: [],
links: []
}kind: 1 はSERVER、同じトレースに含まれるもう1つのspanは kind: 2(CLIENT)で出力されました。両者の traceId は一致し、SERVER spanの parentSpanContext.spanId がCLIENT spanの id と一致しています。これが親子関係です。
parentSpanContext.isRemote: true は「親spanが別プロセスにある」という印です。この情報がHTTPヘッダで運ばれたことを示しています。
注目すべきは属性名です。http.request.method http.response.status_code url.path server.address はすべてOpenTelemetryのHTTPセマンティック規約で定義された名前で、いずれもStable扱いです。属性名が標準化されていることの価値は、バックエンドを乗り換えてもダッシュボードのクエリが壊れないことにあります。
W3C traceparentによるコンテキスト伝播
トレースが複数プロセスにまたがるには、trace IDとspan IDをネットワーク越しに運ぶ必要があります。この形式を標準化したのがW3C Trace Contextで、2021年11月23日にW3C勧告(Recommendation)になっています。
上のデモでサーバが実際に受け取ったヘッダがこちらです。
traceparent: 00-46a86b2e3e53528a1653f0ddbc600728-ec69ad363d986ad1-01ハイフン区切りの4フィールドで、それぞれ長さが厳密に決まっています。
| フィールド | 長さ | 内容 |
|---|---|---|
| version | 16進2文字(1バイト) | 現在は 00。ff は不正 |
| trace-id | 16進32文字(16バイト) | トレース全体で一意。全ゼロは禁止 |
| parent-id | 16進16文字(8バイト) | 呼び出し元のspan ID。全ゼロは禁止 |
| trace-flags | 16進2文字(1バイト) | 現在は最下位ビットのsampledフラグのみ使用 |
末尾の 01 がsampledフラグです。仕様は、このビットが立っているとき「呼び出し元がトレースデータを記録した可能性がある」ことを意味すると定めています。「記録された」ではなく「した可能性がある」という慎重な言い回しで、下流に記録を強制するものではない点が重要です。
ベンダー固有の情報を運ぶ tracestate ヘッダも定義されています(最大32リストメンバー、値は256文字までの印字可能ASCII、実装は最低512文字の伝播を推奨)。さらにW3Cはアプリケーション定義のキーバリューを運ぶ baggage ヘッダも定義しています(2026年9月時点ではCandidate Recommendation Snapshot、2024年5月30日付。上限は64リストメンバーまたは8192バイト)。
baggage: userId=alice,serverNode=DF%2028,isProduction=falsebaggageは便利ですがすべての下流サービスに無条件で伝わる点に注意が必要です。ここに個人情報やテナント秘密を入れると、外部APIへのリクエストにまで載って流出します。なおgRPCではこれらはメタデータとして運ばれます(gRPCとProtocol Buffers入門)。プロトコルが変わっても、trace-idとspan-idを運ぶという構造は変わりません。
時刻とspanの因果関係
トレースを見ると「子spanの開始時刻が親より前」になっていることがあります。バグではなく、サーバ間のクロックずれです。だからこそ親子関係は時刻ではなく明示的なspan IDの参照で表現されているわけです。分散システムで「起きた順序」を時刻に頼れない理由は論理時計入門で扱っています。トレースの因果関係は論理的な参照、タイムスタンプは表示上の目安、と割り切って読むのが安全です。
三本柱をつなぐ
三本柱の価値は接続にあります。方法は具体的に2つです。
ログにtrace_idを埋める
最も費用対効果の高い1手です。すでに示したJSONログには trace_id と span_id が入っていました。これがあれば、トレース画面で見つけた遅いspanから、そのspanの中で何が起きていたかのログへ、IDのコピーだけで飛べます。OpenTelemetryのドキュメントは、SDKや自動計装を有効にすると「既存のログをアクティブなトレースおよびspanと自動的に相関させ、ログ本体をそれらのIDで包む」と説明しています。手で入れる場合も、リクエストスコープのコンテキストからロガーに注入するミドルウェアを1つ書けば済みます。
メトリクスにexemplarを付ける
メトリクスは集計値なので、「p99が悪化した」までは分かってもそれ以上進めません。この壁を壊すのがexemplarです。OpenMetrics仕様は次のように定義しています。
Exemplars are references to data outside of the MetricSet. A common use case are IDs of program traces.
(exemplarはMetricSetの外にあるデータへの参照である。よくある用途はプログラムトレースのIDである)
exposition形式では、サンプル行の後ろに # に続けて記述します。OpenMetrics仕様に載っている例です。
# TYPE foo histogram
foo_bucket{le="0.01"} 0
foo_bucket{le="0.1"} 8 # {} 0.054
foo_bucket{le="1"} 11 # {trace_id="KOO5S4vxi0o"} 0.67
foo_bucket{le="10"} 17 # {trace_id="oHg5SJYRHA0"} 9.8 1520879607.789
foo_bucket{le="+Inf"} 17
foo_count 17
foo_sum 324789.3le="10" のバケットに、実際に9.8秒かかったリクエストのtrace IDが1件だけ添えられています。これにより「遅いバケットのグラフをクリックしたら、実際に遅かった1本のトレースが開く」という導線が成立します。
Prometheus側はこれをフィーチャーフラグ --enable-feature=exemplar-storage で受け入れます。ドキュメントによれば、exemplarは全系列ぶんを固定サイズのリングバッファでメモリに保持し、trace IDを持つexemplar1件あたり約100バイトを消費します。全リクエストではなく代表点だけを保持するので、カーディナリティ爆発を起こさずに高カーディナリティの世界へ橋を架けられるわけです。
OpenTelemetryの位置づけ
API・SDK・Collectorの3層
OpenTelemetryを「もう1つの監視ツール」だと思うと構造を見誤ります。実体は計測(instrumentation)とバックエンドを分離するための標準です。層は次のように分かれています。
| 層 | 役割 |
|---|---|
| API | テレメトリを生成・相関させるためのデータ型と操作を定義する。ライブラリはこれだけに依存する |
| SDK | 言語ごとのAPI実装。サンプリング、バッチ処理、エクスポート先の設定を持つ |
| 計装ライブラリ | HTTPやDBクライアントなど既存ライブラリから自動でテレメトリを取る |
| エクスポータ | バックエンドやCollectorへ送る |
| Collector | ベンダー非依存のプロキシ。受信・処理・送信を担う |
APIとSDKが分かれていることの実務的な意味は大きく、ライブラリ作者はAPIにだけ依存してspanを作れます。SDKが初期化されていなければ何も起きず、アプリ側が導入すれば自動的にテレメトリが流れ始める。ライブラリが特定の監視ベンダーに依存しなくて済むのはこの分離のおかげです。
OTLP
シグナルを運ぶプロトコルがOTLPです。仕様は「テレメトリのソース、Collectorのような中間ノード、そしてバックエンドの間で、テレメトリデータをどう符号化し、転送し、配送するかを記述する」ものと定義しています。
| 実装 | 符号化 | 既定ポート |
|---|---|---|
| OTLP/gRPC | Protocol Buffers | 4317 |
| OTLP/HTTP(バイナリ) | Protobuf、Content-Type: application/x-protobuf | 4318 |
| OTLP/HTTP(JSON) | JSON、Content-Type: application/json | 4318 |
JSON版ではtrace IDとspan IDがbase64ではなく16進で符号化される点が仕様に明記されています。手でデバッグするときはこちらが読みやすくなります。
Collector
Collectorは「テレメトリデータを受信・処理・送信するベンダー非依存の実装」で、公式は導入価値を「複数のエージェント/コレクタを実行・運用・保守する必要をなくす」と説明しています。構成要素はレシーバ、プロセッサ、エクスポータ、コネクタ、エクステンションです。
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
batch:
timeout: 5s
send_batch_size: 1024
exporters:
otlphttp:
endpoint: https://otlp.example.com
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlphttp]アプリからはCollectorにだけ送り、送信先の切り替えはCollectorの設定で行う。この形にしておくとバックエンドを乗り換えるときにアプリを1行も触らずに済み、属性の削除(PIIの落とし忘れ対策)やサンプリングもCollector側に寄せられます。デプロイ形態は、サービスに併走させるエージェント型と集約点として置くゲートウェイ型の2つが基本で、コンテナ環境ではDaemonSetでエージェントを配りその先にゲートウェイを置く2段構成がよく使われます(Linuxコンテナ入門)。
最小セットアップ
実際に動かした構成です。
npm install @opentelemetry/sdk-node @opentelemetry/api \
@opentelemetry/auto-instrumentations-node @opentelemetry/sdk-trace-nodeconst { NodeSDK } = require('@opentelemetry/sdk-node');
const { ConsoleSpanExporter, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const sdk = new NodeSDK({
serviceName: 'checkout-api',
spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();アプリの最初の行でこれを読み込みます。前掲のspan出力は、この構成にHTTPリクエストを1本流しただけで得られたもので、アプリケーションコードには一切手を入れていません。自動計装がHTTPモジュールをフックし、traceparentの送出と受信、属性の付与までを行っています。本番では ConsoleSpanExporter と SimpleSpanProcessor を、OTLPエクスポータと BatchSpanProcessor に置き換えます(前者はspan終了ごとに即エクスポートするためスループットに影響します)。
シグナルの安定状況(2026年9月時点)
OpenTelemetryは仕様と各言語実装で成熟度が異なります。公式のステータスページを執筆時点で確認したところ、次の状況でした。
- トレースは主要言語でStable
- メトリクスも多くの言語でStableだが、Erlang/Elixir、Ruby、Swift、Kotlinなどは開発中
- ログは仕様としてはStableだが、言語実装のばらつきが最も大きい。C++、C#/.NET、Java、PHPがStable、JavaScriptやPythonは開発中
変化が速い領域なので、導入時は必ず公式ステータスページで最新を確認してください。実務的な指針としてはまずトレースから入れるのが安全で、ログは既存のロガーを構造化して trace_id を埋めるだけでも十分な価値が出ます。
サンプリング戦略
トレースを全量保存できる規模のシステムはほとんどありません。何をどう間引くかが設計の要点になります。
head-based(先頭での判断)
リクエストの入口で記録するかどうかを決める方式です。OpenTelemetryのドキュメントは「spanやトレースを記録するか破棄するかの判断が、トレース全体を検査したうえで行われるわけではない」方式と定義しています。もっとも一般的なのはConsistent Probability Samplingで、trace IDと目標割合から判定し、「全トレースの5%といった一定の割合で、spanの欠けがない完全なトレースが得られる」とされています。trace IDから決定的に判定するので、どのサービスで判定しても同じ結論になるのがポイントです。
利点は理解・設定・実装が容易でオーバーヘッドが小さいこと。欠点は公式が「トレース全体のデータにもとづいてサンプリング判断を行うことはできない」と書くとおりで、エラーになったトレースを必ず残すという要求は満たせません。
tail-based(末尾での判断)
トレースの全span(または大部分)が揃ってから判断する方式で、「エラーを含むトレースは全部残す」「500ms以上かかったものは全部残す」「正常系は1%だけ」といった条件を書けます。代償は運用コストで、公式もトレース完了までspanをメモリに保持する必要がありステートフルでリソース集約的なコンポーネントになると指摘しています。Collectorのtail sampling processorの設定はこうなります。
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100
expected_new_traces_per_sec: 10
policies:
- name: sample-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: sample-slow-traces
type: latency
latency:
threshold_ms: 5000
- name: sample-10-percent
type: probabilistic
probabilistic:
sampling_percentage: 10最大の落とし穴はREADMEに明記された制約です。あるトレースの全spanは、同一のCollectorインスタンスに届かなければならない。Collectorを複数台に負荷分散していると同じトレースのspanがバラバラのインスタンスに届き、判断が壊れます。実運用ではtrace IDでルーティングするloadbalancing exporterを前段に置き、その後段でtail samplingを行う2層構成が必要になります。
選び方
| 方式 | 向いている場面 | 注意点 |
|---|---|---|
| head-based | まず導入したい、トラフィックが均一、コストを確実に抑えたい | エラーや遅延の取りこぼしが起きる |
| tail-based | エラーと遅延を確実に残したい、規模が大きい | Collectorがステートフルになる。ルーティング設計が必要 |
| 併用 | 実運用で最も多い形 | SDK側で粗く間引き、Collector側で条件付き保存 |
もう1点、重要な原則があります。メトリクスはサンプリングしない。エラー率やレイテンシ分布はトレースの1%から復元できません。SLOの計算はメトリクスで行い、原因調査はサンプリングされたトレースで行う、という分担にします。
SLI・SLO・エラーバジェット
「どこまで速ければ十分か」を決めないと、オブザーバビリティは単なるグラフ鑑賞になります。Google SRE Bookの定義です。
- SLI - "a carefully defined quantitative measure of some aspect of the level of service that is provided"(提供されているサービスレベルのある側面を、注意深く定義した定量的な尺度)
- SLO - "a target value or range of values for a service level that is measured by an SLI"(SLIで測られるサービスレベルに対する目標値または目標範囲)
- SLA - "an explicit or implicit contract with your users that includes consequences of meeting (or missing) the SLOs they contain"(含まれるSLOの達成・未達に伴う結果を含む、ユーザーとの明示的または黙示的な契約)
違いは結果の有無です。SLAには返金などのペナルティが伴い、SLOには伴いません。だからSLOはSLAより厳しく設定します。
エラーバジェット
SRE Bookはエラーバジェットの形成を「プロダクトマネジメントがSLOを定義し、四半期あたりどれだけの稼働時間を持つべきかの期待値を設定する。実際の稼働時間は中立な第三者、すなわち監視システムによって測定される。この2つの数値の差が、四半期に残された不安定さの『予算』である」と説明し、あわせて「100%はおそらく決して正しい信頼性目標ではない。達成不可能なだけでなく、たいていの場合ユーザーが求めたり気づいたりする以上の信頼性である」と書いています。
数字にすると実感が湧きます。30日を1か月として計算した許容ダウンタイムです。
| SLO | 月あたりの許容ダウンタイム | 年あたり |
|---|---|---|
| 99% | 7時間12分 | 約3.65日 |
| 99.9% | 43分12秒 | 約8時間46分 |
| 99.95% | 21分36秒 | 約4時間23分 |
| 99.99% | 4分19秒 | 約52分34秒 |
| 99.999% | 25.9秒 | 約5分15秒 |
99.99%を掲げるとは、デプロイ起因の障害も含めて月に4分しか止まれないという意味です。人間が気づいてSlackを開く時間すら足りません。掲げる前に、自動ロールバックと多重化がその水準にあるかを確認する必要があります。
バーンレートアラート
エラーバジェットの使い方で最も実用的なのがバーンレートです。SRE Workbookは「バーンレートとは、SLOに対してどれだけ速くサービスがエラーバジェットを消費しているかである」と定義しています。99.9%のSLOで30日ウィンドウの場合、バーンレート1がちょうど1か月で使い切るペースです。
| バーンレート | エラー率(99.9%のSLO) | 予算を使い切るまで |
|---|---|---|
| 1 | 0.1% | 30日 |
| 2 | 0.2% | 15日 |
| 10 | 1% | 3日 |
| 1,000 | 100% | 43分 |
Workbookが推奨するマルチウィンドウ・マルチバーンレートの設定はこうなっています。
| 深刻度 | 長ウィンドウ | 短ウィンドウ | バーンレート | 消費される予算 |
|---|---|---|---|---|
| ページ(呼び出し) | 1時間 | 5分 | 14.4 | 2% |
| ページ(呼び出し) | 6時間 | 30分 | 6 | 5% |
| チケット | 3日 | 6時間 | 1 | 10% |
「エラー率が1%を超えたら通知」という素朴な閾値アラートに対する利点は2つです。短時間の急激な悪化と長時間のじわじわした悪化を同じ枠組みで、しかも別々の深刻度で扱えること。そして短ウィンドウの併用で、すでに回復した障害で鳴り続けるのを防げること。SRE Bookは「人間をページで呼び出すことは、従業員の時間の非常に高価な使い方である」と書いています。アラートを減らすのは手抜きではなく設計であり、レート制限で守る境界(レート制限アルゴリズム入門)と同じく、意図的に「ここまでは許容する」線を引くことが安定運用につながります。
REDメソッドとUSEメソッド
「何を測るか」で迷ったときの型が3つあります。すべて出典がはっきりしています。
Four Golden Signals(Google SRE Book)
ユーザー向けシステムを監視するための4指標です。Latencyは "The time it takes to service a request."(リクエストを処理するのにかかる時間)、Trafficは "A measure of how much demand is being placed on your system"(システムにどれだけの需要がかかっているかの尺度)、Errorsは "The rate of requests that fail"(失敗するリクエストの割合)、Saturationは "How 'full' your service is."(サービスがどれだけ「いっぱい」か)。
Errorsの定義には、明示的な失敗(HTTP 500など)だけでなく暗黙的な失敗(HTTP 200だが内容が誤っている)やポリシー上の失敗も含む、と書かれています。ステータスコードだけを見ていると取りこぼすという警告です。
REDメソッド(Tom Wilkie、2015年)
Grafana Labsのブログによれば、Tom Wilkieが2015年に考案しGrafanaCon EU 2018で発表した型です。
For every resource, monitor: Rate (the number of requests per second), Errors (the number of those requests that are failing), Duration (the amount of time those requests take).
Four Golden SignalsからSaturationを除いた3つに相当し、リクエスト駆動のサービスに焦点を絞ったぶん実装が単純です。前掲のexposition形式に戻ると、REDの3つはこう対応します。
| 指標 | メトリクス | PromQL |
|---|---|---|
| Rate | http_requests_total | sum(rate(http_requests_total[5m])) |
| Errors | http_requests_total の status ラベル | sum(rate(http_requests_total{status=~"5.."}[5m])) |
| Duration | http_request_duration_seconds | histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) |
Counter1本とHistogram1本で3指標すべてが取れます。新しいサービスを立てたら、まずこの2つを入れる。これだけでほとんどの障害は検知できます。
USEメソッド(Brendan Gregg、2012年)
リソース側の型です(ACM Queue 2012年掲載)。一文で言うと「For every resource, check utilization, saturation, and errors.」(すべてのリソースについて、利用率・飽和・エラーを確認せよ)。Resourceは "all physical server functional components (CPUs, disks, busses, ...)"、Utilizationは "the average time that the resource was busy servicing work"(リソースが処理で埋まっていた平均時間)、Saturationは "the degree to which the resource has extra work which it can't service, often queued"(処理しきれずキューに溜まった余剰仕事の度合い)、Errorsは "the count of error events" と定義されています。
UtilizationとSaturationの区別が肝です。CPU使用率90%は「まだ余裕がある」かもしれませんが、ロードアベレージが上がりランキューが伸びているなら飽和しています。利用率は上限100%で頭打ちになるが飽和は青天井なので、悪化の速度は飽和にしか現れません。
RED考案者のWilkie自身が両者の関係を「REDメソッドはユーザーとその満足度を気にかけるもので、USEメソッドはマシンとその満足度を気にかけるものだ」と表現しています。ロードバランサの背後にいる各インスタンスの飽和をUSEで見つつ、利用者から見た体験をREDで見る(ロードバランシングアルゴリズム入門)という組み合わせが実務の標準です。
コストとカーディナリティの現実的な設計
オブザーバビリティの導入が失敗する理由の上位は技術的な難しさではなくコストです。請求額がインフラ本体を超えて打ち切られる、というのが典型的な終わり方になります。コストの構造は3つのシグナルで違います。
| シグナル | コストのドライバ | 効く対策 |
|---|---|---|
| メトリクス | アクティブな時系列数(カーディナリティ) | ラベルの削除、http.route 化、記録ルールで事前集計 |
| トレース | 保存するspan数 | サンプリング(head + tail) |
| ログ | バイト数(行数×行サイズ) | レベル別の保持期間、正常系のサンプリング、冗長フィールドの削除 |
保持期間の階層化が定石です。直近7日は全部(サンプリング済みトレース、全ログ、生メトリクス)、30日から90日はメトリクスをダウンサンプリングしたものとERROR/WARNログだけ、1年以上はSLO計算に必要な集計済み系列だけ。障害調査はほぼ7日以内で終わるので、それより古いデータに生解像度は要りません。
ログ量の見積もりは、必ず先に手で計算しておきます。
毎秒2000リクエスト x 平均10行 x 500バイト = 10 MB/s
-> 864 GB/日 -> 約26 TB/月(生データ)この数字を見てから「INFOを10%にサンプリングすれば約3TB/月」「共通フィールドを削って1行350バイトにすれば約18TB/月」といった打ち手を評価します。先に上限予算を決め、そこから逆算してサンプリング率と保持期間を決める。導入してから請求書で驚くのとは順序が逆です。この発想はキャッシュのTTL設計と同じ性質のものです(Cache-Controlとs-maxageの実践)。
よくある失敗
現場で繰り返し見る失敗を並べます。
1. とりあえず全部集める - 目的のないテレメトリは、コストだけ発生してノイズを増やします。まずSLOを決め、SLOを測るのに必要なものから入れます。
2. メトリクスのラベルにIDを入れる - もっとも破壊力のある事故です。user_id や生のURLパスをラベルに入れた瞬間に時系列数が数桁増え、Prometheusがメモリ不足で落ちます。高カーディナリティはトレースとログの担当です。
3. Summaryでレイテンシを測る、またはヒストグラムのバケットが既定のまま - 前者は分位数が集約できず、インスタンスを増やした瞬間に「全体のp99」が計算できなくなります。後者はSLOのしきい値が300msなのに境界が0.1/0.5/1.0だと、p99の推定が数百ミリ秒単位でぶれます。
4. ログをランダムに行単位でサンプリングする - 追跡中に肝心の行が消えます。trace_id 単位で残すか捨てるかを揃えます。
5. アラートを原因に張る - 「CPU使用率80%超」でページを鳴らすと、ユーザーに影響が無いときも起こされます。アラートは症状(SLOのバーンレート)に張り、原因はダッシュボードとトレースで追います。
6. trace_idがログに入っていない - 三本柱が3本の孤島になります。導入コストが最も低く、効果が最も高い施策なので、これだけは最初にやる価値があります。
7. 自動計装だけで満足する - HTTPとDBは自動で取れますが、ビジネス上の重要な分岐(クーポン適用、在庫引当、外部PSPの選択)はコードの中にあります。ここに手動で属性やspanを足さないと、「なぜこのユーザーだけ遅いか」には辿り着けません。
8. Collectorを挟まず直接バックエンドへ送り、独自の属性名を使う - 前者はバックエンド変更のたびに全アプリの再デプロイを強い、後者は既製のダッシュボードもアラートも使えなくします。http_method ではなく http.request.method を使います。
まとめ
要点を整理します。
- 監視は「既知の未知」、オブザーバビリティは「未知の未知」。判定基準は「新しいコードをデプロイせずにその質問に答えられるか」
- 三本柱の役割分担は「メトリクスで気づき、トレースで絞り、ログで確定する」。価値の大半は柱ではなく柱の間の接続にある
- 接続の実装は2つ。ログに
trace_idを埋めること、メトリクスにexemplarを付けること - メトリクスの型はHistogramを既定に。Summaryの分位数はインスタンス横断で集約できない
- カーディナリティは掛け算で増える。ラベルにIDや生パスを入れない。高カーディナリティはトレースとログに置く
- W3C traceparentは4フィールド固定長。version 2文字、trace-id 32文字、parent-id 16文字、trace-flags 2文字
- OpenTelemetryは計測とバックエンドを分離する標準。API/SDKの分離とCollectorで、乗り換えコストをアプリの外へ追い出せる
- サンプリングはhead-basedとtail-basedの併用が実用解。ただしメトリクスはサンプリングしない
- SLOを決めなければ何も始まらない。エラーバジェットとバーンレートでアラートを症状に張り、コストは予算から逆算して保持期間を決める
ツールもベンダーも入れ替わりますが、「リクエスト単位の因果関係を運ぶIDがあり、集計は集計として持ち、詳細は詳細として持ち、その3つがIDで繋がっている」という構造は変わりません。ここさえ押さえておけば、どのスタックへ移っても設計は流用できます。
参考資料
- OpenTelemetry: Observability primer
- OpenTelemetry: Signals(Traces / Logs / Components)
- OpenTelemetry: Sampling
- OpenTelemetry: OTLP Specification
- OpenTelemetry: Semantic Conventions for HTTP Spans
- OpenTelemetry: Collector
- OpenTelemetry: Status and Stability Matrix
- OpenTelemetry Collector Contrib: Tail Sampling Processor
- W3C: Trace Context
- W3C: Propagation format for distributed context: Baggage
- Prometheus: Metric types
- Prometheus: Instrumentation best practices
- Prometheus: Histograms and summaries(Exposition formats / Feature flags)
- OpenMetrics Specification
- Google SRE Book: Monitoring Distributed Systems
- Google SRE Book: Service Level Objectives
- Google SRE Book: Embracing Risk
- Google SRE Workbook: Alerting on SLOs
- Brendan Gregg: The USE Method
- Grafana Labs: The RED Method - how to instrument your services