Kubernetes » Fundamentals » Health & Resource Management
Teach Kubernetes what “healthy” means and how much your workloads cost: probes, requests and limits, QoS classes, scheduling, and horizontal autoscaling.
Why Health and Resources Go Together
A Deployment will restart a crashed container automatically, but many failures are subtler: a process that is still running yet stuck in a deadlock, or one that has started but is not yet ready to serve traffic. At the same time, the scheduler can only place pods sensibly if you tell it how much CPU and memory each one needs. Health probes and resource declarations are two halves of the same contract — together they tell Kubernetes what “healthy” means for your application and what it costs to run.
This page covers that contract end to end: the three probe types, CPU/memory requests and limits, the Quality of Service classes those values imply, how the scheduler uses requests to place pods, and how the Horizontal Pod Autoscaler uses live metrics to change the replica count.
Keeping Pods Healthy: Probes
A pod that is Running is not necessarily working. The kubelet runs probes — periodic checks against each container — to decide whether to restart it, route traffic to it, or wait for it to boot. The three probe types answer three different questions, and confusing them is one of the most common production mistakes.
| Probe | Question it answers | Action on failure |
|---|---|---|
| Liveness | “Is the container alive, or wedged?” | Restart the container |
| Readiness | “Can it serve traffic right now?” | Remove the pod from Service endpoints (no restart) |
| Startup | “Has a slow-starting app finished booting?” | Hold off liveness/readiness checks until it passes |
spec:
containers:
- name: web
image: myapp:1.4
startupProbe: # Give a slow boot up to 30 x 10s = 300s
httpGet:
path: /healthz/live
port: 8080
failureThreshold: 30
periodSeconds: 10
readinessProbe: # Gate traffic until the app is ready
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe: # Restart if the app wedges
httpGet:
path: /healthz/live
port: 8080
periodSeconds: 15
failureThreshold: 3
Why the distinction matters: a failing readiness probe quietly pulls the pod out of rotation so users are never routed to it, then re-adds it once healthy — ideal for warm-ups or temporary overload. A failing liveness probe kills and restarts the container. Pointing a liveness probe at a dependency you do not control (such as a database) is a classic anti-pattern: a brief database hiccup triggers a restart storm that makes the outage worse.
How a Probe Is Evaluated
Each probe runs on the kubelet of the node hosting the pod, on a fixed interval, and tracks consecutive results against thresholds. The tuning knobs are the same for all three types:
| Field | Meaning | Default |
|---|---|---|
initialDelaySeconds |
Wait this long after the container starts before the first probe | 0 |
periodSeconds |
How often to probe | 10 |
timeoutSeconds |
How long to wait for a single probe to respond | 1 |
successThreshold |
Consecutive successes needed to be considered passing | 1 |
failureThreshold |
Consecutive failures before the probe is considered failed | 3 |
The effective time before a liveness probe restarts a container is roughly initialDelaySeconds + failureThreshold x periodSeconds. In the example above that is 0 + 3 x 15 = 45 seconds of being wedged before a restart.
The Three Probe Mechanisms
A probe can use any of three handlers, regardless of type:
httpGet— the kubelet performs an HTTP GET; any2xx/3xxstatus code is a success. Best for web services; expose a cheap, dependency-free endpoint.tcpSocket— success if the TCP connection opens. Useful for non-HTTP servers (databases, brokers) where “the port accepts connections” is a reasonable proxy for health.exec— runs a command inside the container; exit code0is success. Most flexible (you can check a lockfile, a queue depth, etc.) but the most expensive, since it forks a process on every probe.
# tcpSocket liveness for a message broker
livenessProbe:
tcpSocket:
port: 5672
periodSeconds: 20
# exec readiness that checks a lockfile the app writes when ready
readinessProbe:
exec:
command: ["cat", "/tmp/ready"]
periodSeconds: 5
Startup Probes: Protecting Slow Boots
Before startup probes existed, the only way to accommodate an application that took two minutes to initialize was a large initialDelaySeconds on the liveness probe — which also delayed detection of a genuine hang for the entire life of the container. The startup probe decouples the two concerns:
flowchart LR
Start([Container starts]) --> SU{Startup<br/>probe passing?}
SU -->|"no, keep trying<br/>up to failureThreshold"| SU
SU -->|"yes (booted)"| LR[Liveness & readiness<br/>probes take over]
SU -->|"exceeded threshold"| Kill[Restart container]
While the startup probe runs, the liveness and readiness probes are suspended. Once it passes once, the kubelet hands off to the other two and never runs the startup probe again. Give it a generous budget — failureThreshold x periodSeconds should comfortably exceed the worst-case boot time — and keep the steady-state liveness probe tight so real hangs are caught quickly.
Probe Design Guidelines
- Liveness should be cheap and self-contained. Check only that the process itself is responsive. Never check a database, cache, or downstream API in a liveness probe.
- Readiness may check dependencies. It is acceptable for a readiness probe to fail when a critical dependency is unavailable, because that only removes the pod from rotation — it does not restart it.
- Separate the endpoints. Use distinct paths (
/healthz/livevs/healthz/ready) so the two concerns can diverge. - Avoid making probes too aggressive. A 1-second timeout on an endpoint that occasionally takes 1.2 seconds under load will produce spurious restarts. Leave headroom.
Resources: Requests and Limits
Every container can declare how much CPU and memory it requests (the amount guaranteed and used by the scheduler for placement) and its limit (the hard ceiling). These two numbers drive scheduling, throttling, eviction, and the pod’s Quality of Service class.
resources:
requests:
cpu: "250m" # 0.25 of a core, used for scheduling
memory: "256Mi"
limits:
cpu: "500m" # throttled above this
memory: "512Mi" # killed (OOMKilled) above this
| Type | Purpose | What happens if exceeded |
|---|---|---|
| Request | Scheduling: “I need at least this much” | N/A — it is a guaranteed minimum |
| Limit | Protection: “Never use more than this” | CPU: throttled; Memory: OOMKilled |
How CPU and Memory Differ
The single most important fact about resources is that the two are governed differently because of their physical nature:
- CPU is compressible. A container that tries to exceed its CPU limit is throttled — the Linux Completely Fair Scheduler (CFS) caps the slices it receives, so it simply runs slower. Nothing is killed. CPU is expressed in cores;
1000m(“millicores”) equals one full core,250mis a quarter core. - Memory is incompressible. You cannot give a process “a slower megabyte”. A container that exceeds its memory limit is terminated by the kernel’s OOM killer and shows up as
OOMKilled. If it is managed by a controller, it is then restarted, often into aCrashLoopBackOffif the memory pressure is persistent.
Practical guidance: always set memory requests and limits, and set them equal unless you have a specific reason not to — because memory is incompressible, “bursting” above the request just defers an OOM kill to a less predictable moment. For CPU, setting a request (for scheduling) without a limit is a defensible choice that lets latency-sensitive workloads use idle cores; setting a limit caps the blast radius of a runaway loop. Setting at least requests on every production workload prevents one greedy pod from starving its neighbors.
Units Reference
| Resource | Unit | Examples |
|---|---|---|
| CPU | cores / millicores | 1, 500m (0.5 core), 100m (0.1 core) |
| Memory | bytes (binary or decimal) | 512Mi (512 mebibytes), 1Gi, 500M (decimal megabytes) |
Note the distinction between Mi/Gi (powers of 1024) and M/G (powers of 1000). Mixing them up is a common source of “why did my pod get 4.7% less memory than I asked for” confusion — always prefer the binary Mi/Gi suffixes.
LimitRanges and ResourceQuotas
In a shared cluster you rarely want to trust every team to set sane values by hand. Two namespace-scoped objects enforce policy:
- A
LimitRangesets defaults and bounds per container/pod. It can inject default requests/limits into pods that omit them and reject pods whose values fall outside a min/max window. - A
ResourceQuotacaps the aggregate requests and limits across an entire namespace, so one team cannot consume the whole cluster.
apiVersion: v1
kind: LimitRange
metadata:
name: defaults
namespace: team-a
spec:
limits:
- type: Container
default: # applied as the limit if none is set
cpu: "500m"
memory: "512Mi"
defaultRequest: # applied as the request if none is set
cpu: "100m"
memory: "128Mi"
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-a-quota
namespace: team-a
spec:
hard:
requests.cpu: "10"
requests.memory: 20Gi
limits.cpu: "20"
limits.memory: 40Gi
A subtle but important interaction: once a ResourceQuota constrains requests.cpu or requests.memory in a namespace, every pod in that namespace must specify the corresponding request, or the API server rejects it. A LimitRange with defaults is the usual way to keep that requirement from breaking existing manifests.
Quality of Service (QoS) Classes
Kubernetes derives a Quality of Service class for every pod from its requests and limits. You never set the QoS class directly — it is computed — but it determines who gets evicted first when a node runs low on memory.
| QoS class | Condition | Eviction priority |
|---|---|---|
| Guaranteed | requests == limits for every container (both CPU and memory set) | Evicted last |
| Burstable | at least one request set, but the pod is not Guaranteed | Evicted in the middle |
| BestEffort | no requests or limits set on any container | Evicted first |
flowchart TD
Q{Every container has<br/>requests == limits<br/>for CPU and memory?}
Q -->|yes| G[Guaranteed<br/>evicted last]
Q -->|no| B{Any container has<br/>a request or limit?}
B -->|yes| Bu[Burstable<br/>evicted in the middle]
B -->|no| BE[BestEffort<br/>evicted first]
How QoS Drives Eviction
When a node comes under memory pressure, the kubelet must reclaim memory before the kernel OOM killer fires indiscriminately. It ranks pods for eviction by QoS class and by how far each pod’s memory usage exceeds its request:
- BestEffort pods are evicted first — they made no promises and the scheduler made none to them.
- Burstable pods are evicted next, those most over their requests going first.
- Guaranteed pods are evicted last, and only if reclaiming lower-priority pods was not enough.
The practical takeaway: give your most important workloads requests == limits so they land in the Guaranteed class and survive node pressure. Reserve BestEffort for genuinely disposable, interruptible work. (Pod priority and preemption, set via a PriorityClass, is a separate mechanism that governs scheduling order rather than eviction under node pressure; the two can be combined.)
Scheduler Basics
The kube-scheduler is the control-plane component that decides which node each new pod runs on. It watches the API server for pods with no node assignment (spec.nodeName empty) and, for each one, runs a two-phase algorithm.
flowchart LR
P[Pending pod] --> F["Filter (predicates)<br/>which nodes can run it?"]
F --> S["Score (priorities)<br/>rank the feasible nodes"]
S --> B["Bind<br/>write nodeName via API server"]
B --> K[kubelet on chosen node<br/>starts the container]
- Filtering (predicates). The scheduler eliminates nodes that cannot run the pod. The dominant filter is resource fit: a node is feasible only if its allocatable capacity minus the sum of existing pods’ requests leaves room for this pod’s requests. (Note that it is requests, not actual usage, and not limits, that the scheduler reserves against — this is why setting honest requests matters so much.) Other filters include node selectors/affinity, taints the pod does not tolerate, and unsatisfiable volume topology.
- Scoring (priorities). Among feasible nodes, the scheduler scores each on factors such as spreading pods of the same workload across nodes, packing to reduce fragmentation, and image locality. The highest-scoring node wins (ties broken randomly).
- Binding. The scheduler writes the chosen
nodeNameback through the API server. The kubelet on that node then pulls the image and starts the container.
Steering the Scheduler
You can influence placement with several mechanisms, from blunt to expressive:
| Mechanism | What it does | Typical use |
|---|---|---|
nodeSelector |
Hard requirement: pod runs only on nodes with matching labels | “GPU pods on GPU nodes” |
| Node affinity | Like nodeSelector but supports required and preferred rules and richer operators |
Soft preferences, multi-label logic |
| Pod affinity / anti-affinity | Place pods near (or away from) other pods by label | Co-locate cache with app; spread replicas across zones |
| Taints & tolerations | A node repels pods unless they explicitly tolerate the taint | Reserve nodes for system or special workloads |
| Topology spread constraints | Evenly distribute pods across zones/nodes within a skew budget | High availability across failure domains |
# Require GPU nodes, prefer a specific zone, and tolerate the gpu taint
spec:
nodeSelector:
hardware: gpu
tolerations:
- key: "dedicated"
operator: "Equal"
value: "gpu"
effect: "NoSchedule"
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["us-east-1a"]
Remember the distinction: taints repel, tolerations permit, affinities attract. A taint on a node keeps pods off unless they tolerate it; an affinity on a pod pulls it toward matching nodes. They are frequently combined — taint a pool of expensive GPU nodes so ordinary workloads stay away, and add both a toleration and a nodeSelector to the GPU workloads so only they land there.
What If No Node Fits?
If filtering eliminates every node, the pod stays Pending and the scheduler records an event explaining why (for example, 0/5 nodes are available: 5 Insufficient memory). This is the most common reason a freshly applied pod never starts — and it is almost always a requests-vs-capacity problem, solved by lowering requests, adding nodes, or (with the Cluster Autoscaler installed) letting the cluster grow a node automatically.
Autoscaling: Scaling Out with the HPA
Static replica counts force a trade-off: provision for peak and waste money at the trough, or provision for the average and fall over under load. The Horizontal Pod Autoscaler (HPA) removes the trade-off by changing the replica count of a Deployment (or StatefulSet) automatically in response to live metrics.
The HPA scales the number of pods. To resize an individual pod’s CPU/memory requests, see the Vertical Pod Autoscaler, and for adding whole nodes when pods cannot be scheduled, the Cluster Autoscaler — both covered alongside the HPA in Workloads & Storage. These three operate at different layers and are often used together.
flowchart LR
M[metrics-server<br/>per-pod CPU/mem] --> HPA[HPA controller]
HPA -->|"computes desired replicas"| D[Deployment]
D --> RS[ReplicaSet]
RS --> P1[Pod] & P2[Pod] & P3[Pod]
P1 & P2 & P3 -.usage.-> M
The Scaling Formula
The HPA controller runs a reconciliation loop (every 15 seconds by default). On each pass it reads the current metric across all ready pods, compares it to the target, and computes the desired replica count with a single ratio:
\[\text{desiredReplicas} = \left\lceil \text{currentReplicas} \times \frac{\text{currentMetricValue}}{\text{targetMetricValue}} \right\rceil\]For example, with 3 replicas averaging 90% CPU and a target of 70%, the controller wants ceil(3 x 90/70) = ceil(3.86) = 4 replicas. If utilization later falls to 35%, it wants ceil(4 x 35/70) = 2 replicas. The result is always clamped to the configured minReplicas/maxReplicas window.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: app-deployment
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # target: keep average CPU near 70% of the request
Crucially, averageUtilization: 70 is a percentage of the pod’s CPU request, not of the node or of a raw core. This is the direct link back to the resources section: an HPA cannot scale on CPU utilization unless the pods declare a CPU request to be a percentage of. A pod with no request has undefined utilization, and the HPA will report <unknown> and refuse to scale.
Beyond CPU: Memory, Custom, and External Metrics
The autoscaling/v2 API supports several metric sources, and an HPA may list more than one — it computes a desired replica count for each and takes the maximum, so any single overloaded dimension can scale the workload out:
Resource— CPU or memory utilization (or an absoluteaverageValue).Pods— a custom per-pod metric averaged across pods, e.g. requests-per-second exposed via the custom metrics API.Object— a metric describing a single Kubernetes object, e.g. an Ingress’s request rate.External— a metric from outside the cluster, e.g. the depth of a cloud queue (SQS, Pub/Sub).
Avoiding Thrash: Stabilization and Behavior
Naively applying the formula every 15 seconds would cause flapping — rapidly scaling up and down as the metric oscillates around the target. The HPA dampens this with a stabilization window (by default it considers the highest recommendation over the last 5 minutes when scaling down, so it scales out eagerly but in conservatively) and with a configurable behavior block:
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # wait 5 min of sustained low load before removing pods
policies:
- type: Percent
value: 50 # remove at most 50% of pods per 60s
periodSeconds: 60
scaleUp:
policies:
- type: Pods
value: 4 # add at most 4 pods per 60s
periodSeconds: 60
Operational notes:
- The HPA depends on the metrics-server add-on for CPU/memory metrics; without it, the HPA shows
<unknown>targets and never acts. Verify withkubectl top pods. - Set
minReplicasto survive the loss of a node or an availability zone — do not let an HPA scale a critical service down to one replica. - Do not run an HPA and a VPA against the same resource metric (e.g. both on CPU); they will fight, with one resizing pods while the other counts them. Pair an HPA on CPU with a VPA on memory, or keep them apart entirely.
Key Takeaways
- Three probes, three jobs. Liveness restarts a wedged container, readiness gates traffic, startup protects slow boots. Never point liveness at an external dependency.
- CPU throttles, memory kills. CPU is compressible (excess use is throttled); memory is not (excess use is OOMKilled). Always set memory requests and limits.
- Requests drive everything. The scheduler reserves against requests, not usage; QoS class derives from requests vs limits; HPA utilization is a percentage of the request.
- Scale out on a live signal. The HPA changes replica count from a simple ratio of current to target metric, clamped to min/max and dampened to avoid thrash.
See Also
- Fundamentals - Cluster architecture, Pods, Deployments, Services, and the reconciliation loop
- Workloads & Storage - StatefulSets, persistent volumes, VPA/Cluster Autoscaler, RBAC, and Pod Security
- Operations - kubectl, Helm, and a systematic troubleshooting guide
- Advanced Topics - CRDs, Operators, service mesh, and performance tuning
- Docker - The container fundamentals Kubernetes builds on