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. Dasmbedeutet 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:
- Die Summe der
requestsüberschreitet niemals 100% des allocatable. Falls kein Knoten genug Platz hat, befindet sich der Pod im ZustandPendingmit dem EventInsufficient cpuoderInsufficient memory. - Die Summe der
limitsdarf 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:
availableProcessorswird aus dem CPU-Limit berechnet und dann aufgerundet. Ein Limit von500mergibt 1 Prozessor. Dann wählt die JVM normalerweiseSerialGC, und Thread Pools wieForkJoinPool.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.
-Xmxgenau auf das Memory Limit setzen. Der Container wird mit SicherheitOOMKilled, 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.