Size the agent's heap budget from its container - #138
Open
shawnburke wants to merge 1 commit into
Open
Conversation
Investigating reports of agent containers growing over time, three heap profiles taken 15 minutes apart show no leak: live heap held at 4.69MB, 4.04MB, 4.70MB while RSS climbed 19MB -> 27MB, and a goroutine profile showed 59 goroutines across 25 stacks. What moved was the allocation rate, 102KB/s to 400KB/s. That is the runtime doing what it is configured to do. Go does not read cgroup limits, so with only GOGC to go on it sizes the heap at 2x live and returns pages lazily; a process with a small live set and a high allocation rate lets RSS ratchet toward the container limit and stay there. Nothing is leaked, but the container never gives the memory back. So tell the runtime what its budget is, and take it from the container the operator actually provisioned rather than a setting they have to discover and keep in step. GOMEMLIMIT is a soft limit -- the runtime never refuses an allocation because of it -- so this cannot cause an OOM kill that would not have happened anyway; it makes the collector work before the kernel does. An explicit GOMEMLIMIT still wins. Separately, cap the frame size the agent will accept from ServerHello. Each in-flight call allocates one read buffer of that size, so the agent's ceiling is frame size times its in-flight cap -- and the frame size is announced by the server, not configured where the agent runs. Without a bound, a server-side MAX_FRAME_BYTES change multiplies the memory ceiling of every agent in the fleet, somewhere its operators cannot see. Bound it on both ends: the server refuses values it cannot announce in the int32 the hello carries, and the agent clamps what it accepts. Frames above the clamp are still carried, just in more chunks. MAX_INFLIGHT_REQUESTS is deliberately left unclamped. It is a genuine operator dial, and capping it would silently throttle anyone who sized up for throughput. Verified in containers: 512m -> 409.6MiB, 2g -> 1.6GiB, explicit GOMEMLIMIT respected, no cgroup limit leaves the runtime alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.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.
Why
Agent containers looked like they were leaking — growing over time, and one had reached 200MB before it was killed. Three heap profiles taken 15 minutes apart say otherwise:
Live heap delta across the full 15 minutes: +9.51 kB, and the only two entries in the diff are single 512kB samples that cancel out. A goroutine profile taken alongside showed 59 goroutines across 25 stacks (largest single stack: 15) — no goroutine leak either.
What actually moved was the allocation rate: 102 KB/s → 400 KB/s, tracking RSS 19MB → 27MB.
That's the runtime behaving as configured. Go doesn't read cgroup limits, so with only
GOGCto go on it sizes the heap at 2× live and returns pages lazily. A process with a small live set and a high allocation rate lets RSS ratchet toward the container limit and stay there. Nothing leaks; the container just never gives it back.What this changes
1. Derive
GOMEMLIMITfrom the cgroup limit (agent/memlimit)The agent reads its own cgroup memory limit at startup (v2, falling back to v1) and targets 80% of it. Container sizing becomes the only dial — raise the limit and the working budget follows, with nothing to keep in step.
GOMEMLIMITis a soft limit: the runtime never refuses an allocation because of it, so this cannot cause an OOM kill that wouldn't have happened anyway. It makes the collector work before the kernel does. An explicitGOMEMLIMITalways wins; no cgroup limit leaves the runtime untouched; a limit too small to target safely (<64MiB after headroom) is refused rather than honored.2. Bound the frame size the agent accepts (
agent/server/grpctunnel,server/config)Each in-flight call allocates one read buffer of the server-announced frame size (
call_handler.go:292), so the agent's ceiling is frame size × in-flight cap. That frame size arrives inServerHello— it is not configured where the agent runs. Without a bound, a server-sideMAX_FRAME_BYTESchange silently multiplies the memory ceiling of every agent in the fleet, somewhere their operators can't see or override.Bounded at both ends: the server now refuses values it can't announce in the
int32the hello carries (previouslyint32(cfg.MaxFrameBytes)attunnel/service.go:169would wrap negative, and agents would silently fall back to their own default), and the agent clamps what it accepts at 4MiB. Frames above the clamp are still carried — just read in more than one chunk.3. Document the sizing model in the helm chart. Resources are unchanged (256Mi/512Mi, already what shipped).
What this deliberately does not change
MAX_INFLIGHT_REQUESTSis left unclamped. It's a genuine operator dial, and capping it would silently throttle anyone who sized up for throughput — a functional regression, not a memory one. Container sizing stays the dial.The oversized per-call buffer is left alone.
buf := make([]byte, maxFrame)allocates 1MiB to move ~57KB of payload — 96% of all allocation in the sampled window, at 17-19× overhead. Shrinking it to ~64KB would cut churn and drop the worst-case ceiling ~16×, but that's an efficiency change rather than a safety one, and it belongs in its own PR against its own benchmark.Verification
Unit tests cover cgroup v2/v1 parsing, the
maxand near-MaxInt64unlimited sentinels, missing files, v2→v1 fallthrough, explicit-env precedence, and thatConfigureactually movesdebug.SetMemoryLimitrather than only reporting that it would. Frame clamping is tested at and above the ceiling, at the pass-through values, and with a nil logger.End-to-end in real containers:
go build,go vet, andgo test ./...pass clean on both the agent and server modules. (gofmtflagsagent/test/load/loadgen.goandserver/metrics/metrics.go, both pre-existing and untouched here.)🤖 Generated with Claude Code