Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

advancedloki-labels~30 min

Break/Fix: Loki High-Cardinality Labels

Reported symptoms

  • ●Two of three ingesters were OOM-killed overnight; both came back and were within a few percent of their previous memory use inside ten minutes, and the third is at 91 percent of its limit and still climbing
  • ●Query p99 is above thirty seconds for every tenant on the cluster, including the platform team dashboards needed to debug this
  • ●The object count in the chunks bucket is up roughly fortyfold over nine days while the bytes stored are up about 30 percent - the storage line item that jumped is request charges, not capacity
  • ●The Grafana label browser for the `edge` job times out, and label-value autocomplete in Explore hangs rather than returning
  • ●The `payments` tenant, which shares the cluster and has changed nothing, has opened a ticket about slow dashboards
  • ●Ingested bytes per second is flat - within five percent of the 30-day baseline every hour of the incident. Nobody is logging more than they were
  • ●There has been no change to the Loki configuration, no change to the Alloy pipeline, and no application deploy in the affected namespace for eleven days

Evidence

  • · `loki_ingester_memory_streams{tenant="prod"}` has gone from about 180,000 to about 2.4 million over nine days, as a straight line with no steps
  • · `sum(rate(loki_ingester_streams_created_total{tenant="prod"}[1h]))` is roughly 60 times its 30-day baseline and has been for nine days
  • · `sum(rate(loki_distributor_bytes_received_total[5m]))` is flat across the same nine days
  • · `logcli series --since=1h '{job="edge"}' | wc -l` returns 412,880 where the same command a fortnight ago returned about 4,100
  • · `logcli labels --since=1h upstream | wc -l` returns 41,206; the label registry documents `upstream` as bounded at 12
  • · Sampled values of `upstream` read `10.42.7.19:8080`, `10.42.11.204:8080`, `10.42.3.88:8080` - addresses and ports, where the registry says the values should be service names such as `payments-svc`
  • · `rate(loki_ingester_chunks_flushed_total[1h])` is up about 35 times while flushed bytes per hour is unchanged, so mean chunk size has collapsed
  • · `loki_ingester_chunk_age_seconds` shows chunks being flushed near the idle period rather than when full
  • · Git history shows `upstream` promoted to a stream label 14 months ago, with a review comment reading "bounded - one value per backend service, currently 12"
  • · The `ingress-system` namespace was upgraded nine days ago by the networking team under its own change ticket; the ticket says nothing about logging
Diagnosis and resolutionclick to reveal

Root cause

The `edge` pipeline promotes a parsed field called `upstream` to a stream label, and it has done so for fourteen months without causing any trouble, because the ingress controller wrote the backend service name into that field and there were twelve backend services. Nine days ago the networking team upgraded the ingress controller, and the new default access-log format writes the upstream address rather than the upstream name - an address and port, drawn from the pod IP that served the request. Pod IPs are recycled on every rollout across the whole cluster, so the value space went from twelve names that change when someone adds a service to an effectively unbounded set that churns continuously. Loki identifies a stream by the hash of its complete label set, so a new value of `upstream` is not a new value on an existing stream: it is a new stream, with its own entry in the ingester memory map, its own index fingerprint, and its own chunk to fill. That is the whole incident. Bytes are flat because nobody is logging more; what changed is how those same bytes are distributed across streams, and every cost in Loki that matters here is per stream rather than per byte. The ingester memory is the first casualty, because each stream holds the head of its current chunk in memory; the restarts did not help because the write-ahead log replay reopens every one of those streams, which is why memory returned to its previous level within ten minutes. Queries are slow for every tenant because a selector must fan out across every fingerprint that matches it, and that count is now four hundred thousand rather than four thousand. And the bucket is full of tiny objects because a stream that receives a handful of lines never fills a chunk, so it is flushed on the idle timer instead - the same bytes, cut into forty times as many pieces, each of which is a separate object to store, list and fetch. The label rule was not broken by anyone in the logging estate. It was broken by a routine upgrade two teams away, because the bound on `upstream` was never a property of the label. It was a property of somebody else's log format.

Remediation

Stop the growth before trying to recover the memory, and do not start by restarting ingesters. A restart is not neutral here: the WAL replay reopens precisely the streams that caused the OOM, so memory returns immediately and you have paid for the replay on top. The real fix is one line at the agent - drop `upstream` from the label set - and it should be paired with a structured-metadata stage that keeps the value attached to the line, because the value is genuinely useful and the goal is to move it out of the index rather than to throw it away. Be honest about what that achieves and when: dropping the label stops new streams being created from the moment the agent fleet picks up the change, and does nothing at all to the two million streams already resident. Those drain as their chunks idle out and flush, so memory falls over tens of minutes to hours rather than at once. While the rollout is in flight, `max_streams_per_user` on the offending tenant is the correct backstop, and its cost should be stated plainly: the tenant starts losing lines, visible as `loki_distributor_discarded_samples_total` with `reason="stream_limit"`, and that is a deliberate trade of some data for a cluster that stays up for everyone else. Two tempting moves are wrong. Raising the ingester memory limit buys hours, costs money permanently, and makes the next WAL replay longer; it converts a sharp problem into a slow one. Deleting the tenant's data to reclaim memory does nothing, because the memory is held by open streams rather than by stored chunks. There is also a fix upstream of the pipeline entirely, and it may be the cheapest one: ask the networking team to log the upstream name alongside the address, at which point the label can come back with its bound restored. Hold is a legitimate answer at 02:00 - cap the tenant, accept the line loss, page the pipeline owner, and roll the label change in daylight with a named owner and a time, rather than pushing an agent-fleet change nobody can review.

Verification

Watch the creation rate, not the level, because they recover on different timescales and only one of them is the leading signal. `sum(rate(loki_ingester_streams_created_total{tenant="prod"}[1h]))` should return to its 30-day baseline within one agent rollout; if it does not, some fraction of the fleet has not picked up the change and the level will never come down. `loki_ingester_memory_streams` follows over the next few hours as idle streams are flushed and removed, and the honest test is that it stays down across a full daily traffic cycle rather than dipping during the quiet hours. Then prove the streams are actually gone rather than merely idle: restart one ingester deliberately and confirm that memory after WAL replay settles near the new level rather than the old one. That is the check that would have told the team on night one that a restart was not a fix. Confirm the data was moved rather than lost - a query that filters on the upstream value through structured metadata must still return the lines it used to return, and `logcli labels upstream` over a recent window must return nothing. Confirm the storage shape recovered: chunks flushed per hour back near baseline with flushed bytes unchanged, which together mean mean chunk size is normal again. Measure query latency for a tenant that was collateral damage rather than for the tenant that caused it, over real dashboard load, because the tenants who suffered are the ones who will tell you whether it is fixed. And prove the backstop can fire: set `max_streams_per_user` low in a test tenant, push traffic at it, and confirm the discard counter moves with `reason="stream_limit"`. A limit that has never rejected anything is a configuration value, not a control.

Prevention

Alert on the rate at which streams are created, not on the number that exist. The count is a lagging indicator - it only falls when idle streams are removed, and it only rises as fast as the streams fill - whereas the creation rate moves within minutes of a bad label arriving and would have paged nine days earlier here. Pair it with an alert on the ratio of bytes ingested to streams created, because that ratio is the signature of this entire failure class: a cardinality incident holds bytes flat and multiplies streams, and the volume alerts everyone already has cannot see it. Run the label audit on a schedule rather than after an incident, comparing the labels actually present against a canonical set, with a documented cardinality bound for each one. The audit is what catches drift without an outage, and this incident is what it exists to catch. Then go one step further than the usual advice and write down, next to each promoted label, not just the bound but who guarantees it. `upstream` was reviewed carefully and approved correctly fourteen months ago; the reviewer verified that there were twelve values, and nobody recorded that the twelve were a property of the ingress controller default log format rather than of anything the logging team controls. A label whose bound depends on another team's software has a dependency, and the change review for that software should be able to see it. Default to structured metadata for any parsed field and promote to a label only by exception, with the bound and its owner written in the pull request. Enforce an explicit allowlist at the agent so a new label cannot appear without a pipeline change. Size `max_streams_per_user` so that it fires before the ingester dies rather than after, because a limit above the memory ceiling protects nothing. And host the dashboards used to debug the logging platform somewhere that is not the logging platform, since this incident took out the tooling needed to diagnose it.

Reported symptoms

Two of three Loki ingesters were OOM-killed overnight. Both restarted cleanly and both were back within a few percent of their previous memory use inside ten minutes. The third has not been killed yet and is sitting at 91 percent of its limit.

Four other things are wrong, and they do not obviously belong together:

  • Every tenant is slow. Query p99 is above thirty seconds across the cluster, including for the platform team’s own dashboards - the ones needed to work out what is happening.
  • The storage bill changed shape. The object count in the chunks bucket is up roughly fortyfold over nine days. The bytes stored are up about 30 percent. The line item that jumped is request charges, not capacity.
  • Grafana’s label browser hangs. Opening the label list for the edge job times out, and value autocomplete in Explore never returns.
  • A tenant that changed nothing has opened a ticket. payments shares the cluster, has deployed nothing, and its dashboards are unusable.

And the number everybody looks at first has not moved. Ingested bytes per second is within five percent of its 30-day baseline, every hour of the incident. Nobody is logging more than they were last week.

There has been no change to the Loki configuration, no change to the Alloy pipeline, and no application deploy in the affected namespace for eleven days.

Evidence provided

Read-only / Safea straight line, no steps
$ promtool query instant http://prometheus:9090 'loki_ingester_memory_streams{tenant="prod"}'
nine days ago   181,400
seven days ago  644,900
four days ago   1,502,000
now             2,411,300

Illustrative output

Read-only / Safestreams created per second, about 60x baseline
$ promtool query instant http://prometheus:9090 'sum(rate(loki_ingester_streams_created_total{tenant="prod"}[1h]))'
=> 2914   (30-day baseline: 48)

Illustrative output

Read-only / Safebytes are flat - this is the whole diagnostic
$ promtool query instant http://prometheus:9090 'sum(rate(loki_distributor_bytes_received_total[5m]))'
=> 1.14e+07   (30-day baseline: 1.11e+07)

Illustrative output

The two numbers above are the incident in miniature. Volume unchanged, stream count multiplied by thirteen. Whatever happened did not add data; it cut the same data into far more pieces.

Read-only / Safea fortnight ago this returned about 4,100
$ logcli series --since=1h '{job="edge"}' | wc -l
412880

Illustrative output

Read-only / Safethe label registry documents this one as bounded at 12
$ logcli labels --since=1h upstream | wc -l
41206

Illustrative output

Read-only / Safeaddresses and ports, where the registry says service names
$ logcli labels --since=1h upstream | head -5
10.42.7.19:8080
10.42.11.204:8080
10.42.3.88:8080
10.42.9.140:8080
10.42.14.61:8080

Illustrative output

The pipeline stage that promotes it, unchanged for fourteen months:

# loki.process "edge" - the promotion, in outline
#   stage.regex   parses the access-log line and captures `upstream`
#   stage.labels  promotes `upstream` into the stream label set
#
# Reviewed 2025-06-14. Review comment on the pull request:
#   "bounded - one value per backend service, currently 12"

And the change nobody connected to logging:

CHG-4417  ingress-system  ingress controller upgrade   9 days ago
          Owner: networking. Rollback tested. No logging impact stated.

Work the evidence before reading on

Six questions, in the order the evidence answers them.

  1. Bytes ingested are flat and stream count is up thirteenfold. What kind of change increases streams without increasing volume, and what does that rule out immediately?
  2. Both restarted ingesters were back at their previous memory use within ten minutes. What does an ingester do on startup that would produce exactly that, and what does it tell you about where the memory is held?
  3. The object count in the bucket is up fortyfold and the bytes stored are up 30 percent. What has to be true of the average object for both of those to hold at once?
  4. payments deployed nothing and its queries are slow. What resource does a tenant share with every other tenant on a Loki cluster?
  5. The values of upstream are addresses and ports. Who chooses what goes in that field, and is it anyone in the logging estate?
  6. The label was reviewed and approved fourteen months ago by someone who checked that it had twelve values. Was the review wrong?

Before continuing: nothing in the logging estate changed. So what is the shortest path from a change in another team’s namespace to an ingester running out of memory?

Root cause

A label that used to be bounded

The edge pipeline parses the ingress access log and promotes a field called upstream into the stream label set. For fourteen months the ingress controller wrote the backend service name into that field, there were twelve backend services, and twelve is a perfectly good number of label values.

Nine days ago the networking team upgraded the ingress controller. The new default access-log format writes the upstream address rather than the upstream name - an address and port, taken from the pod IP that actually served the request.

Pod IPs are recycled on every rollout, across the whole cluster, continuously. The value space went from twelve names that change when somebody adds a service to an unbounded set that churns all day.

Why a new label value is a new stream

Loki identifies a stream by a hash of its complete label set. A new value for upstream therefore does not annotate an existing stream. It creates a new one, with its own entry in the ingester’s in-memory map, its own fingerprint in the index, and its own chunk to fill.

That single mechanism produces every symptom in the incident:

SymptomMechanism
Ingester memory climbing linearlyEach open stream holds the head of its current chunk in memory
Restarts do not helpWAL replay reopens exactly those streams, so memory returns in minutes
Every tenant’s queries slowA selector fans out across every matching fingerprint, and that count is now a hundredfold larger
Object count up 40x, bytes up 30 percentStreams that receive a few lines never fill a chunk, so they flush on the idle timer as tiny objects
Bytes ingested flatNothing is logging more; the same bytes are simply spread across far more streams
Label browser times outIt enumerates label values, and one of them now has forty thousand

Bytes are flat, and that is the diagnostic

The instinct in a Loki incident is to look for volume, because volume is what most incidents are made of and because the ingest dashboard is the one on the wall. Here volume is the control, not the variable.

Every cost that is hurting - ingester memory, index fan-out, object count - is charged per stream, not per byte. A cardinality incident is precisely the failure mode in which the byte-rate panel stays green from beginning to end, which is why teams that watch only volume find these late and find them by being paged for an OOM.

The review was not wrong. It was incomplete

The pull request that promoted upstream was reviewed by someone who checked the cardinality and wrote down what they found: twelve values, one per backend service. That was true, and it stayed true for fourteen months.

What was never written down is that the twelve was a property of the ingress controller’s default log format - somebody else’s software, in somebody else’s namespace, changed on somebody else’s schedule. The label had a dependency and nobody recorded it, so when the dependency changed there was no review anywhere that could have caught it. The networking team’s change ticket is honest and complete; “no logging impact stated” is entirely reasonable when nothing tells you that a logging pipeline is reading your log format as a contract.

Resolution

  1. Confirm the shape before touching anything: streams created per second against bytes received per second. Streams up, bytes flat, is a cardinality incident and nothing else. It takes two queries and it eliminates every volume-shaped explanation.
  2. Name the label rather than guessing it. Rank labels by distinct values over a recent window, then sample the values of the winner. The sample matters as much as the count - forty thousand values told you there is a problem, and seeing that they are IP addresses told you where it came from.
  3. Cap the offending tenant with max_streams_per_user as a backstop while the real fix rolls. Say the cost out loud: that tenant starts losing lines, visible as loki_distributor_discarded_samples_total with reason="stream_limit". It is a deliberate trade of some data for a cluster that stays up for the other tenants.
  4. Drop the label at the agent, and attach the value as structured metadata in the same change. The value is genuinely useful; the goal is to move it out of the index, not to throw it away. Dropping it alone loses information the support team relies on.
  5. Do not restart ingesters to reclaim memory. WAL replay reopens the same streams, the memory returns in minutes, and the replay itself pushes load onto the ingesters that are still alive.
  6. Do not raise the ingester memory limit as the fix. It buys hours, costs money permanently, lengthens the next WAL replay, and leaves the cardinality in place to grow into the new headroom.
  7. Talk to the networking team, because the cheapest fix may be theirs. If the access-log format can carry the upstream name alongside the address, the label can come back with its bound restored and the query patterns that depended on it keep working.
  8. If it is the middle of the night and the agent rollout cannot be reviewed properly, hold deliberately: cap the tenant, accept the line loss, page the pipeline owner, and ship the label change in daylight with a named owner and a time. Write the hold down as a decision rather than leaving it as an absence.

Verification

  1. Stream creation rate is back to baseline within one agent rollout. This is the leading indicator and it recovers first; if it has not fallen, part of the fleet has not picked up the change and the level never will.
  2. Resident stream count falls and stays down across a full daily traffic cycle. A dip during the quiet hours is not recovery - the streams have to stay gone through the next peak.
  3. A deliberate ingester restart settles near the new level rather than the old one. This is the check that proves the streams are genuinely gone rather than merely idle, and it is the one that would have told the team on night one that restarting was not a fix.
  4. The data moved rather than disappeared. A query filtering on the upstream value through structured metadata returns the lines it used to return, and logcli labels upstream over a recent window returns nothing.
  5. Chunk flushes per hour are back near baseline while flushed bytes per hour are unchanged, which together mean average chunk size has recovered. Object count in the bucket stops growing at the incident rate.
  6. Query latency is measured for a tenant that was collateral damage, under real dashboard load. payments is the tenant that will tell you whether this is fixed; the tenant that caused it will look fine either way.
  7. The backstop can actually fire. Set max_streams_per_user low in a test tenant, push traffic at it, and confirm the discard counter moves with reason="stream_limit". A limit that has never rejected anything is a configuration value, not a control.
  8. The new alerts fire on a rehearsal. Promote a deliberately unbounded label in a test tenant and confirm both the creation-rate alert and the bytes-to-streams ratio alert go off before anything runs out of memory.

Prevention

  • Alert on streams created per second, not on stream count. The count is a lagging indicator: it rises only as fast as streams are opened and falls only when idle streams are removed. The creation rate moves within minutes of a bad label arriving, and it would have paged nine days before the first OOM.
  • Alert on the ratio of bytes ingested to streams created. That ratio is the signature of this whole failure class. Volume alerts cannot see a cardinality incident, because volume is the one thing that does not change.
  • Run the label audit on a schedule. Compare the labels actually present against a canonical set with a documented cardinality bound for each. The audit is the only mechanism that catches label drift without an outage.
  • Record who guarantees each bound, not just the bound. upstream was reviewed correctly and the reviewer’s note was accurate. What was missing is that the twelve values were a property of another team’s log format. A label whose bound depends on somebody else’s software has a dependency, and it should be written where that team’s change review can see it.
  • Default to structured metadata for parsed fields. Promote to a stream label only by exception, and require the pull request to state the bound and its owner. The default should be the safe one.
  • Enforce an explicit label allowlist at the agent. A new label should not be able to appear in the index without a pipeline change that somebody reviewed.
  • Size the stream limit so it fires before the ingester dies. A limit set above the point at which memory runs out protects nothing; it just records the death afterwards.
  • Do not host the dashboards that debug the logging platform on the logging platform. This incident degraded the tooling needed to diagnose it, which cost the first hour.