ログとメトリクスだけではproductionのデバッグに不十分なとき
マイクロサービスシステムで遅いリクエストのデバッグに3時間費やしたことがあります——Lokiからのログも、Prometheusからのメトリクスも十分あったのに、それでもどのサービスがボトルネックなのかわかりませんでした。ログは「何かがおかしい」と示し、メトリクスは「latencyが上昇している」と示します。しかし、それらをつなぐものが何もない:リクエストはどのサービスを通ったのか?各ステップでどれだけ時間がかかったのか?どこで失敗したのか?
そこでDistributed TracingとGrafana Tempoを調べ始めました。この記事ではセットアップに直接入ります:TempoをゼロからインストールしてLokiとPrometheusに接続し、一つのtraceから関連するlogsとそのサービスのmetricsへ直接ジャンプできるよう設定します——すべて一つのGrafana内で。
Distributed Tracingとは何か、なぜTempoを選ぶのか
最もわかりやすいイメージは、各リクエストにGPSをつけることです。リクエストがservice-auth、service-order、service-paymentを通過するとき——各ステップで「span」が生成されます。すべてのspanをつなぎ合わせると一つの「trace」となり、リクエスト全体の経路が見えてきます:どこを通り、どこで滞留し、どこで失敗したのか。
Grafana Tempoはtraceをオブジェクトストレージ(local、S3、GCS)にインデックスなしで直接保存します——つまり、Jaegerのようにエラスティクサーチやカサンドラをメンテナンスする必要がありません。決め手となったポイント:TempoはLokiとPrometheusと一緒に一つのGrafana上で動くよう構築されているため、traceからlogsへ、metricsへのジャンプはネイティブにサポートされており、プラグインの追加インストールや手動リンクの作成は不要です。
Docker ComposeでTempoをインストールする
ディレクトリ構成の準備
mkdir -p tempo-stack/{tempo,grafana/provisioning/datasources}
cd tempo-stack
Tempoの設定ファイル
tempo/tempo.yamlを作成します:
stream_over_http_enabled: true
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
jaeger:
protocols:
thrift_http:
endpoint: 0.0.0.0:14268
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 24h # productionでは168hに増やす
storage:
trace:
backend: local
local:
path: /tmp/tempo/blocks
wal:
path: /tmp/tempo/wal
metrics_generator:
registry:
external_labels:
source: tempo
cluster: docker-compose
storage:
path: /tmp/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
overrides:
defaults:
metrics_generator:
processors: [service-graphs, span-metrics]
generate_native_histograms: both
Docker Compose
docker-compose.ymlを作成します:
version: "3.8"
services:
tempo:
image: grafana/tempo:latest
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./tempo/tempo.yaml:/etc/tempo.yaml
- tempo-data:/tmp/tempo
ports:
- "3200:3200" # Tempo HTTP API
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "14268:14268" # Jaeger HTTP
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--enable-feature=remote-write-receiver" # TempoがメトリクスをプッシュするためTempo側で必須
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
ports:
- "9090:9090"
restart: unless-stopped
loki:
image: grafana/loki:latest
ports:
- "3100:3100"
restart: unless-stopped
grafana:
image: grafana/grafana:latest
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- grafana-data:/var/lib/grafana
ports:
- "3000:3000"
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
restart: unless-stopped
volumes:
tempo-data:
prometheus-data:
grafana-data:
重要な注意点:Prometheusはmetricsジェネレーターからmetricsを受信するために、--enable-feature=remote-write-receiverフラグを必ず有効化する必要があります。最初にこれを忘れてしまい、service-graphがGrafanaに何も表示されない原因を特定するのにかなり時間がかかりました。
Correlationを機能させるためのDatasource設定
Tempoをインストールした後に多くの人が見落とすステップです——そして、traceをクリックしてもlogsに飛べない原因を不思議に思います。grafana/provisioning/datasources/datasources.yamlを作成します:
apiVersion: 1
datasources:
- name: Tempo
type: tempo
uid: tempo
url: http://tempo:3200
jsonData:
httpMethod: GET
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: "-1m"
spanEndTimeShift: "1m"
filterByTraceID: true
filterBySpanID: false
tracesToMetrics:
datasourceUid: prometheus
spanStartTimeShift: "-1m"
spanEndTimeShift: "1m"
tags:
- key: service.name
value: service
queries:
- name: Request Rate
query: rate(traces_spanmetrics_calls_total{$$__tags}[5m])
- name: Error Rate
query: rate(traces_spanmetrics_calls_total{$$__tags,status_code="STATUS_CODE_ERROR"}[5m])
- name: Duration P95
query: histogram_quantile(0.95, sum(rate(traces_spanmetrics_duration_milliseconds_bucket{$$__tags}[5m])) by (le))
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
lokiSearch:
datasourceUid: loki
- name: Loki
type: loki
uid: loki
url: http://loki:3100
jsonData:
derivedFields:
- datasourceUid: tempo
matcherRegex: '"trace_id":"(\w+)"'
name: TraceID
url: "$${__value.raw}"
urlDisplayLabel: "View Trace in Tempo"
- name: Prometheus
type: prometheus
uid: prometheus
url: http://prometheus:9090
isDefault: true
この設定は主に3つのことを行います:
- tracesToLogsV2:Tempo内の一つのspanから、そのspanの時間範囲のログをLokiから自動的にクエリします——タイムスタンプを手動で指定したりフィルタリングしたりする必要はありません
- tracesToMetrics:traceからPrometheusにジャンプして、そのサービスのrequest rate、error rate、P95 latencyを確認します
- Loki derivedFields:
trace_idを含むログエントリから、Tempo内の対応するtraceへの直接リンクを生成します
アプリケーションからTempoにtraceを送信する
TempoはJaeger、Zipkin、OTLPなど複数のプロトコルでtraceを受け取ります。OTLPはオープンスタンダードであり、ほとんどのモダンフレームワークが標準でサポートしているため、最良の選択です。Pythonでの例:
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc opentelemetry-instrumentation-fastapi
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
resource = Resource.create({
"service.name": "my-api",
"service.version": "1.0.0",
})
provider = TracerProvider(resource=resource)
otlp_exporter = OTLPSpanExporter(
endpoint="http://localhost:4317",
insecure=True,
)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
# コード内での使用例:
with tracer.start_as_current_span("process-order") as span:
span.set_attribute("order.id", "12345")
span.set_attribute("user.id", "user-abc")
# ... ビジネスロジックをここに記述
Traces → Logsのcorrelationを機能させるには、アプリケーションのログにtrace_idを含める必要があります。このフィルターをloggerに追加します:
import logging
from opentelemetry import trace
class TraceIDFilter(logging.Filter):
def filter(self, record):
span = trace.get_current_span()
ctx = span.get_span_context()
record.trace_id = format(ctx.trace_id, "032x") if ctx.is_valid else ""
return True
# LokiがパースしてGrafanaがtrace_idを抽出できるようJSON形式で
handler = logging.StreamHandler()
handler.addFilter(TraceIDFilter())
handler.setFormatter(logging.Formatter(
'{"time": "%(asctime)s", "level": "%(levelname)s", "msg": "%(message)s", "trace_id": "%(trace_id)s"}'
))
logging.getLogger().addHandler(handler)
動作確認と実際の使い方
スタックの起動と動作確認
docker compose up -d
# Tempoが起動していることを確認
curl http://localhost:3200/status
# 受信したtraceを確認(アプリがデータを送信した後)
curl "http://localhost:3200/api/search?limit=5"
TraceQLでクエリを実行する
TraceQLはTempoに固有のクエリ言語です——PromQLに似た構文ですが、metricsではなくtraceに対して動作します。Grafana → Explore → datasourceでTempoを選択して試してみましょう:
# 特定のサービスのtraceを検索
{ .service.name = "my-api" }
# エラーのあるtraceを検索
{ status = error }
# 1秒より遅いtraceを検索
{ duration > 1s }
# 組み合わせ:my-apiのエラーリクエストで500ms超
{ .service.name = "my-api" && status = error && duration > 500ms }
実際のデバッグワークフロー
以前は、latencyのアラートが来ると、3〜4タブを並べて開いていました:Prometheusでmetricを確認し、Lokiでログをgrepして、タイムスタンプを手動で合わせて影響を受けたリクエストを特定する。このスタックをセットアップしてから、残るフローはこれだけです:
- AlertmanagerからLatency増加のアラートを受信
- Grafana Explore → Tempoで
{ duration > 2s }をフィルタリングして、遅いリクエストをすぐに特定 - traceをクリックして、最も時間を占めているspanを確認
- 「Logs for this span」をクリック → Lokiがそのspanの時間範囲に対応するログを自動でフィルタリング
- 「Metrics」をクリック → Prometheusがその時点でのサービスのrequest rateとリソース使用量を表示
調査にかかる時間は30分から5分に短縮され、チームはアラートを無視するのではなく信頼するようになりました。
Productionで運用する際のポイント
- Sampling:100%のtraceを送信しないでください。ほとんどのシステムでは10〜20%のHead-based samplingで十分です。エラーのあるtraceを100%保持したい場合はTail-based samplingを使用します。
- Object storage:コンテナの再起動でデータを失わないよう、
backend: localをS3またはGCSに変更してください - Retention:開発環境では24時間、Productionではコンプライアンス要件に応じて7〜30日間
# Production: localの代わりにS3を使用
storage:
trace:
backend: s3
s3:
bucket: my-tempo-traces
endpoint: s3.amazonaws.com
region: ap-northeast-1
access_key: ${S3_ACCESS_KEY}
secret_key: ${S3_SECRET_KEY}
このセットアップがあれば、次にlatencyのアラートが来ても、私のように3時間も原因を探し回る必要はなくなります。一つのTrace IDから関連するlogsとmetricsへのジャンプはわずか数秒——インシデントのすべてのコンテキストが一つのGrafana画面に収まっています。

