Describe the bug
When -distributor.use-stream-push=true, MakeIngesterClient() creates the
grpc.ClientConn before starting the push-stream workers. If starting those
workers fails, the function returns the error without closing the connection or
cancelling the stream context. The ClientConn is now unreferenced but still
alive: its addrConn reconnect loop keeps dialing the (usually dead) ingester
address forever, with gRPC's 120s maximum backoff.
Because ring/client.Pool.GetClientFor() only records the client after the
factory succeeds, a failed factory call leaves nothing in Pool.clients. Neither
removeStaleClients() nor cleanUnhealthy() can ever reach these connections,
and cortex_distributor_ingester_clients keeps reporting the correct (low)
number while orphans accumulate invisibly. The pool itself is fine — the leak is
entirely outside its bookkeeping.
Every failed factory call leaks one connection permanently, and failures cluster
during ingester rollouts. The window that matters is the one where an ingester's
address is still in the ring but no longer reachable (pod terminating or
already gone). In that window grpc.NewClient still succeeds — it is lazy — and
the first PushStream then fails fast, because the addrConn reaches
TRANSIENT_FAILURE and the stream is created without WaitForReady. So every
GetClientFor for that address leaks one connection, at whatever rate the
component happens to be calling it. The leak grows by a burst on each rollout and
never shrinks until the process restarts.
(Streams can also fail while the ingester is gracefully shutting down. Note the
server-side PushStream handler does not reject at stream-open time — it has no
checkRunning of its own and only reaches one per message via i.Push — so a
draining-but-alive ingester generally accepts the stream. Unreachability is the
dominant trigger.)
Why this also hits components that never push
Run() opens all 100 PushStreams eagerly, at construction time, not lazily
on the first push. Every component that builds an ingester client pool through
the distributor gets this — including the querier, whose embedded distributor
creates the pool with the same factory and reaches GetClientFor from the read
path (ForReplicationSet,
distributor.go#L1364).
Two consequences:
- Ordinary query traffic is sufficient to trigger this leak. No write traffic
to the affected process is required.
- A querier permanently maintains 100 idle push streams per live ingester that it
will never use. That is a separate design question from this bug, but it is why
the goroutine numbers below are as large as they are.
The code
pkg/ingester/client/client.go#L146-L175
(master e450d52; identical in v1.21.1):
conn, err := grpc.NewClient(addr, dialOpts...)
if err != nil {
return nil, err
}
c := &closableHealthAndIngesterClient{ /* ... */ conn: conn /* ... */ }
if useStreamConnection {
streamCtx, streamCancel := context.WithCancel(context.Background())
err = c.Run(make(chan *streamWriteJob, INGESTER_CLIENT_STREAM_WORKER_COUNT), streamCtx, streamCancel)
if err != nil {
return nil, err // <-- conn and streamCtx are both leaked here
}
}
return c, nil
Run() starts INGESTER_CLIENT_STREAM_WORKER_COUNT (100) workers, each of which
opens a PushStream. A single worker failing is enough to make Run() return
non-nil.
Three secondary problems in the same path
Run() does not stop the workers that already started. It waits for all
100 to return, keeps only the last error, and leaves the successful ones
running even though the client is about to be discarded.
- Goroutine leak.
worker() spawns a job-processing goroutine for every
stream it opens successfully. Those goroutines only exit on ctx.Done() or on
streamPushChan closing. When Run() returns an error, streamCancel() is
never called and Close() is never reached, so they leak alongside the
connection. In the fully-unreachable case above all 100 workers fail, so
little or nothing leaks here — this matters in the partial-failure case, where
up to 99 goroutines and their streams survive a discarded client.
- Data race. In
Run(),
workerErr is written from all 100 worker goroutines with no
synchronisation. It should be an atomic.Error, a mutex-guarded value, or a
multierror collected through a channel.
To Reproduce
- Run Cortex
v1.21.1 (or master) with -distributor.use-stream-push=true.
- Roll the ingester StatefulSet, so that one ingester's address remains in the
ring while its pod is unreachable.
- During that window, keep traffic flowing so
Pool.GetClientFor() is called
repeatedly for that address. Read traffic is enough — a querier reaches the
same factory through ForReplicationSet.
- After the ingester's pod is gone, observe that the process keeps opening TCP
connections to the now-dead pod IP indefinitely.
Useful confirmations:
# Reconnect loops far exceed the number of real peers
$ curl -s localhost:8080/debug/pprof/goroutine?debug=1 \
| awk '/^[0-9]+ @/{n=$1} /addrConn.resetTransportAndUnlock/{print n}'
(Frame name as of grpc-go v1.82.1, the version pinned in go.mod.)
Note: grep -c on the pprof text output gives 1, not the goroutine count —
aggregated pprof prints one stack per unique trace with an N @ prefix.
Expected behavior
A failed MakeIngesterClient() should leave nothing behind. The early return
needs to undo what was already set up:
if useStreamConnection {
streamCtx, streamCancel := context.WithCancel(context.Background())
err = c.Run(make(chan *streamWriteJob, INGESTER_CLIENT_STREAM_WORKER_COUNT), streamCtx, streamCancel)
if err != nil {
+ streamCancel()
+ _ = conn.Close()
return nil, err
}
}
Cancelling streamCtx also unblocks the job-processing goroutines started by the
workers that did succeed.
Environment
- Infrastructure: Kubernetes on AWS EKS, Cilium CNI
- Deployment tool: Helm / FluxCD
- Cortex version:
v1.21.1 (defect verified unchanged on master e450d52)
- grpc-go:
v1.82.1
- Relevant config:
distributor.use_stream_push: true
Storage Engine
Additional Context
Observed on a production querier, which receives no write traffic at all —
its embedded distributor builds the ingester pool through the same factory, and
the read path calls GetClientFor. One ingester was replaced; ~12.8h later the
querier still held roughly 165 orphaned ClientConns to the dead pod's IP,
producing a sustained ~1.5 SYN/s that the CNI dropped — after a pod is
deleted its IP no longer maps to a pod identity, so the network policy that
allows querier→ingester traffic no longer matches and the packets are denied.
| Signal |
Observed |
Expected |
goroutines in addrConn.resetTransportAndUnlock |
180 |
~15 (6 ingesters + 3 store-gateways + 3 frontends + 3 schedulers) |
goroutines in newClientStreamWithParams |
620 |
~600 (6 live ingesters × 100 eagerly opened, permanently idle streams) |
cortex_distributor_ingester_clients |
6 |
6 — correct, and blind to the orphans |
| SYNs to the dead ingester IP |
~1.5/s |
0 |
The orphan count is derived from the reconnect-goroutine delta (180 observed − 15
legitimate peers). The measured SYN rate is an independent corroboration:
165 orphans ÷ gRPC's 120s maximum backoff ≈ 1.4 attempts/s, against ~1.5/s
actually observed on the wire.
The goroutine counts are consistent with one leaked ClientConn per orphan and
no accompanying goroutine leak, which is what the unreachable-address path
predicts: all 100 workers fail, so only the connection survives.
In the interest of precision: the code defect and these measurements are certain,
but the attribution of these specific orphans to that early return is inference
from the arithmetic and the timeline rather than a heap dump. I could not find
another path in Cortex that creates an ingester ClientConn without registering
it in the pool.
Queries were unaffected in steady state; the cost is leaked sockets, leaked
goroutines and continuous dropped packets, all of which persist for the lifetime
of the process and grow with every ingester rollout.
Rings, DNS and EndpointSlices were all verified clean — the dead address exists
nowhere in Cortex's own service discovery, which is what pointed at connections
that no component is tracking.
Describe the bug
When
-distributor.use-stream-push=true,MakeIngesterClient()creates thegrpc.ClientConnbefore starting the push-stream workers. If starting thoseworkers fails, the function returns the error without closing the connection or
cancelling the stream context. The
ClientConnis now unreferenced but stillalive: its
addrConnreconnect loop keeps dialing the (usually dead) ingesteraddress forever, with gRPC's 120s maximum backoff.
Because
ring/client.Pool.GetClientFor()only records the client after thefactory succeeds, a failed factory call leaves nothing in
Pool.clients. NeitherremoveStaleClients()norcleanUnhealthy()can ever reach these connections,and
cortex_distributor_ingester_clientskeeps reporting the correct (low)number while orphans accumulate invisibly. The pool itself is fine — the leak is
entirely outside its bookkeeping.
Every failed factory call leaks one connection permanently, and failures cluster
during ingester rollouts. The window that matters is the one where an ingester's
address is still in the ring but no longer reachable (pod terminating or
already gone). In that window
grpc.NewClientstill succeeds — it is lazy — andthe first
PushStreamthen fails fast, because theaddrConnreachesTRANSIENT_FAILUREand the stream is created withoutWaitForReady. So everyGetClientForfor that address leaks one connection, at whatever rate thecomponent happens to be calling it. The leak grows by a burst on each rollout and
never shrinks until the process restarts.
(Streams can also fail while the ingester is gracefully shutting down. Note the
server-side
PushStreamhandler does not reject at stream-open time — it has nocheckRunningof its own and only reaches one per message viai.Push— so adraining-but-alive ingester generally accepts the stream. Unreachability is the
dominant trigger.)
Why this also hits components that never push
Run()opens all 100PushStreams eagerly, at construction time, not lazilyon the first push. Every component that builds an ingester client pool through
the distributor gets this — including the querier, whose embedded distributor
creates the pool with the same factory and reaches
GetClientForfrom the readpath (
ForReplicationSet,distributor.go#L1364).Two consequences:
to the affected process is required.
will never use. That is a separate design question from this bug, but it is why
the goroutine numbers below are as large as they are.
The code
pkg/ingester/client/client.go#L146-L175(master
e450d52; identical inv1.21.1):Run()startsINGESTER_CLIENT_STREAM_WORKER_COUNT(100) workers, each of whichopens a
PushStream. A single worker failing is enough to makeRun()returnnon-nil.
Three secondary problems in the same path
Run()does not stop the workers that already started. It waits for all100 to return, keeps only the last error, and leaves the successful ones
running even though the client is about to be discarded.
worker()spawns a job-processing goroutine for everystream it opens successfully. Those goroutines only exit on
ctx.Done()or onstreamPushChanclosing. WhenRun()returns an error,streamCancel()isnever called and
Close()is never reached, so they leak alongside theconnection. In the fully-unreachable case above all 100 workers fail, so
little or nothing leaks here — this matters in the partial-failure case, where
up to 99 goroutines and their streams survive a discarded client.
Run(),workerErris written from all 100 worker goroutines with nosynchronisation. It should be an
atomic.Error, a mutex-guarded value, or amultierrorcollected through a channel.To Reproduce
v1.21.1(or master) with-distributor.use-stream-push=true.ring while its pod is unreachable.
Pool.GetClientFor()is calledrepeatedly for that address. Read traffic is enough — a querier reaches the
same factory through
ForReplicationSet.connections to the now-dead pod IP indefinitely.
Useful confirmations:
(Frame name as of grpc-go
v1.82.1, the version pinned ingo.mod.)Expected behavior
A failed
MakeIngesterClient()should leave nothing behind. The early returnneeds to undo what was already set up:
if useStreamConnection { streamCtx, streamCancel := context.WithCancel(context.Background()) err = c.Run(make(chan *streamWriteJob, INGESTER_CLIENT_STREAM_WORKER_COUNT), streamCtx, streamCancel) if err != nil { + streamCancel() + _ = conn.Close() return nil, err } }Cancelling
streamCtxalso unblocks the job-processing goroutines started by theworkers that did succeed.
Environment
v1.21.1(defect verified unchanged on mastere450d52)v1.82.1distributor.use_stream_push: trueStorage Engine
Additional Context
Observed on a production querier, which receives no write traffic at all —
its embedded distributor builds the ingester pool through the same factory, and
the read path calls
GetClientFor. One ingester was replaced; ~12.8h later thequerier still held roughly 165 orphaned
ClientConns to the dead pod's IP,producing a sustained ~1.5 SYN/s that the CNI dropped — after a pod is
deleted its IP no longer maps to a pod identity, so the network policy that
allows querier→ingester traffic no longer matches and the packets are denied.
addrConn.resetTransportAndUnlocknewClientStreamWithParamscortex_distributor_ingester_clientsThe orphan count is derived from the reconnect-goroutine delta (180 observed − 15
legitimate peers). The measured SYN rate is an independent corroboration:
165 orphans ÷ gRPC's 120s maximum backoff ≈ 1.4 attempts/s, against ~1.5/s
actually observed on the wire.
The goroutine counts are consistent with one leaked
ClientConnper orphan andno accompanying goroutine leak, which is what the unreachable-address path
predicts: all 100 workers fail, so only the connection survives.
In the interest of precision: the code defect and these measurements are certain,
but the attribution of these specific orphans to that early return is inference
from the arithmetic and the timeline rather than a heap dump. I could not find
another path in Cortex that creates an ingester
ClientConnwithout registeringit in the pool.
Queries were unaffected in steady state; the cost is leaked sockets, leaked
goroutines and continuous dropped packets, all of which persist for the lifetime
of the process and grow with every ingester rollout.
Rings, DNS and EndpointSlices were all verified clean — the dead address exists
nowhere in Cortex's own service discovery, which is what pointed at connections
that no component is tracking.