ObservabilityLXXI · Tempo at ScaleTempoScale
Tempo Microservices Mode
What you'll learn
- Name the six Tempo roles and the boundary each one owns in the read and write path
- Choose the correct role to scale first for read load, write load, and bucket growth
- Configure each role as its own workload with the right address wiring between roles
- Diagnose the failure shape unique to microservices mode (role address drift, missing compactor, ring churn)
- Apply ring KV choices (memberlist vs etcd) and replication factors appropriate for a multi-replica ingester
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
At 02:14 a payment-svc trace query returned the right trace in
under a second. At 02:47 the same query returned the same trace
in twenty-two seconds. The metric tempo_querier_query_seconds
jumped with it. The on-call engineer, trained on
simple-scalable mode, restarted the distributor pod and watched
the metric get worse. The distributor was not the bottleneck.
The querier pool was at saturation. The fix was three additional
querier pods and a query-frontend in front of them, both
deployed with target: querier and target: query-frontend.
This lesson is the entry point for a microservices Tempo deployment: the six roles, the wiring between them, and the order in which to scale them.
What it is
Microservices mode runs each Tempo role as its own process and
its own workload. The same tempo binary plays the role selected
by the top-level target flag and the YAML keys present. Six
roles cover the whole pipeline:
Role Stateful? Scales on Default target
------------------ ----------- ------------------------ -------------------
distributor no spans per second distributor
ingester yes spans per second + RF ingester
querier no concurrent queries querier
query-frontend no long TraceQL queries query-frontend
compactor no bucket block count compactor
metrics-generator no derived RED cardinality metrics-generator
The first three roles are mandatory. The query-frontend is optional but recommended once the querier pool exceeds two pods. The compactor is mandatory in any deployment that retains data beyond the flush window. The metrics-generator is optional and runs as a sidecar to the querier.
Why a sysadmin cares
Three operational pains push teams from simple-scalable to microservices:
- One role is the bottleneck and the rest are idle. A platform with heavy ingest but light query load wastes CPU when distributor and querier share a process. Splitting them lets each scale on its own metric.
- Failure isolation. A crash of the ingester in simple-scalable mode also restarts the querier running in the same process. In microservices only the ingester pod restarts; queriers keep serving from compacted blocks.
- Multi-tenant isolation. Multi-tenant deployments need per-tenant rate limits at the distributor and per-tenant compaction at the compactor. Both are easier when each role is its own workload with its own config map.
The cost is operational surface. A microservices cluster needs six or more StatefulSets / Deployments, a per-role SLO, and an operator who can read ring state.
How it works
The pipeline stays the same as in simple-scalable mode. The difference is that each box in the picture is its own pod, with its own address, and the wiring between them is explicit.
Application / Collector
|
v
+-------------------+ +-------------------+
| distributor x N | ---> | ingester x N | ---> S3 / GCS / Azure
| OTLP / Jaeger / | | head blocks + WAL |
| Zipkin receivers | | ring, RF=3 |
+-------------------+ +-------------------+
^
| (tail block reads)
+-------------------+ |
| query-frontend |---+ (cache, split, fan-out)
+-------------------+
|
v
+-------------------+
| querier x N |
| TraceQL execution |
+-------------------+
^
|
+-------------------+
| compactor (one) |----> walks S3, merges blocks
+-------------------+
+-------------------+
| metrics-generator |----> service-graph metrics
+-------------------+
The roles, in order of how often they need attention:
- Distributor. Stateless. Validates spans, applies per-tenant rate limits, hashes the trace ID, and forwards the span to the three ingesters that own that hash range. The distributor is CPU-bound on JSON / thrift decoding.
- Ingester. Stateful. Accumulates spans into head blocks,
fsyncs the head block to a WAL on local disk, and flushes
completed blocks to object storage. Runs as a
StatefulSetwith amemberlistoretcdring KV. - Querier. Stateless. On a TraceQL or trace-by-ID request, fetches blocks from object storage, reconstructs traces, returns results. CPU- and network-bound on block fetches.
- Query-frontend. Stateless. Splits long TraceQL queries into subqueries, caches subresults in a FIFO cache, and parallelises work across queriers. Required when the querier pool exceeds two pods.
- Compactor. Mostly stateless. Walks the bucket, merges small blocks into larger ones, deduplicates blocks that share trace IDs. Runs as a singleton by default; sharded compaction exists in recent versions but requires explicit configuration.
- Metrics-generator. Stateless. Reads spans from the ingester pipeline and derives Prometheus metrics: service graphs and span-derived RED metrics. Runs as a querier sidecar.
How to configure it
A minimal microservices config pins every role and the wiring between them. The values below match the official Helm chart defaults but are written out for inspection:
server:
http_listen_port: 3200
# Shared by all roles.
distributor:
receivers:
otlp:
protocols:
grpc: { endpoint: '0.0.0.0:4317' }
http: { endpoint: '0.0.0.0:4318' }
jaeger:
protocols:
grpc: { endpoint: '0.0.0.0:14250' }
thrift_http: { endpoint: '0.0.0.0:14268' }
ring:
kvstore:
store: memberlist
ingester:
trace_idle_period: 10s
max_block_duration: 30m
lifecycler:
ring:
kvstore:
store: memberlist
replication_factor: 3
heartbeat_period: 5s
join_after: 10s
observe_period: 10s
querier:
frontend_worker:
frontend_address: tempo-query-frontend:9095
query_frontend:
max_concurrent_queries: 200
results_cache:
cache:
embedded_cache:
max_size_items: 1024
ttl: 1h
compactor:
compaction:
block_retention: 48h
compaction_window: 1h
ring:
kvstore:
store: memberlist
metrics_generator:
registry:
external_labels:
source: tempo
storage:
trace:
backend: s3
s3:
bucket_name: tempo-traces-prod
region: eu-west-1
access_key: ${AWS_ACCESS_KEY_ID}
secret_key: ${AWS_SECRET_ACCESS_KEY}
wal:
path: /var/tempo/wal
metrics:
backend: s3
s3:
bucket_name: tempo-metrics-prod
region: eu-west-1
Three details to call out:
distributor.receiverslists every receiver the process exposes. In microservices mode each distributor pod exposes all receivers; a service load-balances the OTLP and Jaeger ports.querier.frontend_worker.frontend_addressmust point at the query-frontend DNS, notlocalhost. A common post-migration bug is leaving the simple-scalable default.compactor.ringis mandatory when sharded compaction is enabled. Without the ring the compactor still works as a singleton.
How to validate it
Severity: READ-ONLY.
- Confirm each role is ready in its own pod:
for role in distributor ingester querier query-frontend compactor metrics-generator; do
echo -n "$role: "
curl -s "http://tempo-${role}.observability.svc:3200/ready"
echo
done
# distributor: ready
# ingester: ready
# querier: ready
# query-frontend: ready
# compactor: ready
# metrics-generator: ready
- Confirm the ingester ring has the right membership:
curl -s http://tempo-ingester:3200/ingester/ring | jq '.members | length'
# 3
curl -s http://tempo-ingester:3200/ingester/ring \
| jq '.members[] | {addr, state}'
# {"addr": "tempo-ingester-0:3200", "state": "ACTIVE"}
# {"addr": "tempo-ingester-1:3200", "state": "ACTIVE"}
# {"addr": "tempo-ingester-2:3200", "state": "ACTIVE"}
- Confirm spans arrive at the distributor and reach the ingester:
curl -sG http://tempo-distributor:3200/metrics \
| grep tempo_distributor_spans_received_total
# tempo_distributor_spans_received_total{tenant="single-tenant"} 12842
- Confirm the querier answers TraceQL:
curl -sG http://tempo-querier:3200/api/search \
--data-urlencode 'q={ resource.service.name = "checkout" }' \
--data-urlencode 'limit=5' | jq '.traces | length'
# 5
- Confirm the compactor is making progress on the bucket:
curl -s http://tempo-compactor:3200/metrics \
| grep tempo_compactor_blocks_compacted_total
# tempo_compactor_blocks_compacted_total 4231
How it can fail
Six shapes appear repeatedly in microservices mode:
- Role address drift after a Helm upgrade. A chart rename
changes the service name (
tempo-query-frontendtotempo-queryfrontend). Queriers keep the old DNS in their config and lose contact. Symptom is the querier returningno frontenderrors and trace-by-ID lookups succeeding only when the request lands on a pod that can reach the frontend. - Ingester ring unstable across replicas. A misconfigured
join_afterorheartbeat_timeoutcauses ingesters to flap betweenJOININGandACTIVE. Symptom istempo_ingester_lifecycler_ring_inconsistencies_totalrising and the distributor returning503. - Compactor missing. The binary tolerates its absence, the
bucket does not. Symptom is
tempo_compactor_blocks_compacted_totalflat at zero while the bucket grows. The bill arrives first, the query latency second. - Querier pool too small. Query latency rises but ingest is
healthy. Symptom is
tempo_querier_query_secondsp99 growing and the querier CPU near saturation. The fix is pods, not config. - Query-frontend cache misconfigured. A TTL of
0smakes the cache useless; a TTL of24hreturns stale results after a config change. Symptom is the same query returning different results across two consecutive Grafana clicks. - Metrics-generator attached to the querier but the querier
has no ingester connection. Service-graph metrics are
missing for new tenants. Symptom is the
tempo_metrics_generator_registryfailing to register andservice_graph_request_totalflat at zero.
How to troubleshoot it
The diagnostic order matters. Each step rules out one role:
- Is every role ready?
/readyper role. A404points to a wrongtargetflag in the unit file or to a Helm value that disables the role. - Is the ring healthy? Hit
/ingester/ringand/distributor/ring. An empty members list means the KV store (memberlist or etcd) is not reachable between pods. - Are spans arriving?
tempo_distributor_spans_received_totalandtempo_ingester_spans_received_total. A flat distributor counter is a client-side problem; a flat ingester counter is a wiring problem. - Are blocks flushing?
tempo_ingester_local_blocksand the checkpoint timestamp. Stale checkpoints point at object storage failures. - Is the bucket compacting?
tempo_compactor_blocks_compacted_total. A flat counter for hours means the compactor is wedged. - Is the query path responsive?
tempo_querier_query_secondsandtempo_query_frontend_queries_total. p99 latency over five seconds almost always points at the bucket, not the querier.
Security implications
Microservices mode exposes more ports because each role has its own service. Three surfaces:
- Per-role addresses become stable DNS names. Bind every role’s HTTP and gRPC listen address to a private interface. Treat the OTLP, Jaeger, and Zipkin ports as service-internal.
- mTLS between roles. The ring KV and the querier-to-query-frontend link are cluster-internal but should not be plaintext in a shared cluster. Use cert-manager or your platform’s mTLS mesh.
- Bucket credentials. The ingester and compactor both write
to the bucket. A leaked AWS key with
s3:PutObjectis a trace-data breach. Use scoped credentials per role and rotate them.
Performance implications
The cost profile differs by role:
- Distributor. CPU-bound on decoding. Memory grows with the rate-limit queue.
- Ingester. Memory-bound on the head block; disk-bound on the WAL.
- Querier. CPU- and network-bound on block fetches.
- Query-frontend. Memory-bound on the in-memory cache; CPU on query splitting.
- Compactor. CPU- and network-bound during the compaction window.
- Metrics-generator. Memory-bound on the in-memory registry per service.
The right scale order, when one role is the bottleneck:
- Querier. Read load grows with the number of Grafana users and dashboards. Add pods first when p99 query latency rises.
- Distributor. Write load grows with the number of
instrumented services. Add pods when CPU is saturated and
distributor_spans_received_totalis still rising. - Ingester. Only when the head-block memory ceiling is
reached, because each new pod rebalances the ring and may
cause a brief
503window. - Compactor. Almost never. Singleton is the default and is sufficient for millions of blocks.
- Metrics-generator. When the derived-metrics cardinality is the bottleneck, not when the underlying trace volume is.
Production guidance
- Start on simple-scalable mode. Move to microservices when one role is provably the bottleneck.
- Pin the ingester WAL to a dedicated, fast disk. NVMe-backed local SSD is the right answer.
- Run exactly one compactor by default; only enable sharded compaction when the bucket exceeds what a single compactor can process per cycle.
- Front the querier with the query-frontend once the querier pool exceeds two pods.
Verification
You should now be able to answer:
- Name the six Tempo roles and which are stateless, stateful, or optional.
- Which role do you scale first when query latency rises?
- What does the query-frontend add when the querier pool exceeds two pods?
- Why does the compactor run as a singleton by default?
- What is the right ring KV choice for a single Kubernetes cluster, and when does that change?
Quiz
Knowledge check · 8 questions
Q1. Which Tempo role is stateless and validates incoming OTLP, Jaeger, and Zipkin spans before forwarding them to the ingester ring?
Q2. When p99 query latency rises in a microservices Tempo cluster but ingest is healthy, which is the right first action?
Q3. The query-frontend is required in every microservices Tempo deployment.
Q4. Which of the following are valid scale targets when one role is the bottleneck? (select all that apply)
Q5. What does the ingester ring replicate on each push?
Q6. Name the ingester metric that shows how many head blocks are held in memory.
Q7. A stopped compactor prevents new trace ingestion.
Q8. Which role walks the bucket and merges small blocks into larger ones?
Passing score: 75%. Answers are checked in this browser.