- Published on
アプリケーションにOpenTelemetryを付ける — 自動計測から手動スパンまで、そしてコレクタを置く理由
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — ダッシュボードはあるのになぜ遅いか分からないとき
- 順序を先に決める
- 1段階目 — 自動計測だけでどこまで行けるか
- 2段階目 — リソース属性は後から直せない
- 3段階目 — コンテキスト伝播が途切れる四つの地点
- 4段階目 — 手動スパンはself timeが大きい場所にだけ
- 5段階目 — コレクタをアプリとバックエンドの間に置く理由
- 計測がアプリを壊す方法
- おわりに — 計測の価値はスパン数ではなくトレースの完結性から生まれる
はじめに — ダッシュボードはあるのになぜ遅いか分からないとき
注文APIのp99が1.4秒です。GrafanaにはCPU、メモリ、リクエスト数、エラー率のパネルがすでにあり、すべて緑です。データベースのダッシュボードも正常です。ところがユーザーは遅いと言い、私たちはどのコードがその1.4秒を使ったのか分かりません。
この状態で必要なのはパネルをもうひとつ増やすことではなく、計測です。計測とは「何がいつどれだけ掛かったか」をコード自身に語らせる作業であり、OpenTelemetryはその語り方と伝送規約を標準化したプロジェクトです。
この記事はサービスひとつを最初から最後まで計測します。Python FastAPIサービスを例に使いますが、順序は言語に依存しません。検証基準は2026年7月時点のOpenTelemetry Collector v0.157.0、Semantic Conventions v1.43.0、Python SDK 1.3x系です。セマンティック規約は名前が変わり続けている領域が残っているため、属性名は常にそのバージョンのSemantic Conventionsレジストリで確認するほうが安全です。
順序を先に決める
計測に失敗するチームはほとんど同じやり方で失敗します。コードに手動スパンから埋め込み始め、2週間後にはスパンが300個あるのに、トレースは相変わらずサービス境界で途切れている状態になります。
うまくいく順序は以下のとおりです。
| 段階 | やること | かかる時間 | この段階を飛ばすと |
|---|---|---|---|
| 1 | 自動計測を有効にしてトレースがバックエンドに届くか確認 | 半日 | 以降のすべてのデバッグが推測になります |
| 2 | リソース属性の確定 (service.nameなど) | 半日 | 後で変えると過去データとのつながりが切れます |
| 3 | サービス境界を越える伝播が生きているか検証 | 1日 | スパンをいくら増やしてもトレースは断片化します |
| 4 | self timeが大きい区間だけに手動スパンを追加 | 継続 | 自動計測の空白が永遠に残ります |
| 5 | コレクタを前段に置き、加工とサンプリングを移管 | 1日 | ポリシーを変えるたびに全サービスを再デプロイします |
核心は3番が4番より先だということです。伝播が途切れた状態で手動スパンを追加するのは、断片化したトレースをさらに細かく砕くだけの作業です。
1段階目 — 自動計測だけでどこまで行けるか
Pythonではopentelemetry-instrumentランチャーがプロセス起動時にインストール済みの計測パッケージをフックとして付けます。コード変更はありません。
pip install \
'opentelemetry-distro[otlp]' \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-requests \
opentelemetry-instrumentation-redis \
opentelemetry-instrumentation-logging
# インストール済みの計測パッケージを自動検出して付ける
opentelemetry-bootstrap --action=install
実行は環境変数だけで制御します。これが自動計測の核心的な利点です。計測設定がコードではなくデプロイマニフェストにあるので、エンドポイントやサンプリング率を変えるのにコードレビューは要りません。
export OTEL_SERVICE_NAME=checkout-api
export OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,deployment.environment.name=prod,service.namespace=commerce
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_TRACES_SAMPLER=parentbased_always_on
export OTEL_PYTHON_LOG_CORRELATION=true
opentelemetry-instrument uvicorn app.main:app --host 0.0.0.0 --port 8000
parentbased_always_onで始めることを勧めます。最初から比率サンプリングを有効にすると、トレースが見えないときにそれが計測の問題なのかサンプリングのせいなのか区別できません。サンプリングはデータが流れているのを確認した後にコレクタで付けます。
この状態で得られるのは正確にひとつ、ネットワーク境界です。
SERVER checkout-api POST /v1/orders 1421ms
├─ CLIENT GET http://auth.internal/verify 31ms
├─ CLIENT SELECT carts WHERE id = ? 6ms
├─ CLIENT redis GET promo:rules:t-8871 2ms
├─ CLIENT POST http://payment.internal/charge 74ms
└─ (残りの1308msはどのスパンにも属さない)
最後の行がすべてです。自動計測はどこが問題「ではない」かを1308msの空白で教えてくれます。その空白をself timeと呼び、4段階目でここにだけ手動スパンを入れます。
自動計測が絶対に見られないもの
- プロセス内のCPU作業 — シリアライズ、圧縮、テンプレートレンダリング、暗号化、画像処理
- ロック待ちとコネクションプール待ち — 待機時間はコネクションを得た後のクエリスパンには含まれません
- GIL競合とイベントループの遅延
- 計測パッケージのないサードパーティSDK呼び出し
- ビジネスロジックの分岐 — どのルールが何個評価されたか
2段階目 — リソース属性は後から直せない
リソースは「このテレメトリを作った主体は何か」を説明する属性の集合です。スパン属性と違い、リソース属性はそのプロセスが出すすべての信号に付きます。そして一度決めると変えるのが難しくなります。service.nameを変えた瞬間、ダッシュボード、アラート、サービスグラフ、過去データとのつながりがすべて切れます。
# 最小集合 — この三つがないとデータがどこから来たか分からない
OTEL_SERVICE_NAME=checkout-api
OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,deployment.environment.name=prod
# 実務で追加であると便利なもの
OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,\
deployment.environment.name=prod,\
service.namespace=commerce,\
service.instance.id=checkout-api-7d9f4b-x2k9m
Kubernetesではインスタンス識別子をハードコードせず、Downward APIで注入します。
# deployment.yaml
env:
- name: OTEL_SERVICE_NAME
value: checkout-api
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OTEL_RESOURCE_ATTRIBUTES
value: >-
service.version=2.7.1,
deployment.environment.name=prod,
service.namespace=commerce,
service.instance.id=$(POD_NAME),
k8s.namespace.name=$(POD_NAMESPACE)
命名規約でよく間違える点を二つ挙げます。
第一に、環境属性の名前はdeployment.environment.nameです。古い名前であるdeployment.environmentはもう使いません。名前が違うと二つの別々の属性になり、ダッシュボードの変数はそのうちひとつしか読みません。
第二に、service.nameはデプロイ単位ではなくサービス単位であるべきです。同じコードベースをカナリアとして二つ立てたなら、どちらもcheckout-apiであり、区別はservice.versionか別の属性で行います。カナリアをcheckout-api-canaryと呼んだ瞬間、サービスグラフに幽霊ノードが生まれます。
| 属性 | 値の例 | カーディナリティ | 変えられるか |
|---|---|---|---|
| service.name | checkout-api | サービス数 | 事実上不可 |
| service.namespace | commerce | チーム数 | 難しい |
| service.version | 2.7.1 | デプロイ回数 | デプロイのたびに変わる |
| deployment.environment.name | prod | 3〜5 | 不可 |
| service.instance.id | ポッド名 | ポッド数 | 再起動のたびに変わる |
service.instance.idはカーディナリティが高いですが、リソース属性なのでトレースとログでは問題ありません。ただしこの属性をそのままメトリクスのラベルに昇格させると、時系列がポッド数だけ掛け算されます。コレクタでメトリクスパイプラインに限って取り除くのが一般的です。
3段階目 — コンテキスト伝播が途切れる四つの地点
伝播はトレースを作る唯一のメカニズムです。呼び出す側がW3Cのtraceparentヘッダーに現在のトレースIDとスパンIDを入れ、受け取る側がそれを読んで親とします。その検証はコマンド一行で済みます。
# ゲートウェイになりすましてヘッダーを直接入れ、バックエンドでこのtrace IDを検索する
curl -sS -o /dev/null -w '%{http_code}\n' \
http://checkout-api.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'content-type: application/json' \
-d '{"cart_id":"c-1"}'
# スパンがこのtrace IDの下に付かなければ、下の四つのうちどれかです
途切れ1 — スレッドプールとexecutor
もっとも多く遭遇するタイプです。コンテキストはスレッドローカル(またはasyncioのcontextvar)に入っているため、作業を別のスレッドに渡すとそのコンテキストは付いてきません。
# 途切れる — ワーカースレッドにはコンテキストがないので新しいトレースが始まる
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(max_workers=8)
def enrich_all(items):
return list(pool.map(fetch_details, items))
# 生き残る — 現在のコンテキストをキャプチャしてワーカーの中で再度有効化する
from concurrent.futures import ThreadPoolExecutor
from opentelemetry import context as otel_context
pool = ThreadPoolExecutor(max_workers=8)
def _with_context(ctx, fn, *args):
token = otel_context.attach(ctx)
try:
return fn(*args)
finally:
otel_context.detach(token)
def enrich_all(items):
ctx = otel_context.get_current()
futures = [pool.submit(_with_context, ctx, fetch_details, it) for it in items]
return [f.result() for f in futures]
JavaではContext.current().wrap(runnable)、Goではcontext.Contextをgoroutineの引数として渡すこと、Node.jsではAsyncLocalStorageが同じ役割を果たします。言語ごとに名前は違いますが原理は同じです。コンテキストは実行単位に付いてこないので、明示的に運ぶ必要があります。
途切れ2 — メッセージキュー
キューはプロセス境界であり時間境界でもあります。HTTPのようにヘッダーが自動的に流れないため、メッセージに直接注入する必要があります。
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind
tracer = trace.get_tracer("checkout", "2.7.1")
def publish_order(producer, order):
with tracer.start_as_current_span(
"orders publish", kind=SpanKind.PRODUCER
) as span:
span.set_attribute("messaging.system", "kafka")
span.set_attribute("messaging.destination.name", "orders")
headers = {}
propagate.inject(headers) # traceparentをdictに入れる
producer.send(
"orders",
value=order.to_bytes(),
headers=[(k, v.encode()) for k, v in headers.items()],
)
コンシューマー側では抽出したコンテキストを親として使います。ただしバッチで複数のメッセージを一度に処理するなら親はひとつしかないので、リンクを使います。
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind, Link
def consume_batch(messages):
links = []
for m in messages:
headers = {k: v.decode() for k, v in (m.headers or [])}
ctx = propagate.extract(headers)
sc = trace.get_current_span(ctx).get_span_context()
if sc.is_valid:
links.append(Link(sc))
with tracer.start_as_current_span(
"orders process", kind=SpanKind.CONSUMER, links=links
) as span:
span.set_attribute("messaging.batch.message_count", len(messages))
for m in messages:
handle(m)
キューの待機時間が数分以上あるなら、メッセージがひとつでもリンクを検討します。親子でつなぐと、トレースひとつの持続時間が待機時間の分だけ伸びて、バックエンドで扱いにくくなります。
途切れ3 — バックグラウンドジョブとスケジューラ
cron、Celery beat、FastAPIのBackgroundTasksのようにリクエストと無関係に動く作業には親がありません。ここでよくある間違いは、リクエストのコンテキストを無理やりつなぐことです。リクエストはすでにレスポンスを返して終わっているのに、そのトレースに30秒の子が付くと、リクエストのレイテンシ統計が汚染されます。
# バックグラウンド作業は新しいルートトレースとして始め、原因となったリクエストはリンクとして残す
def schedule_reindex(cart_id):
origin = trace.get_current_span().get_span_context()
def run():
links = [Link(origin)] if origin.is_valid else []
with tracer.start_as_current_span(
"cart.reindex", kind=SpanKind.INTERNAL, links=links
) as span:
span.set_attribute("cart.id", cart_id)
reindex(cart_id)
background.add_task(run)
途切れ4 — ヘッダーを消す中間層
プロキシ、WAF、APIゲートウェイ、CDNがホワイトリスト方式でヘッダーをフィルタリングすると、traceparentが静かに消えます。ログには何も残らず、症状は「ゲートウェイの後からトレースが新しく始まる」というものです。
# 実際に届いているヘッダーを確認するもっとも速い方法
kubectl -n commerce exec deploy/checkout-api -- \
sh -c 'timeout 20 tcpdump -A -s0 -i any "tcp port 8000" 2>/dev/null | grep -i traceparent'
# またはアプリに一時的なエンドポイントを置き、受け取ったヘッダーをそのまま返させる
curl -s http://checkout-api.internal/__debug/headers \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' | jq .
EnvoyやIstioを使っているなら、traceparent、tracestate、baggageが許可リストにあるか確認します。B3ヘッダーを使うレガシーサービスが混ざっているなら、伝播器を複数設定します。
OTEL_PROPAGATORS=tracecontext,baggage,b3multi
4段階目 — 手動スパンはself timeが大きい場所にだけ
1308msの空白に戻ります。手動スパンを入れる候補は五種類です。
- ループとバッチの境界 — 繰り返し回数を属性として残します
- キャッシュ参照 — ヒット有無を属性として残すと、キャッシュ効率がトレースで直接見えます
- 計測パッケージのないサードパーティSDK呼び出し
- ロック、キュー、コネクションプールの待機
- CPUを長く使う区間 — シリアライズ、圧縮、レポート生成
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("checkout", "2.7.1")
async def apply_promotions(cart, tenant_id):
with tracer.start_as_current_span("checkout.apply_promotions") as span:
span.set_attribute("cart.item_count", len(cart.items))
span.set_attribute("tenant.id", tenant_id)
span.set_attribute("promotion.engine", "rules-v3")
try:
with tracer.start_as_current_span("promotion.load_rules") as load:
cached = await rules.from_cache(tenant_id)
load.set_attribute("cache.hit", cached is not None)
ruleset = cached or await rules.compile(tenant_id)
load.set_attribute("promotion.rule_count", len(ruleset))
with tracer.start_as_current_span("promotion.evaluate") as ev:
result = ruleset.evaluate(cart)
ev.set_attribute("promotion.evaluated", result.evaluated)
ev.set_attribute("promotion.matched", len(result.matched))
return result
except Exception as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)))
raise
この計測を入れた後、同じリクエストのトレースはこう変わります。
SERVER checkout-api POST /v1/orders 1421ms
├─ CLIENT GET http://auth.internal/verify 31ms
├─ CLIENT SELECT carts WHERE id = ? 6ms
├─ INTERNAL checkout.apply_promotions 1298ms
│ ├─ INTERNAL promotion.load_rules cache.hit=false 1241ms <-- ここ
│ └─ INTERNAL promotion.evaluate evaluated=812 54ms
├─ CLIENT POST http://payment.internal/charge 74ms
└─ (self time 12ms)
スパン名のルールはひとつだけ守ればよいです。名前は低カーディナリティであるべきです。GET /v1/orders/A-99183ではなくGET /v1/orders/:idであり、具体的な値はすべて属性に入れます。バックエンドはスパン名でグルーピングしてレイテンシ統計とサービスグラフを作るため、名前にIDが入るとその集計ビュー全体が崩れます。
5段階目 — コレクタをアプリとバックエンドの間に置く理由
SDKがバックエンドに直接送っても動作はします。それでもコレクタを置く理由は五つあります。
- ポリシーを再デプロイなしで変えられます。 サンプリング率、属性フィルタ、保持対象は運用中に調整することになる値です。それがアプリの環境変数にあると、20個のサービスをロールアウトしなければなりません。
- アプリをバックエンド障害から隔離します。 バックエンドが遅くなったときSDKの送信キューが満杯になると、アプリのメモリが上がり、ひどい場合はリクエスト処理に影響します。コレクタが前にあれば、その圧力を代わりに受け止めます。
- バックエンドを変えられます。 メトリクスはPrometheus、トレースはClickHouse、ログはOpenSearchのように信号ごとに違う送り先を使ったり、二つのバックエンドを並行運用しながら移行したりする作業が、コレクタの設定ファイル一箇所で完結します。
- 機微な情報をアプリの外で消せます。 トークンやメールアドレスが属性に混ざり込む事故は必ず起きます。コレクタに防御線を置いておけば、事故対応が再デプロイではなく設定変更で済みます。
- テイルサンプリングができます。 トレースの結果を見て判断するには、スパンが一箇所に集まる必要があり、その場所はアプリにはなりえません。
バッチサイズ、リトライ、メモリ上限を設定する最小構成です。
# otel-collector.yaml — アプリと同じノードまたはサイドカーに置くエージェント層
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# 必ず最初。メモリ上限に達すると受信を拒否し、コレクタ自体が死ぬのを防ぐ
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
# Kubernetesのメタデータをリソース属性として付ける
k8sattributes:
auth_type: serviceAccount
extract:
metadata:
- k8s.namespace.name
- k8s.deployment.name
- k8s.pod.name
- k8s.node.name
# 機微な属性の除去 — アプリを直さずここで止める
attributes/redact:
actions:
- key: http.request.header.authorization
action: delete
- key: user.email
action: delete
- key: db.query.text
action: hash
# 常に最後。ネットワーク往復を減らす
batch:
timeout: 5s
send_batch_size: 8192
send_batch_max_size: 16384
exporters:
otlp/gateway:
endpoint: otel-gateway.observability.svc:4317
tls:
insecure: true
sending_queue:
enabled: true
num_consumers: 10
queue_size: 5000
retry_on_failure:
enabled: true
initial_interval: 5s
max_elapsed_time: 300s
service:
telemetry:
metrics:
level: detailed
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, attributes/redact, batch]
exporters: [otlp/gateway]
metrics:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp/gateway]
logs:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, attributes/redact, batch]
exporters: [otlp/gateway]
プロセッサの順序には意味があります。memory_limiterが最初でないと過負荷時にコレクタがOOMで死に、batchが最後でないと後続のプロセッサがバッチを再び分割して恩恵が消えます。Collectorのドキュメントも同じ順序を推奨しています。
コレクタを二層に分けるのが一般的です。アプリの隣のエージェントは収集とメタデータの付与だけを行い、ゲートウェイ層でテイルサンプリングとバックエンドへのルーティングを行います。テイルサンプリングを使うなら、ゲートウェイの前にtrace ID基準のルーティングが必要です。同じトレースのスパンが別々のインスタンスに散らばると、それぞれが自分の断片だけを見て判定するため、トレースが無作為に切られます。
計測がアプリを壊す方法
ハッピーパスだけを見せるガイドは役に立たないので、実際に遭遇する失敗を集めておきます。
| 症状 | 原因 | 確認方法 | 対応 |
|---|---|---|---|
| デプロイ後にメモリが増え続ける | バックエンドの応答遅延でエクスポータのキューが満杯のまま | コレクタのキューサイズメトリクスとアプリのRSS推移 | キューサイズの上限とドロップポリシーを明示、コレクタ経由に切り替え |
| スパンが一部しか届かない | プロセス終了時にflushなしで死ぬ | バッチプロセッサのタイムアウトと終了フック | 終了時にshutdownを呼ぶ、コンテナのterminationGracePeriodを拡大 |
| スパンひとつが数百KB | リクエスト本文全体を属性に入れている | バックエンドのスパンサイズ分布 | 属性値の長さ上限を設定 |
| レイテンシが目に見えて増加 | 同期エクスポータ、またはホットループ内でのスパン生成 | 計測前後のベンチマーク | バッチプロセッサを使う、ループの内部ではなく境界にスパンを置く |
| トレースがゲートウェイで新しく始まる | プロキシがヘッダーを除去 | ヘッダーダンプ | 許可リストにtraceparentを追加 |
| サービスグラフに幽霊ノード | カナリアを別のservice.nameでデプロイ | リソース属性の確認 | service.nameはサービス単位で固定 |
属性のサイズはSDKレベルで上限を掛けておけます。デフォルトは属性数128個で値の長さには制限がないため、明示的に指定するほうが安全です。
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048
OTEL_BSP_MAX_QUEUE_SIZE=4096
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_BSP_SCHEDULE_DELAY=2000
終了時のflushは言語ごとに違います。Pythonの自動計測は正常終了時にプロバイダをshutdownしますが、SIGKILLで死ぬとキューに残っていたスパンは消えます。短いバッチジョブなら明示的にflushするほうが確実です。
from opentelemetry import trace
def main():
run_job()
# バッチジョブは必ず明示的に空にする
trace.get_tracer_provider().force_flush(timeout_millis=10_000)
trace.get_tracer_provider().shutdown()
計測が終わったと言える基準
このチェックリストを通過してはじめて次の段階に進みます。
- 任意のプロダクションリクエストをひとつ選んでトレースを開いたとき、関与したサービス数とSERVERスパン数が一致する
- ルートスパンの持続時間がゲートウェイのアクセスログの応答時間と誤差範囲内で一致する
- もっとも大きいself timeが全体の20%未満である
- トレースからtrace IDをコピーしてログ検索に入れると、該当リクエストのログが出てくる
- スパン名の一覧をカーディナリティ順に並べたとき、上位20個がルートテンプレートであってIDではない
- メッセージキューを越える作業がひとつのトレースまたはリンクでつながっている
- コレクタを再起動してもアプリが影響を受けない
最後の項目は実際にやってみて初めて分かります。コレクタを一度落としてみて、アプリのエラー率とレイテンシが揺れないか確認します。揺れるなら、エクスポータが同期的であるかキューポリシーが間違っています。
おわりに — 計測の価値はスパン数ではなくトレースの完結性から生まれる
スパン500個の断片化したトレースより、スパン12個の完結したトレースのほうが圧倒的に有用です。だから投資の順序も決まります。伝播が途切れないことが第一で、リソース属性が一貫していることが第二であり、手動スパンは最後です。
今すぐできるもっとも安価な検証は、プロダクションのトレースをひとつ開いてSERVERスパンを数えてみることです。リクエストが通過したサービスが六つなのにSERVERスパンが二つなら、今週やるべきことは手動スパンの追加ではなく、残り四箇所の伝播を生き返らせることです。
さらに掘り下げるための資料です。