🔭

OpenTelemetryで始める分散トレーシング

に公開

はじめに

マイクロサービスアーキテクチャの普及に伴い、「オブザーバビリティ(Observability)」がインフラ運用の重要テーマになっています。

従来のモニタリング(監視)が「何が壊れたか」を検知するのに対し、オブザーバビリティは 「なぜ壊れたか」を追跡できる仕組み です。複数のサービスが連携するマイクロサービス環境では、1つのリクエストがどのサービスを通過し、どこでエラーや遅延が発生したかを把握する必要があります。

そこで注目されているのがOpenTelemetryです。

OpenTelemetryとは

オブザーバビリティを実現するには、アプリケーションの各所からテレメトリデータ(トレース・メトリクス・ログ)を取得する計装(instrumentation)が不可欠です。OpenTelemetry(OTel)は、CNCF(Cloud Native Computing Foundation)が管理するオープンソースのオブザーバビリティフレームワークで、計装から収集までの標準仕様を提供します。

OpenTelemetryの主要コンポーネント

OpenTelemetryは主に次の3つの要素で構成されています。

要素 役割 動く場所
API / SDK(言語別) アプリケーションに組み込み、テレメトリデータを生成・送信する アプリケーションのプロセス内
OTLP(プロトコル) テレメトリデータをやり取りする標準プロトコル(gRPC / HTTP) ネットワーク上
Collector 受信したテレメトリデータを加工・転送する中継サーバー アプリとは別プロセス

本記事では、API / SDK(計装の標準化)→ 扱うシグナル → Collectorの順で各要素を解説します。

計装を標準化することのメリット

OpenTelemetry登場以前は、監視ツールごとに独自のSDKやエージェントが必要でした。

【Before】ベンダーごとにバラバラ
App → Datadog Agent → Datadog
App → Jaeger SDK → Jaeger
App → Zipkin SDK → Zipkin

OpenTelemetryは、この計装の部分を標準化します。アプリケーション側はOTelの仕様に従って計装すれば、送信先を自由に切り替えられます。

【After】OpenTelemetryで統一
App → OTel SDK → OpenTelemetry Collector → Jaeger / ClickHouse / Datadog など任意のバックエンドへ

OpenTelemetryが扱うテレメトリデータ

OpenTelemetryは次の3種類のシグナルを標準仕様として扱います。

シグナル 内容
トレース リクエストがサービスを横断する流れを追跡 ユーザーのAPIリクエストがどのサービスを通過したか
メトリクス 数値データの集計 リクエスト数、レイテンシ、エラーレート
ログ イベントの記録 エラーメッセージ、デバッグ情報

この記事では、マイクロサービス環境で最も価値が出やすいトレース(分散トレーシング)に絞ってハンズオンを行います。自動計装を使い、アプリコードを変更せずに導入するところまで試します。

トレースの基本概念

分散トレーシングを理解するために、2つの用語を押さえておきましょう。

  • Trace(トレース): 1つのリクエスト全体の流れ。複数のSpanで構成される
  • Span(スパン): 1つの処理単位。開始時刻、終了時刻、ステータスなどを持つ
Trace(1リクエスト全体)
├── Span: App API(受信 → Backend呼び出し)
│   └── Span: Backend API(処理 → レスポンス)

サービス間でTrace IDが伝搬される仕組み(Context Propagation)により、別々のサービスのSpanが1つのTraceとして紐づきます。

OpenTelemetry Collector

ここからは、前述のOpenTelemetryの3つ目の要素である Collector を詳しく見ていきます。

Collectorとは

OpenTelemetry Collector は、アプリケーションから送信されたテレメトリデータを受け取る中継サーバーです。必要に応じてデータを加工したうえで、バックエンド(Jaeger / Datadog / Grafana Tempo 等)へ転送します。OpenTelemetry プロジェクトが公式に提供するベンダー中立なコンポーネントで、単体のバイナリまたはコンテナとして動作します。

App → OpenTelemetry Collector → バックエンド(Jaeger / Datadog / Grafana Tempo 等)

Collectorを挟むメリット

アプリケーションから直接バックエンドへ送らずにCollectorを経由させることで、次のメリットがあります。

  • 送信先の切り替えをアプリ側で意識しなくてよい — アプリはOTLPでCollectorに送るだけ。Jaeger → Grafana Tempo などの切り替えはCollector側のExporter設定だけで完結する
  • アプリの負荷を下げる — バッチ化・リトライ・圧縮などをCollectorに任せられる
  • データの加工を集中管理できる — 機密情報のフィルタ、サンプリング、属性の付与などをCollectorで一元化できる

Collectorの内部構造

OpenTelemetry Collectorは、次の3つのコンポーネントを連結して処理パイプラインを構成します(トレース・メトリクス・ログそれぞれで別のパイプラインを定義できます)。

コンポーネント 役割
Receiver データを受け取る(OTLP, Prometheus等)
Processor データを加工する(バッチ化、フィルタリング等)
Exporter データを送信する(Jaeger, ClickHouse等)

構築する環境

本記事では、Kubernetes上に簡単なサンプルアプリケーションとOpenTelemetry Collectorを構築してトレースを確認します。

全体構成図

curl ──▶ App API ──▶ Backend API
           (Pod)        (Pod)
             │            │
             └─────┬──────┘

             OpenTelemetry Collector (Pod)


               Jaeger (Pod)  ← ブラウザでトレースを確認

各コンポーネントの役割

Pod 役割 使用イメージ
App API 中間API。リクエストを受けてBackend APIを呼び出す Python FastAPI
Backend API 最終API。データを返す Python FastAPI
OpenTelemetry Collector テレメトリデータの受信・加工・転送 otel/opentelemetry-collector-contrib
Jaeger トレースの保存・可視化(Web UI付き) jaegertracing/all-in-one

※本記事はCollectorの設定(Receiver → Processor → Exporter)を自分で書き、構造を理解することを目的としているため、Helmは使わずにYAMLを直接記述します。

前提条件

以下のツールがインストール済みであること。

ツール 用途 インストール
Docker Desktop コンテナ実行環境 brew install --cask docker
minikube ローカルKubernetes環境 brew install minikube
kubectl Kubernetes操作CLI brew install kubectl

※本記事のインストール例は macOS(Homebrew)を前提にしています。Linux / Windows の場合は各ツールの公式ドキュメントを参照してください。

なお、本記事ではコンテナイメージに :latest を使用しています。再現性のため、以下のバージョン要件を確認してください。

  • Jaeger all-in-one: v1.35 以降(OTLP gRPC 受信 4317 に対応したバージョン)
  • OpenTelemetry Collector (contrib): 任意の安定版
  • Python: 3.10 以降推奨(自動計装ライブラリの動作確認のため)

Kubernetesクラスタの起動

Docker Desktopを起動した状態で、minikubeを起動します。

# Kubernetesクラスタを作成・起動(Dockerドライバを指定)
minikube start --driver=docker

起動後、kubectl get nodes で1ノードが Ready になっていれば成功です。

サンプルアプリの作成

curlでApp APIを叩くと、App APIがBackend APIを呼び出してレスポンスを返すシンプルな構成です。以下のディレクトリ構成で作成します。

otel-handson/
├── app-api/
│   ├── main.py
│   ├── requirements.txt
│   └── Dockerfile
└── backend-api/
    ├── main.py
    ├── requirements.txt
    └── Dockerfile

Backend API

backend-api/main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/data")
def get_data():
    return {"message": "Hello from Backend API"}

App API

app-api/main.py
import httpx
from fastapi import FastAPI

app = FastAPI()

BACKEND_URL = "http://backend-api:8000"

@app.get("/hello")
def hello():
    response = httpx.get(f"{BACKEND_URL}/data")
    return {"backend": response.json()}

BACKEND_URLbackend-api は、KubernetesのService名として名前解決されます。

Dockerfile と requirements.txt(自動計装のポイント)

OpenTelemetryは主要言語(Python、Java、Node.js、.NET、Ruby、Go 等)で自動計装(Auto-Instrumentation)を提供しており、アプリケーションコード(main.py)を変更せずに計装を有効化できます。従来はSpanの開始・終了やContext伝搬をSDK呼び出しで main.py に書く必要がありましたが、その手間が不要になります。

App API 側は httpx を追加します。

app-api/requirements.txt
fastapi
uvicorn
httpx
opentelemetry-distro
opentelemetry-exporter-otlp
  • opentelemetry-distro — OpenTelemetryの自動計装に必要なパッケージ一式
  • opentelemetry-exporter-otlp — OpenTelemetry CollectorへOTLPプロトコルでデータを送信するExporter

Backend API 側は他サービスを呼び出さないため httpx の行は不要です。

backend-api/requirements.txt
fastapi
uvicorn
opentelemetry-distro
opentelemetry-exporter-otlp

以下のDockerfileをApp API / Backend API 両方のディレクトリに配置してください。

Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN opentelemetry-bootstrap -a install
COPY main.py .
CMD ["opentelemetry-instrument", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

通常のDockerfileとの差分は2点です。1つ目は RUN opentelemetry-bootstrap -a install の追加で、使用ライブラリを検出して計装ライブラリを自動インストールします。2つ目は起動コマンドを opentelemetry-instrument でラップする変更です。

【Before】通常の起動コマンド
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

【After】opentelemetry-instrument でラップする
CMD ["opentelemetry-instrument", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

これでFastAPIのリクエスト処理やhttpxのHTTP呼び出しが自動的にSpanとして記録され、後ほどJaeger UIで可視化されます。

opentelemetry-instrument は、プロセス起動時に計装対象ライブラリ(FastAPI、httpx 等)の関数をモンキーパッチ(実行時に差し替え)します。これにより、リクエスト処理の前後でSpanの生成・終了が自動的に行われます。main.py を一切変更しなくてよいのはこの仕組みによるものです。

Kubernetesマニフェストの作成

アプリのマニフェスト

App API、Backend APIそれぞれにDeploymentとServiceを定義します。ここではApp APIの例を示します(Backend APIも同じ構造です)。

k8s/app-api.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app-api
  template:
    metadata:
      labels:
        app: app-api
    spec:
      containers:
        - name: app-api
          image: app-api:latest
          imagePullPolicy: Never
          ports:
            - containerPort: 8000
          env:
            - name: OTEL_SERVICE_NAME
              value: "app-api"
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: "http://otel-collector:4317"
---
apiVersion: v1
kind: Service
metadata:
  name: app-api
spec:
  selector:
    app: app-api
  ports:
    - port: 8000
      targetPort: 8000

ポイント:

  • imagePullPolicy: Never — レジストリからpullせずminikube内でビルドしたローカルイメージを使用
  • OTEL_SERVICE_NAME — Jaegerに表示されるサービス名
  • OTEL_EXPORTER_OTLP_ENDPOINT — トレースの送信先(OpenTelemetry CollectorのgRPCエンドポイント)

Backend APIのマニフェストは、上記マニフェストの app-apibackend-api に置換するだけです。

OpenTelemetry Collectorのマニフェスト

公式のKubernetesマニフェスト例をベースに、学習用の最小構成としました。公式例は Agent / Gateway パターン(DaemonSetのAgentとDeploymentのGatewayを併用)ですが、ここではGateway相当のDeploymentのみに簡略化しています。

k8s/otel-collector.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-collector-conf
  labels:
    app: opentelemetry
    component: otel-collector
data:
  otel-collector-config: |
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318

    processors:
      batch: {}

    exporters:
      otlp/jaeger:
        endpoint: jaeger:4317
        tls:
          insecure: true
      debug:
        verbosity: detailed

    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [batch]
          exporters: [otlp/jaeger, debug]
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: otel-collector
  labels:
    app: opentelemetry
    component: otel-collector
spec:
  replicas: 1
  selector:
    matchLabels:
      app: opentelemetry
      component: otel-collector
  template:
    metadata:
      labels:
        app: opentelemetry
        component: otel-collector
    spec:
      containers:
        - name: otel-collector
          image: otel/opentelemetry-collector-contrib:latest
          args: ["--config", "/conf/otel-collector-config.yaml"]
          ports:
            - containerPort: 4317
            - containerPort: 4318
          volumeMounts:
            - name: otel-collector-config-vol
              mountPath: /conf
      volumes:
        - name: otel-collector-config-vol
          configMap:
            name: otel-collector-conf
            items:
              - key: otel-collector-config
                path: otel-collector-config.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: otel-collector
  labels:
    app: opentelemetry
    component: otel-collector
spec:
  selector:
    app: opentelemetry
    component: otel-collector
  ports:
    - name: otlp-grpc
      port: 4317
      protocol: TCP
      targetPort: 4317
    - name: otlp-http
      port: 4318
      protocol: TCP
      targetPort: 4318

このConfigMapには、前述のReceiver / Processor / Exporterの3コンポーネントが定義されています。OTLP(gRPC: 4317 / HTTP: 4318)で受信し、batchプロセッサでバッチ化したのち、otlp/jaeger exporterでJaegerへ転送します(debug exporterはstdoutへのデバッグ出力用)。

なお、本ハンズオンではアプリからCollectorへの送信はgRPC (4317) のみを使います(OpenTelemetry Pythonでは OTEL_EXPORTER_OTLP_PROTOCOL 未指定時のデフォルトがgRPCです)。HTTP (4318) は「どちらでも受けられる」ことを示すために定義していますが、本記事のフローでは通信していません。

DeploymentではこのConfigMapをVolumeとしてマウントし、--config引数で読み込みます。Serviceは他のPod(App API / Backend API)からhttp://otel-collector:4317で名前解決できるよう公開しています。

Jaegerのマニフェスト

公式のKubernetesデプロイテンプレートをベースとして最小構成にしました(リポジトリはアーカイブ済みですが、all-in-one構成のリファレンスとして引用しています。本番運用では Jaeger公式のデプロイドキュメント を参照してください)。公式テンプレートにはZipkinやThriftなど複数のプロトコル用ポートがありますが、今回はOTLP受信(4317)とWeb UI(16686)のみに絞っています。

k8s/jaeger.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: jaeger
  labels:
    app.kubernetes.io/name: jaeger
    app.kubernetes.io/component: all-in-one
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: jaeger
      app.kubernetes.io/component: all-in-one
  template:
    metadata:
      labels:
        app.kubernetes.io/name: jaeger
        app.kubernetes.io/component: all-in-one
    spec:
      containers:
        - name: jaeger
          image: jaegertracing/all-in-one:latest
          ports:
            - containerPort: 16686
            - containerPort: 4317
          readinessProbe:
            httpGet:
              path: "/"
              port: 14269
            initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: jaeger
  labels:
    app.kubernetes.io/name: jaeger
    app.kubernetes.io/component: all-in-one
spec:
  type: NodePort
  selector:
    app.kubernetes.io/name: jaeger
    app.kubernetes.io/component: all-in-one
  ports:
    - name: query-http
      port: 16686
      protocol: TCP
      targetPort: 16686
    - name: otlp-grpc
      port: 4317
      protocol: TCP
      targetPort: 4317

Serviceは2つのポートを公開します。query-http(16686)はブラウザからJaeger UIを開くために、otlp-grpc(4317)はOpenTelemetry Collectorからトレースを受け取るためにそれぞれ使用します。

デプロイ

Dockerイメージのビルド

通常、ローカルの docker build で作成したイメージは Docker Desktop側に保存されるため、minikube内のPodからは参照できません。そこで、docker コマンドの接続先を Docker DesktopのDockerデーモン → minikube内のDockerデーモン に切り替えてからビルドします。これにより、レジストリへのpushなしでPodからそのまま使えます。

# minikubeのDocker環境に切り替え
eval $(minikube docker-env)

# イメージをビルド
docker build -t app-api:latest app-api/
docker build -t backend-api:latest backend-api/

マニフェストの適用

kubectl apply -f k8s/jaeger.yaml
kubectl apply -f k8s/otel-collector.yaml
kubectl apply -f k8s/backend-api.yaml
kubectl apply -f k8s/app-api.yaml

起動確認

kubectl get pods

全てのPodが STATUS: Running かつ READY: 1/1 になれば成功です(Jaegerは readinessProbe を定義しているため、Running でも probeが成功するまでの数秒は READY: 0/1 で表示されます)。

NAME                              READY   STATUS    RESTARTS   AGE
app-api-5b54dd6f9d-bqkz6          1/1     Running   0          30s
backend-api-696b97d89c-b7jcl      1/1     Running   0          30s
jaeger-c9b947d75-7nls6            1/1     Running   0          30s
otel-collector-6f659fb8c6-lk27k   1/1     Running   0          30s

トレースの確認

リクエストを送る

App APIにポートフォワードしてリクエストを送ります。

kubectl port-forward svc/app-api 8000:8000

別のターミナルで:

curl -s http://localhost:8000/hello

{"backend":{"message":"Hello from Backend API"}} が返ってくれば、App API → Backend APIの通信は正常です。

Jaeger UIでトレースを確認

Jaegerにポートフォワードします。

kubectl port-forward svc/jaeger 16686:16686

ブラウザで http://localhost:16686 を開きます。

左側のServiceドロップダウンでapp-apiを選択し、「Find Traces」をクリックすると、先ほどのリクエストのトレースが表示されます。

トレースをクリックすると、タイムライン表示でSpanの親子関係が確認できます。

jaeger-trace-timeline

main.pyには一切トレースのコードを書いていません。自動計装によって、FastAPIのリクエスト処理やhttpxのHTTP呼び出しがSpanとして記録されています。さらに、サービス間でTrace IDが伝搬(Context Propagation)されていることも確認できます。

まとめ

本記事では、ローカルKubernetes上にOpenTelemetryの分散トレーシング環境を構築しました。

  • OpenTelemetryの自動計装により、アプリケーションコードを変更せずにトレースを導入できた
  • OpenTelemetry CollectorがReceiver → Processor → Exporterのパイプラインでデータを中継する仕組みを体験した
  • Jaegerでサービス間のSpan伝搬(Context Propagation)を視覚的に確認できた

OpenTelemetry CollectorのExporter設定を変えるだけで、JaegerからClickHouseやGrafana Tempoなど別のバックエンドに切り替えることも可能です。OpenTelemetryによる標準化の恩恵を実感できるハンズオンです。

参考リンク

Virtual Craft Tech Blog

Discussion