🚚

OpenTelemetry Protocol(OTLP)の気持ちになってみる

に公開

はじめに

OpenTelemetry (OTel) を使ったトレース収集は様々なツールやエージェントが関わりますが、仕組みを理解するには「トレースデータがどう流れるか」を把握するのが一番だと考えます。
「パケットの気持ちになってみるのが大事」と逸般の誤家庭でもよく言われますよね

今回は、トレースデータ(OTLP フォーマット)を自分で手書きし、それを curl コマンドで OpenTelemetry Collector(以下、otel-collector)に送信して Jaeger で可視化する、という体験を通じて OTLP の気持ちを追体験してみます。

本記事は、以下のような方におすすめです:

  • OTLP フォーマットでのトレースデータ送信を手動で試してみたい
  • Kubernetes クラスター上で OpenTelemetry Collector と Jaeger を動かしてみたい
  • データが可視化されるまでの流れを具体的に理解したい

OTLP とは(簡単に)

OTLP(OpenTelemetry Protocol)は、OpenTelemetry が定義する観測データの送受信に使う標準プロトコルです。

従来、トレース・メトリクス・ログを転送するには、それぞれ異なる形式やプロトコル(例: Jaeger 用の Thrift、Prometheus 用の HTTP エンドポイントなど)が必要でした。OpenTelemetry はこの複雑さを解消するために、統一されたフォーマットと転送方式として OTLP を採用しています。

OTLP の主な特徴は以下の通りです:

  • トレース / メトリクス / ログを一つのプロトコルで扱える
  • gRPC または HTTP(JSON または Protobuf)で送信可能
  • OpenTelemetry Collector や様々な Backend(例: Jaeger、Prometheus、Datadog 等)と連携しやすい
  • ベンダーニュートラルで拡張性が高い

本記事では、特に OTLP/HTTP + JSON を用いて、手動でトレースデータを送信する方法を解説します。

環境準備

本検証のアーキテクチャ概要

トレースの送信を体験するために、下記のような環境を構築します。

+------------------------------+
|                    Local PC  |
|  +-----------------------+   |
|  |  curl (OTLP JSON)     |   |
|  +----------+------------+   |
|             |                |
|      kubectl proxy           |
|             |                |
+-------------|----------------+
              v
+------------------------------+
|           Kubernetes Cluster |
|  +------------------------+  |
|  | otel-collector Pod     |  |
|  |  - Receiver: OTLP/HTTP |  |
|  |  - Exporter: OTLP/HTTP |  |
|  +----------+-------------+  |
|             |                |
|             v                |
|    +--------------------+    |
|    |  Jaeger Pod        |    |
|    |  - OTLP/HTTP       |    |
|    |  - Trace UI        |    |
|    +--------------------+    |
+------------------------------+

Kubernetes クラスター(kind)の構築

お手持ちに Kubernetes クラスターが無い場合は、例えば kind を使ってローカルに Kubernetes クラスターを立てます。
Mac ユーザーの方は こちら の記事も参考にしてください。

OpenTelemetry Collector & Jaeger のデプロイ

Kubernetes クラスター構築後は、OpenTelemetry Collector と Jaeger を Kubernetes オブジェクトとしてデプロイしていきます。

  1. 名前空間 observability の作成
kubectl create ns observability
  1. Jaeger Pod ならびに Otel Collector Pod の導入

jaeger.yaml は Jaeger の all-in-one 構成、otel-collector.yaml は Collector が HTTP 経由で OTLP 受信・送信を行う構成になっています。

jaeger.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: jaeger
  namespace: observability
spec:
  replicas: 1
  selector:
    matchLabels:
      app: jaeger
  template:
    metadata:
      labels:
        app: jaeger
    spec:
      containers:
        - name: jaeger
          image: jaegertracing/all-in-one:latest
          ports:
            - containerPort: 16686  # UI
            - containerPort: 4318   # OTLP HTTP
          env:
            - name: COLLECTOR_OTLP_ENABLED
              value: "true"
            - name: COLLECTOR_OTLP_HTTP_HOST_PORT
              value: ":4318"
---
apiVersion: v1
kind: Service
metadata:
  name: jaeger
  namespace: observability
spec:
  selector:
    app: jaeger
  ports:
    - name: ui
      port: 16686
      targetPort: 16686
    - name: otlp-http
      port: 4318
      targetPort: 4318

otel-collector.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: otel-collector
  namespace: observability
spec:
  replicas: 1
  selector:
    matchLabels:
      app: otel-collector
  template:
    metadata:
      labels:
        app: otel-collector
    spec:
      containers:
        - name: otel-collector
          image: otel/opentelemetry-collector-contrib:latest
          args: ["--config=/conf/otel-config.yaml"]
          volumeMounts:
            - name: config-volume
              mountPath: /conf
          ports:
            - containerPort: 4318  # OTLP HTTP
      volumes:
        - name: config-volume
          configMap:
            name: otel-collector-config
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-collector-config
  namespace: observability
data:
  otel-config.yaml: |
    receivers:
      otlp:
        protocols:
          http:
            endpoint: "0.0.0.0:4318"

    exporters:
      otlphttp:
        endpoint: http://jaeger.observability.svc.cluster.local:4318
        tls:
          insecure: true

    service:
      pipelines:
        traces:
          receivers: [otlp]
          exporters: [otlphttp]
      telemetry:
        logs:
          level: "debug"
---
apiVersion: v1
kind: Service
metadata:
  name: otel-collector
  namespace: observability
spec:
  selector:
    app: otel-collector
  ports:
    - name: otlp-http
      port: 4318
      targetPort: 4318
kubectl apply -f jaeger.yaml
kubectl apply -f otel-collector.yaml
  1. Jaeger UI にアクセスする

kubectl proxy & コマンドを実行後、ブラウザで Jaeger UI http://localhost:8001/api/v1/namespaces/observability/services/jaeger:16686/proxy/search にアクセスします。

kubectl proxy &
open http://localhost:8001/api/v1/namespaces/observability/services/jaeger:16686/proxy/search


(サービス名 jaeger-all-in-one はデフォルトで取得されます)

実際にトレースデータを curl で送ってみる

今回の肝であるトレースデータ(OTLP フォーマット)を手書きで作成し、OpenTelemetry Collector 経由で Jaeger に送信してみます。

単一トレースデータ(単一スパン)の送信

まずは、単一トレースデータ(単一スパン)を作成します。トレースデータの OTLP 形式はこちらを参考にし、任意の値を入れていきます。

start_time=$(($(date +%s%N) - 5000000))
end_time=$(date +%s%N)

cat <<EOF > sample-span.json
{
  "resourceSpans": [
    {
      "resource": {
        "attributes": [
          {
            "key": "service.name",
            "value": { "stringValue": "sample-client" }
          }
        ]
      },
      "scopeSpans": [
        {
          "scope": {
            "name": "sample-lib",
            "version": "0.1.0"
          },
          "spans": [
            {
              "traceId": "aabbccddeeff00112233445566778899",
              "spanId": "9999999999999999",
              "name": "sample-span",
              "kind": "SPAN_KIND_CLIENT",
              "startTimeUnixNano": $start_time,
              "endTimeUnixNano": $end_time,
              "attributes": [
                {
                  "key": "http.method",
                  "value": { "stringValue": "GET" }
                },
                {
                  "key": "http.url",
                  "value": { "stringValue": "https://example.com" }
                }
              ],
              "status": {
                "code": "STATUS_CODE_OK"
              }
            }
          ]
        }
      ]
    }
  ]
}
EOF

そして、上記で作成した OTLP 形式のトレースデータ(sample-span.json)を curl コマンドで otel-collector に送信します。

curl -X POST \
  http://localhost:8001/api/v1/namespaces/observability/services/otel-collector:4318/proxy/v1/traces \
  -H "Content-Type: application/json" \
  -d @sample-span.json

すると、送信したデータが Jaeger UI で可視化されていることが確認できます。

もしデータが表示されない場合は、otel-collector pod や jaeger pod のログを確認し、エラーが発生していないか等について確認してください。以下は、正しく送信できているときのログ

kubectl logs -n observability deploy/otel-collector
...
2025-05-30T05:32:59.654Z	debug	otlphttpexporter@v0.127.0/otlp.go:177	Preparing to make HTTP request	{"resource": {}, "otelcol.component.id": "otlphttp", "otelcol.component.kind": "exporter", "otelcol.signal": "traces", "url": "http://jaeger.observability.svc.cluster.local:4318/v1/traces"}

複数トレースデータ(複数スパン)の送信

単一スパンでは可視化にあまり面白味がないため、複数スパンにもチャレンジしてみます。
ここでは簡単にトレースデータを作成するため、以下のシェルスクリプトを実行します(1 つの親スパンと 2 つの子スパン、計 3 つのトレースデータを定義し、JSON ファイルとして保存します)。

trace.sh

#!/bin/bash

# ランダムな 16 バイト (128bit) の traceId を生成(hexで32文字)
TRACE_ID=$(xxd -l 16 -p /dev/urandom)

# 親 span ID(8バイト = 16桁 hex)
PARENT_SPAN_ID="1111111111111111"

# 子 span ID(固定でも可、またはランダムにしてもよい)
CHILD1_SPAN_ID="2222222222222222"
CHILD2_SPAN_ID="3333333333333333"

# 現在時刻(ナノ秒)を基準にして span の時刻を設定
NOW=$(date +%s%N)

PARENT_START=$((NOW - 10000000))
PARENT_END=$((NOW - 5000000))

CHILD1_START=$((NOW - 9000000))
CHILD1_END=$((NOW - 7000000))

CHILD2_START=$((NOW - 8000000))
CHILD2_END=$((NOW - 6000000))

# trace-parent.json の出力
cat <<EOF > trace-parent.json
{
  "resourceSpans": [
    {
      "resource": {
        "attributes": [
          {
            "key": "service.name",
            "value": { "stringValue": "sample-client" }
          }
        ]
      },
      "scopeSpans": [
        {
          "scope": {
            "name": "sample-lib",
            "version": "0.1.0"
          },
          "spans": [
            {
              "traceId": "$TRACE_ID",
              "spanId": "$PARENT_SPAN_ID",
              "name": "HTTP GET /api/data",
              "kind": "SPAN_KIND_SERVER",
              "startTimeUnixNano": $PARENT_START,
              "endTimeUnixNano": $PARENT_END,
              "attributes": [
                {
                  "key": "http.method",
                  "value": { "stringValue": "GET" }
                },
                {
                  "key": "http.route",
                  "value": { "stringValue": "/api/data" }
                },
                {
                  "key": "http.status_code",
                  "value": { "intValue": 200 }
                }
              ],
              "status": {
                "code": "STATUS_CODE_OK"
              }
            }
          ]
        }
      ]
    }
  ]
}
EOF

# trace-children.json の出力
cat <<EOF > trace-children.json
{
  "resourceSpans": [
    {
      "resource": {
        "attributes": [
          {
            "key": "service.name",
            "value": { "stringValue": "sample-client" }
          }
        ]
      },
      "scopeSpans": [
        {
          "scope": {
            "name": "sample-lib",
            "version": "0.1.0"
          },
          "spans": [
            {
              "traceId": "$TRACE_ID",
              "spanId": "$CHILD1_SPAN_ID",
              "parentSpanId": "$PARENT_SPAN_ID",
              "name": "SELECT FROM users",
              "kind": "SPAN_KIND_CLIENT",
              "startTimeUnixNano": $CHILD1_START,
              "endTimeUnixNano": $CHILD1_END,
              "attributes": [
                {
                  "key": "db.system",
                  "value": { "stringValue": "postgresql" }
                },
                {
                  "key": "db.statement",
                  "value": { "stringValue": "SELECT * FROM users WHERE id=1" }
                }
              ],
              "status": {
                "code": "STATUS_CODE_OK"
              }
            },
            {
              "traceId": "$TRACE_ID",
              "spanId": "$CHILD2_SPAN_ID",
              "parentSpanId": "$PARENT_SPAN_ID",
              "name": "GET https://api.external.com/info",
              "kind": "SPAN_KIND_CLIENT",
              "startTimeUnixNano": $CHILD2_START,
              "endTimeUnixNano": $CHILD2_END,
              "attributes": [
                {
                  "key": "http.method",
                  "value": { "stringValue": "GET" }
                },
                {
                  "key": "http.url",
                  "value": { "stringValue": "https://api.external.com/info" }
                },
                {
                  "key": "http.status_code",
                  "value": { "intValue": 200 }
                }
              ],
              "status": {
                "code": "STATUS_CODE_OK"
              }
            }
          ]
        }
      ]
    }
  ]
}
EOF

echo "traceId: $TRACE_ID"
echo "trace-parent.json と trace-children.json を生成しました"

実行例

chmod +x trace.sh
./trace.sh
traceId: dda9bbbd99e4aea9a76a71f22cd18ac4
trace-parent.json と trace-children.json を生成しました

得られた 2 つの JSON ファイルをもとに、同様に curl コマンドを実行します。

curl -X POST \
  http://localhost:8001/api/v1/namespaces/observability/services/otel-collector:4318/proxy/v1/traces \
  -H "Content-Type: application/json" \
  -d @trace-parent.json

curl -X POST \
  http://localhost:8001/api/v1/namespaces/observability/services/otel-collector:4318/proxy/v1/traces \
  -H "Content-Type: application/json" \
  -d @trace-children.json

Jaeger UI を参照し、以下のような可視化が行われていることを確認します。関連する 3 つのスパンが表示されれば成功です。

おわりに

トレースの送信は、既存の機能やライブラリ任せにすることがほとんどですが、手動で作って送ってみることで、実際にトレースがどのような構造でどのように collector を通っていくのか、解像度が高まったのではないでしょうか。
特にトラブル時には「Collector に届いているのか?」「フォーマットが不正なのか?」など、今回の手動送信の知識が役立つはずです。

本記事が Observarbilliy や OpenTelemetry に興味を持つきっかけになれば嬉しいです。

Datadog Tech Blog

Discussion