You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A client agent serving a transparent-proxy sidecar with cluster peering leaks one
watcher goroutine per imported peered upstream, on every peered-upstreams update, for
the lifetime of the process. On our cluster this reaches ~8000 goroutines within a few
hours and ends in the agent being OOM-killed.
handlerConnectProxy.handleUpdate re-runs setupWatchesForPeeredUpstream for every
imported service on everypeered-upstreams update
(connect_proxy.go#L358-L377),
and that function has no "already watched" guard, passes the long-lived state context,
and records a nil cancel func
(connect_proxy.go#L270-L284):
Each Health.Notify starts a goroutine in submatview.Store.NotifyCallback
whose only exit is ctx.Err() != nil. Because the context is the state's own and the
stored cancel is nil, nothing can ever reclaim it — not the next update, and not reconcilePeeringWatches, whose CancelWatch
is a no-op against a nil cancel. So removing an imported service leaks too. Compare watchUpstreamTarget, which does the same registration correctly: child context, real
cancel func stored.
A second, related defect. state.Close
returns early once the run loop has stopped, and Manager.register
replaces a stopped state without calling Close at all. state.run recovers panics,
which leaves the loop dead but every watch it registered still live and uncancelled; local.Sync then recreates the state on its next resync ("recreate any terminated
watches"), so one recovered panic strands that proxy's entire watch set. We do not
observe this in production (zero unexpected panic while running proxycfg in our
logs) — reporting it because it is the same class of bug in the same recovery path.
Why this is filed separately from #23923. I originally reported it there, but the
evidence since says they are independent:
The leaking code is byte-identical in v1.21.0-rc2, v1.22.0, v1.22.3, v2.0.1, v2.0.3, v2.0.4 and main. It is not the 2.0.4 regression.
@sawall reports v2.0.4 giving i/o deadline reached errors #23923 at full strength with no goroutine growth at all. That is
consistent: they have the EOF regression but no peering + transparent proxy, so this
code path never runs for them.
This leak needs no EOF at all — any peered-upstreams index bump triggers it. v2.0.4 giving i/o deadline reached errors #23923
plausibly raises the rate by churning peering streams, but is not the cause.
Reproduction Steps
Preconditions: transparent proxy mode (the peered-upstreams watch is only
registered in tproxy mode) and at least one service imported from a peer.
Create two datacenters with a cluster peering connection between them, and export
at least one service from the peer.
On a client agent in the importing datacenter, register a connect sidecar with mode = "transparent" and no explicit upstreams, so its upstreams are inferred
from the imported services.
Poll /v1/agent/metrics for consul.runtime.num_goroutines, or take /debug/pprof/goroutine?debug=1 captures a few minutes apart.
Observe a step function: flat, then a jump of (imported services x tproxy proxies)
per query window, never coming back down, until the agent is OOM-killed.
The same thing is reproducible as two unit tests, both failing on v2.0.4 and main:
Drive handlerConnectProxy.handleUpdate with three identical peered-upstreams
events and count Health.Notify calls for one upstream: 3 registrations instead of
1, all sharing a context that cannot be individually cancelled.
Panic inside handleUpdate, wait for the run loop to exit, then call state.Close:
the watch context is never cancelled.
Consul info for both Client and Server
Note on the client sample below: the affected client agents have since been patched, so
what is shown is a client in the exporting (peer) datacenter. It runs an otherwise
identical config, including one transparent-proxy sidecar, but imports no peered
services — and it does not leak (7 days uptime, zero restarts, no OOM). It is included
as a control. The affected agents were 2.0.4 (and previously 2.0.3) clients in the
importing datacenter, whose measurements are in Log Fragments below.
Consul 2.0.4 client agents (also seen with 2.0.3 clients), Linux/amd64, running in
Docker. Cluster peering between two datacenters, mesh gateways, ACLs enabled, auto_encrypt plus TLS on internal RPC. The affected sidecar runs in transparent proxy
mode with zero explicit upstreams, so all of its upstreams are inferred from the
imported peer services.
Log Fragments
One client agent in the importing datacenter, one tproxy sidecar with 59 imported
upstreams across two peers. Counting Failed to handle update from watch events
carrying an upstream-peer: correlation ID:
distinct peered upstreams
59
total error deliveries
5,582
from the tproxy sidecar
5,567
from 5 non-tproxy sidecars
3 each
max deliveries of one watch ID within one second
38
The same logical watch reporting an error 38 times at consecutive microsecond
timestamps is 38 stranded watcher goroutines for that one watch. The non-tproxy
sidecars got exactly one delivery per watch, as expected:
{"@level":"error","@message":"Failed to handle update from watch","@module":"agent.proxycfg",
"@timestamp":"...342943Z","error":"error filling agent cache: rpc error: code = Unknown desc = subscription closed by server, server is shutting down",
"id":"upstream-peer:<svc>?peer=<peer>","kind":"connect-proxy","service_id":"<tproxy sidecar>"}
{"@level":"error","@message":"Failed to handle update from watch","@module":"agent.proxycfg",
"@timestamp":"...342961Z","error":"...","id":"upstream-peer:<svc>?peer=<peer>", ...}
{"@level":"error","@message":"Failed to handle update from watch","@module":"agent.proxycfg",
"@timestamp":"...342970Z","error":"...","id":"upstream-peer:<svc>?peer=<peer>", ...}
... 35 more for the same id, same second ...
Goroutine growth on that agent, from two /debug/pprof/goroutine?debug=1 captures
360s apart:
The container is then OOM-killed (Exit Code: 137) against a 512 MiB limit.
For contrast, the control client above — same config, one tproxy sidecar, no imported
peered services — has been up 7 days with zero restarts and a flat goroutine count.
Overview of the Issue
A client agent serving a transparent-proxy sidecar with cluster peering leaks one
watcher goroutine per imported peered upstream, on every peered-upstreams update, for
the lifetime of the process. On our cluster this reaches ~8000 goroutines within a few
hours and ends in the agent being OOM-killed.
handlerConnectProxy.handleUpdatere-runssetupWatchesForPeeredUpstreamfor everyimported service on every
peered-upstreamsupdate(connect_proxy.go#L358-L377),
and that function has no "already watched" guard, passes the long-lived state context,
and records a nil cancel func
(connect_proxy.go#L270-L284):
Each
Health.Notifystarts a goroutine insubmatview.Store.NotifyCallbackwhose only exit is
ctx.Err() != nil. Because the context is the state's own and thestored cancel is nil, nothing can ever reclaim it — not the next update, and not
reconcilePeeringWatches, whoseCancelWatchis a no-op against a nil cancel. So removing an imported service leaks too. Compare
watchUpstreamTarget, which does the same registration correctly: child context, realcancel func stored.
A second, related defect.
state.Closereturns early once the run loop has stopped, and
Manager.registerreplaces a stopped state without calling
Closeat all.state.runrecovers panics,which leaves the loop dead but every watch it registered still live and uncancelled;
local.Syncthen recreates the state on its next resync ("recreate any terminatedwatches"), so one recovered panic strands that proxy's entire watch set. We do not
observe this in production (zero
unexpected panic while running proxycfgin ourlogs) — reporting it because it is the same class of bug in the same recovery path.
Why this is filed separately from #23923. I originally reported it there, but the
evidence since says they are independent:
v1.21.0-rc2,v1.22.0,v1.22.3,v2.0.1,v2.0.3,v2.0.4andmain. It is not the 2.0.4 regression.consistent: they have the EOF regression but no peering + transparent proxy, so this
code path never runs for them.
plausibly raises the rate by churning peering streams, but is not the cause.
Reproduction Steps
Preconditions:
transparentproxy mode (thepeered-upstreamswatch is onlyregistered in tproxy mode) and at least one service imported from a peer.
at least one service from the peer.
mode = "transparent"and no explicit upstreams, so its upstreams are inferredfrom the imported services.
/v1/agent/metricsforconsul.runtime.num_goroutines, or take/debug/pprof/goroutine?debug=1captures a few minutes apart.per query window, never coming back down, until the agent is OOM-killed.
The same thing is reproducible as two unit tests, both failing on
v2.0.4andmain:handlerConnectProxy.handleUpdatewith three identicalpeered-upstreamsevents and count
Health.Notifycalls for one upstream: 3 registrations instead of1, all sharing a context that cannot be individually cancelled.
handleUpdate, wait for the run loop to exit, then callstate.Close:the watch context is never cancelled.
Consul info for both Client and Server
Note on the client sample below: the affected client agents have since been patched, so
what is shown is a client in the exporting (peer) datacenter. It runs an otherwise
identical config, including one transparent-proxy sidecar, but imports no peered
services — and it does not leak (7 days uptime, zero restarts, no OOM). It is included
as a control. The affected agents were 2.0.4 (and previously 2.0.3) clients in the
importing datacenter, whose measurements are in Log Fragments below.
Client info
Server info
Operating system and Environment details
Consul 2.0.4 client agents (also seen with 2.0.3 clients), Linux/amd64, running in
Docker. Cluster peering between two datacenters, mesh gateways, ACLs enabled,
auto_encryptplus TLS on internal RPC. The affected sidecar runs in transparent proxymode with zero explicit upstreams, so all of its upstreams are inferred from the
imported peer services.
Log Fragments
One client agent in the importing datacenter, one tproxy sidecar with 59 imported
upstreams across two peers. Counting
Failed to handle update from watcheventscarrying an
upstream-peer:correlation ID:The same logical watch reporting an error 38 times at consecutive microsecond
timestamps is 38 stranded watcher goroutines for that one watch. The non-tproxy
sidecars got exactly one delivery per watch, as expected:
Goroutine growth on that agent, from two
/debug/pprof/goroutine?debug=1captures360s apart:
consul.runtime.num_goroutinessampled over the same period — flat, then a jump perquery window, with only ~4 of each batch released:
The container is then OOM-killed (
Exit Code: 137) against a 512 MiB limit.For contrast, the control client above — same config, one tproxy sidecar, no imported
peered services — has been up 7 days with zero restarts and a flat goroutine count.