Introdução
Esse é um bug extremamente comum em aplicações Go rodando em Kubernetes — inclusive em times experientes.
Os sintomas quase sempre são os mesmos:
- CPU alta mesmo com pouco tráfego
- latência instável
- throttling frequente no pod
- autoscaling agressivo sem ganho real de throughput
Na maioria dos casos, o culpado é este aqui:
runtime.NumCPU()
Neste post você vai entender:
- Por que
NumCPU()é errado em containers - Como o Kubernetes realmente limita CPU
- A solução correta (com e sem
automaxprocs) - Qual é a boa prática de pool size por core
- Quantas goroutines por core fazem sentido na prática
O problema
Muitas libs Go dimensionam concorrência com base em runtime.NumCPU():
- worker pools
- connection pools
- filas
- paralelismo interno
Exemplo real:
poolSize := 10 * runtime.NumCPU()
Isso parece razoável fora de containers.
Mas dentro do Kubernetes, isso é um erro sério.
O que runtime.NumCPU() realmente retorna
runtime.NumCPU() não lê o cgroup.
Ele retorna os CPUs visíveis do host, não o limite do container.
Exemplo:
- Node com 64 CPUs
- Pod com resources.limits.cpu: 500m
Resultado:
runtime.NumCPU() = 64
poolSize = 640 goroutines
Seu pod tem meio core e está tentando rodar 640 goroutines concorrentes.
Resultado:
- context switch excessivo
- CPU throttling constante
- latência imprevisível
- performance pior
Como o Kubernetes limita CPU
Quando você define:
resources:
limits:
cpu: "500m"
O Kubernetes configura o cgroup:
/sys/fs/cgroup/cpu.max = 50000 100000
Isso significa 0.5 CPU.
O Go runtime não considera isso ao usar NumCPU().
Provando com uma POC
package main
import (
"fmt"
"runtime"
_ "go.uber.org/automaxprocs"
)
func main() {
fmt.Printf("NumCPU() = %d\n", runtime.NumCPU())
fmt.Printf("GOMAXPROCS(0) = %d\n", runtime.GOMAXPROCS(0))
}
Rodando com Docker:
docker run --cpus=0.5 → NumCPU=10 GOMAXPROCS=1
docker run --cpus=1 → NumCPU=10 GOMAXPROCS=1
docker run --cpus=2 → NumCPU=10 GOMAXPROCS=2
Conclusão:
- NumCPU() sempre retorna o host
- GOMAXPROCS respeita o limit quando configurado corretamente
Regra de ouro
Nunca use runtime.NumCPU() para dimensionar concorrência em containers.
Sempre use runtime.GOMAXPROCS(0).
A solução correta
// errado
poolSize := 10 * runtime.NumCPU()
// correto
poolSize := 10 * runtime.GOMAXPROCS(0)
Go ≤ 1.24 vs Go ≥ 1.25
Go 1.24 e abaixo
Até o Go 1.24, o runtime não era container-aware.
É obrigatório usar:
import _ "go.uber.org/automaxprocs"
Go 1.25+
A partir do Go 1.25:
- o runtime já lê o cgroup
- GOMAXPROCS é ajustado automaticamente
- não precisa mais do automaxprocs
Quantas goroutines por core?
CPU-bound
Regra:
1 goroutine por core
poolSize := runtime.GOMAXPROCS(0)
I/O-bound
Boa prática:
5 a 10 goroutines por core
poolSize := 5 * runtime.GOMAXPROCS(0)
No máximo:
poolSize := 10 * runtime.GOMAXPROCS(0)
Pools de conexão
Boa prática:
2 a 4 conexões por core
maxConns := 2 * runtime.GOMAXPROCS(0)
Comparativo
| CPU limit | NumCPU() | GOMAXPROCS | Pool recomendado |
|---|---|---|---|
| 500m | 10 | 1 | 5–10 |
| 1000m | 10 | 1 | 5–10 |
| 2000m | 10 | 2 | 10–20 |
TL;DR
- Nunca use runtime.NumCPU() em containers
- Sempre use runtime.GOMAXPROCS(0)
- Go ≤ 1.24 → use automaxprocs
- Go ≥ 1.25 → não precisa
- CPU-bound → 1 goroutine por core
- I/O-bound → 5–10 goroutines por core
Top comments (0)