#Observability とは

Observability: 可観測性とは、システムから収集したデータをもとに、内部の稼働状況を正確に把握・理解・分析する能力、またはその仕組みのことです。

似たような概念に監視 (モニタリング) があります。こちらのほうがイメージはしやすいかもしれません。 監視は既知の問題を見張り、エラーや閾値オーバーがある場合はアラートを出すものです。

一方で Observability はシステムの内部状態を把握することで未知の問題の根本原因を特定することを目的としたものです。

#OpenTelemetry とは

OpenTelemetry (OTel) とは、 Observability を実現するためのフレームワークです。 アプリケーション内部の活動記録を生成し、外部へ吐き出す機能を持っています。

Observability を実現するためには、収集したデータを保存して可視化する必要がありますが、その機能は OTel にはありません。 データの保存は Jaeger や Prometheus などのオブザーバビリティバックエンドを、可視化はオブザーバビリティフロントエンドの Grafana などを使用する必要があります。

OTel 自体もオープンソースであり、オブザーバビリティバックエンド・フロントエンドもオープンソースのものがたくさんあり、ベンダーフリーであるのが良いところです。

##シグナル

まず前提として、システムが自身の状態や挙動を外部から把握できるように出力するデータを テレメトリデータ と呼びます。「テレメトリ (telemetry)」は遠隔 (tele) で計測する (metry) という意味で、システムの内部で起きていることを離れた場所から観測するためのデータ、というイメージです。

シグナルとは、このテレメトリデータの種類のことです。

OTel は以下のシグナルをサポートしています。

  • トレース
  • メトリック
  • ログ

###1. トレース

トレースはアプリケーションへリクエストを投げてからレスポンスが返ってくるまでの経路を記述します。 システムがモノリスであろうとマイクロサービスであろうと、リクエストからレスポンスまでの1本の経路を追いかけることができます。

マイクロサービスでトレースがない場合を考えてみましょう。 EC サイトのユーザーから「商品を注文するときの処理が遅すぎる」という声が上がっているとします。 システム内部では認証サービス、在庫サービス、注文サービス、DB が複雑に絡み合っており、ユーザーが注文を確定してから以下のような処理が動いています。

  1. 認証
  2. 在庫の引き当て
  3. 在庫 DB の在庫を減らす
  4. 注文 DB に注文を作成する

どこが悪さをしているかを調べるには、各サービスのログやタイムスタンプを別々に開いて睨めっこする必要があります。

トレースでは、各サービスの処理の活動記録を一つに繋ぐため、リクエストがいつどこを通ったかを見ることができます。 先ほどの例では、レスポンス時間が長いトレースを探し当て、そのトレースの詳細を見にいくことで問題の特定が可能となります。

###2. メトリック

メトリックはある時点のシステムの状態を数値で表したものです。一定間隔で計測・集計される時系列データで、その数値の推移を追いかけることが多いです。

トレースが1本のリクエストを追う「木を見る」シグナルだとすると、メトリックはシステム全体の傾向を掴む「森を見る」シグナルです。

先ほどのトレースは特定のリクエストの経路を追うのは得意ですが、そもそも今全体のエラー率が上がっているのか、CPU が逼迫していないかといった全体の状況を把握するのには向いていません。 メトリックがあれば、エラー率やレイテンシ、リソース使用率などを継続的に監視でき、異常の予兆や全体の傾向に気づくことができます。

代表的なメトリックには次のようなものがあります。

  • リクエスト数・エラー率・レイテンシ
  • CPU・メモリ・ディスクの使用率

実際の運用では、メトリックで異常に気づき、その時間帯のトレースを深掘りする、といったようにシグナルを組み合わせて使います。

###3. ログ

ログはいつ・何が起きたかを記録したテキストのメッセージです。3つのシグナルの中では最も身近かなと思います。 console.log やロギングライブラリで出力しているやつです。

トレースやメトリックが「どこが遅いか」「全体がどういう状態か」を教えてくれるのに対して、ログは個々のイベントの詳細な文脈を残します。 エラーメッセージやスタックトレース、処理中の変数の値など、原因を特定するための具体的な手がかりが得られます。

ただし、ログ単体では大量のメッセージの中から関連するものを探し出すのが大変です。 そこで OTel では、ログにトレース ID を紐付けることができます。これにより、レスポンスの遅いトレースを見つける→そのトレースに紐づくログだけを絞り込んで原因を読む、といった横断的な調査が可能になります。

このように、トレース・メトリック・ログはそれぞれ役割が異なり、それぞれをうまく使うことで Observability が実現されます。

#OTel を用いた Observability 基盤の典型的な構成

まず、計装について説明します。 計装とは、アプリケーションの処理時間やエラーなどを記録できるように、ライブラリやコードを設定することです。 トレースでは、HTTP リクエストや DB 問い合わせのような処理の一単位をスパンと呼びます。

計装が作ったテレメトリデータは、OTLP (OpenTelemetry Protocol) という共通の形式で Collector に送ります。 Collector は受け取ったデータを保存先へ振り分けます。

flowchart LR
    subgraph APP["アプリケーション"]
        auto["自動計装"]
        program["プログラム計装"]
        manual["手動計装<br/>(OTel API)"]
        sdk["OTel SDK"]
        auto --> sdk
        program --> sdk
        manual --> sdk
    end

    collector["OTel Collector"]

    subgraph BACKEND["Telemetry Backend"]
        tempo["Tempo(Traces)"]
        loki["Loki(Logs)"]
        prom["Prometheus(Metrics)"]
    end

    grafana["Grafana<br/>(Telemetry Frontend)"]

    sdk --> collector
    collector -- OTLP --> tempo
    collector -- OTLP --> loki
    collector -- OTLP --> prom
    tempo --> grafana
    loki --> grafana
    prom --> grafana

##3種類の計装のやり方

計装には、自動計装・プログラム計装・手動計装の3つのやり方があります。違いは、どこまで自分でコードを書くかです。 導入の速さと、業務処理の細かさを追えることはトレードオフになります。

こちらを参考にしています。

###1. 自動計装

自動計装は、アプリケーションのコードをほとんど変更せずにテレメトリデータを収集できる方法です。ゼロコードで始められるので、まず「どの API が遅いか」「DB 呼び出しで失敗していないか」を早く知りたいときに便利です。 言語ごとに用意されたエージェントやライブラリを組み込むだけで、主要なHTTP サーバーフレームワーク・ライブラリの呼び出しに対して自動的にスパンやメトリックが生成されます。

以下は Python でのセットアップ例です。1

opentelemetry-instrument \
    --traces_exporter console,otlp \
    --metrics_exporter console \
    --service_name your-service-name \
    --exporter_otlp_endpoint otel-lgtm:4317 \
    python myapp.py

導入コストが低く、まず全体像を掴みたいときに向いています。一方で、計装できるのは対応ライブラリの範囲内に限られるため、アプリケーション固有のビジネスロジックの詳細までは追えません。

###2. プログラム計装

プログラム計装は、Flask や FastAPI、データベースクライアントのような特定ライブラリ向けの計装パッケージを、アプリケーションコードで有効にする方法です。 自動計装のようにプロセス全体へ一括で適用するのではなく、どのライブラリを計装するかを明示できます。

たとえば FastAPI だけを対象にするなら、次のように書きます。

from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

app = FastAPI(title="Order Service")
FastAPIInstrumentor.instrument_app(app)

使っているライブラリと、計装される範囲が対応するため、意図しない計装を避けやすいのが利点です。ただし、ここでも独自のビジネスロジックまでは追えません。

###3. 手動計装

手動計装は、OTel API を使ってスパン、メトリック、ログを自分で発行する方法です。 たとえば注文処理に「在庫引当」と「決済」の処理があるとします。 以下のように、それぞれのビジネスロジックにスパンを追加すれば、その箇所の開始から終了までの時間や、起こったイベントなどが追えるようになります。 障害が起こったときに「注文 API が遅い」だけでなく、「決済サービスの応答待ちが 2 秒だった」と追えるようになります。

from opentelemetry import trace

tracer = trace.get_tracer(__name__)


def create_order(order):
    with tracer.start_as_current_span("在庫引当"):
        reserve_inventory(order)

    with tracer.start_as_current_span("決済"):
        charge(order)

##OTel Collector

OTel Collector は、アプリケーションとテレメトリの保存先の間に置く中継役です。

Collector の中では、データが次の順で流れます。

flowchart LR
    receiver["Receiver<br/>受信"] --> processor["Processor<br/>加工"] --> exporter["Exporter<br/>送信"]

たとえば Receiver はアプリケーションから OTLP を受け取り、Processor はデータをまとめて送ったり、メールアドレスのような機微情報を削ったりします。 Exporter は加工済みのデータを Tempo や Prometheus などの Observability Backends へ届けます。

郵便局にたとえると、アプリケーションは手紙を書く人、Collector は集荷と仕分けをする郵便局、backend は手紙を保管する場所です。 送り先が変わっても、手紙を書く人全員に新しい住所を覚えさせる必要はありません。 郵便局の転送先を変えれば済みます。

たとえば、バックエンドを移行するとき、Collector がなければ各アプリケーションの設定を変更して再デプロイすることになりがちです。 Collector があれば、基本的には Collector の送信先設定だけを変えればよく、アプリケーションへの影響を小さくできます。 リトライ、バッチ送信、サンプリングといった重い処理も一か所にまとめることができます。

##Observability backends

Observability backend は、テレメトリデータを保存し、あとから検索・集計する場所です。 OTel はデータを作って運ぶところまでを担当し、長期間保管したり検索画面を提供したりはしません。

代表的な OSS の組み合わせには、トレース用の Jaeger や Tempo、メトリック用の Prometheus、ログ用の Loki があります。

ここに OTel を使う嬉しさがあります。 アプリケーションは OTLP という共通形式で Collector に送るだけなので、保存先を変えても計装コードを大きく書き換えずに済みます。 まずは OSS で試し、運用の手間が増えたら SaaS に移す、といった選択もしやすくなります。

##Observability frontend

Observability frontend は、backend に蓄えたデータを可視化するための画面です。 Grafana、Jaeger UI、各 SaaS のダッシュボードがこれにあたります。

たとえば、アラートで注文 API のレイテンシ上昇に気づいたとします。 画面上でその時間帯のメトリックを確認し、遅いトレースを開き、そこから同じ trace ID を持つログへ移動できれば、調査のために複数の画面やログ基盤を往復しなくて済みます。 夜間の障害対応で欲しいのはデータの量より、この移動の短さです。

Prometheus と Grafana のように保存先と画面を組み合わせる構成もあれば、Datadog のように一つの製品で完結する構成もあります。どちらを選んでも、トレース・メトリック・ログの間を行き来できるようにしておくと、OTel の効果を実感しやすいです。

#OTel SDK のセットアップ例

ここでは Python と FastAPI を使い、トレース、メトリック、ログを Collector へ送ります。初期化処理を otel.py にまとめておくと、アプリケーション本体は観測の設定をあまり意識せずに済みます。

この例では、接続先、サービス名、リソース属性を環境変数で指定します。開発環境と本番環境でコードを変えず、コンテナ設定だけで送信先を切り替えられるためです。

##テレメトリーの初期化

Resource は、どのサービスや環境から出たデータかを示す名札のようなものです。この例ではトレース、メトリック、ログに同じ Resource を設定します。トレース画面で見つけた注文サービスのログを絞り込むときに、サービス名や環境が揃っていないと地味に困ります。

Exporter は Collector への送信役です。Provider に Exporter と処理方法を登録すると、アプリケーション内で作られた各シグナルが Collector へ流れます。

# otel.py

def setup_telemetry() -> Callable[[], None]:
    # OTEL_SERVICE_NAME と OTEL_RESOURCE_ATTRIBUTES を環境変数から読み込む
    resource = Resource.create()

    # トレース
    tracer_provider = TracerProvider(resource=resource)
    tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
    trace.set_tracer_provider(tracer_provider)

    # メトリック
    metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter())
    meter_provider = MeterProvider(
        resource=resource,
        metric_readers=[metric_reader],
    )
    metrics.set_meter_provider(meter_provider)

    # ログ。trace_id と span_id を含めて OTLP で送信する
    logger_provider = LoggerProvider(resource=resource)
    logger_provider.add_log_record_processor(
        BatchLogRecordProcessor(OTLPLogExporter())
    )
    _logs.set_logger_provider(logger_provider)
    logging.getLogger().addHandler(LoggingHandler(logger_provider=logger_provider))

    # HTTP とメッセージングで W3C Trace Context を伝播する
    set_global_textmap(TraceContextTextMapPropagator())

    def shutdown() -> None:
        tracer_provider.shutdown()
        meter_provider.shutdown()
        logger_provider.shutdown()

    return shutdown

BatchSpanProcessor と BatchLogRecordProcessor は、データを少しまとめてから送信します。 リクエストのたびにネットワークへ送るより負荷を抑えやすい一方、アプリケーションが落ちる直前のデータは送信前に失われることがあります。 このあたりは、データをどれだけ取りこぼしたくないかと、運用コストの折り合いです。

##アプリケーションへの組み込み

FastAPI 用の計装ライブラリを有効にすると、HTTP リクエストごとのトレースを自動で作成します。 LoggingInstrumentor は標準の Python ログに trace ID と span ID を付けます。 エラーログから該当トレースを開けるようになるので、障害調査の往復が減ります。

# main.py
from otel import setup_telemetry

shutdown_telemetry = setup_telemetry()
atexit.register(shutdown_telemetry)
LoggingInstrumentor().instrument(set_logging_format=True)

app = FastAPI(title="Order Service")
FastAPIInstrumentor.instrument_app(app)


@app.get("/")
def index():
    logging.getLogger(__name__).info("order API was called")
    return {"message": "hello, OpenTelemetry"}

##環境変数と起動

Collector の OTLP gRPC エンドポイントが otel-lgtm:4317 で待ち受けているとします。

export OTEL_EXPORTER_OTLP_ENDPOINT=otel-lgtm:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_SERVICE_NAME=order-service
export OTEL_RESOURCE_ATTRIBUTES=service.namespace=sample-ec,deployment.environment=local

uvicorn main:app --host 0.0.0.0 --port 8000

OTEL_SERVICE_NAME はトレース画面に表示されるサービス名です。 OTEL_RESOURCE_ATTRIBUTES には環境名や名前空間など、検索で役立つ共通属性を入れます。 注文番号やメールアドレスのように値の種類が増え続ける属性、個人情報や秘密情報は入れません。

Collector が受信し、設定した backend に送れていれば、トレース画面に order-service の GET / が表示されます。 ログには trace ID と span ID が入り、その ID から同じリクエストのトレースへ移動できます。

この例で見えるのは HTTP リクエストの単位です。注文処理のどの段階に時間がかかったかまで追いたくなったら、OTel API で 在庫引当 や 決済 の独自スパンを追加します。

#Footnotes

  1. traces_exporter、 metrics_exporter はトレース・メトリックの出力先を指定しています。ここでは標準出力 (console)および OTLP を指定しています。service_name でテレメトリーバックエンドに送信する際の名前を決めています。分析の際はこの名前を見てどこのテレメトリーなのかを判断します。exporter_otlp_endpoint はエクスポート先のエンドポイントを指定しています。 ↩