Head-of-line blocking: how one dead output stalled a Logstash pipeline
This is a write-up of a production incident from September 2026. A Logstash pipeline on three log shipping nodes stopped sending anything for two days, and nobody noticed: the process was running, the process check was green, and the persisted queue was filling up. The cause was one output pointing at an Elasticsearch cluster that had just been turned off. The notes apply to any single-pipeline Logstash (7.17 here) with several outputs and queue.type: persisted.
Problem
The setup is one pipeline with several outputs, chosen by conditionals on routing key, channel and application. Then one destination dies. Here it was an old Elasticsearch 6 cluster behind the log relay host, stopped for decommissioning.
The elasticsearch output retries a batch forever on connection errors. The worker thread holding that batch never finishes. The other workers pick up batches that also contain events for the dead output, and they get stuck too. Result:
events.outdrops to 0 for the whole pipeline, including the healthy outputs (the main Elasticsearch cluster, APM).- The persisted queue fills to
queue.max_bytes, then back-pressures the inputs (Beats clients stall). - A tiny share of traffic is enough. On one node, 170 events for the dead output (vs 3.05B for the healthy one) wedged everything.
Nagios only noticed when a restart left the process down (PROCS CRITICAL: 0 processes). The stall itself ran for two days unnoticed, because the process was running the whole time.
Detection
# per pipeline: throughput + queue fill
curl -s localhost:9600/_node/stats/pipelines/main | python3 -c '
import sys,json
p=json.load(sys.stdin)["pipelines"]["main"];q=p["queue"]
print("out",p["events"]["out"],"q_events",q["events_count"],
"q_bytes",q["queue_size_in_bytes"],"/",q["max_queue_size_in_bytes"])
for o in p["plugins"]["outputs"]:
e=o.get("events",{}); print(o["name"],o["id"][:8],"in",e.get("in"),"out",e.get("out"))'
# map output plugin ids to config lines
curl -s "localhost:9600/_node/pipelines/main?graph=true"
Signs:
queue_size_in_bytesclose tomax_queue_size_in_bytes, andevents.outnot moving between two samples.- Log flooded by
logstash.outputs.elasticsearch“No Available connections” / “No living connections”. - On restart:
persistedqueueconfigvalidator“current queue size … is greater than ‘queue.max_bytes’”. - Shutdown hangs:
ShutdownWatcherExt“shutdown process appears to be stalled”. systemdTimeoutStopSec(90s here) then SIGKILLs. That kill does not restart the service; Puppet or a human has to.
Monitor events.out rate and PQ fill, not only “is the process running”.
Fix applied
- Remove the dead output instead of pointing it at nothing. Here the output nodes got their own config template, so the shared template used by the other Logstash hosts stayed untouched.
- Route leftover events to Kafka with bounded retries, so they are neither dropped silently nor allowed to block the pipeline. Tag them in
filter{}(the@metadatais not serialized by the json codec), then branch inoutput{}:
filter {
if [channel] != 'apm' and ([application] == "legacy-app" or ([@metadata][output][routing_key] != "main-elk" and [@metadata][output][routing_key] != "%{routing_key}")) {
mutate { add_field => { "[@metadata][output][unrouted]" => "true" } }
mutate { add_field => { "unrouted_routing_key" => "%{[@metadata][output][routing_key]}" } }
if [@metadata][dead_letter_queue][reason] {
mutate { add_field => { "unrouted_reason" => "dead-letter" } }
} else {
mutate { add_field => { "unrouted_reason" => "unknown-routing-key" } }
}
}
}
output {
if [channel] != 'apm' {
if [@metadata][output][unrouted] == "true" {
kafka {
bootstrap_servers => "kafka-1.example.com:9092"
topic_id => "logstash-unrouted"
codec => json
retries => 3
retry_backoff_ms => 1000
}
} else if [@metadata][output][routing_key] == "main-elk" {
elasticsearch { hosts => ["es-main.example.com:9203"] ... }
} else if [@metadata][output][routing_key] == "%{routing_key}" {
elasticsearch { hosts => ["es-main.example.com:9203"] ... }
}
}
}
- Create the Kafka topic before rollout, with explicit retention. Do not rely on auto-create.
Gotchas
- With
retriesunset, the Kafka output retries forever (verified in thelogstash-integration-kafka10.12.1 source). On a single-broker Kafka that recreates the exact same wedge. Withretries => Nit logs “Exhausted user-configured retry count … Dropping these events” and moves on. That is an accepted loss for a diagnostic topic. - A sprintf reference to a missing field stays literal:
add_field => { "x" => "%{routing_key}" }stores the string%{routing_key}when the field is absent. Compare against that literal to detect “no routing key”. - DLQ events lose
tags. The dead-letter ruby block keeps only@*andevent__*fields, so"dead_letter" in [tags]never matches after it. Use[@metadata][dead_letter_queue][reason], which thedead_letter_queueinput sets and which survives. - DLQ events keep the original
@metadata, soadd_fieldonto[@metadata][output][routing_key]turns it into an array (main-elk,old-es-cluster). A!=comparison against one key still works; the value only looks odd. - Avoid single-element
in [...]lists in conditionals; chain!=withandinstead. - Absent fields compare as not-equal:
[channel] != 'apm'is true whenchanneldoes not exist.
Rollout and backlog
- The persisted queue sits before the filter stage. After the config change, the queued backlog goes through the new filter and output, so there is no need to purge
/var/lib/logstash/queue/main. We confirmed this in practice: ~11.6M queued events drained through the new outputs in ~50 min (about 2.5-2.8k ev/s per node) with zero errors. - Disable Puppet on all nodes (
puppet agent --disable "<ticket> ..."), then--enable && puppet agent -tone node at a time, starting with the worst one. Expect 1-2 min of PQ recovery before_node/statsshows a queue. - Queue size in bytes shrinks more slowly than
events_count, because disk pages are freed only when fully acked. - Watch the downstream cluster during the flush (
_cat/thread_pool/write?v&h=node_name,active,queue,rejected).
Longer-term options
- Isolate outputs with pipeline-to-pipeline (the distributor pattern) and one persisted queue per destination, so a dead destination only fills its own queue.
- Add a Nagios/Prometheus check that alerts on PQ fill ratio and on
events.outnot increasing.