GOMAXPROCS and Kubernetes: Why Your Go App Gets Throttled (and How to Fix It)

The Go pod is running in production. CPU limit set to 2, metrics look reasonable. But under load, P99 latencies spike intermittently with no obvious cause. No errors, no goroutine leaks, just latency blowing up on traffic bursts.

The root cause is usually invisible: GOMAXPROCS equals the number of CPUs on the physical node, not the container limit. Your Go app thinks it has 32 CPUs when it only has 2. The Linux kernel handles the gap in its own way — CFS throttling.

What GOMAXPROCS reads (and what it ignores)

By default, the Go runtime computes GOMAXPROCS via runtime.NumCPU(), which reads the number of CPUs available at the OS level. On a 32-core Kubernetes node, that returns 32 — regardless of what resources.limits.cpu says in your pod spec.

Kubernetes CPU limits are enforced through Linux cgroups (v1 or v2). Cgroups are transparent to processes: a pod with limits.cpu: "2" doesn't see two virtual CPUs, it sees all the node's CPUs and gets suspended when it consumes too much. The Go runtime, historically, never read cgroups. It trusted the physical core count.

package main

import (
    "fmt"
    "runtime"
)

func main() {
    // Inside a pod with limits.cpu: "2" on a 32-core node
    fmt.Println(runtime.NumCPU())      // → 32
    fmt.Println(runtime.GOMAXPROCS(0)) // → 32
}

CFS throttling: how the kernel slows you down

The Linux CFS (Completely Fair Scheduler) enforces CPU limits via two cgroup parameters: cpu.cfs_quota_us (allowed CPU time) and cpu.cfs_period_us (measurement window, 100 ms by default). A pod limited to 2 CPUs gets at most 200 ms of CPU time per 100 ms window.

When Go spawns 32 OS threads for 32 parallel goroutines, those threads compete for physical CPUs. Once their combined usage exceeds the cgroup quota within the current window, the kernel suspends all threads in the cgroup until the next window starts. That's throttling: a complete application freeze lasting anywhere from a few milliseconds to several tens of milliseconds. A handful of these per second is enough to tank your P99.

The signal shows up in Kubernetes Prometheus metrics:

container_cpu_cfs_throttled_periods_total
container_cpu_cfs_periods_total

# Throttling ratio (should be near 0)
rate(container_cpu_cfs_throttled_periods_total[5m]) /
rate(container_cpu_cfs_periods_total[5m])

The fix: automaxprocs (and Go 1.25)

Uber published go.uber.org/automaxprocs exactly for this. The package reads the cgroup at startup (v1 or v2), computes the effective quota by dividing quota_us / period_us, and calls runtime.GOMAXPROCS(n) with the right value.

Integration is a single blank import:

package main

import (
    "fmt"
    "runtime"

    _ "go.uber.org/automaxprocs" // reads cgroup in init()
)

func main() {
    // Inside a pod with limits.cpu: "2"
    fmt.Println(runtime.GOMAXPROCS(0)) // → 2
}
go get go.uber.org/automaxprocs

If you'd rather avoid the dependency and know the value at deploy time, set it manually:

import "runtime"

func init() {
    runtime.GOMAXPROCS(2) // match your limits.cpu
}

The downside of the manual approach: change the pod spec and forget to update the code, and you're back to square one. With automaxprocs, the value tracks the cgroup automatically.

Go 1.25: fixed in the runtime itself

Since Go 1.25 (August 2025), the runtime reads cgroups by default on Linux. If the process runs inside a container with a CPU quota lower than the node's core count, GOMAXPROCS is adjusted automatically — no external dependency needed. Go 1.25 also watches for quota changes at runtime, which matters if Kubernetes adjusts resource limits on a live pod.

Two GODEBUG options let you opt out if needed:

# Ignore cgroup quota (pre-1.25 behavior)
GODEBUG=containermaxprocs=0

# Keep the initial value, skip dynamic updates
GODEBUG=updatemaxprocs=0

On Go 1.25 and above, automaxprocs is no longer needed. On Go 1.24 and below, Uber's package remains the standard solution.

Verify it's working

To check the effective value inside a running pod:

# Open a shell in the pod
kubectl exec -it <pod-name> -- /bin/sh

# Read the cgroup v2 quota
cat /sys/fs/cgroup/cpu.max
# → 200000 100000  (= 2 CPUs: 200ms quota / 100ms period)

# Read cgroup v1 values
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us

If you expose Go runtime metrics via Prometheus, the gauge go_sched_gomaxprocs_threads (available with the runtime/metrics collector since Go 1.17) shows the current value. It should match your CPU quota, not the node's core count.

Conclusion

CFS throttling in Go Kubernetes workloads is a silent problem: no error logs, no obvious cause, just P99 latency spikes under load. The root cause is almost always the same — GOMAXPROCS sized to the node, not the container.

The fix is proportionate to the problem: one import or one runtime flag. On Go 1.25, it's handled by upgrading. On older versions, automaxprocs has been battle-tested at Uber scale for years. Either way, it's a five-minute change with a measurable effect the next time your traffic spikes.

Comments (0)