How a Terminated Kubernetes Node Blackholed a Kafka Connection and Silently Failed a Third of Our Orders
← Back
September 14, 2026Kubernetes9 min read

How a Terminated Kubernetes Node Blackholed a Kafka Connection and Silently Failed a Third of Our Orders

Published September 14, 20269 min read

03:20 UTC. PagerDuty pages the on-call engineer for checkout-api: 5xx rate on POST /orders just crossed 15%. Support hasn't reported anything yet. The dashboard has.


the setup

checkout-api is a FastAPI service that accepts an order, does a quick validation, and publishes it to order.submitted on Kafka for downstream fulfillment. It doesn't wait for a delivery confirmation before responding to the client, it hands the message to the producer's internal buffer and returns 202 immediately.

checkout-api, order endpoint
producer = Producer({
    'bootstrap.servers': 'kafka-headless.kafka.svc.cluster.local:9092',
    'queue.buffering.max.messages': 10000,
    'queue.buffering.max.ms': 50,
    'acks': 'all',
})

@app.post("/orders")
async def create_order(order: OrderIn):
    payload = order.model_dump_json().encode()
    try:
        producer.produce('order.submitted', key=str(order.id), value=payload)
    except BufferError:
        raise HTTPException(status_code=503, detail="order queue full, retry")
    return {"status": "accepted", "order_id": order.id}

Kafka runs as a three-broker StatefulSet in its own namespace, kafka-0, kafka-1, kafka-2, behind a headless service. Twenty-four partitions on order.submitted, eight led by each broker. The cluster had been stable for months, and node pool upgrades happened weekly without anyone thinking about them as a Kafka event at all.


the scramble

First theory: a bad deploy, checkout-api's deploy history showed nothing in the last six hours. Ruled out in under a minute.

Second theory: Postgres. checkout-api writes the order row before publishing to Kafka, so a database problem was a plausible first guess. Query latency, lock waits, connection pool saturation, all flat.

Third theory, and the one that ate the most time: a bad partition key. order.id is the Kafka key, and someone suggested a burst from one high-volume customer was hammering a single partition and backing up its broker. The failing requests, though, weren't clustered on any one customer, region, or order type. Whatever was failing was failing at random from the application's point of view, which usually means the cause isn't in the application at all.

The Kafka cluster dashboard itself looked fine the entire time: all three brokers up, zero under-replicated partitions, ISR full on every partition. That ruled out a cluster-wide problem and pointed the investigation back at the producer.


the hunt

checkout-api's logs were full of the same error, all from the last twelve minutes.

checkout-api application log
ERROR BufferError: Local: Queue full
  producer.produce('order.submitted', key='ord_9f21ac', ...) failed

queue.buffering.max.messages was set to 10,000, a deliberately conservative cap left over from an earlier incident where an unbounded producer buffer had contributed to an OOM kill. If the buffer was full, something downstream of the producer had stopped draining it. librdkafka exposes a stats callback with per-broker internals, and that's where the shape of the problem showed up.

producer stats_cb output, trimmed
"brokers": {
  "kafka-0.../0": { "state": "UP", "rtt": { "avg": 1421 }, "req_cnt": 88213 },
  "kafka-1.../1": { "state": "UP", "rtt": { "avg": 0 },    "req_cnt": 4102, "outbuf_msg_cnt": 9944 },
  "kafka-2.../2": { "state": "UP", "rtt": { "avg": 1358 }, "req_cnt": 90544 }
}

Broker 1's req_cnt hadn't moved across two consecutive polls of the stats callback, while its outbound queue climbed toward the 10,000 ceiling. Brokers 0 and 2 were sending and receiving fine. This wasn't a cluster problem or a hot-key problem, it was one specific broker connection that had stopped moving traffic while still reporting itself as UP.

A raw socket check on the checkout-api pod confirmed it.

ss -ti, from inside the checkout-api pod
ESTAB  0  45231  10.44.1.9:51882  10.42.3.17:9092
    cubic wscale:7,7 rto:912000 rtt:228000/8 mss:1420 retrans:0/47 unacked:31

10.42.3.17 was kafka-1's pod IP, or rather, it had been. The connection was still open from the kernel's point of view, forty-five kilobytes sitting unacknowledged in the send queue, retransmit timeout backed off to 912 seconds after 47 failed attempts. No RST, no FIN, ever. Just silence.

Kubernetes events for the node explained why.

kubectl get events -n kafka --sort-by=.lastTimestamp
03:06:58Z  Normal   NodeNotSchedulable  node/gke-prod-pool-7f3a2b  Node is now unschedulable
03:07:12Z  Normal   Killing             pod/kafka-1                Stopping container kafka
03:07:19Z  Warning  NodeNotReady        node/gke-prod-pool-7f3a2b  Node is not ready
03:08:03Z  Normal   Scheduled           pod/kafka-1                Assigned to gke-prod-pool-7f3a2b-m9qw
03:08:41Z  Normal   Started             pod/kafka-1                Started container kafka

A node pool upgrade drained kafka-1's node at 03:06:58 and the underlying VM was gone seven seconds after the container got its stop signal, before it finished closing its sockets. The pod rescheduled cleanly onto a new node with a new IP, and Kafka itself never noticed anything was wrong, because from Kafka's perspective nothing was: the new broker pod came up healthy and rejoined the cluster in under a minute.

The problem was entirely on the producer side. checkout-api's open connection to 10.42.3.17 had no peer left to answer it. Packets to a dead node don't get refused, they go nowhere, and a TCP stack has no way to tell "nobody's listening" apart from "the network is briefly congested." It just keeps retransmitting with exponential backoff until it gives up.


the find

Root cause: the producer's connection to broker 1 survived the node that ran it. Because the node was terminated rather than the container shutting down cleanly, no FIN or RST ever reached checkout-api, so librdkafka had no signal to close the connection and open a new one. It kept queueing messages for broker 1's eight partitions against a socket that was never going to accept them again.

Left alone, the kernel would have caught this on its own. Linux's default tcp_retries2 is 15, and against the backoff schedule that connection was on, that typically works out to somewhere around fifteen to twenty minutes before the socket gives up with a timeout error. checkout-api's own buffer cap of 10,000 messages filled long before that, in about twelve minutes at its steady produce rate for broker 1's share of traffic, and that's what actually paged anyone: not the dead connection itself, but the buffer built to protect against a different failure mode entirely.


the fix

The immediate fix was a rolling restart of checkout-api. Fresh pods open fresh connections, resolve kafka-1's DNS name again, and get the current IP. Recovery was under a minute once that shipped.

The real fix was making sure a blackholed connection gets detected fast instead of waiting on a kernel default tuned for general-purpose traffic, not for a service where every producer needs to notice a dead broker within seconds, not minutes.

producer config, after
producer = Producer({
    'bootstrap.servers': 'kafka-headless.kafka.svc.cluster.local:9092',
    'queue.buffering.max.messages': 10000,
    'queue.buffering.max.ms': 50,
    'acks': 'all',
    'socket.keepalive.enable': True,
})
checkout-api pod spec
securityContext:
  sysctls:
    - name: net.ipv4.tcp_keepalive_time
      value: "30"
    - name: net.ipv4.tcp_keepalive_intvl
      value: "10"
    - name: net.ipv4.tcp_keepalive_probes
      value: "3"

With keepalive enabled and tuned down from the OS default of two hours, a dead broker connection now gets probed every ten seconds and torn down within about a minute of going silent, well inside the buffer's headroom at normal traffic. librdkafka reconnects automatically once the socket errors out, and the reconnect does a fresh DNS lookup against the broker's stable hostname.

On the Kafka side, the StatefulSet got a longer termination grace period and a preStop hook so a future node drain gives the broker time to close its client connections cleanly before the node underneath it disappears.

kafka StatefulSet, after
terminationGracePeriodSeconds: 60
containers:
  - name: kafka
    lifecycle:
      preStop:
        exec:
          command: ["/bin/sh", "-c", "kafka-server-stop.sh"]

And the stats callback that had already exposed the per-broker breakdown during the hunt got turned into an actual alert, instead of something only pulled up manually mid-incident: any broker reporting UP with no successful request in 60 seconds, while its siblings are healthy, pages on-call directly, before the producer's buffer has any reason to fill.


the aftermath

12 min From node termination to the producer's buffer filling and errors starting
612 Orders that returned 503 before the rolling restart resolved it
1 of 3 Brokers affected, matching the fraction of partitions and traffic that failed
~1 min New time to detect a dead broker connection, down from a ~15-20 min kernel default

Nothing here crashed. The node upgrade succeeded, the new broker pod came up healthy, and the Kafka cluster reported green the entire time. The failure was a connection that looked alive to everything that could see it and wasn't, and a kernel retry budget built for general traffic, not for a system where a few minutes of silence on one broker translates directly into failed customer orders.

  • A node that disappears without the container inside it getting to close its sockets leaves every peer holding a connection with no way to distinguish "briefly slow" from "gone forever."
  • Fire-and-forget producers hide backpressure until a buffer fills. The buffer cap that stops an OOM is also, by design, the thing that turns a silent stall into a loud one, just later than you'd want.
  • Per-broker connection health is a different signal from cluster health. A dashboard showing every broker up says nothing about whether any specific producer can actually talk to all of them.
  • TCP keepalive isn't on by default in most client libraries. For anything talking to a service behind infrastructure that can disappear out from under a connection, that default is worth revisiting.

The graceful shutdown hook is what should stop this specific sequence from happening again. The keepalive tuning is what stops the next one, whatever disappears out from under a connection next time, from taking twelve minutes to notice.

Share this
← All Posts9 min read