ObservabilityLXXI · Tempo at ScaleTempoScale
Tempo Ingester Scaling
What you'll learn
- Pick the smallest replication_factor that tolerates the loss profile the platform can absorb
- Size the ingester StatefulSet for peak spans per second with headroom, not for steady load
- Pin the WAL to local SSD and validate the disk before promoting an ingester to production
- Diagnose ring instability during rolling restarts and the symptoms of an under-replicated ring
- Avoid scale-down scenarios that orphan in-flight spans or break the write quorum
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 11:42 a Tempo deployment was running five ingester pods with
replication_factor: 3. An operator, asked to save cost, scaled
the StatefulSet to three. Writes continued for a few minutes, then
the distributor started returning 503. The remaining three pods
had not yet absorbed the ring rebalance; two spans arrived at the
same time, hit the same ingester, and the write quorum for one of
them returned only two out of three required replicas before the
new ring state was loaded. The fix was to roll forward to five,
wait for the ring to settle, and then drain one pod at a time.
This lesson is the discipline of sizing the ingester StatefulSet in microservices mode: replication factor, ring churn, WAL placement, and the right number of replicas.
What it is
The Tempo ingester is the only component that holds spans in memory before they reach object storage. It is therefore the only component that can lose data on a single-host failure, and the only component whose scaling must respect state.
Scaling the ingester is the practice of choosing:
- The replication factor (
replication_factor). - The replica count of the
StatefulSet. - The placement of the WAL on local disk.
- The ring KV backend (
memberlistoretcd). - The lifecycle timings that decide how long a rebalance takes.
Each choice has a production trade-off. The wrong combination produces write outages during planned maintenance.
Why a sysadmin cares
Three operational pains are specific to ingester scaling:
- Under-replicated ring. A
replication_factorthat exceeds the replica count at any moment produces write failures that look like a Tempo bug. They are a sizing bug. - Ring churn during rolling restarts. A misconfigured
join_afterorheartbeat_timeoutmakes each restart take minutes instead of seconds and produces503responses throughout the rollout. - WAL on slow disk. The WAL is fsync-heavy under sustained
write load. Network-attached storage does not have the IOPS
profile. The symptom is rising
tempo_ingester_wal_write_bytesandtempo_ingester_failed_flushes_totaltogether.
The ingester is the most expensive component to run and the most expensive to misconfigure.
How it works
The distributor hashes the trace ID to a position on the ring.
Each ingester owns a contiguous range. For replication_factor: 3,
the distributor forwards each span to the three ingesters that
own the position and the two ranges adjacent to it.
Distributor
|
| hash(trace_id) = 17
|
v
+----------------------------------------------+
| Ingester ring (logical view) |
| |
| ingester-0 owns tokens 0-31, 96-127 |
| ingester-1 owns tokens 32-63, 128-159 |
| ingester-2 owns tokens 64-95 |
| |
| For trace_id at position 17, RF=3: |
| primary = ingester-0 |
| replica 1 = ingester-2 (next clockwise) |
| replica 2 = ingester-1 (next clockwise) |
| |
| Quorum = RF/2 + 1 = 2 acks needed |
+----------------------------------------------+
The ring KV (memberlist gossip or etcd consensus) is the
authoritative source for which ingester owns which token. The
distributor reads the ring on every push. When the ring changes,
spans in flight on the old ring may go to ingesters that no
longer own the trace’s hash; the distributor retries against
the new ring.
The WAL is local to each ingester pod. The PVC bound to the
StatefulSet member must be on local SSD, not NFS or a remote
block device.
How to configure it
A production ingester config pins the ring, the WAL, and the flush cadence. The values below are the production defaults for a mid-scale cluster:
ingester:
trace_idle_period: 10s
max_block_duration: 30m
flush_check_period: 5s
max_block_bytes: 524288000 # 500 MiB cap per block
lifecycler:
ring:
kvstore:
store: memberlist
replication_factor: 3
heartbeat_timeout: 1m
heartbeat_period: 5s
join_after: 10s
observe_period: 10s
final_sleep: 0s
storage:
trace:
backend: s3
s3:
bucket_name: tempo-traces-prod
region: eu-west-1
wal:
path: /var/tempo/wal
Three details to call out:
replication_factor: 3requires at least three ingester pods. Two is not enough for the write quorum (RF/2 + 1 = 2) to succeed under a single-pod loss.heartbeat_timeout: 1mandheartbeat_period: 5stogether decide how long a dead pod takes to be evicted from the ring. Too short: false evictions during a slow restart. Too long: the distributor forwards spans to a dead pod for too long.wal.pathmust point at a directory backed by local SSD. The StatefulSet must mount ahostPathor alocalPV, not a network PVC.
When the deployment uses Kubernetes, the matching StatefulSet fragment:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: tempo-ingester
spec:
replicas: 5 # 5 > RF=3 absorbs one pod + one rolling
serviceName: tempo-ingester
selector:
matchLabels:
app: tempo-ingester
template:
metadata:
labels:
app: tempo-ingester
spec:
containers:
- name: tempo
image: grafana/tempo:latest
args:
- '-target=ingester'
- '-config.file=/etc/tempo/tempo.yaml'
volumeMounts:
- name: wal
mountPath: /var/tempo/wal
volumes:
- name: wal
hostPath:
path: /var/tempo/wal # local SSD on the node
Severity: CONFIGURATION. Reloading the YAML requires a process restart. The StatefulSet replica count is a runtime edit that triggers a rolling restart.
How to validate it
Severity: READ-ONLY.
- Confirm the ingester ring membership and state:
curl -s http://tempo-ingester:3200/ingester/ring | jq '
{ members: .members | length,
active: [.members[] | select(.state == "ACTIVE")] | length }
'
# { "members": 5, "active": 5 }
- Confirm
replication_factormatches the live replica count:
curl -s http://tempo-ingester:3200/ingester/ring \
| jq '.replication_factor'
# 3
kubectl get statefulset tempo-ingester \
-o jsonpath='{.spec.replicas}'
# 5
- Confirm the WAL is being drained:
curl -s http://tempo-ingester:3200/metrics \
| grep tempo_ingester_local_checkpoint_manager_last_saved_timestamp
# tempo_ingester_local_checkpoint_manager_last_saved_timestamp 1723655400
The timestamp should be within max_block_duration of now. A
value older than that means flushes have stalled.
- Confirm the WAL disk has headroom:
kubectl exec tempo-ingester-0 -- df -h /var/tempo/wal
# Filesystem Size Used Avail Use% Mounted on
# /dev/nvme0n1 100G 12G 88G 12% /
- Confirm replication is working by sending a single span and checking it on each ingester:
otel-cli span export --endpoint tempo-distributor:4317 \
--service checkout --name POST /charge
TRACE_ID=$(otel-cli span ls --limit 1 --format json | jq -r '.[0].TraceId')
for i in 0 1 2 3 4; do
echo -n "ingester-$i: "
curl -s "http://tempo-ingester-$i:3200/api/traces/${TRACE_ID}" \
| jq '.batches | length'
done
# ingester-0: 1
# ingester-1: 1
# ingester-2: 1
# ingester-3: 1
# ingester-4: 1
A non-1 count from one pod means the ring KV is reporting the
pod as a primary for that trace, but the span has not arrived
there yet. This is normal during a ring rebalance.
How it can fail
Six shapes appear repeatedly:
- Replica count below
replication_factor. A scale-down leaves the ring with too few owners; quorum writes fail. Symptom is503from the distributor andtempo_distributor_dropped_spans_totalrising. - Ring churn during a rolling restart. A misconfigured
join_after: 30sandheartbeat_timeout: 10stogether cause the new pod to be marked dead before it has finished joining. Symptom istempo_ingester_lifecycler_ring_inconsistencies_totalrising and per-podstateflipping betweenACTIVEandJOINING. - WAL on network-attached storage. NFS or a remote block
device cannot sustain the fsync rate under sustained load.
Symptom is
tempo_ingester_wal_write_bytesrising andtempo_ingester_failed_flushes_totalrising in lockstep. replication_factortoo low for the loss profile. Areplication_factor: 1deployment loses data on every pod restart. Symptom is missing traces during planned maintenance.- Tokens distributed unevenly across replicas. Older
versions of Tempo used a fixed token scheme; newer versions
use
lifecycler.tokens_per_local_ring(default 128). A misconfiguration that drops the token count produces hot pods. Symptom is one pod’stempo_ingester_local_blocksfar above the others. - WAL full because flushes have stalled. Object storage
credentials have rotated or the bucket is throttling. The
ingester keeps accepting spans, the WAL grows, the disk
fills. Symptom is
dfshowing the WAL path near 100% andtempo_ingester_failed_flushes_totalrising.
How to troubleshoot it
The diagnostic order, cheapest first:
- How many ingester pods are healthy?
kubectl get statefulset tempo-ingesterand/ingester/ring. The two must agree. - Is the ring KV reachable?
memberlistis gossip; a pod that cannot reach any other pod will not see the ring.etcdis consensus; a quorum loss stalls the ring. - Is the WAL draining? Check the checkpoint timestamp.
- Are flushes failing? Check
tempo_ingester_failed_flushes_totaland the bucket credentials. - Is disk filling?
df -hon the WAL path. - Is memory under pressure? Check
go_memstats_heap_inuse_bytesand the OOM killer logs.
Security implications
The ingester is local-network-only:
- WAL on local disk. The WAL contains span payloads. Restrict the WAL path to root or to a dedicated service user. Anyone with read access to the WAL path can read every span that arrived in the last flush window.
- No inbound network. The ingester accepts traffic from the distributor over the in-cluster network. The distributor must not be exposed publicly; the ingester inherits that protection.
- Tracing sensitive data. If traces contain PII or secrets, the WAL and the bucket both contain them. Tempo does not redact at ingest; redaction is the responsibility of the SDK or the collector.
Performance implications
The ingester is the most expensive component to run:
- Memory. Each open head block holds all spans for one trace ID. A burst of long-lived traces can exhaust heap memory.
- Disk. The WAL writes one record per span. A 20 MiB/s ingest writes roughly 20 MiB/s to the WAL until the next checkpoint.
- Network. Each span is replicated
replication_factortimes inside the ring (the distributor fans out). Atreplication_factor: 3a 20 MiB/s ingest becomes a 60 MiB/s internal stream. - CPU. Span decoding and WAL encoding consume CPU. A sustained 30 MiB/s ingest saturates one CPU core per ingester pod.
Sizing rule of thumb: one ingester pod handles roughly 20k spans/sec at typical span size on a 4-vCPU / 8 GiB host. Add pods (not CPU) for more ingest.
Production guidance
- Pin the WAL to local SSD. Verify the disk with
fiobefore promoting an ingester to production. - Run at least
replication_factor + 2ingester pods so one pod can be in a rolling restart without dropping below RF. - Set
replication_factorto the smallest number that tolerates your expected loss profile.3is the production default.2is acceptable for dev only. - Alert on
tempo_ingester_local_checkpoint_manager_last_saved_timestampage and ondfof the WAL path. - Use
memberlistfor a single Kubernetes cluster. Switch toetcdwhen the ring spans clusters or when the cluster has more than a few hundred members.
Verification
You should now be able to answer:
- What does
replication_factordecide, and what is the smallest value that tolerates a single ingester failure? - Why must the WAL live on local SSD?
- What does
join_aftercontrol, and what is the failure shape if it is too low? - How do you validate that replication is working?
- Why is scaling down below
replication_factora write outage?
Quiz
Knowledge check · 8 questions
Q1. What is the smallest replication_factor that tolerates the loss of one ingester pod while still satisfying the write quorum?
Q2. A replication_factor of 3 requires how many ingester pods as an absolute minimum at every moment?
Q3. Adding more ingester pods always improves write throughput proportionally.
Q4. Which settings govern ingester state durability? (select all that apply)
Q5. Name the metric that shows how many head blocks each ingester holds in memory.
Q6. What happens when the ingester StatefulSet is scaled below replication_factor?
Q7. The ingester ring is sharded by consistent hashing on the trace ID.
Q8. Where must the ingester WAL live in a production deployment?
Passing score: 75%. Answers are checked in this browser.