Resource requests und limits in Kubernetes

1. Übersicht

Wenn Sie mehrere Pods im selben Cluster ausführen, müssen Sie Kubernetes mitteilen, wie viel CPU und Speicher jeder Container benötigt. Ohne Deklaration kann ein Service alle Ressourcen des Knotens aufbrauchen und andere Services beeinträchtigen. Bei falscher Deklaration kann ein Pod kontinuierlich OOMKilled werden oder wegen Drosselung langsam laufen.

Dieser Artikel erklärt, was requests und limits sind und wie Kubernetes sie nutzt. Der Artikel behandelt auch die QoS-Klasse und wie man Java- und Spring-Boot-Anwendungen für diese Werte richtig konfiguriert.

2. Requests und limits

Jeder Container in einem Pod kann zwei Arten von Werten deklarieren:

  • requests: Die Mindestmenge an Ressourcen, die dem Container garantiert wird. Der Scheduler verwendet diesen Wert, um einen Knoten für den Pod auszuwählen.
  • limits: Der Maximalwert, den der Container nutzen darf. Kubelet und Container Runtime erzwingen diesen Grenzwert auf Kernel-Ebene.

Die zwei gängigsten Ressourcentypen sind cpu und memory. Es gibt auch ephemeral-storage und erweiterte Ressourcen wie GPU.

2.1. Einheiten für CPU und Speicher

CPU wird in Kernen gemessen. 1 bedeutet einen vCPU, 500m (500 Millicores) bedeutet einen halben Kern. Speicher wird in Bytes gemessen. Kubernetes akzeptiert sowohl binäre Suffixe (Ki, Mi, Gi) als auch dezimale Suffixe (K, M, G).

Anfänger verwechseln oft 512M mit 512Mi. Um den Unterschied klar zu sehen, schreiben wir eine kleine Utility-Klasse zur Umwandlung dieser Werte:

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

Die Methode cpuToMillicores behandelt zwei Formate: mit dem Suffix m und Dezimalzahlen wie 1.5. Die Methode memoryToBytes prüft zuerst auf zwei-Zeichen-Suffixe (Mi) vor ein-Zeichen-Suffixen (M), da "512Mi" auch mit "i" endet und nicht verwechselt werden darf.

Test mit JUnit 5 und 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);
    }
}

Die zwei Werte unterscheiden sich um etwa 24 MB. Mit einem Container dicht an seiner Grenze kann das ausreichen, um zu entscheiden, ob der Pod lebt oder getötet wird.

Hinweis: Schreiben Sie nicht memory: 512m. Das m bedeutet normalerweise “milli”, also wären dies 0,512 Bytes. Kubernetes akzeptiert es zwar, aber der Fehler ist sehr schwer zu finden.

3. Deklaration in einer Deployment

Das folgende Beispiel verwendet einen order-service, der in Spring Boot geschrieben ist:

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"

Bedeutung der einzelnen Werte:

  • requests.cpu: 500m: der Scheduler ordnet den Pod nur Knoten zu, die mindestens 0,5 Kerne verfügbar haben.
  • requests.memory: 512Mi: ähnlich, aber für Speicher.
  • limits.cpu: "1": der Container darf maximal 1 Kern nutzen. Wird dies überschritten, wird er gedrosselt.
  • limits.memory: 768Mi: wird dies überschritten, wird der Container vom Kernel getötet.

Wenden Sie das Manifest an und überprüfen Sie das Ergebnis:

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. Wie der Scheduler requests nutzt

Der Scheduler schaut nicht auf die tatsächliche Ressourcennutzung des Knotens. Er addiert nur die Summe der requests aller bereits auf dem Knoten befindlichen Pods und vergleicht dies mit dem allocatable. Sie können diese Zahl sehen mit:

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

Das Ergebnis zeigt zwei Dinge:

  1. Die Summe der requests überschreitet niemals 100% des allocatable. Falls kein Knoten genug Platz hat, befindet sich der Pod im Zustand Pending mit dem Event Insufficient cpu oder Insufficient memory.
  2. Die Summe der limits darf 100% überschreiten. Dies wird Overcommit genannt. Overcommit ist sicher, wenn die Pods nicht gleichzeitig ihre limits erreichen.

Konsequenz: Wenn Sie requests zu hoch setzen, sieht der Knoten “voll” aus, obwohl die CPU tatsächlich untätig ist. Wenn Sie zu niedrig setzen, werden viele Pods auf einen Knoten gepackt und konkurrieren um Ressourcen.

5. Wenn ein Container sein Limit überschreitet

CPU und Speicher werden auf zwei sehr unterschiedliche Arten behandelt.

5.1. CPU: wird gedrosselt

CPU ist eine komprimierbare Ressource. Wenn der Container sein Kontingent verbraucht hat, setzt der Kernel (über cgroup CFS) den Container bis zum nächsten Zyklus aus, der normalerweise 100ms dauert. Der Container stirbt nicht, läuft nur langsamer. Das Symptom ist, dass die p99-Latenz in die Höhe schnellt, während kubectl top normal aussieht.

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

Der erste Pod nähert sich dem Limit von 1, daher wird er mit hoher Wahrscheinlichkeit gedrosselt.

5.2. Speicher: wird getötet

Speicher ist eine nicht komprimierbare Ressource. Wenn der Container seine limits.memory überschreitet, beendet der Kernel OOM Killer den Prozess sofort:

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 bedeutet, dass der Prozess SIGKILL erhielt (128 + 9). Die Anwendung hat keine Chance, Shutdown Hooks auszuführen oder zu protokollieren.

Ein anderer Fall: Der Container verbraucht mehr als requests.memory, bleibt aber unter dem Limit. Wenn dem Knoten der Speicher ausgeht, wird kubelet diese Pods evict (entfernen), bevor es die Pods entfernt, die ihre Requests einhalten.

6. QoS-Klasse

Basierend darauf, wie Sie requests und limits deklarieren, weist Kubernetes jedem Pod eine QoS-Klasse zu. Diese Klasse bestimmt die Reihenfolge der Eviction, wenn dem Knoten Ressourcen fehlen.

QoS-Klasse Bedingung Eviction-Reihenfolge
Guaranteed Jeder Container hat requests == limits für CPU und Speicher Zuletzt
Burstable Mindestens ein Request oder Limit, aber nicht Guaranteed In der Mitte
BestEffort Kein Container deklariert etwas Zuerst

Der Pod order-service oben gehört zur Klasse Burstable. Sie können dies überprüfen mit:

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

Für einen wichtigen Service wie payment-service können Sie requests gleich limits setzen, damit der Pod zur Klasse Guaranteed gehört:

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

Wenn Sie nur limits deklarieren und requests leer lassen, weist Kubernetes requests automatisch gleich limits zu. Der Pod wird dann auch Guaranteed.

7. Java-Anwendungen in Containern

Ab Java 10 erkennt die JVM cgroups. Java 21 unterstützt sowohl cgroup v1 als auch v2. Die JVM liest limits.memory, um die Heap-Größe zu berechnen, und limits.cpu, um die Anzahl der Prozessoren zu bestimmen. Das Problem liegt im Standardwert: Der maximale Heap ist nur 25% des Memory-Limits. Ihre Anwendung verschwendet den größten Teil des angeforderten Speichers.

Fügen Sie diese Komponente zu Spring Boot hinzu, um zu protokollieren, was die JVM tatsächlich sieht:

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

Mit einem Limit von 768Mi und ohne weitere Konfiguration gibt das Log aus:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=192Mi

Sie sollten das Heap-Verhältnis mit MaxRAMPercentage anpassen, anstatt -Xmx hart zu codieren:

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

Nach der erneuten Bereitstellung:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=576Mi

Die verbleibenden 25% sind für Metaspace, Thread Stacks, Code Cache, Direct Buffers und GC reserviert. Setzen Sie diesen Prozentsatz nicht auf 90% oder höher. Der Heap ist nicht voll, aber die Gesamtspeicher des Prozesses überschreitet das Limit, und der Container wird OOMKilled, ohne dass ein OutOfMemoryError im Log erscheint.

Hinweis: availableProcessors wird aus dem CPU-Limit berechnet und dann aufgerundet. Ein Limit von 500m ergibt 1 Prozessor. Dann wählt die JVM normalerweise SerialGC, und Thread Pools wie ForkJoinPool.commonPool() haben nur 1 Worker. Wenn Ihre Anwendung viel parallele Verarbeitung benötigt, überprüfen Sie diese Werte erneut.

8. Standardwerte pro Namespace setzen

Um zu vermeiden, dass BestEffort Pods in den Cluster gelangen, können Sie LimitRange verwenden, um Standardwerte pro Namespace festzulegen:

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

Container, die keine resources deklarieren, erhalten automatisch die oben genannten Werte, wenn sie erstellt werden.

Kombinieren Sie dies mit ResourceQuota, um die Gesamtressourcen zu begrenzen, die der Namespace anfordern darf:

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

Wenn der Namespace Quoten für CPU oder Speicher hat, wird der API Server jeden Pod ablehnen, dem die entsprechenden requests/limits fehlen. Deshalb sollte LimitRange damit kombiniert werden.

9. Häufige Fehler und wann man es nicht verwenden sollte

  • Nichts deklarieren. Der Pod wird zu BestEffort, wird zuerst evicted, und der Scheduler hat keine Daten für die Zuteilung.
  • Memory Limit niedriger als der Bedarf beim Start. Spring Boot benötigt normalerweise am meisten Speicher während der Context-Initialisierung. Messen Sie mit kubectl top über einen echten Lasttest, bevor Sie die Zahlen festlegen.
  • Zu hohe Requests “um sicherzustellen”. Der Knoten sieht voll aus, obwohl die CPU untätig ist, was den Cluster-Autoscaler dazu veranlasst, unnötige Knoten hinzuzufügen und Geld zu verschwenden.
  • CPU-Limit zu niedrig für JVM-Anwendungen. JIT-Compiler und GC laufen parallel mit der Anwendung, daher werden sie leicht gedrosselt beim Start. Dies führt zu langsamem Startup und fehlgeschlagenen Readiness Probes.
  • -Xmx genau auf das Memory Limit setzen. Der Container wird mit Sicherheit OOMKilled, da die JVM auch Speicher außerhalb des Heaps nutzt.

Hinweis: Viele Teams wählen, kein CPU-Limit für latency-sensitive Services zu setzen, nur CPU-Requests. Der Container kann dann freie CPU des Knotens nutzen, ohne gedrosselt zu werden, während der Request immer noch ein Minimum garantiert. Dies passt nicht zu Clustern, die von mehreren Teams gemeinsam genutzt werden, da ein fehlerhafter Service ohne Limit die anderen verlangsamen könnte. Andererseits sollten Sie immer ein Memory-Limit setzen: Ohne Memory-Limit könnte ein Memory Leak einen ganzen Knoten in Speichermangel versetzen.

10. Fazit

In diesem Artikel haben Sie gesehen, dass requests das Scheduling und die garantierte Ressourcenebene bestimmen, während limits die obere Grenze bestimmen. Das Überschreiten des CPU-Limits führt zu Drosselung, das Überschreiten des Memory-Limits zu OOMKilled. Die Kombination dieser beiden Werte bestimmt die QoS-Klasse, und diese bestimmt die Eviction-Reihenfolge, wenn dem Knoten Ressourcen fehlen.

Verwenden Sie für Java-Anwendungen MaxRAMPercentage, um den Heap proportional zum Memory-Limit zu skalieren, und reservieren Sie genug Platz für den Non-Heap-Teil. Verwenden Sie abschließend LimitRange und ResourceQuota, um konsistente Standards über den Namespace hinweg durchzusetzen. Die besten Zahlen kommen immer aus echten Laststests, nicht aus Vermutungen.

kubernetesresource-managementjvmdevopsspring-boot