TIL: Go's GC has no idea it's running inside a container — unless you tell it
I assumed the Go runtime would respect the container’s memory limit the same way it respects available RAM on a bare machine. It doesn’t — and the difference is exactly what gets you OOM-killed.
By default, Go’s GC collects when the live heap doubles relative to what was alive at the last collection. That heuristic works fine with room to grow. In a container with a hard ceiling, though, the heap can climb to within a few MB of the limit without ever triggering a collection — because it hasn’t doubled yet. The OOM killer gets there first.
GOMEMLIMIT, added in Go 1.19, gives the runtime an explicit ceiling. As
memory approaches the limit, the GC runs more aggressively to stay under
it:
GOMEMLIMIT=400MiB
It’s a soft limit — a momentary spike past it won’t panic, the runtime just tries harder to pull back. The convention is to set it around 90% of the container’s memory limit to leave headroom for the stack, runtime internals, and cgo allocations that live outside the heap.
There are now two knobs controlling GC behavior:
GOGC— the heap-doubling heuristic (default100: collect when heap reaches 2× the previous live set)GOMEMLIMIT— caps total memory and drives GC harder as you approach it
A pattern worth knowing: turn GOGC off entirely and let GOMEMLIMIT do
all the work.
GOGC=off
GOMEMLIMIT=400MiB
With GOGC=off, GC only fires when the process is approaching the limit —
no spurious collections mid-request just because the heap happened to
double. You trade slightly more aggressive GC near the ceiling for far more
predictable behavior everywhere else.
In Kubernetes you can pull the limit directly into the container using the Downward API, so the two numbers never drift apart:
env:
- name: GOMEMLIMIT
valueFrom:
resourceFieldRef:
resource: limits.memory
- name: GOGC
value: "off"
resourceFieldRef is how the Downward API exposes pod resource fields as
environment variables — no hardcoding, no separate ConfigMap to keep in sync.