DEV Community

Cover image for Seu app Go no K8s está usando até 20x mais CPU do que deveria (e como corrigir do jeito certo)
renanbastos93
renanbastos93

Posted on

Seu app Go no K8s está usando até 20x mais CPU do que deveria (e como corrigir do jeito certo)

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()
Enter fullscreen mode Exit fullscreen mode

Neste post você vai entender:

  1. Por que NumCPU() é errado em containers
  2. Como o Kubernetes realmente limita CPU
  3. A solução correta (com e sem automaxprocs)
  4. Qual é a boa prática de pool size por core
  5. 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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

O Kubernetes configura o cgroup:

/sys/fs/cgroup/cpu.max = 50000 100000
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

I/O-bound

Boa prática:

5 a 10 goroutines por core

poolSize := 5 * runtime.GOMAXPROCS(0)
Enter fullscreen mode Exit fullscreen mode

No máximo:

poolSize := 10 * runtime.GOMAXPROCS(0)
Enter fullscreen mode Exit fullscreen mode

Pools de conexão

Boa prática:

2 a 4 conexões por core

maxConns := 2 * runtime.GOMAXPROCS(0)
Enter fullscreen mode Exit fullscreen mode

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

Código da POC

https://github.com/renanbastos93/numcpu-k8s

Top comments (0)