Reported symptoms
At 03:40 the hypervisor hosting obs-02 took a hardware fault and
did not come back. obs-02 is one of three nodes running the
observability stack: three Loki ingesters, three Tempo ingesters,
three Grafana replicas behind a load balancer, three
Alertmanagers, two Prometheus servers. A replacement VM was built
and joined the ring at 04:26.
By 04:30 the platform looked entirely recovered, and the incident was closed at 05:10 as “node failure, HA worked”. Four things came in afterwards, over three days, from three different people.
A thirty-second Grafana outage. At 03:40 Grafana returned 502 for about half a minute even though two of its three replicas were healthy the whole time. Filed as “Grafana HA does not work”.
Two pages for one alert. The on-call engineer was paged twice for the same alert, four minutes apart, with the same summary. Filed as “Alertmanager duplicate notifications”.
An hour of slow dashboards after everything was fine. Query latency sat three to four times baseline from 04:26 to about 05:20 — beginning when the replacement node joined, not when the old one died. Filed as “queries slow after maintenance”.
Something does not add up in the incident review. Three days
later, someone writing up an unrelated application incident from
that night finds the log volume for 02:00 to 03:30 looks thin. The
collectors say they shipped a certain number of log records; a
count_over_time over the same window returns about two thirds of
it.
And the thing that is not a symptom: Prometheus is flawless for
the whole window, including the metrics it scraped from obs-02
right up to 03:40. Half the platform has a perfect record of a
night the other half has partially lost. Nobody was paged about
missing data at any point, and ingestion recovered by itself about
a minute after the node was lost.
Evidence provided
Start with the accounting, because it is the only symptom that implies permanent damage and the only one that cannot be argued away. Ask the collectors what they sent, and ask whether any of it failed.
$ curl -sG http://prometheus.internal.example.com:9090/api/v1/query \
--data-urlencode 'query=sum(increase(otelcol_exporter_sent_log_records[2h]))' \
--data-urlencode 'time=2026-08-11T03:40:00Z' \
| jq -r '.data.result[0].value[1]'
curl -sG http://prometheus.internal.example.com:9090/api/v1/query \
--data-urlencode 'query=sum(increase(otelcol_exporter_send_failed_log_records[2h]))' \
--data-urlencode 'time=2026-08-11T03:40:00Z' \
| jq -r '.data.result[0].value[1]'41295310
0Illustrative output
Then ask the platform to return the same window.
$ curl -sG http://loki-gateway.internal.example.com:3100/loki/api/v1/query \
-H 'X-Scope-OrgID: prod' \
--data-urlencode 'query=sum(count_over_time({cluster="prod"}[2h]))' \
--data-urlencode 'time=2026-08-11T03:40:00Z' \
| jq -r '.data.result[0].value[1]'27498004Illustrative output
Traces show the same proportion, and the shape of the loss is the tell: the traces that survive are complete. Nothing is partially missing.
$ for id in $(cat /tmp/trace-ids-from-window.txt); do
code=$(curl -s -o /dev/null -w '%{http_code}' \
"http://tempo.internal.example.com:3200/api/traces/$id")
echo "$id $code"
done | awk '{c[$2]++} END {for (k in c) print k, c[k]}'200 134
404 66Illustrative output
Now ask the ring who owns what. This is one command, it needs no incident context, and it answers the question the HA dashboard was never asked.
$ curl -s http://loki-distributor.internal.example.com:3100/ring \
| jq '[.shards[] | {zone: .zone, owners: [.owners[] | select(.state == "ACTIVE") | .addr] | length}] | .[0:4]'[
{ "zone": "a", "owners": 1 },
{ "zone": "a", "owners": 1 },
{ "zone": "a", "owners": 1 },
{ "zone": "a", "owners": 1 }
]Illustrative output
$ curl -s http://loki-distributor.internal.example.com:3100/config \
| jq '.loki.config.ingester.lifecycler.ring.replication_factor'1Illustrative output
Two supporting facts. loki_ingester_streams on the two surviving
ingesters did not rise by a third after 03:40, so the lost streams
were not picked up from anywhere — they were simply gone.
alertmanager_cluster_health_score dipped at 03:40 and recovered
without anyone touching it. The ingester config has not been
changed since the original single-node install, and there is no
record of a failure test ever having been run against this stack.
Work the evidence before reading on
Three loud symptoms, one quiet one, and a stack that has three of everything.
- The collectors report zero send failures and the distributor confirms it accepted the data. Where can data go after it has been accepted and acknowledged but before it reaches the object store, and how many copies of it are there while it is in that place?
- The missing window is the two hours before the node died, not the forty-six minutes it was gone. What has a two-hour boundary in this stack, and why does that boundary decide what was lost?
- Traces are missing whole or present whole, never partial. What does that tell you about how work is distributed, and would you expect the same shape if the cause were a network problem or a disk problem?
- Three of the four symptoms cleared themselves without anyone doing anything. Is self-healing evidence that those three were the same fault as the fourth?
Before continuing: the ring reported one owner per shard before the incident as well as during it. Given that, what would this stack have to lose for the loss to be zero, and what does the phrase “three-node HA cluster” actually promise here?
Root cause
1. Three instances, one copy
replication_factor was 1.
That is the value a single-binary Loki install ships with, and it is correct for a single-binary install. When this deployment grew from one node to three, the ingester config was not revisited. It has not been changed since.
With a replication factor of one, the hash ring maps every stream and every trace to exactly one owner. Three ingesters divide the keyspace into three parts and each part has a single holder. The ring is doing precisely what it was told: it is sharding, and it is not replicating. Adding processes made the platform bigger. It did not make any piece of data exist twice.
This is why the trace losses are whole traces rather than partial ones. Tempo shards by trace ID, so every span of a given trace lands on the same owner. Lose the owner and you lose the trace entirely; the traces that survive are untouched. A network fault or a disk fault would have produced ragged, partial damage. A clean partition of the keyspace produces exactly what the evidence shows.
2. The lost window is two hours because that is the WAL’s job
The missing data is not the traffic during the outage. It is the traffic from the two hours before it.
An ingester acknowledges a write once it is fsynced to the local write-ahead log, then holds it in memory and flushes it to object storage periodically. Between the acknowledgement and the flush, the data exists on that ingester and nowhere else — that is the gap the WAL exists to bridge, and it is about two hours wide.
A process restart is survivable: the WAL is on disk, it replays,
the state comes back. That is what the WAL protects against, and
it is why teams with replication_factor: 1 can restart ingesters
for years without noticing anything wrong.
obs-02 did not restart. The host was lost and did not return,
so its disk went with it. Every acknowledged-but-unflushed write
that hashed to obs-02 was destroyed, along with the only copy of
each one. The collectors had long since released those records,
because the platform told them the write had succeeded.
Traffic during the outage is fine, which is the detail that made
this look survivable at 04:30. Once the ring marked obs-02
unhealthy, the distributor re-hashed its keys onto the survivors
and ingestion resumed on its own. That is real recovery, and it
recovered the wrong thing: the future, not the past.
3. The three loud symptoms are the system working
None of the other three tickets is this fault, and it is worth saying so precisely rather than leaving them attached to it.
The Grafana blip. Grafana with an external database is a genuinely stateless replica set; state lives in the database, not in the process. The load balancer needs a few consecutive failed health checks before evicting a replica, so for a short window a fraction of requests still route at a node that has just died. Thirty seconds of 502 is that window. Shortening it is a tuning decision with a flapping cost on the other side, not a defect.
The duplicate page. Alertmanager replicas gossip their notification log so a given alert notifies once. An instance that sends a notification and dies before that entry propagates leaves a peer with no record of it, and the peer sends again. Two pages for one alert is the cost of not missing the page entirely, and the alternative failure — zero pages — is far worse.
The hour of slow queries after recovery. This one starts when the replacement joined at 04:26, not when the original died. Joining a ring triggers a redistribution, and a fresh replica replays its WAL while taking on new ownership; both compete for the same disk. That hour is a cost to plan for and test, not a fault.
Resolution
- Establish and publish the loss before fixing anything. Reconcile what the collectors sent against what the platform returns, per signal, for the affected window. The data is not recoverable — the collectors acknowledged it and released it, and the object store never received it — so the only useful output is an accurate statement of what is missing, given to anyone reviewing that night. A query that silently returns two thirds of its input is worse than one that errors.
- Detach the three benign tickets. The Grafana blip, the duplicate page and the post-recovery latency are correct behaviour under a node loss. Leaving them attached to this incident invites three changes that each make something else worse.
- Measure the headroom before touching the ring. Three copies means roughly three times the per-stream memory and WAL I/O on every ingester. If it is not there, stop here and hold.
- Set
replication_factor: 3on the Loki and Tempo ingester rings, and confirm each ingester is configured to flush on shutdown with a termination grace period long enough to complete the flush. - Roll the tier one ingester at a time, gracefully. Wait for the ring to report the restarted ingester ACTIVE and its WAL replayed before starting the next. Never restart two while the replication factor is still 1.
- Confirm the ring reports three ACTIVE owners per shard afterwards. Configuration and ring state can disagree, and the ring is the one that decides what happens when a node dies.
- Replace the process-counting HA panel with one that reads the owner count per shard from the ring. The old panel was accurate and useless; make the new one answer the question that was actually being asked of it.
- Schedule the failure test. Until a node has been killed deliberately and the result recorded, the change above is a belief rather than a property.
Verification
- The ring reports three ACTIVE owners for every shard, and the running config reports
replication_factor: 3on every Loki and Tempo ingester. Check both: they can disagree, and the ring is authoritative. - Kill a node, not a process. Killing a process tests the application; killing a node tests the orchestrator, the load balancer, the ring, the WAL and the humans, which is where the failures that matter actually live.
- During the failure, writes continue to be accepted and a query over the recent window still returns the streams the dead node owned. That second half is the whole test — the first half was already true when the replication factor was 1.
- During the failure, rule evaluation continues and alerts that were firing stay firing. An alert that goes to no-data because its evaluator is unreachable is the failure mode that hides the next incident.
- After the failure, reconcile accepted against queryable for the test window. The number that matters is that the two match, not that the dashboards went green again.
- Watch the recovery phase as closely as the failure phase. A churning ring and a WAL replay that saturates the disk will pass the during phase and fail the after phase.
- The new ring-based HA panel goes amber during the same test and returns to green afterwards. A panel that stays green throughout a deliberate node kill is the panel that failed this incident.
Prevention
-
Set the replication factor at provisioning time and treat the single-node default as a trap rather than a starting point. It is correct for the install it ships with and wrong for every deployment that grows past it, and the growth is exactly the moment nobody revisits the config.
-
Alert on owners per shard, read from the ring. A process count cannot tell three replicas from three shards, and that distinction is the whole difference between an outage and a permanent loss. The ring endpoint answers it in one request and would have been amber every day for eighteen months.
-
Reconcile accepted against queryable on a schedule. This class of failure produces no errors at all — the write succeeded and then the data stopped existing — so it is invisible to every error rate and every availability check.
loki-canarywrites a synthetic line and reads it back, and its round-trip metrics catch the shape; be honest that it only covers the streams the canary itself owns, so it complements the ring check rather than replacing it. -
Test the failure on a cadence. High availability is a claim about behaviour under a condition that has not happened yet. This stack had never been tested, and the first test was conducted by a hypervisor at 03:40 with the on-call engineer as the only observer.
-
Express the failure domain. Zone-aware replication with explicit failure-domain labels stops three replicas landing behind one hypervisor — a distinct problem that a correct replication factor does not solve.
-
Separate the loud symptoms from the damaging ones during the review. Three tickets self-healed and one destroyed data, and the three that self-healed are the ones that got filed. A review that ranks by how much noise a symptom made will fix the wrong three things.