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 オブジェクトとしてデプロイしていきます。
- 名前空間 observability の作成
kubectl create ns observability
- 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
- 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 に興味を持つきっかけになれば嬉しいです。
Discussion