LabHub

ブログ

オブザーバビリティ徹底解説:ログ・トレーシング・LLM監視

한국어English日本語中文

はじめに — オブザーバビリティとは何か

システムが遅くなりました。ユーザーは「決済ができない」と言います。ところがサーバーは問題なく動いていて、CPUも正常、エラーログも特にありません。いったいどこが問題なのか? この問いに答える能力こそがオブザーバビリティ(observability)です。

オブザーバビリティはモニタリングとよく混同されます。モニタリングが「あらかじめ決めた指標が正常範囲か」を見るものだとすれば、オブザーバビリティは「システムの外から投げる任意の問いに、内部状態で答えられるか」です。前者はすでに分かっている問題(known unknowns)を監視し、後者は予想外の問題(unknown unknowns)を掘り下げます。

この能力をつくる材料が、よく三本柱(three pillars)と呼ばれるログ・メトリクス・トレースです。この記事は三本柱を一つずつ押さえ、ログ保存先(Loki vs OpenSearch)と分散トレーシング(OpenTelemetry、Jaeger、Tempo)の選択肢を対比したうえで、最近新しく台頭したLLMオブザーバビリティ(Langfuseなど)まで続けて整理します。

三本柱 — ログ、メトリクス、トレース

三つの信号は、同じ出来事を異なる角度から記録します。

ここに最近、継続的プロファイリング(continuous profiling)が四本目の柱としてよく挙がります。本番でCPU・メモリを関数単位で常時サンプリングし、「どの関数がCPUを食うか」をトレースより深いコードレベルで答えます。Grafana Pyroscope、Parcaといったツールが代表的です。

肝心なのは三つの信号を別々に見るのではなく、結びつけて見ることです。メトリクスで「エラー率が跳ねた」を見つけ、その時点のトレースへ移って「決済サービスが遅かった」を確認し、そのトレースに紐づくログで「カード会社のタイムアウト」という原因を読み取る。この流れがオブザーバビリティの核心です。

信号はどう結びつくか — trace_idとexemplar

三つの信号を結ぶ接着剤が相関ID(correlation ID)、なかでもtrace_idです。

もっとも実用的な結びつきはログにtrace_idを埋め込むことです。構造化ログのフィールドとしてtrace_idを一緒に残せば、あるトレースを見ながら「このリクエストが残したログだけ」を絞り込めます。逆にエラーログを見ながら、そのtrace_idで全体のトレースを開くこともできます。

{
  "timestamp": "2026-07-03T12:03:11.482Z",
  "level": "ERROR",
  "service": "payment-service",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "message": "card declined",
  "order_id": 4821
}

メトリクスとトレースはexemplar(見本)で結びつきます。exemplarはメトリクスのデータポイントに代表的なtrace_idを一つ付けておく手法です。たとえば「p99レイテンシ」ヒストグラムの遅いバケットに、実際に遅かったリクエストのtrace_idを付けておけば、ダッシュボードでグラフの跳ねた点をクリックして、そのままそのトレースへジャンプできます。メトリクス(何が)からトレース(なぜ)へ渡る橋です。

この結びつきを機能させることが、オブザーバビリティ設計の半分です。信号がいくらあっても互いにつながっていなければ、問題に出くわしたとき、三つの別々の窓を目で見比べる苦痛が残ります。

ログ — Loki vs OpenSearch

ログをどこにどう保存するかは、コストと検索体験を大きく分けます。代表的な二つの陣営がGrafana LokiとOpenSearch(旧Elasticsearch系)です。

Loki — 「ログ界のPrometheus」

Grafana Lokiの思想は一文で言えば「ログ本文はインデックスしない」です。Lokiはログの全文に転置インデックスを作らず、代わりにラベル(label)だけをインデックスします。ログ本文そのものは圧縮して安価なオブジェクトストレージ(S3、GCSなど)にそのまま放り込みます。だからPrometheusがメトリクスをラベルで扱うように、Lokiはログをラベルで扱います。「ログ界のPrometheus」という別名はここから来ています。

クエリ言語はLogQLです。まずラベルでログストリームを絞り、その中でテキストをフィルタします。

{app="payment-service", level="error"} |= "card declined" | json | order_id="4821"

ここで前の波括弧の部分がラベルセレクタ(インデックスがかかった部分)で、後ろの|=からが本文フィルタです。この構造のおかげでインデックスが小さく、保存コストが低く運用が軽いです。一方で、ラベルで十分に絞らないまま広大な期間を全文検索すると遅くなり得ます。ラベル設計が成否を分けます。

注意すべき罠は高カーディナリティ(high cardinality)のラベルです。user_idやtrace_idのように値の種類が爆発するものをラベルに使うと、ストリームが数百万に分裂してLokiが急激に遅くなります。そうした値はラベルではなくログ本文に入れ、フィルタで探すべきです。

OpenSearch — 強力な全文検索

OpenSearch(そしてその源流であるElasticsearch)は正反対のアプローチです。ログのほぼすべてのフィールドに転置インデックス(inverted index)を作ります。おかげで任意のフィールドで複雑な全文検索、集計、ファセット分析を高速に行え、Kibana / OpenSearch Dashboardsという強力な探索UIが付きます。

同じクエリをOpenSearchのクエリDSLで書くとこうなります。

{
  "query": {
    "bool": {
      "must": [
        { "match": { "service": "payment-service" } },
        { "match": { "message": "card declined" } },
        { "term": { "order_id": 4821 } }
      ]
    }
  }
}

代償は重さです。すべてをインデックスするので保存容量とメモリを多く使い、クラスタ運用(シャード、ヒープ、リバランス)も手がかかります。ログ量が増えるほどインフラコストが急に上がります。

いつどちらを選ぶか

構造化ロギングとログレベル

どの保存先を使うにせよ共通の原則があります。ログは人が読む文章ではなく、機械がパースする構造化データ(JSON)として残すのが良いです。"2026-07-03 payment failed for order 4821"のような文字列より、上で見たようにフィールドが分かれたJSONのほうがフィルタ・集計に圧倒的に有利です。

ログレベル(DEBUG、INFO、WARN、ERROR)は騒音を制御する最初のダイヤルです。そしてトラフィックの大きいサービスならサンプリングが必須です。成功したリクエストのINFOログを100%残す必要はありません。たとえば正常リクエストは1%だけサンプリングし、エラーは100%残す、というふうにコストと情報量のバランスを取ります。

分散トレーシング — OpenTelemetryが標準になる

マイクロサービスでは、一つのユーザーリクエストが数多くのサービスを経ます。この旅路を再構成するのが分散トレーシングであり、この分野の事実上の標準がOpenTelemetry(OTel)です。

なぜOpenTelemetryか

OpenTelemetryはCNCF傘下のベンダー中立の標準です。以前は観測ツールごとに計測SDKが別々で、バックエンドを変えるとコードの計測を全部作り替える必要がありました。OTelはこの計測層を標準化しました。コードはOTelで一度だけ計測し、データをどこへ送るか(Jaeger、Tempo、商用ベンダーなど)は後からエクスポーター設定を変えるだけで決めます。ロックインを解くことが核心の価値です。

トレーシングの基本概念はこうです。

コンテキスト伝播 — traceparentヘッダー

サービスAがサービスBを呼ぶとき、二つのサービスのスパンが同じトレースにつながるにはtrace_idが渡らなければなりません。このコンテキスト伝播(context propagation)の標準がW3C Trace Context、すなわちHTTPリクエストに載るtraceparentヘッダーです。値はおおよそバージョン、trace_id、親span_id、フラグをハイフンでつないだ形です。

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
        バージョン trace_id (128ビット)         親span_id       フラグ(サンプル済)

受信側サービスはこのヘッダーを読み、自分のスパンを同じtrace_idの下に子として付けます。こうしてサービス境界を越えてトレースが一つにつながります。

OTel Collectorと計測方式

OTel Collectorは、アプリケーションとバックエンドの間に置く独立したプロセスです。多くのサービスが送ったテレメトリを受け取り(receive)、加工し(process:バッチ、サンプリング、属性の編集)、望むバックエンドへ送り出します(export)。アプリはCollector一つを知っていればよく、バックエンドの入れ替え・多重送信・サンプリングポリシーをCollectorで中央集権的に管理します。

計測には二つの方式があります。

最小限のOTel SDK初期化はおおよそこんな形です(Node.jsの例)。

const { NodeSDK } = require("@opentelemetry/sdk-node");
const {
  getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node");
const {
  OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-http");

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: "http://otel-collector:4318/v1/traces",
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

バックエンド — JaegerとTempo

収集したトレースはトレーシングバックエンドで保存・照会します。

サンプリング — head vs tail

トレースを100%保存すると量とコストが手に負えません。だからサンプリングをします。

LLMオブザーバビリティ — なぜ特別なツールが必要か

ここで最近急浮上した新しい領域へ移ります。LLMアプリケーション(チャットボット、RAG、エージェント)は、従来の観測ツールだけでは不十分です。理由は明確です。

Langfuse — LLM呼び出しのトレース

Langfuseは、この問題に正面から取り組むオープンソースのLLMオブザーバビリティプラットフォームです。概念的には分散トレーシングに似ています。一つのユーザー対話を一つのトレースとして捉え、その中の各段階を入れ子のスパンとして記録します。ただしスパンの内容がLLMに特化しています。各LLM呼び出しごとにプロンプト、完了結果、トークン使用量、コスト、レイテンシを収め、チェーン・エージェント・検索の段階を親子に入れ子にします。

RAGパイプライン一つのトレースの形をごく単純化するとこうなります。

{
  "trace_id": "t_9f2c",
  "name": "rag-chat",
  "input": "返金ポリシーはどうなっていますか?",
  "spans": [
    {
      "name": "retrieval",
      "type": "retriever",
      "input": "返金ポリシー",
      "output": ["doc-12", "doc-45"],
      "latency_ms": 82
    },
    {
      "name": "llm-answer",
      "type": "generation",
      "model": "claude-x",
      "prompt_tokens": 1240,
      "completion_tokens": 180,
      "cost_usd": 0.0042,
      "latency_ms": 1310
    }
  ],
  "scores": [{ "name": "helpfulness", "value": 0.9 }]
}

Langfuseが与える核心機能はこうです。

他の選択肢 — LangSmith、Helicone、Phoenix

Langfuseだけではありません。

選ぶ基準はおおむねこうです。LangChain中心ならLangSmith、コード変更なしで素早く付けたいならHelicone、オープンソースとOTel標準を重視するならPhoenixやLangfuseが自然です。

一つにまとめる — OpenTelemetry中心のスタック

では全体を一つの絵に結んでみましょう。最近よくある構成はOpenTelemetryをハブに置くことです。

  アプリ(サービス群) ── OTel SDK ──▶ [ OTel Collector ]
              ┌─────────────────────────┼─────────────────────────┐
              ▼                         ▼                         ▼
        [ Loki (ログ) ]         [ Tempo (トレース) ]     [ Prometheus (メトリクス) ]
              └───────────── Grafana で統合照会 ──────────────┘

  LLM呼び出し ────────────────▶ [ Langfuse (LLMトレース・コスト・評価) ]

核心の原理は先に強調したtrace_id相関です。アプリはOTelで計測してCollectorへ送り、CollectorがログはLoki、トレースはTempo、メトリクスはPrometheusへ振り分けます。Grafanaで三つを一画面に並べ、trace_idでログ↔トレースを、exemplarでメトリクス→トレースを行き来します。LLM部分はLangfuseが別途捉えますが、同じtrace_idを載せておけば通常のトレースともつなげられます。

よくハマる罠 — コストとカーディナリティ

決定チェックリスト

おわりに

オブザーバビリティはツール一つで終わるものではなく、ログ・メトリクス・トレースという三本柱(そして台頭するプロファイリング)をtrace_idで結び、「任意の問いに答えられるシステム」をつくる営みです。ログはLokiとOpenSearchがコストと検索力の両極をなし、トレーシングはOpenTelemetryが標準を統一しJaeger・Tempoがそれを受け、その上にLLMという新しい層をLangfuseのようなツールが埋めていきます。

肝心なのは「何が最高か」ではなく「何がこのシステムに合うか」、そして何より「信号どうしがつながっているか」です。三本柱をそれぞれよく立て、trace_idで固く結んでおけば、次の午前3時の障害で三つの窓を目で見比べる代わりに、一度のクリックで原因まで下りていけます。

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます