My containers keep getting OOMKilled—how do I set memory limits correctly for PHP-FPM and Go?
Question
On Kubernetes we run both PHP-FPM (Laravel) and Go services. Running locally with no limits everything looks fine, but in production under load the pods keep getting OOMKilled and restarting. How do I set the memory request/limit values correctly for PHP-FPM and Go containers? Beyond just raising the limit forever, which application-level knobs should I look at?
Answer
Short answer: OOMKilled isn’t an application bug, it’s the signal that the container exceeded its cgroup memory limit.
Short answer
Locally there’s no limit, so the kernel lets it grow; in production, limit + load means it hits the ceiling and gets killed. The fix isn’t to blindly raise the limit but to measure the real peak and tune your app knobs to live under it.
Why
-
Measure first: what’s the working set? Look at
container_memory_working_set_bytes. Set the limit above the observed peak with a reasonable margin. “Fine locally” is misleading because there’s no limit there; the real question is peak usage under load. -
In PHP-FPM the killer is pm.max_children. Total memory ≈
max_children × average memory per request. A giantmax_childrencopied from a tutorial spawns all workers under load and blows the limit. The formula:max_children = (container limit − margin) / worker peak. Measure worker peak from a single process’s actual RSS. -
PHP’s
memory_limitdoes not protect the container. That setting is per-process; it gives no container-wide guarantee and can even let a single worker grow up tomemory_limit. Your real shield against the container ceiling ismax_children; keepmemory_limitsane but don’t mistake it for container protection. -
The Go runtime doesn’t know the cgroup limit by default. It paces GC by heap growth (
GOGC), not by the container’s limit; in a limited container that means over-allocating and getting OOMed. The fix: setGOMEMLIMIT(Go 1.19+) a bit below the limit — it’s a soft limit that makes GC more aggressive as you approach it. Also setGOMAXPROCSto the CPU limit (automaxprocs) — before Go 1.25 the scheduler would otherwise run against every core on the node; since Go 1.25 (August 2025) the runtime auto-detects the cgroup’s CPU quota and sets GOMAXPROCS accordingly.# Go container resources: requests: { memory: "256Mi" } limits: { memory: "512Mi" } env: - { name: GOMEMLIMIT, value: "450MiB" } # limit − margin (soft limit) - { name: GOMAXPROCS, value: "1" } # match the CPU limit
What to do
-
In K8s, requests = typical, limits = peak + margin. Always leave headroom for non-heap usage (stacks, buffers, off-heap, PHP extensions); if you set the limit too tight, even a normal burst kills the pod. If you want no surprises, consider request = limit for Guaranteed QoS.
-
Distinguish a leak from a high peak. If
working_setplateaus at a high level under load, it’s a sizing problem — tune the limit and knobs to fit. If it climbs monotonically and never plateaus, it’s a memory leak; raising the limit only delays it, and you need to fix the code.
Bottom line: personally I’d measure the working set first and set the limit to observed peak + margin. In PHP-FPM I’d pin max_children against the limit via the formula; in Go I’d set GOMEMLIMIT + GOMAXPROCS. I’d always leave headroom for non-heap and watch the graph to tell a leak from a high peak. Raising the limit forever isn’t a fix — it’s just a way to postpone the real problem.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.