Limites de CPU no Kubernetes: como isso afeta aplicações em Go

· 1082 words · 6 minute read

Sumário 🔗

  1. Introdução
  2. CPU no Kubernetes: tempo, não quantidade
  3. O scheduler não se importa com seu código
  4. O custo real das operações
  5. Onde o Go entra nessa história
  6. Paralelismo demais custa caro
  7. Concorrência não é paralelismo
  8. CPU-bound vs I/O-bound
  9. O problema real: previsibilidade
  10. Conclusão

Introdução 🔗

Limites de CPU no Kubernetes: uma análise para aplicações em Go

Configurar limites de CPU no Kubernetes costuma ser tratado como algo simples. Você escolhe um valor em millicores, ajusta requests e limits e segue em frente.

O problema é que, quando rodamos aplicações em Go dentro de containers, essa decisão afeta diretamente o comportamento do runtime, o escalonamento de goroutines e, em muitos casos, a previsibilidade da aplicação.

Este texto não é sobre YAML. É sobre o que realmente acontece quando você limita CPU no Kubernetes e como isso impacta aplicações escritas em Go.

CPU no Kubernetes: tempo, não quantidade 🔗

CPU no Kubernetes não é “quantidade”

Considere a configuração abaixo:

1resources:
2  requests:
3    cpu: "250m"
4  limits:
5    cpu: "250m"

É comum interpretar 250m como “25% de um core”. Essa interpretação está errada ou, no mínimo, incompleta.

No Kubernetes, CPU é tratada como tempo, não como uma fração fixa de hardware.

Na prática:

  • 250m significa 25% do tempo de CPU
  • Em uma janela de 100ms, o container pode executar por cerca de 25ms
  • No restante do tempo, ele simplesmente não roda

Do ponto de vista da aplicação, isso não aparece como erro. A execução apenas fica mais lenta e menos previsível.

O scheduler não se importa com seu código 🔗

O controle de CPU no Kubernetes é feito via o CFS (Completely Fair Scheduler) do Linux. Ele interrompe o processo quando o orçamento de CPU é excedido.

Para a aplicação Go, isso é invisível. Não existe callback, sinal ou erro.

Um loop CPU-bound simples já evidencia isso:

1func work() {
2    for {
3        _ = 1 + 1
4    }
5}

Esse código nunca bloqueia. Ele tenta usar CPU o tempo todo.

Em um container sem limite, ele roda continuamente. Com limite de CPU, ele passa boa parte do tempo impedido de executar, mesmo sem fazer I/O ou bloqueios explícitos.

O custo real das operações 🔗

Uma CPU moderna executa bilhões de ciclos por segundo. Ainda assim, o custo das operações varia drasticamente.

Por exemplo, um mutex simples:

1var mu sync.Mutex
2
3func critical() {
4    mu.Lock()
5    defer mu.Unlock()
6
7    // seção crítica
8}

Esse Lock/Unlock já custa centenas de ciclos. Em um ambiente com CPU limitada, esse custo passa a competir diretamente com todo o resto da aplicação.

Nada muda no código. O impacto aparece no tempo.

Onde o Go entra nessa história 🔗

O Go tem seu próprio scheduler. Ele trabalha com três entidades principais:

  • G: goroutine
  • M: thread do sistema operacional
  • P: logical processor

O número de Ps define quantas goroutines podem executar em paralelo. Esse valor é controlado por GOMAXPROCS.

Um exemplo simples:

1fmt.Println(runtime.GOMAXPROCS(0))

Historicamente, esse valor era calculado com base no número de cores visíveis para o processo, ignorando limites de CPU impostos pelo container.

O efeito prático disso é que o runtime assume que há mais capacidade de execução do que realmente existe.

Paralelismo demais custa caro 🔗

Considere um workload CPU-bound simples:

1func cpuBound() {
2    sum := 0
3    for i := 0; i < 100_000_000; i++ {
4        sum += i
5    }
6    _ = sum
7}

Agora execute isso em paralelo:

 1var wg sync.WaitGroup
 2
 3for i := 0; i < 8; i++ {
 4    wg.Add(1)
 5    go func() {
 6        defer wg.Done()
 7        cpuBound()
 8    }()
 9}
10
11wg.Wait()

Se o container tiver CPU suficiente, isso pode escalar bem. Mas em um container limitado, o resultado costuma ser o oposto:

  • Mais goroutines competindo pelo mesmo tempo de CPU
  • Mais preempção
  • Mais overhead de escalonamento
  • Menor previsibilidade de latência

Criar mais paralelismo não cria mais CPU.

Concorrência não é paralelismo 🔗

Go facilita concorrência. Isso não significa que você deva executar tudo em paralelo.

Para workloads CPU-bound, limitar paralelismo explicitamente costuma ser a escolha correta:

1sem := make(chan struct{}, runtime.GOMAXPROCS(0))
2
3for _, job := range jobs {
4    sem <- struct{}{}
5    go func(j Job) {
6        defer func() { <-sem }()
7        process(j)
8    }(job)
9}

Esse padrão mantém concorrência, mas impede que a aplicação tente executar mais trabalho em paralelo do que o ambiente suporta.

CPU-bound vs I/O-bound 🔗

Nem toda aplicação sofre da mesma forma com limites de CPU.

Um handler I/O-bound típico:

1http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
2    time.Sleep(50 * time.Millisecond)
3    w.WriteHeader(http.StatusOK)
4})

Aqui, a goroutine passa boa parte do tempo bloqueada. O impacto do limite de CPU é menor.

Agora compare com um handler CPU-bound:

1http.HandleFunc("/compute", func(w http.ResponseWriter, r *http.Request) {
2    cpuBound()
3    w.WriteHeader(http.StatusOK)
4})

Nesse caso, o limite de CPU afeta diretamente:

  • Latência
  • Throughput
  • Consistência das respostas

Saber em qual categoria sua aplicação se encaixa é fundamental.

O problema real: previsibilidade 🔗

O maior problema dos limites de CPU não é apenas performance. É previsibilidade.

Quando a aplicação:

  • Acredita ter mais CPU do que realmente tem
  • Cria paralelismo excessivo
  • É constantemente interrompida pelo scheduler

Os sintomas aparecem como:

  • Picos de latência difíceis de explicar
  • Quedas de throughput sem erro aparente
  • Diferenças grandes entre ambientes

Tudo isso sem que o código esteja tecnicamente errado.

Conclusão 🔗

Limitar CPU no Kubernetes não é apenas uma decisão operacional. É uma decisão que afeta diretamente:

  • O modelo de execução da aplicação
  • O comportamento do runtime do Go
  • A eficiência do paralelismo
  • A previsibilidade do sistema

Durante muito tempo, aplicações Go rodando em containers sofriam porque o runtime não tinha consciência real dos limites de CPU impostos pelo Kubernetes. O valor de GOMAXPROCS era calculado com base nos cores visíveis para o processo, não no tempo de CPU efetivamente disponível.

A partir do Go 1.25, em sistemas Linux, isso muda. O runtime passa a detectar automaticamente os limites de CPU quando executando dentro de containers, ajustando GOMAXPROCS de forma coerente com o ambiente do Kubernetes.

Isso reduz paralelismo excessivo, melhora previsibilidade e evita parte dos problemas discutidos ao longo deste texto, especialmente em workloads CPU-bound.

Ainda assim, isso não elimina a necessidade de entendimento. Limites de CPU continuam sendo limites de tempo e o comportamento da aplicação continua dependendo do tipo de workload, do padrão de concorrência e das decisões de design.

Não é sobre confiar cegamente no runtime. É sobre entender como ele funciona e como o ambiente influencia suas escolhas.

Aqui você pode assistir na íntegra a palestra onde apresentei esse conteúdo:

Limites de CPU no k8s: Uma Ánalise para Aplicações em Go