Resource requests and limits in Kubernetes

1. Overview

When running multiple Pods on the same cluster, you need to tell Kubernetes how much CPU and memory each container needs. If you don’t declare them, a service can consume all of a node’s resources and impact other services. If you declare them incorrectly, a Pod can be constantly OOMKilled or run slowly due to throttling.

This article explains what requests and limits are and how Kubernetes uses them. The article also covers QoS classes and how to configure Java/Spring Boot applications appropriately for these values.

2. What are requests and limits

Each container in a Pod can declare two types of values:

  • requests: the minimum amount of resources guaranteed to the container. The scheduler uses this value to choose which node to place the Pod.
  • limits: the maximum amount of resources the container is allowed to use. Kubelet and the container runtime enforce this limit at the kernel level.

The two most common resource types are cpu and memory. There are also ephemeral-storage and extended resources like GPU.

2.1. CPU and memory units

CPU is measured in cores. 1 means one vCPU, while 500m (500 millicores) means half a core. Memory is measured in bytes. Kubernetes accepts both binary (Ki, Mi, Gi) and decimal (K, M, G) suffixes.

Newcomers often confuse 512M with 512Mi. To understand the difference, let’s write a small class to convert these values:

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);
    }
}

The cpuToMillicores function handles two formats: those with the m suffix and decimal numbers like 1.5. The memoryToBytes function checks the two-character suffix (Mi) before the one-character suffix (M), because "512Mi" also ends with "i" and should not be matched incorrectly.

Test with JUnit 5 and 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);
    }
}

The two values differ by about 24 MB. For a container near its limit, this difference is enough to determine whether a Pod survives or gets killed.

Note: Don’t write memory: 512m. The letter m usually means “milli”, so this value is 0.512 bytes. Kubernetes will still accept it, but the error is very hard to detect.

3. Declaring in Deployment

The following example uses an order-service written in 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"

The meaning of each value:

  • requests.cpu: 500m: the scheduler only places the Pod on a node that has at least 0.5 cores not yet requested.
  • requests.memory: 512Mi: similarly, but for memory.
  • limits.cpu: "1": the container can use at most 1 core. Exceeding this will result in throttling.
  • limits.memory: 768Mi: exceeding this will cause the container to be killed by the kernel.

Apply the manifest and check the result:

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. How the scheduler uses requests

The scheduler does not look at the actual resource usage of the node. It only sums up the requests of the Pods already on the node and compares them to the node’s allocatable resources. You can see this number:

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%)

The output shows two things:

  1. The total requests never exceed 100% of allocatable resources. If no node has enough space, the Pod remains in Pending state with an event showing Insufficient cpu or Insufficient memory.
  2. The total limits can exceed 100%. This is called overcommit. Overcommit is safe when Pods don’t use their limits simultaneously.

As a consequence: if you set requests too high, nodes appear “full” even when CPU is actually idle. If you set them too low, multiple Pods get packed on one node and compete for resources.

5. When a container exceeds its limits

CPU and memory are handled in two very different ways.

5.1. CPU: throttling

CPU is a compressible resource. When a container exhausts its quota, the kernel (via cgroup CFS) pauses the container until the next period, usually lasting 100ms. The container doesn’t die, it just runs slower. The symptom is a spike in p99 latency while kubectl top looks 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

The first Pod is approaching its limit of 1, so it’s almost certainly being throttled.

5.2. Memory: being killed

Memory is an incompressible resource. When a container exceeds limits.memory, the kernel’s OOM killer terminates the process immediately:

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

Exit code 137 means the process received SIGKILL (128 + 9). The application has no chance to run shutdown hooks or write logs.

Another scenario: a container uses more than requests.memory but still below the limit. When the node runs low on memory, kubelet will evict these Pods before those using exactly what they requested.

6. QoS classes

Based on how you declare requests and limits, Kubernetes assigns each Pod a QoS class. This class determines the eviction order when a node runs low on resources.

QoS class Condition Eviction order
Guaranteed Every container has requests == limits for both CPU and memory Last
Burstable At least one container has a request or limit, but doesn’t satisfy Guaranteed Middle
BestEffort No container declares any requests or limits First

The order-service Pod above belongs to the Burstable class. You can check with:

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

For important services like payment-service, you can set requests equal to limits so the Pod belongs to the Guaranteed class:

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

If you only declare limits without specifying requests, Kubernetes automatically sets requests equal to limits. The Pod then also becomes Guaranteed.

7. Java applications in containers

Starting from Java 10, the JVM is cgroup-aware. Java 21 supports both cgroup v1 and v2. The JVM reads limits.memory to calculate heap size, and reads limits.cpu to determine the number of processors. The problem lies in the default behavior: the maximum heap is only 25% of the memory limit. Your application wastes most of the memory you requested.

Add the following component to Spring Boot to log what the JVM actually sees:

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);
    }
}

With a limit of 768Mi and no additional configuration, the log outputs:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=192Mi

You should adjust the heap percentage using MaxRAMPercentage instead of hard-coding -Xmx:

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

After redeploying:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=576Mi

The remaining 25% is reserved for metaspace, thread stacks, code cache, direct buffers, and GC. Don’t set this ratio above 90%. The heap might not be full, but the total memory of the process exceeds the limit, and the container gets OOMKilled with no OutOfMemoryError in the logs.

Note: availableProcessors is calculated from the CPU limit, then rounded up. A limit of 500m results in 1 processor. In that case, the JVM usually chooses SerialGC, and thread pools like ForkJoinPool.commonPool() have only 1 worker. If your application needs many threads in parallel, double-check these values.

8. Setting defaults for a namespace

To prevent BestEffort Pods from entering the cluster, you can use LimitRange to assign default values per 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"

Any container that doesn’t declare resources will automatically receive these values when created.

You can also combine this with ResourceQuota to limit the total resources a namespace is allowed to request:

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

When a namespace has a quota for CPU or memory, any Pod missing the corresponding requests/limits will be rejected by the API server. This is why you should pair it with LimitRange.

9. Common mistakes and when not to use them

  • Declaring nothing. The Pod becomes BestEffort, gets evicted first, and the scheduler has no data for allocation.
  • Memory limit lower than startup needs. Spring Boot typically uses the most memory during context initialization. Measure with kubectl top through a real load cycle before committing to a number.
  • Requests too high “to be safe”. Nodes appear full even when CPU is idle, causing the cluster autoscaler to add unnecessary nodes and waste money.
  • CPU limit too tight for JVM applications. The JIT compiler and GC run in parallel with the application, so they’re easily throttled during startup. This results in slow startup and readiness probe failures.
  • Setting -Xmx equal to the memory limit. The container will certainly be OOMKilled, because the JVM also uses memory outside the heap.

Note: Many teams choose not to set CPU limits for latency-sensitive services, only setting CPU requests. The container can then use the node’s idle CPU without throttling, while the request still guarantees a minimum. This doesn’t work well for clusters shared by multiple teams, because without a limit, a faulty service can slow down all others. Conversely, you should always set memory limits: without a memory limit, a memory leak can drag the entire node into memory starvation.

10. Conclusion

In this article, you’ve seen how requests determine scheduling and guaranteed resource levels, while limits set the ceiling. Exceeding CPU limits causes throttling, while exceeding memory limits causes OOMKilled. How you combine these two values creates a QoS class, and the QoS class determines the eviction order when a node runs low on resources.

For Java applications, use MaxRAMPercentage to make the heap scale with the memory limit, and leave enough room for non-heap memory. Finally, use LimitRange and ResourceQuota to ensure the entire namespace follows the same standard. The best numbers always come from measuring real load, not guessing.

kubernetesresource-managementjvmdevopsspring-boot