Skip to main content
RunBook Academy

← All runbooks in Observability

high riskcluster affecting~45 min

Runbook: Investigate Loki High Ingestion Volume

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The spike is confirmed as still happening: sum(rate(loki_distributor_bytes_received_total[5m])) compared against the same expression over the last 24 hours. A spike that has already stopped is a cost review, not an incident.
  • · The magnitude is written down as a multiple of baseline, not as an absolute. "Six times the 24-hour mean" is actionable; "40 MB/s" needs a second query before anyone can act on it.
  • · The saturating component is identified: agent queue, distributor rejections, or ingester memory. Each points at a different layer and a different lever.
  • · Whether Loki is still ACCEPTING writes is established. Bytes rising with loki_discarded_bytes_total flat is a spike; bytes flat with discards rising is already a rejection incident and the blast radius is larger.
  • · The tenant boundary is established: one tenant spiking, or several. Several at once points at a shared collector pipeline or a fleet-wide config push, not at one service.
  • · The chunk-store headroom is checked. A spike against a bucket with weeks of margin is a cost problem; a spike against a bucket near its budget is a data-loss problem, because the compactor evicts oldest-first.
  • · A deploy timeline for the last two hours is open before any query is run. Most spikes are a change, and the change window narrows the search more than any query does.

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Confirm the spike is live and locate the saturating layer. Read rate(loki_distributor_bytes_received_total[5m]) (the wire rate), loki_ingester_memory_chunks and loki_ingester_memory_streams (the working set), and rate(loki_ingester_chunks_flushed_total[5m]) (whether streams are closing). Rising bytes with a flat flush rate is the dangerous combination.
  2. 2Attribute the bytes to a tenant, then to a job. Run logcli volume --since=1h --limit=20 against a broad job selector. It reads the index rather than the chunks, so it answers the attribution question without adding query load to a cluster that is already saturated. Expect one job to carry more than half the excess; if two share it, treat them as two investigations.
  3. 3Decide whether this is bytes or streams. logcli series --analyze-labels --since=15m on the suspect selector reports the distinct value count per label. A byte spike with a stable stream count is a volume problem. A stream count climbing with normal bytes is a label-cardinality problem, and the lever is completely different.
  4. 4Find the line. Query the top messages in the suspect job over the last 15 minutes. One message pattern repeating at orders of magnitude above the rest is a retry loop or a tight-loop debug print. Many different messages from one job is a level toggle or a newly onboarded service.
  5. 5Find the host. Break the same rate down by instance or pod. One host dominating means a per-instance toggle or a stuck process; every host sharing it evenly means a fleet-wide config change or a deploy.
  6. 6Name the shape before choosing a lever. Debug toggle, log loop, new or mis-sized service, or legitimate growth. The four have different first actions and picking the wrong one costs a full round trip through change, reload and observation.
  7. 7Stop the burn at the cheapest reversible layer that works. In order of preference: revert the log level at the source (runtime logger endpoint if the service has one); add a drop or sample rule in the collector; apply a per-stream limit at Loki; apply a per-tenant limit at Loki. Each step down the list widens the blast radius and each one discards data permanently.
  8. 8If the source cannot be reached in 15 minutes, cap the bleed and escalate in parallel. A per-tenant limit is a deliberate, documented decision to discard that tenant logs rather than let every other tenant degrade. Record the start time; it is the start of a data gap.
  9. 9Verify the rate fell, and that it fell for the right reason. Bytes down with discards flat is a source-side fix. Bytes down with loki_discarded_bytes_total climbing is a throttle: the burn moved upstream into the collector buffer and then into the void, and the application is still emitting.
  10. 10Watch the ingester drain. loki_ingester_memory_chunks and loki_ingester_memory_streams should fall as chunks flush. If they do not, the spike created streams that never close, and the label set is the remaining problem.
  11. 11Capture the evidence before it ages out of retention. Peak bytes per second, duration, per-tenant attribution, and the message pattern. These four numbers are the input to the cost review and they are unavailable next week.
  12. 12Close the loop at the source. The throttle is a brake. The incident is not closed until the log level, the retry backoff, the label set or the rate-limit agreement has an owner and a ticket.

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓sum(rate(loki_distributor_bytes_received_total[5m])) has returned to within the normal band for this hour of the week - compared against the same window last week, not against zero.
  • ✓The specific job that carried the spike is back to its own baseline: logcli volume for that selector shows it below its usual share of the total.
  • ✓loki_discarded_bytes_total and loki_discarded_samples_total are FLAT. If they are climbing, the volume is being rejected rather than not being produced, and the incident is still live upstream.
  • ✓loki_ingester_memory_chunks and loki_ingester_memory_streams are descending toward their pre-incident level, and rate(loki_ingester_chunks_flushed_total[5m]) shows streams closing again.
  • ✓Query latency for the OTHER tenants has recovered. The spike was paid for by them and the recovery has to be checked on them.
  • ✓Chunk-store growth rate has returned to the level the retention budget was sized for, and the projected fill date is back beyond the retention window.
  • ✓A test query against the affected job returns lines, proving the throttle did not silently cut the tenant off entirely.
  • ✓The evidence for the cost review is captured: peak rate, duration, tenant attribution, message pattern - and the data gap, if a throttle created one, is documented with a start and an end time.

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Steps 1 to 6 are read-only queries and have nothing to undo.
  • ↶To restore a reverted log level: set it back through the same runtime endpoint or config path used to lower it. This is the cheapest thing on the list to undo and the one least likely to need undoing.
  • ↶To remove a collector drop or sample rule: revert the commit and reload the collector, then confirm the rule is gone from the collector active component list rather than only from the file on disk.
  • ↶To raise a per-stream or per-tenant limit back: revert the value in the Loki limits or the runtime overrides file and confirm the effective value at the Loki config endpoint. The runtime overrides file is picked up within seconds; the static limits need a reload.
  • ↶There is no rollback for lines discarded while a throttle was in place. Record the window; it is a permanent gap for that tenant.
  • ↶There is no rollback for chunks the compactor evicted early because the bucket filled. If the spike pushed the store past its budget, older logs for EVERY tenant are gone and the retention promise was broken during the incident.
  • ↶Any limit raised under pressure to let a legitimate tenant through must be re-reviewed against the capacity budget within the week, or it silently becomes the new ceiling.

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the owning service team the moment the job is named, in parallel with the platform-side throttle - they hold the only durable fix and the throttle only buys time.
  • · Escalate after 15 minutes without an identified source. The continued spike is doing measurable damage to other tenants; a second engineer and the suspect team lead in a channel costs less than another 30 minutes of unattributed burn.
  • · Escalate to whoever owns the chunk-store budget as soon as the projected fill date lands inside the retention window. That is the point at which a cost incident becomes a data-loss incident.
  • · Escalate to security if the spike is a retry loop against an authentication endpoint, or if the volume is driven by attacker-controllable input. A flood that is also an auth flood is not a capacity event.
  • · Escalate to the data-protection owner if the spiking lines contain personal data, credentials or tokens. The volume makes the exposure window wide and the deletion obligation real.
  • · Escalate to capacity planning rather than throttling if the shape is legitimate growth. Capping a tenant that is behaving correctly is a decision with a business owner, not an operational one.

The defining property of a Loki ingestion spike is that the tenant causing it does not pay for it. The ingesters, the compactor, the index and the object-store IOPS are shared. One service emitting six times its normal volume degrades query latency for every other tenant on the cluster, and it does so without any of them showing a symptom on their own dashboards.

That is why the triage order in this runbook is attribution before action. You cannot pick a lever until you know whose bytes these are, and the levers get more expensive and less reversible the further down the list you go.

When this runbook applies, and when it does not

It applies when log volume entering Loki is materially above baseline and Loki is still accepting the writes.

It does not apply when:

  • Logs are missing rather than excessive. That is the ingestion failure path - a broken hop between the application and the distributor - and it has its own runbook with a completely different diagnostic ladder.
  • loki_discarded_bytes_total is already climbing and received bytes are flat. You are past the spike and into rejection. The volume problem is still real, but the immediate problem is that a limit is firing and someone is losing logs right now.
  • Query latency is bad with ingest at baseline. That is a read-path problem - query parallelism, index fan-out from historical cardinality, cache misses - and throttling ingest will not touch it.
  • The rise is a planned event. A migration, a load test, a Black Friday. Those need a capacity conversation and a temporary limit raise, not a throttle. Check the calendar before the queries.

Blast radius: the levers, cheapest first

Every lever below stops the burn. They differ in who pays and in whether the data comes back.

LeverWhere it actsReversible?What it costs
Revert the log level at the sourceApplicationYes, instantlyNothing - the lines were never wanted
Runtime logger endpoint, one packageApplicationYes, instantlyScoped to one instance unless rolled out
Drop or sample rule in the collectorAgentYes, on reloadThose lines never reach Loki; the app still emits and still pays CPU
per_stream_rate_limitLoki distributorYes, on reloadThe noisiest stream is rejected; others in the tenant survive
ingestion_rate_mb for the tenantLoki distributorYes, on reloadThe whole tenant is rejected once the bucket empties - a data gap
Shorten retention_periodLoki compactorNoOlder logs are evicted permanently to buy space

Read that table before choosing, not after. The first two rows fix the problem; the middle rows hide it from Loki while leaving it running in the application; the last row is not a spike response at all, it is a capacity emergency response, and it destroys data belonging to tenants who did nothing wrong.

The shape decides the first action

Four shapes account for nearly every spike, and they are distinguishable in the first three queries.

ShapeSignatureFirst action
Debug toggleLine rate up an order of magnitude, byte rate up less, many distinct messages, correlates with a deployRevert the level - runtime endpoint if available
Log loopOne message pattern dominating, rate sustained rather than transient, rate grows with an underlying failure rateBreak the feedback path; throttle only buys time
New or mis-sized serviceA job label that did not exist yesterday, or an existing one many times its estimateRate-limit conversation with the owning team
Legitimate growthLines look normal, correlates with traffic or a business eventCapacity, not a throttle

The log loop is the expensive one, because it is the only shape that does not stop on its own. A debug toggle ends when the level goes back; a new service ends when its deployment is rolled back; a loop ends only when the feedback path is broken. And its rate scales with the rate of the failure it is reporting, so a worsening downstream makes the loop burn faster.

Step 1 - Confirm the spike, and find the saturating layer

Read-only / Safewire rate, working set, and whether streams are closing
PROM=http://localhost:9090

for Q in \
'sum(rate(loki_distributor_bytes_received_total[5m]))' \
'sum(rate(loki_distributor_lines_received_total[5m]))' \
'sum(loki_ingester_memory_chunks)' \
'sum(loki_ingester_memory_streams)' \
'sum(rate(loki_ingester_chunks_flushed_total[5m]))' \
'sum(rate(loki_discarded_bytes_total[5m]))'
do
printf '%s\t' "$Q"
curl -s "$PROM/api/v1/query" --data-urlencode "query=$Q" \
  | jq -r '.data.result[0].value[1] // "no data"'
done

Three readings, three different conclusions:

  • Bytes up, chunks flushing normally, memory stable. The cluster is absorbing it. You have time. This is a cost problem and possibly a capacity one, but not yet an availability one.
  • Bytes up, flush rate flat, loki_ingester_memory_chunks climbing. Streams are not closing. The ingesters are accumulating and will not stop until something changes. This is the dangerous shape.
  • Bytes flat, discards climbing. A limit is already firing. Someone is losing logs while you read this. Jump to the rejection question: which reason label, and is it the right limit for that tenant.

The distributor byte rate is the fastest signal - it moves within seconds of the offending change. Ingester memory moves minutes later, after the streams have been opened. Alerting on the second and investigating with the first is the wrong way round and costs you the head start.

Step 2 - Attribute the bytes

Do not start by reading log lines. Reading lines answers “what does the noisy message say”, which is the third question, and answering it first usually produces the wrong service, because the most verbose message is rarely the message that is new.

Read-only / Safevolume by label, without reading every line
export LOKI_ADDR=http://loki-query.monitoring.svc:3100
export LOKI_ORG_ID=prod

logcli volume --since=1h --limit=20 '{job=~".+"}'
{job="checkout-svc"}      812.4 GB
{job="inventory-svc"}     41.2 GB
{job="payment-service"}   38.9 GB
{job="edge-proxy"}        22.7 GB

Illustrative output

The volume endpoint reads the index rather than the chunks, so it answers the attribution question cheaply on a cluster that is already under pressure. That matters: the naive alternative - a broad sum by (job) (bytes_over_time(...)) across an hour - is exactly the kind of query that competes with the spike for the resources you are trying to protect.

Expect one job to carry more than half the excess. If two share it, treat them as two separate investigations rather than looking for a single cause; spikes arrive in pairs more often than intuition suggests, usually because one deploy touched two services.

Step 3 - Bytes, or streams?

This is the branch most often skipped, and skipping it is how an operator spends twenty minutes throttling a byte rate that was never the problem.

Read-only / Safehow many distinct values does each label take
export LOKI_ADDR=http://loki-query.monitoring.svc:3100
export LOKI_ORG_ID=prod

logcli series --analyze-labels --since=15m '{job="checkout-svc"}'
Total Streams:  184203
Unique Labels:  6

Label Name     Unique Values   Found In Streams
request_id     183914          183914
instance       12              184203
job            1               184203
level          4               184203
namespace      1               184203
env            1               184203

Illustrative output

A label whose unique-value count is close to the stream count is opening a new stream per line. That is the worst secondary effect in this whole incident class: a stream that receives one line never fills a chunk, never closes, and never flushes, so ingester memory grows without the byte rate looking alarming at all. Throttling bytes does nothing about it. The fix is to get that value out of the label set - into the log line, or into structured metadata - and the lever is the collector pipeline, not the Loki limits.

The byte-volume case and the stream-cardinality case are both “high ingestion volume” on the alert that paged you. They share nothing else.

Step 4 - Find the line, then the host

Two cuts, in this order. Each is one query and each halves the search.

# Which message is repeating? A single dominant pattern is a loop
# or a tight-loop debug print.
topk(10, sum by (level) (rate({job="checkout-svc"}[5m])))

# Which host is producing it? One host dominating means a per-instance
# toggle; an even spread means a fleet-wide change.
topk(10, sum by (instance) (bytes_rate({job="checkout-svc"}[5m])))

If the level breakdown shows debug where info used to dominate, you have your shape and you can stop investigating. A typical request handler emits a handful of lines at info and one to two orders of magnitude more at debug; the level distribution is the whole diagnosis.

If the levels look normal but the volume is up, read the actual lines, scoped tightly and with a hard limit so the query itself is not another load event:

Read-only / Saferead the lines only after the cheap cuts are done
export LOKI_ADDR=http://loki-query.monitoring.svc:3100
export LOKI_ORG_ID=prod

logcli query --since=5m --limit=200 --quiet \
'{job="checkout-svc"}' \
| cut -c1-120 | sort | uniq -c | sort -rn | head -10

Step 5 - Take the cheapest lever that actually works

The source-side revert is almost always available and almost always faster than a redeploy. A service that exposes a runtime logger endpoint can be corrected in milliseconds, and the correction is scoped to what you name.

For the loop shape, understand what you are buying. A collector drop rule or a per-stream limit stops Loki paying for the loop. It does not stop the loop. The application keeps retrying, keeps formatting lines, keeps burning CPU, and keeps filling its own local buffer - and when that buffer fills it starts dropping or the process runs out of room. The throttle makes the loop invisible to you while it continues to damage the service. Say so explicitly when you hand over.

# /etc/loki/config.yaml - Loki 3.x
# The per-stream limit is the right first platform-side lever for a
# single noisy stream: it bounds one stream without rejecting the
# tenant. Applies at the distributor.
limits_config:
  per_stream_rate_limit: 8MB
  per_stream_rate_limit_burst: 16MB
  max_line_size: 256KB
# /etc/loki/runtime-config.yaml
# Per-tenant overrides are picked up within seconds and are the right
# place for an incident-scoped change: one file, one tenant, easy to
# revert, and it does not require touching the static config.
overrides:
  checkout:
    ingestion_rate_mb: 24
    ingestion_burst_size_mb: 36
    per_stream_rate_limit: 4MB

Step 6 - Hold is a real option, with three parts

If the cluster is absorbing the spike, the chunk store has weeks of margin, and the owning team is already engaged, the correct action can be to do nothing further and watch. A throttle applied to a spike that was about to end on its own costs a data gap for no benefit.

Holding is only legitimate when it is stated:

  • Owner. The operator on the incident, by name, not “the platform team”.
  • Checkpoint. What is re-read and how often - distributor byte rate, ingester memory, and projected chunk-store fill date, every ten minutes.
  • Exit condition. The threshold that ends the hold and what happens when it is crossed, and the clock time at which the hold is re-evaluated regardless.

Without those three, “hold” is indistinguishable from “nobody is doing anything”, and the next person to look will apply a throttle you did not want.

Step 7 - Verify, and check who you verified for

Read-only / Safedid it fall, and did it fall for the right reason
PROM=http://localhost:9090

curl -s "$PROM/api/v1/query" --data-urlencode \
'query=sum(rate(loki_distributor_bytes_received_total[5m]))' | jq -r '.data.result[0].value[1]'

curl -s "$PROM/api/v1/query" --data-urlencode \
'query=sum by (reason) (rate(loki_discarded_bytes_total[5m]))' \
| jq -r '.data.result[] | "\(.metric.reason)\t\(.value[1])"'

curl -s "$PROM/api/v1/query" --data-urlencode \
'query=sum(loki_ingester_memory_streams)' | jq -r '.data.result[0].value[1]'

Bytes down with discards flat means the source stopped producing. Bytes down with discards climbing means you moved the burn rather than ending it - the collector is now buffering and then dropping, and there is a data gap growing that nobody has been told about.

Then check the tenants who did not cause this. Their query latency is where the cost of the spike actually landed, and it is the only place the recovery can honestly be confirmed.

The fifteen-minute rule

If you have not identified the source after fifteen minutes of querying, stop querying alone. Pull in a second engineer and the suspect team lead. Apply a per-tenant cap to bound the bleed while the investigation continues, and say clearly that the cap is a bleed-control measure with a data cost, not a fix.

Thrashing past fifteen minutes is expensive in a way that is easy to underestimate, because the damage is happening to other people’s dashboards and none of it shows up on yours.

Common patterns

What you seeLikely causeWhere to look
Line rate up 40x, byte rate up less, many messagesDebug or trace level enabledLevel distribution by job; the last deploy
One message dominating, rate sustained and growingRetry loop with no backoff, or a parser echoThe failing downstream, not the log pipeline
Job label that did not exist yesterday at the topUnannounced service onboardingThe deploy timeline; then the owning team
Stream count tracking line countA per-line value promoted to a labellogcli series --analyze-labels; fix the pipeline
Bytes normal, ingester memory climbingStreams that never closeFlush rate; the label set
Every tenant spiking at onceShared collector config pushThe agent fleet, not the applications
Agent host disk filling, Loki rate normalForwarder queue backing upThe agent, upstream of Loki entirely
Spike ended by itself in minutesRestart replay or a burstNothing to fix; note it and move on

After: capture the cost while it still exists

The evidence you need for the review rolls out of retention. Before you close the channel, record the peak bytes per second, the duration, the per-tenant attribution, and the message pattern. Those four numbers plus the storage and egress unit costs are the entire cost calculation, and they are the only thing that turns “the platform team throttled us” into a conversation about a rate-limit agreement.

The action items that actually prevent the next one are small: a CI check that fails a build whose default log level is below warn; a throttled logger in the retry path so one failure emits one line per window rather than one per attempt; an onboarding audit that estimates lines per request and label cardinality before a new service ships; and a drop-rules snippet already reviewed in the repository, so that mitigating the next spike is a merge rather than authorship under fire.

References

  1. Loki configuration: limits_config
  2. Loki operations: ingestion tuning
  3. Loki component metrics reference
  4. LogQL query reference
  5. logcli
  6. Loki labels and structured metadata
  7. Grafana Alloy: loki.process