Resource requests e limits no Kubernetes

1. Visão geral

Ao executar vários Pods no mesmo cluster, você precisa informar ao Kubernetes quanto CPU e memória cada container necessita. Se você não declarar, um serviço pode consumir todos os recursos do node e impactar outros serviços. Se declarar incorretamente, o Pod pode ser continuamente OOMKilled ou executar lentamente devido à limitação.

Este artigo explica o que são requests e limits e como o Kubernetes os utiliza. Também discute classes QoS e como configurar aplicações Java/Spring Boot apropriadamente com esses valores.

2. O que são requests e limits

Cada container em um Pod pode declarar dois tipos de valores:

  • requests: a quantidade mínima de recursos garantida ao container. O scheduler usa esse valor para escolher em qual node colocar o Pod.
  • limits: a quantidade máxima que o container tem permissão para usar. O kubelet e o container runtime aplicam esse limite em nível de kernel.

Os dois tipos de recursos mais comuns são cpu e memory. Também existem ephemeral-storage e recursos estendidos como GPU.

2.1. Unidades de CPU e memória

CPU é medida em cores. 1 significa um vCPU, enquanto 500m (500 millicores) significa meio core. Memória é medida em bytes. Kubernetes aceita sufixos binários (Ki, Mi, Gi) e sufixos decimais (K, M, G).

Iniciantes frequentemente confundem 512M com 512Mi. Para ver a diferença claramente, vamos escrever uma pequena classe para converter esses valores:

import java.util.Map;

public final class ResourceQuantity {

    private static final Map<String, Long> SUFFIXES = Map.of(
            "Ki", 1024L,
            "Mi", 1024L * 1024,
            "Gi", 1024L * 1024 * 1024,
            "K", 1_000L,
            "M", 1_000_000L,
            "G", 1_000_000_000L
    );

    private ResourceQuantity() {
    }

    public static long cpuToMillicores(String value) {
        if (value.endsWith("m")) {
            return Long.parseLong(value.substring(0, value.length() - 1));
        }
        return Math.round(Double.parseDouble(value) * 1000);
    }

    public static long memoryToBytes(String value) {
        for (String suffix : new String[]{"Ki", "Mi", "Gi", "K", "M", "G"}) {
            if (value.endsWith(suffix)) {
                String number = value.substring(0, value.length() - suffix.length());
                return Long.parseLong(number) * SUFFIXES.get(suffix);
            }
        }
        return Long.parseLong(value);
    }
}

A função cpuToMillicores lida com dois formatos: com sufixo m e números decimais como 1.5. A função memoryToBytes verifica o sufixo de dois caracteres (Mi) antes do sufixo de um caractere (M), porque "512Mi" também termina com "i" e não deveria ser correspondido por erro.

Teste com JUnit 5 e AssertJ:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ResourceQuantityTest {

    @Test
    void givenMillicoreOrDecimal_whenConvert_thenReturnsMillicores() {
        assertThat(ResourceQuantity.cpuToMillicores("500m")).isEqualTo(500);
        assertThat(ResourceQuantity.cpuToMillicores("1.5")).isEqualTo(1500);
    }

    @Test
    void givenBinaryAndDecimalSuffix_whenConvert_thenValuesDiffer() {
        long mebibytes = ResourceQuantity.memoryToBytes("512Mi");
        long megabytes = ResourceQuantity.memoryToBytes("512M");

        assertThat(mebibytes).isEqualTo(536_870_912L);
        assertThat(megabytes).isEqualTo(512_000_000L);
        assertThat(mebibytes - megabytes).isEqualTo(24_870_912L);
    }
}

Os dois valores diferem em cerca de 24 MB. Com um container perto do limite, isso é suficiente para decidir se o Pod vive ou é eliminado.

Nota: Não escreva memory: 512m. O sufixo m normalmente significa “milli”, então esse valor é 0,512 bytes. O Kubernetes ainda aceitará, e o erro é muito difícil de detectar.

3. Declarando em um Deployment

O exemplo a seguir usa um order-service escrito em Spring Boot:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 2
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: app
          image: nonla/order-service:1.4.0
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "768Mi"

O significado de cada valor:

  • requests.cpu: 500m: o scheduler coloca o Pod em um node que tenha pelo menos 0,5 core não requisitado.
  • requests.memory: 512Mi: similar, mas para memória.
  • limits.cpu: "1": o container pode usar no máximo 1 core. Exceder isso causa throttling.
  • limits.memory: 768Mi: exceder isso causa um kill do container pelo kernel.

Aplique o manifest e verifique o resultado:

kubectl apply -f order-service.yaml
kubectl get pod -l app=order-service -o jsonpath='{.items[0].spec.containers[0].resources}'
{"limits":{"cpu":"1","memory":"768Mi"},"requests":{"cpu":"500m","memory":"512Mi"}}

4. Como o scheduler usa requests

O scheduler não olha o uso real do node. Ele apenas soma o total de requests dos Pods já no node e compara com allocatable. Você pode ver esse número:

kubectl describe node worker-1 | grep -A 8 "Allocated resources"
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests      Limits
  --------           --------      ------
  cpu                3200m (80%)   6 (150%)
  memory             5Gi (66%)     9Gi (118%)

O resultado mostra duas coisas:

  1. O total de requests nunca ultrapassa 100% do allocatable. Se nenhum node tiver espaço suficiente, o Pod fica em estado Pending com evento Insufficient cpu ou Insufficient memory.
  2. O total de limits pode ultrapassar 100%. Isso é chamado overcommit. O overcommit é seguro quando os Pods não usam seus limites ao mesmo tempo.

Consequência: se você definir requests muito alto, o node parece “cheio” mesmo com CPU ociosa. Se definir muito baixo, muitos Pods são agrupados em um node e disputam recursos.

5. Quando o container ultrapassa o limit

CPU e memória são tratadas de formas muito diferentes.

5.1. CPU: é limitado

CPU é um recurso compressível. Quando o container usa toda sua quota, o kernel (via cgroup CFS) pausa o container até o próximo ciclo, geralmente 100ms. O container não morre, apenas fica mais lento. O sintoma é latência p99 aumentando drasticamente enquanto kubectl top parece normal.

kubectl top pod -l app=order-service
NAME                             CPU(cores)   MEMORY(bytes)
order-service-7d9f8c6b5-k2xqp    998m         610Mi
order-service-7d9f8c6b5-tm4wz    412m         590Mi

O primeiro Pod está quase no limite de 1, então provavelmente está sendo limitado.

5.2. Memória: é eliminado

Memória é um recurso incompressível. Quando o container ultrapassa limits.memory, o OOM killer do kernel encerra o processo imediatamente:

kubectl describe pod order-service-7d9f8c6b5-k2xqp | grep -A 4 "Last State"
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Tue, 13 Oct 2026 09:12:03 +0700

O código de saída 137 significa que o processo recebeu SIGKILL (128 + 9). A aplicação não tem chance de executar shutdown hooks ou gravar logs.

Outro cenário: o container usa mais do que requests.memory mas abaixo do limit. Quando o node fica com pouca memória, o kubelet evict esses Pods antes daqueles que usam exatamente o que requisitaram.

6. Classe QoS

Baseado em como você declara requests e limits, Kubernetes atribui a cada Pod uma classe QoS. Essa classe determina a ordem de eviction quando o node fica sem recursos.

Classe QoS Condição Ordem de eviction
Guaranteed Todo container tem requests == limits para CPU e memória Por último
Burstable Tem pelo menos um request ou limit, mas não satisfaz Guaranteed No meio
BestEffort Nenhum container declara nada Primeiro

O Pod order-service acima é da classe Burstable. Você pode verificar com:

kubectl get pod -l app=order-service -o jsonpath='{.items[*].status.qosClass}'
Burstable Burstable

Para serviços críticos como payment-service, você pode definir requests iguais aos limits para que o Pod seja Guaranteed:

resources:
  requests:
    cpu: "1"
    memory: "1Gi"
  limits:
    cpu: "1"
    memory: "1Gi"

Se você define apenas limits e deixa requests em branco, Kubernetes automaticamente define requests iguais aos limits. O Pod se torna Guaranteed.

7. Aplicações Java em container

A partir do Java 10, a JVM reconhece cgroups. Java 21 suporta tanto cgroup v1 quanto v2. A JVM lê limits.memory para calcular o tamanho da heap, e lê limits.cpu para calcular o número de processadores. O problema está nos valores padrão: a heap máxima é apenas 25% do memory limit. Sua aplicação está desperdiçando a maior parte da memória que pediu.

Adicione esse componente ao Spring Boot para registrar o que a JVM realmente consegue ver:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class ContainerResourceLogger {

    private static final Logger log = LoggerFactory.getLogger(ContainerResourceLogger.class);

    @EventListener(ApplicationReadyEvent.class)
    public void logResources() {
        Runtime runtime = Runtime.getRuntime();
        long maxHeapMi = runtime.maxMemory() / (1024 * 1024);
        log.info("availableProcessors={}, maxHeap={}Mi",
                runtime.availableProcessors(), maxHeapMi);
    }
}

Com um limit de 768Mi e nenhuma configuração adicional, o log mostra:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=192Mi

Você deve ajustar a proporção de heap usando MaxRAMPercentage em vez de hardcoded -Xmx:

          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

Após o redeploy:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=576Mi

Os 25% restantes são reservados para metaspace, thread stacks, code cache, direct buffers e GC. Não defina essa proporção acima de 90%. A heap pode não estar cheia, mas a memória total do processo já ultrapassou o limit, e o container é OOMKilled sem nenhum OutOfMemoryError nos logs.

Nota: availableProcessors é calculado a partir do CPU limit, depois arredondado para cima. Um limit de 500m resulta em 1 processador. Nesse caso, a JVM normalmente escolhe SerialGC, e pools de threads como ForkJoinPool.commonPool() têm apenas 1 worker. Se sua aplicação precisa de muitos threads paralelos, verifique esses valores novamente.

8. Definindo padrões para o namespace

Para evitar que Pods BestEffort entrem no cluster, você pode usar LimitRange para atribuir valores padrão por namespace:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-resources
  namespace: shop
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: "200m"
        memory: "256Mi"
      default:
        cpu: "500m"
        memory: "512Mi"

Containers que não declaram resources receberão automaticamente esses valores quando forem criados.

Combine com ResourceQuota para limitar o total de recursos que todo o namespace pode requisitar:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: shop-quota
  namespace: shop
spec:
  hard:
    requests.cpu: "8"
    requests.memory: "16Gi"
    limits.memory: "24Gi"

Quando um namespace tem quota para CPU ou memória, Pods que não declarem requests/limits correspondentes serão rejeitados pelo API server. Essa é a razão para usar LimitRange junto.

9. Erros comuns e quando não usar

  • Não declarar nada. O Pod se torna BestEffort, é evicted primeiro, e o scheduler não tem dados para balancear.
  • Memory limit inferior à necessidade no startup. Spring Boot geralmente usa mais memória durante a inicialização do contexto. Meça com kubectl top em um ciclo de carga real antes de fixar um número.
  • Requests muito altos “para estar seguro”. O node parece cheio mesmo com CPU ociosa, levando o autoscaler a adicionar nodes desnecessários e custar caro.
  • CPU limit muito restritivo para aplicações JVM. O compilador JIT e GC rodam em paralelo com a aplicação, então facilmente sofrem throttling na inicialização. O resultado é startup lento e readiness probe falhando.
  • Definir -Xmx igual ao memory limit. O container certamente será OOMKilled, porque a JVM usa memória além da heap.

Nota: Muitos times escolhem não definir CPU limit para serviços sensíveis a latência, apenas definindo CPU request. O container pode então usar CPU ociosa do node sem sofrer throttling, enquanto o request ainda garante um mínimo. Essa abordagem não é adequada para clusters compartilhados entre múltiplos times, porque sem limit um serviço quebrado pode desacelerar todos os outros. Por outro lado, você deveria sempre definir memory limit: sem memory limit, um memory leak pode colocar todo o node em falta de memória.

10. Conclusão

Neste artigo, você viu que requests determina scheduling e o nível de recurso garantido, enquanto limits determina o teto. Exceder CPU limit causa throttling, enquanto exceder memory limit causa OOMKilled. A forma como você combina esses dois valores cria a classe QoS, que determina a ordem de eviction quando o node fica sem recursos.

Com aplicações Java, use MaxRAMPercentage para que a heap seja proporcional ao memory limit, e deixe espaço suficiente para a parte non-heap. Finalmente, use LimitRange e ResourceQuota para que todo o namespace siga o mesmo padrão. Os melhores números sempre vêm da medição de carga real, não de suposições.

kubernetesresource-managementjvmdevopsspring-boot