Fix cluster-proxy memory leak by pooling proxied transports - #19
Open
RokibulHasan7 wants to merge 12 commits into
Open
Fix cluster-proxy memory leak by pooling proxied transports#19RokibulHasan7 wants to merge 12 commits into
RokibulHasan7 wants to merge 12 commits into
Conversation
tamalsaha
force-pushed
the
leak
branch
2 times, most recently
from
June 26, 2026 06:10
65edf20 to
2be11fc
Compare
Signed-off-by: RokibulHasan7 <mdrokibulhasan@appscode.com> Signed-off-by: Tamal Saha <tamal@appscode.com>
GetClientTLSConfig reads the CA/cert/key files from disk on every call. It was being invoked inside the per-dial closure, so every new tunnel connection re-read three files. Hoist it into the dialer-builder body so the TLS material is loaded once when the dialer is constructed (i.e. once per process for the pooled DialHolder). The gRPC transport credentials are likewise built once and shared across dials. Signed-off-by: Tamal Saha <tamal@appscode.com>
Previously proxyTransportFor fell back to restclient.TransportFor(cfg) when the DialHolder could not be obtained. That path bypasses the shared DialHolder and reintroduces the per-request transport (the very leak this change fixes) without any signal. Surface the error instead so the request fails loudly rather than silently regressing to the leaking transport. Signed-off-by: Tamal Saha <tamal@appscode.com>
ServeHTTP called cfg.TransportConfig() and proxyTransportFor() called it again internally, so each request derived the transport config twice. Compute it once in ServeHTTP and pass it into proxyTransportFor, which now also reuses it for the TLS and upgrader wrappers. transport.New(transportCfg) is equivalent to the prior restclient.TransportFor(cfg) for the non-cluster-proxy path. Signed-off-by: Tamal Saha <tamal@appscode.com>
The upgrade (exec/attach/port-forward) path built a fresh http.Transport on every request. Cache it in a process-wide map keyed by the TLS credential so repeated upgrade requests to the same cluster reuse one transport. The shared DialHolder dial routes connections by address, so the transport is safe to share across clusters presenting the same credential. Non-cluster-proxy endpoints keep the per-request transport. Signed-off-by: Tamal Saha <tamal@appscode.com>
- Don't latch a transient dial-holder build error forever: build the DialHolder lazily under a mutex and memoize only on success, so a startup race (certs not yet mounted) is retried instead of bricking cluster-proxy until a restart. - Stop re-reading the client TLS material per request: NewConfigFromCluster now reuses the pooled DialHolder dial for the cluster-proxy endpoint instead of calling DialerGetter (and GetClientTLSConfig) on every request and discarding the result. - Bound the upgrade (SPDY) transport cache: key it by cluster name and replace-on-credential-rotation (closing the stale transport's idle connections) instead of an unbounded, never-evicted per-credential map. - Reload the cluster-proxy client cert/key via tls.Config.GetClientCertificate so rotation no longer requires a process restart, while keeping the TLS read off the per-dial hot path. - Resolve the dial holder once in ServeHTTP and fail closed consistently for both the proxied and the upgrade transport, removing the duplicated endpoint-type branching and the fail-open/fail-closed mismatch. - Replace the hand-rolled client-go tls cache-key copy with a private credential fingerprint used only for rotation detection. - Apply cfg.Proxy to the non-cluster-proxy upgrade transport so SPDY (exec/attach/port-forward) honors a configured const-endpoint ProxyURL. - Build the upgrade tls.Config only on the non-pooled path / cache miss instead of unconditionally per request. Signed-off-by: Tamal Saha <tamal@appscode.com>
For the cluster-proxy endpoint NewConfigFromCluster called DialerGetter(ctx) on every request, which re-read the CA/cert/key files from disk and allocated a fresh tunnel-dial closure. ServeHTTP then discarded that cfg.Dial: it injects the shared singleton DialHolder via proxyTransportFor and uses dialHolder.Dial for the upgrade transport. So the per-request TLS read was pure waste on the hot path and added a transient-failure surface (a momentary unreadable cert returned 500) that the memoized holder was built to avoid. Resolve cfg.Dial from the pooled ClusterProxyDialHolder so no files are touched per request and every consumer of the rest.Config gets the pooled dialer. Signed-off-by: Tamal Saha <tamal@appscode.com>
ClusterProxyDialHolder returns either a non-nil holder with a nil error or a nil holder with a non-nil error; it never returns (nil, nil). The explicit `if dialHolder == nil` branch after a successful call was dead code that implied a contract the function does not have. Signed-off-by: Tamal Saha <tamal@appscode.com>
The upgrade-transport map is keyed by cluster name and there is no cluster-deletion hook at this layer, so a fleet whose cluster names churn over time would accumulate one *http.Transport per name forever. Cap the cache and evict the least-recently-used entry (closing its idle connections) when full, so the upgrade path cannot reintroduce the unbounded growth this change set is fixing. Signed-off-by: Tamal Saha <tamal@appscode.com>
The previous reloader only handled the client cert/key and triggered on strict mtime ordering, so a same-second secret swap, clock skew, or an mtime-preserving write would silently keep the stale (eventually expired) cert; the CA bundle was pinned for the process lifetime; and it stat-ed the files under a process-global mutex on every TLS handshake. Replace it with reloadingTLS, which reloads both the CA bundle and the client cert/key from disk at most once per interval, compares by content rather than mtime, and keeps the last good material if a re-read transiently fails (e.g. the brief window while a Kubernetes secret ..data symlink is swapped). Each new konnectivity tunnel gets a fresh tls.Config carrying the current CA pool and cert, so CA rotation no longer requires a restart. This drops the only remaining use of sigs.k8s.io/apiserver-network-proxy/pkg/util, so go mod tidy removes that dependency. Signed-off-by: Tamal Saha <tamal@appscode.com>
Updating GetClientCertificate/the CA pool only affects new TLS handshakes; konnectivity tunnels already pooled by the http.Transport keep presenting the old client cert (and trusting the old CA) until they idle out (~90s) or die, so right after a rotation warm exec/attach/port-forward connections still use the stale credential. Route every tunnel dial through client-go's connrotation.Dialer and call CloseAll from the reloader's onRotate hook when the cert or CA content changes, so existing pooled tunnels are dropped and re-dial with the new material. This reuses the same connection-rotation primitive client-go uses for its own dynamic client certificates. Signed-off-by: Tamal Saha <tamal@appscode.com>
Address minor review findings on the rotation reloader: - Reload the CA bundle and client cert/key independently: a transient failure reading one no longer discards a successfully re-read other (previously the whole pass bailed on the first error). - Do not cache unparseable CA bytes: on an AppendCertsFromPEM failure keep the last good pool without storing the bad bytes, so a later revert to the original content does not look like a rotation. - Invoke onRotate (which closes pooled tunnels) after releasing r.mu instead of while holding it, so a slow connection close cannot stall concurrent dials. reloadLocked now reports whether anything changed and the caller fires the hook. - Add a test helper that resets the process-wide cluster-proxy caches so a test overriding DialerGetter is not affected by state memoized by an earlier test. - Clarify the ServeHTTP comment: the dial-holder resolution returns the memoized holder (already resolved in NewConfigFromCluster) and its error branch is a defensive fail-closed guard. Signed-off-by: Tamal Saha <tamal@appscode.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Every proxied request to a
ClusterGatewaybuilt a brand-newhttp.Transport(viarestclient.TransportFor) and, for cluster-proxy endpoints, a fresh konnectivity gRPC tunnel — none of which were pooled or reused. Under sustained proxy / exec / attach / port-forward traffic the process steadily accumulated transports, TLS state, and gRPC connections, leaking memory and connections/file descriptors.This PR pools the cluster-proxy transports so one
http.Transportis reused per credential instead of per request, then hardens the pooling, the credential rotation, and the cache lifecycle in response to two rounds of review.Scope is limited to
transport.go,clustergateway_proxy.go, and ago.mod/go.sumtidy. (The unit-test repair and CI fixes that surfaced alongside this work merged separately in #21; GitHub Actions SHA-pinning merged in #20.)Commits
Initial pooling
DialHolder(ClusterProxyDialHolder) so client-go's transport cache can reuse one pooledhttp.Transportper credential across requests instead of allocating one per request.GetClientTLSConfig(which reads CA/cert/key files from disk) out of the per-dial closure so the TLS material and gRPC transport credentials are loaded once per process for the pooledDialHolder.restclient.TransportFor(cfg)(which would reintroduce the per-request transport / the leak).ServeHTTPand pass it in, reusing it for the TLS and upgrader wrappers;transport.New(transportCfg)is equivalent to the priorrestclient.TransportFor(cfg)for the non-cluster-proxy path.http.Transportinstead of rebuilding it on every request; non-cluster-proxy endpoints keep the per-request transport.Review hardening
ServeHTTPand fail closed consistently for both the proxied and upgrade transports; key the upgrade-transport cache by cluster name with replace-on-rotation; applycfg.Proxyto the non-cluster-proxy upgrade transport; build the upgradetls.Configonly on the non-pooled path.DialerGetter(and re-reading the client TLS material) on every cluster-proxy request only to haveServeHTTPdiscard the result; resolvecfg.Dialfrom the pooled holder so no files are touched per request and every consumer of the config gets the pooled dialer.ClusterProxyDialHoldernever returns(nil, nil); remove the dead branch.tls.Config, so CA rotation no longer requires a restart.connrotation.Dialerand callCloseAllwhen the cert/CA content changes, so warm pooled tunnels re-dial with the new material instead of clinging to the old credential until they idle out.Verification
go build ./pkg/... ./cmd/...,make vet, andgo test ./pkg/apis/gateway/v1alpha1/all pass on the rebased branch.Notes / trade-offs