← All runbooks in Observability
Runbook: Investigate Loki High Ingestion Volume
1 · Prerequisites
Confirm every item is in place before any state change.
- Log Ingestion Spike Anatomy
- Debug Logging Enabled
- Log Loop
- New High-Volume Service
- Loki Capacity Incident
- Loki Ingestion Limits
- Read access to the Loki read path (logcli or Grafana Explore) with the tenant header the affected tenant uses
- Read access to the Prometheus instance that scrapes the Loki components, for the distributor and ingester metrics
- The ability to change the affected service log level - via its runtime logger endpoint, its deployment config, or its owning team
- Write access to the repository that owns the Loki limits and the collector pipeline, plus a path to merge under incident conditions
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_totalflat 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.
- 1Confirm the spike is live and locate the saturating layer. Read
rate(loki_distributor_bytes_received_total[5m])(the wire rate),loki_ingester_memory_chunksandloki_ingester_memory_streams(the working set), andrate(loki_ingester_chunks_flushed_total[5m])(whether streams are closing). Rising bytes with a flat flush rate is the dangerous combination. - 2Attribute the bytes to a tenant, then to a job. Run
logcli volume --since=1h --limit=20against 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. - 3Decide whether this is bytes or streams.
logcli series --analyze-labels --since=15mon 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. - 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.
- 5Find the host. Break the same rate down by
instanceorpod. 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. - 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.
- 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.
- 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.
- 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_totalclimbing is a throttle: the burn moved upstream into the collector buffer and then into the void, and the application is still emitting. - 10Watch the ingester drain.
loki_ingester_memory_chunksandloki_ingester_memory_streamsshould fall as chunks flush. If they do not, the spike created streams that never close, and the label set is the remaining problem. - 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.
- 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 volumefor that selector shows it below its usual share of the total. - ✓
loki_discarded_bytes_totalandloki_discarded_samples_totalare 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_chunksandloki_ingester_memory_streamsare descending toward their pre-incident level, andrate(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_totalis 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.
| Lever | Where it acts | Reversible? | What it costs |
|---|---|---|---|
| Revert the log level at the source | Application | Yes, instantly | Nothing - the lines were never wanted |
| Runtime logger endpoint, one package | Application | Yes, instantly | Scoped to one instance unless rolled out |
| Drop or sample rule in the collector | Agent | Yes, on reload | Those lines never reach Loki; the app still emits and still pays CPU |
per_stream_rate_limit | Loki distributor | Yes, on reload | The noisiest stream is rejected; others in the tenant survive |
ingestion_rate_mb for the tenant | Loki distributor | Yes, on reload | The whole tenant is rejected once the bucket empties - a data gap |
Shorten retention_period | Loki compactor | No | Older 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.
| Shape | Signature | First action |
|---|---|---|
| Debug toggle | Line rate up an order of magnitude, byte rate up less, many distinct messages, correlates with a deploy | Revert the level - runtime endpoint if available |
| Log loop | One message pattern dominating, rate sustained rather than transient, rate grows with an underlying failure rate | Break the feedback path; throttle only buys time |
| New or mis-sized service | A job label that did not exist yesterday, or an existing one many times its estimate | Rate-limit conversation with the owning team |
| Legitimate growth | Lines look normal, correlates with traffic or a business event | Capacity, 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
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"'
doneThree 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_chunksclimbing. 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.
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 GBIllustrative 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.
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 184203Illustrative 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:
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 -10Step 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
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 see | Likely cause | Where to look |
|---|---|---|
| Line rate up 40x, byte rate up less, many messages | Debug or trace level enabled | Level distribution by job; the last deploy |
| One message dominating, rate sustained and growing | Retry loop with no backoff, or a parser echo | The failing downstream, not the log pipeline |
| Job label that did not exist yesterday at the top | Unannounced service onboarding | The deploy timeline; then the owning team |
| Stream count tracking line count | A per-line value promoted to a label | logcli series --analyze-labels; fix the pipeline |
| Bytes normal, ingester memory climbing | Streams that never close | Flush rate; the label set |
| Every tenant spiking at once | Shared collector config push | The agent fleet, not the applications |
| Agent host disk filling, Loki rate normal | Forwarder queue backing up | The agent, upstream of Loki entirely |
| Spike ended by itself in minutes | Restart replay or a burst | Nothing 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.