Sumário 🔗
- Introdução
- CPU no Kubernetes: tempo, não quantidade
- O scheduler não se importa com seu código
- O custo real das operações
- Onde o Go entra nessa história
- Paralelismo demais custa caro
- Concorrência não é paralelismo
- CPU-bound vs I/O-bound
- O problema real: previsibilidade
- 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:
250msignifica 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: goroutineM: thread do sistema operacionalP: 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:
