Skip to content

feat(http): add max connection age for inbound HTTP/2 server connections - #4618

Open
ergdevops wants to merge 2 commits into
linkerd:mainfrom
ergdevops:inbound-h2-max-connection-age
Open

feat(http): add max connection age for inbound HTTP/2 server connections#4618
ergdevops wants to merge 2 commits into
linkerd:mainfrom
ergdevops:inbound-h2-max-connection-age

Conversation

@ergdevops

@ergdevops ergdevops commented Aug 31, 2026

Copy link
Copy Markdown

Addresses linkerd/linkerd2#15623.

Problem

Pooled proxy-to-proxy HTTP/2 connections currently live forever: TCP and HTTP/2 keepalives keep them healthy indefinitely, MAX_IDLE_CONNS_PER_ENDPOINT (default 10,000) only caps the count, the HTTP/1 pool idle timeout doesn't apply to them, and the outbound discovery idle timeout only fires when a service receives no traffic at all. As far as we can tell there is no setting that closes a pooled connection for being idle or bounds its lifetime (checked linkerd/app/src/env.rs on main and back through v2.311.0).

This matters for memory: each held server-side connection retains its receive-buffer high-water mark — bounded by the connection window (1MB default) — for its entire lifetime. In our mesh (arm64 EKS), a large caller fleet holds ~300 pooled connections into each server pod; after ~300 rps load bursts we measured ~700KB retained per connection, i.e. ~200MB of proxy RSS per pod that was only ever released when peer pods restarted. Verified via tcp_open_total/tcp_close_total accounting on the admin endpoint — the pooling itself works as designed; the cost is the unbounded buffer lifetime.

Change

Adds LINKERD2_PROXY_INBOUND_SERVER_HTTP2_MAX_CONNECTION_AGE (duration). Unset (the default) preserves current behavior exactly.

When set, the inbound HTTP/2 serve loop arms a timer as an additional select! branch alongside the existing drain/teardown triggers, reusing the same graceful_shutdown() (GOAWAY) path: no new streams are accepted, in-flight streams run to completion, then the connection closes and the client reconnects transparently — the identical sequence every pod drain already exercises. A deterministic per-connection jitter of up to +10% (derived from the client's ephemeral port; no new dependency) prevents synchronized shutdown storms. Each shutdown is logged at DEBUG with the client address.

Includes an integration test covering: requests served normally before the age elapses, an in-flight stream completing across the age boundary, and the connection closing once drained.

Results from our environment (15m age, production-like mesh)

  • Proxy RSS became a bounded ~15-minute sawtooth instead of a monotonic plateau — buffers released every cycle with no pod restarts. (Observed on a build with jemalloc enabled on aarch64 — see feat(allocator): enable jemalloc on aarch64-unknown-linux-gnu #4613 — so freed memory was actually returned to the OS.)
  • Zero failed requests across many recycle cycles; p50/p99 unchanged through recycle waves.
  • Cost: one TLS handshake per connection per age period, spread over ~90s by the jitter — below measurement noise in-cluster.

Similar in spirit to gRPC's MAX_CONNECTION_AGE; follows the same params/env plumbing pattern as #4542.

Pooled proxy-to-proxy HTTP/2 connections have no idle timeout or age
bound: peers keep them alive indefinitely (keepalives prevent closure;
MAX_IDLE_CONNS_PER_ENDPOINT only caps the count), and each held
connection retains its receive-buffer high-water mark - up to the
connection window - for its entire lifetime. In a mesh where a large
caller fleet held ~300 connections into each server pod, we measured
~700KB retained per connection after ~300 rps load bursts (~200MB of
proxy RSS per pod), released only when peer pods restarted.

This adds LINKERD2_PROXY_INBOUND_SERVER_HTTP2_MAX_CONNECTION_AGE
(duration; unset preserves current behavior). When set, the inbound
HTTP/2 serve loop arms a timer as an additional select branch alongside
the existing drain/teardown triggers and reuses the same graceful
shutdown (GOAWAY) path: in-flight streams complete and clients
reconnect transparently. A deterministic per-connection jitter of up to
+10% (derived from the client's ephemeral port, avoiding a new
dependency) prevents synchronized shutdown storms. Shutdowns are logged
at INFO with the client address.

Signed-off-by: ergdevops <115262164+ergdevops@users.noreply.github.com>
@ergdevops
ergdevops requested a review from a team as a code owner August 31, 2026 10:02
At scale this fires once per connection per age period, which is too
chatty for INFO; operators who want per-shutdown visibility can enable
it via the proxy log filter.

Signed-off-by: ergdevops <115262164+ergdevops@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant