Three questions Kubernetes keeps asking

Once a Deployment runs its pods, Kubernetes needs answers to three questions that it cannot work out on its own:

  • Is the application healthy? A process can be running while the application inside hangs, waits for a lock forever or has not finished starting. You answer this with probes.
  • How much does it need? The scheduler has to know how much CPU (central processing unit) and memory a pod needs before it can choose a node, and the node has to know how far a container may go. You answer this with requests and limits.
  • How many copies are needed right now? Monday morning, when customers check their energy bills, the Kestrel Energy portal needs more pods than at 03:00. The HorizontalPodAutoscaler answers this from measurements.

All three are part of the pod template in your manifests, so they are reviewed, versioned and rolled out like any other change. This page builds on the manifest walk-through in Kubernetes Basics; there is no live cluster behind this site, so all kubectl output on this page is illustrative, written from the Kubernetes documentation.

Probes: is the container healthy?

A probe is a check the kubelet runs against a container at a fixed interval. It is the Kubernetes form of a health check. There are four ways to probe:

  • httpGet: an HTTP request to a path and port; any status code from 200 to 399 counts as success.
  • tcpSocket: success if a TCP connection to the port can be opened.
  • grpc: the standard gRPC health-checking protocol, for applications that speak gRPC.
  • exec: runs a command inside the container; exit status 0 is success. Costs a process start every time, so use it sparingly.

What Kubernetes does with a failure depends on the kind of probe. That difference is the most important thing on this page.

Liveness probes

A liveness probe answers "should this container be restarted?". When it fails a number of times in a row, the kubelet kills the container and starts it again (following the pod's restart policy). It is meant for situations the application cannot recover from by itself, such as a deadlock: the process still exists, but it will never answer again.

A liveness probe should therefore check only the process itself: "can I still answer a trivial request?". The Kestrel meter-api exposes /healthz for this; it returns 200 without touching the database.

Readiness probes

A readiness probe answers "should this pod receive traffic now?". While it fails, the pod is taken out of the endpoints of every Service that selects it, so no new requests reach it. The container is not restarted; as soon as the probe passes again the pod is put back. Readiness is the right place to check dependencies: meter-api's /readyz also checks that it can reach PostgreSQL, because without the database it cannot do useful work.

Readiness also protects rolling updates: a Deployment counts a new pod as available only once it is ready, so a version that never becomes ready stops the rollout instead of replacing all healthy pods.

Startup probes

A startup probe answers "has the application finished starting?". While a container has a startup probe that has not yet succeeded, Kubernetes does not run its liveness and readiness probes. Once it succeeds it is switched off for the rest of the container's life; if it keeps failing beyond its threshold, the container is killed and restarted.

This is the clean solution for slow starters such as Java applications that warm up for a minute: without a startup probe, the liveness probe kills them before they ever finish starting, and the pod ends up in a restart loop. The startup window is failureThreshold × periodSeconds: with 30 × 2 seconds, the application gets up to 60 seconds.

Probe settings and timing

Field Default Meaning
initialDelaySeconds 0 Wait before the first check (a startup probe is usually the better tool)
periodSeconds 10 Time between checks
timeoutSeconds 1 A check that takes longer counts as a failure
failureThreshold 3 Failures in a row before Kubernetes acts
successThreshold 1 Successes in a row to count as passing again (must be 1 for liveness and startup)

With the defaults, a liveness probe restarts a hung container after roughly 3 × 10 = 30 seconds of failures. The one-second timeout is short: an application that answers in 1.2 seconds under load "fails" its probe even though it works.

Probe timeline over 100 seconds. The startup probe checks every 2 seconds and passes at 24 seconds, then switches off. From then on the readiness probe marks the pod ready; between 60 and 75 seconds the database is slow, the pod is not ready and gets no traffic, but it is not restarted; after 75 seconds it is ready again. The liveness probe checks every 10 seconds from 24 seconds on and would restart the container only after 3 failures in a row. Startup Readiness Liveness every 2 s until it passes passed: switched off Ready: receives traffic not ready Ready again every 10 s; 3 failures in a row → restart 0 s 20 s 40 s 60 s 80 s 100 s
The three probes over the first 100 seconds of a meter-api container. A slow database makes the pod not ready for 15 seconds; it is taken out of the Service, not restarted.

The three probes in a manifest

The meter-api Deployment, with all three probes and its resource settings (explained in the next section):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: meter-api
  namespace: kestrel-portal
  labels:
    app.kubernetes.io/name: meter-api
    app.kubernetes.io/part-of: kestrel
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: meter-api
  template:
    metadata:
      labels:
        app.kubernetes.io/name: meter-api
        app.kubernetes.io/part-of: kestrel
    spec:
      containers:
        - name: meter-api
          image: registry.example.com/kestrel/meter-api:2.3.0
          ports:
            - containerPort: 8000
          startupProbe:            # up to 30 × 2 s = 60 s to start
            httpGet:
              path: /healthz
              port: 8000
            periodSeconds: 2
            failureThreshold: 30
          readinessProbe:          # checks the database too
            httpGet:
              path: /readyz
              port: 8000
            periodSeconds: 5
            timeoutSeconds: 2
          livenessProbe:           # the process only, never dependencies
            httpGet:
              path: /healthz
              port: 8000
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              memory: 512Mi

Requests and limits

Each container can declare, per resource, a request and a limit. They do different jobs, at different moments.

Requests: what the scheduler reserves

A resource request is the amount of CPU or memory the scheduler sets aside for a container when it picks a node. A node with 4 cores fits at most sixteen pods requesting 250m each, whether or not those pods are busy. Requests are a reservation, not a measurement: a pod may use less, or (for CPU) more when the node has spare capacity.

Requests are also what makes overcommitment safe to reason about, the same idea as resource overcommitment on hypervisors: the sum of the limits on a node may be far above its capacity, but the sum of the requests never is. A pod whose requests fit on no node stays Pending; see kubectl and Troubleshooting.

Limits: the ceiling

A resource limit is the maximum a container may use, enforced on the node through Linux cgroups (see What Are Containers?). The two resources behave very differently at the limit:

Resource Above the limit What you notice
CPU The container is throttled: it gets no more CPU time in that period Slow responses, timeouts, failing probes; no restart
Memory The kernel's out-of-memory killer ends the process Restart with reason OOMKilled and exit code 137 (see Debugging Containers)

Memory cannot be "throttled" — memory in use cannot be taken back without the program's cooperation — so the only enforcement is a kill. That is why the memory limit deserves the most care. If you set a limit and no request, Kubernetes sets the request equal to the limit.

A common choice, shown in the manifest above, is a CPU request without a CPU limit, and a memory limit somewhat above the memory request. Without a CPU limit, a busy pod can use idle CPU on the node instead of being throttled while cores sit unused; the request still guarantees its share under contention. Other teams set CPU limits to make performance predictable. Neither is wrong; decide deliberately, and measure.

CPU and memory units

CPU is measured in cores: 1 means one physical core or one vCPU, 0.5 is half of one, and the m suffix means millicores (thousandths of a core), so 0.5 and 500m are the same. The smallest unit is 1m.

Memory is measured in bytes, with decimal suffixes (k, M, G: powers of 1000) and binary prefixes (Ki, Mi, Gi: powers of 1024). The same m suffix exists for memory too, where it means millibytes — a classic typo. This script converts the quantities the way Kubernetes reads them:

from decimal import Decimal

# Kubernetes quantity suffixes: binary (Ki, Mi, Gi), decimal (k, M, G) and milli (m)
SUFFIXES = {
    "Ki": 2**10, "Mi": 2**20, "Gi": 2**30,
    "k": 10**3, "M": 10**6, "G": 10**9,
    "m": Decimal("0.001"),
}


def parse_quantity(text):
    """Turn a quantity such as '250m', '1.5' or '512Mi' into a number."""
    for suffix in sorted(SUFFIXES, key=len, reverse=True):
        if text.endswith(suffix):
            return Decimal(text[: -len(suffix)]) * SUFFIXES[suffix]
    return Decimal(text)


print("CPU (1 = one core or vCPU)")
for cpu in ["100m", "250m", "0.5", "1", "1.5"]:
    print(f"  {cpu:>5} = {int(parse_quantity(cpu) * 1000):>5} millicores")

print("Memory")
for memory in ["256Mi", "256M", "1Gi", "1G", "400m"]:
    print(f"  {memory:>5} = {parse_quantity(memory):>13,} bytes")

mi, m = parse_quantity("512Mi"), parse_quantity("512M")
print(f"512Mi is {(mi - m) / m:.2%} more than 512M")
CPU (1 = one core or vCPU)
   100m =   100 millicores
   250m =   250 millicores
    0.5 =   500 millicores
      1 =  1000 millicores
    1.5 =  1500 millicores
Memory
  256Mi =   268,435,456 bytes
   256M =   256,000,000 bytes
    1Gi = 1,073,741,824 bytes
     1G = 1,000,000,000 bytes
   400m =         0.400 bytes
512Mi is 4.86% more than 512M

Decimal keeps the arithmetic exact, as Kubernetes does. Two lessons: memory: 400m asks for less than one byte (you meant 400Mi). The API server still accepts it, with only a warning that a fractional byte value is invalid — easy to miss in pipeline output. As a request it reserves nothing; as a limit no process fits in it. And M versus Mi is almost a 5% difference — enough to turn a carefully sized limit into an OOM kill. Use Mi and Gi consistently.

Quality of service classes

From the requests and limits of its containers, Kubernetes gives every pod a quality of service class (QoS class). When a node runs short of memory, the kubelet evicts pods to protect the node, and the class decides who goes first:

  • Guaranteed: every container has a CPU and a memory limit, and its requests equal its limits. Evicted last.
  • Burstable: at least one container has a request or a limit, but the pod does not meet the Guaranteed rule.
  • BestEffort: no container has any request or limit. Evicted first.
def qos_class(containers):
    """Work out a pod's quality of service class from its containers."""
    if not any(c.get("requests") or c.get("limits") for c in containers):
        return "BestEffort"
    for c in containers:
        limits = c.get("limits", {})
        # A request that is left out defaults to the limit.
        requests = {**limits, **c.get("requests", {})}
        for resource in ("cpu", "memory"):
            if resource not in limits or requests[resource] != limits[resource]:
                return "Burstable"
    return "Guaranteed"


pods = {
    "meter-api": [{"requests": {"cpu": "250m", "memory": "256Mi"},
                   "limits": {"memory": "512Mi"}}],
    "billing-export": [{"requests": {"cpu": "500m", "memory": "1Gi"},
                        "limits": {"cpu": "500m", "memory": "1Gi"}}],
    "portal": [{"limits": {"cpu": "500m", "memory": "256Mi"}}],
    "debug-shell": [{}],
}
for name, containers in pods.items():
    print(f"{name:<15} {qos_class(containers)}")
meter-api       Burstable
billing-export  Guaranteed
portal          Guaranteed
debug-shell     BestEffort

The portal is Guaranteed although it sets only limits, because the missing requests default to the limits. (Real Kubernetes compares quantities, so 0.5 and 500m are equal; this sketch compares the strings.) kubectl describe pod shows the result in the line QoS Class:. Since Kubernetes 1.37, Memory QoS — using the cgroup v2 memory controls to protect requested memory more precisely — is in beta.

Horizontal pod autoscaling

The HorizontalPodAutoscaler (HPA) is a Kubernetes object, and a controller behind it, that changes the number of replicas of a Deployment or StatefulSet based on a measured metric. It is Kubernetes' horizontal scaling; for the general idea of autoscaling see the Cloud topic. The stable API is autoscaling/v2; older manifests with v2beta2 must be rewritten.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: portal
  namespace: kestrel-portal
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: portal
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70    # percent of the CPU REQUEST
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300   # the default, written out

Utilization is measured as a percentage of the pods' requests. A pod without a CPU request has no utilization, and the HPA cannot act on it. The measurements come from the resource metrics API (metrics.k8s.io, which became stable in Kubernetes 1.37 after years in beta), usually served by the Metrics Server add-on; many managed clusters install it for you.

$ kubectl get hpa portal -n kestrel-portal
NAME     REFERENCE           TARGETS        MINPODS   MAXPODS   REPLICAS   AGE
portal   Deployment/portal   cpu: 91%/70%   2         10        4          12d

The formula

About every 15 seconds the controller computes:

desiredReplicas = ceil(currentReplicas × currentMetricValue ÷ desiredMetricValue)

If the ratio is within a tolerance of 1.0 (10% by default), it does nothing, so small fluctuations do not cause constant scaling. The result is then clamped between minReplicas and maxReplicas. Scaling down is deliberately slow: the HPA applies the highest recommendation of the last 300 seconds, so a short dip does not remove pods that are needed a minute later.

import math

MIN_REPLICAS, MAX_REPLICAS = 2, 10
TARGET = 70  # target average CPU utilization, percent of the request


def desired_replicas(current, utilization, tolerance=0.1):
    """The HorizontalPodAutoscaler formula, with tolerance and min/max."""
    ratio = utilization / TARGET
    if abs(ratio - 1.0) <= tolerance:
        return current  # close enough: do nothing
    wanted = math.ceil(current * ratio)
    return max(MIN_REPLICAS, min(MAX_REPLICAS, wanted))


for current, utilization in [(4, 90), (6, 75), (6, 160), (10, 20)]:
    print(f"{current:>2} pods at {utilization:>3}% -> "
          f"{desired_replicas(current, utilization)} pods")

# Scaling down waits: the HPA uses the HIGHEST recommendation
# of the last 300 seconds (the downscale stabilization window).
print()
recommendations = [(0, 10), (60, 7), (120, 4), (240, 3), (330, 3), (420, 3)]
for now, _ in recommendations:
    window = [r for t, r in recommendations if now - 300 < t <= now]
    print(f"t={now:>3}s  recommended {window[-1]:>2}  applied {max(window):>2}")
 4 pods at  90% -> 6 pods
 6 pods at  75% -> 6 pods
 6 pods at 160% -> 10 pods
10 pods at  20% -> 3 pods

t=  0s  recommended 10  applied 10
t= 60s  recommended  7  applied 10
t=120s  recommended  4  applied 10
t=240s  recommended  3  applied 10
t=330s  recommended  3  applied  7
t=420s  recommended  3  applied  3

Four pods at 90% against a 70% target need ceil(4 × 90 ÷ 70) = ceil(5.14) = 6 pods. Six pods at 75% are within the tolerance (75 ÷ 70 = 1.07), so nothing happens. A spike to 160% would ask for 14 but stops at the maximum of 10, and when load drops the replica count follows only after the window has passed.

Other ways to scale

  • Vertical scaling changes the requests and limits of pods instead of their number — the vertical scaling counterpart. The Vertical Pod Autoscaler (VPA) is an add-on that recommends or applies new values based on usage; resizing a running pod in place is also possible in recent Kubernetes versions. Do not let a VPA and an HPA act on the same CPU metric: they fight each other.
  • Event-driven scaling: KEDA (Kubernetes Event-driven Autoscaling) is a popular add-on that feeds queue lengths, database rows or other event sources to the HPA as external metrics. It is a natural fit for jobs like Kestrel's billing export.
  • Node autoscaling: when new pods do not fit on any node, a cluster autoscaler (such as Cluster Autoscaler or Karpenter on the managed cloud services) adds nodes, and removes them when they are empty. The HPA adds pods; something else must add the machines they run on.

Right-sizing in practice

Good numbers come from measurement, not from guesses copied between manifests. A practical loop, which mirrors right-sizing in the cloud:

  1. Start with a reasonable request based on a load test or the old VM's usage, and a memory limit with headroom.
  2. Watch actual usage over at least a full business cycle (a week with a Monday morning in it), with kubectl top for a quick look and your monitoring system's metrics for history.
  3. Set the request near typical usage and the memory limit above the observed peak; check for CPU throttling and OOM kills after each change.
$ kubectl top pods -n kestrel-portal -l app.kubernetes.io/name=meter-api
NAME                         CPU(cores)   MEMORY(bytes)
meter-api-6d9f7c8b5d-4kq2x   212m         301Mi
meter-api-6d9f7c8b5d-9zrtm   187m         296Mi
meter-api-6d9f7c8b5d-lp7wd   240m         318Mi

The meter-api pods use around 200m CPU against a 250m request, and about 300Mi of memory: above the 256Mi request but well below the 512Mi limit. Raising the memory request to 320Mi would make the scheduler's picture match reality.