The previous lesson put quotas on the three Rutas Norte environments and left two very specific loose ends. The first is practical and gets in the way every day: ever since rutas-norte-dev got a compute quota, a simple kubectl run fails with must specify requests.cpu, and any third-party manifest that does not declare resources is unusable. The second is deeper: we know that when a node runs short of memory somebody has to die, but we do not know who, and that decision is not random at all. This lesson closes both: the LimitRange injects default values and sets per-container ceilings within the namespace, and the quality of service classesGuaranteed, Burstable and BestEffort — determine the exact order in which the kubelet evicts and the kernel kills. By the end you will have assigned a reasoned class to every Rutas Norte component and triggered a real eviction in your minikube to see it with your own eyes.

Contents

  1. What a LimitRange solves
  2. The fields: default, defaultRequest, min, max, maxLimitRequestRatio
  3. Demonstration: a pod with no resources comes out with resources
  4. The types: Container, Pod and PersistentVolumeClaim
  5. The exact interaction between LimitRange and ResourceQuota
  6. The three QoS classes and the rule that determines them
  7. Checking a pod's class
  8. Consequence 1: oom_score_adj and the kernel's OOM killer
  9. Consequence 2: the kubelet's eviction order
  10. The QoS class of each Rutas Norte component
  11. The LimitRange for the three environments
  12. Experiment: trigger an eviction and see who falls first

  1. What a LimitRange solves

The LimitRange is a namespaced object that does two distinct things to every container created in it:

  1. It injects default values into containers that do not declare requests or limits.
  2. It validates ceilings: it rejects containers asking for less than a minimum or more than a maximum.

The difference from a ResourceQuota is the scope, and it is worth being crystal clear about it because the two get confused constantly:

ResourceQuota LimitRange
Scope The whole namespace, aggregated Each individual container or pod
Question it answers "How much can this environment consume in total?" "How much can a single container ask for?"
Modifies the object No: it only accepts or rejects Yes: it injects default values
When it acts When the object is created When the object is created
Objects affected Pods, Services, PVCs, ConfigMaps... Containers, pods and PVCs
Typical effect exceeded quota minimum memory usage per Container is 32Mi
Can there be several Yes (all of them apply) Yes (all of them apply)

An analogy with Rutas Norte: the ResourceQuota is the department's annual budget (you cannot spend more than X euros between all of you). The LimitRange is the per-person expenses policy (nobody can book a ticket costing more than Y euros, and if no class is specified, economy is assumed).

The three concrete problems it solves:

Problem 1: the quota breaks everything convenient. We already saw it:

kubectl run debugger --image=busybox:1.36 -n rutas-norte-dev -- sleep 3600
Error from server (Forbidden): pods "debugger" is forbidden: failed quota: quota-dev:
  must specify limits.cpu for: debugger; limits.memory for: debugger;
  requests.cpu for: debugger; requests.memory for: debugger

Problem 2: a single container can hog the entire quota. Without a LimitRange, somebody can deploy a pod with requests.memory: 4Gi in rutas-norte-dev and exhaust the environment's whole memory quota in one go, leaving the five components with nowhere to run.

Problem 3: absurd values. A container with requests.memory: 4Mi will never start reliably, but nothing stops it.

  1. The fields: default, defaultRequest, min, max, maxLimitRequestRatio

apiVersion: v1
kind: LimitRange
metadata:
  name: limits-dev
  namespace: rutas-norte-dev
spec:
  limits:
    - type: Container                    # applies to EACH container
      default:                           # LIMITS if the container does not declare them
        cpu: 300m
        memory: 256Mi
      defaultRequest:                    # REQUESTS if the container does not declare them
        cpu: 100m
        memory: 128Mi
      min:                               # nobody can ask for LESS than this
        cpu: 10m
        memory: 32Mi
      max:                               # nobody can ask for MORE than this
        cpu: "2"
        memory: 2Gi
      maxLimitRequestRatio:              # maximum limit/request ratio
        cpu: "10"
        memory: "4"

Field by field:

Field What it does If it is breached
default Fills in the missing limits — (it only fills in)
defaultRequest Fills in the missing requests — (it only fills in)
min Minimum that can be declared The pod is rejected
max Maximum that can be declared The pod is rejected
maxLimitRequestRatio Ceiling on the limit / request division The pod is rejected

Resolution rules you need to know precisely:

Rule 1: what is declared always wins. The LimitRange never overwrites a value the container declares. It only fills gaps.

Rule 2: if you declare limits but not requests, the request takes the value of the limit, not of defaultRequest. This behaviour catches a lot of people out:

          resources:
            limits:
              memory: 1Gi            # I declare no requests

With the LimitRange above (defaultRequest.memory: 128Mi) you would expect requests: 128Mi. But the result is:

          resources:
            limits:
              memory: 1Gi
            requests:
              memory: 1Gi            # copied from the limit, NOT the defaultRequest

The Kubernetes rule of "if there are only limits, the request equals the limit" takes precedence. Practical consequence: you reserve 1 GiB of the namespace budget without meaning to.

Rule 3: if you declare requests but not limits, default is applied. Here it does work as you would expect... unless default is lower than your request, in which case the pod is rejected as inconsistent.

Rule 4: maxLimitRequestRatio limits individual overcommitment. With memory: "4", a container with requests.memory: 128Mi cannot have a limits.memory greater than 512Mi. It is the tool for stopping somebody reserving little and then consuming a lot, which is exactly the pattern that causes evictions.

A table of the six possible scenarios, with the example LimitRange:

What the container declares Result after the LimitRange
Nothing requests: 100m/128Mi, limits: 300m/256Mi
Only requests: cpu 200m requests: 200m/128Mi, limits: 300m/256Mi
Only limits: memory 1Gi requests: 100m/**1Gi**, limits: 300m/1Gi
Both complete Unchanged (if it passes min, max and ratio)
requests.memory: 16Mi Rejected: below min (32Mi)
requests: 128Mi, limits: 1Gi Rejected: ratio 8 > maxLimitRequestRatio 4

  1. Demonstration: a pod with no resources comes out with resources

Nothing is as convincing as seeing it. We start from rutas-norte-dev with its quota and we apply the LimitRange:

kubectl apply -f k8s/environments/dev/limitrange.yaml
kubectl describe limitrange limits-dev -n rutas-norte-dev
limitrange/limits-dev created

Name:       limits-dev
Namespace:  rutas-norte-dev
Type        Resource  Min   Max  Default Request  Default Limit  Max Limit/Request Ratio
----        --------  ---   ---  ---------------  -------------  -----------------------
Container   cpu       10m   2    100m             300m           10
Container   memory    32Mi  2Gi  128Mi            256Mi          4

Now the same kubectl run that failed before:

kubectl run debugger --image=busybox:1.36 -n rutas-norte-dev -- sleep 3600
kubectl get pod debugger -n rutas-norte-dev
pod/debugger created

NAME       READY   STATUS    RESTARTS   AGE
debugger   1/1     Running   0          6s

It works. And the interesting part is seeing what resources it actually has:

kubectl get pod debugger -n rutas-norte-dev -o jsonpath='{.spec.containers[0].resources}' | jq .
{
  "limits": {
    "cpu": "300m",
    "memory": "256Mi"
  },
  "requests": {
    "cpu": "100m",
    "memory": "128Mi"
  }
}

The manifest we sent had no resources and the object stored in etcd does have them. The LimitRange injected them.

Who performs that injection? An admission controller called LimitRanger, which is part of the apiserver. Remember the flow from module 1: a request goes through authentication, authorization and admission before being written to etcd. Admission has two phases:

flowchart LR
    A["kubectl apply"] --> B["Authentication<br/>who you are"]
    B --> C["RBAC authorization<br/>what you can do"]
    C --> D["MUTATING admission<br/>LimitRanger injects<br/>default and defaultRequest"]
    D --> E["VALIDATING admission<br/>LimitRanger checks min/max<br/>ResourceQuota checks the total"]
    E --> F["etcd<br/>object stored ALREADY MODIFIED"]

The order is crucial and explains everything in section 5: the LimitRange mutates first, the ResourceQuota validates afterwards. By the time the quota looks at the pod, it already has its resources injected.

And a consequence to keep in mind: the object in the cluster is no longer identical to the YAML file in your repository. It is the first time in the course that this happens, and it is worth knowing so you are not baffled by a kubectl diff. The pod also records it as an annotation:

kubectl get pod debugger -n rutas-norte-dev -o jsonpath='{.metadata.annotations}' | jq .
{
  "kubernetes.io/limit-ranger": "LimitRanger plugin set: cpu, memory request for container debugger;
   cpu, memory limit for container debugger"
}

Let us also check the ceilings. A container asking for too much:

kubectl run giant --image=nginx:1.27.1-alpine -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"giant","image":"nginx:1.27.1-alpine",
  "resources":{"requests":{"memory":"3Gi"},"limits":{"memory":"3Gi"}}}]}}'
Error from server (Forbidden): pods "giant" is forbidden:
  maximum memory usage per Container is 2Gi, but limit is 3Gi

And one asking for too little:

kubectl run dwarf --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"dwarf","image":"busybox:1.36",
  "resources":{"requests":{"memory":"8Mi"},"limits":{"memory":"8Mi"}}}]}}'
Error from server (Forbidden): pods "dwarf" is forbidden:
  minimum memory usage per Container is 32Mi, but request is 8Mi

All three behaviours verified: it injects, it sets a ceiling and it sets a floor.

  1. The types: Container, Pod and PersistentVolumeClaim

A LimitRange can have several entries in spec.limits, each with its own type:

apiVersion: v1
kind: LimitRange
metadata:
  name: limits-pro
  namespace: rutas-norte-pro
spec:
  limits:
    # 1. Per CONTAINER: the only one that accepts default and defaultRequest
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 200m
        memory: 256Mi
      min:
        cpu: 50m
        memory: 64Mi
      max:
        cpu: "4"
        memory: 8Gi
      maxLimitRequestRatio:
        cpu: "4"
        memory: "2"

    # 2. Per POD: the sum of ALL its containers. It accepts no defaults
    - type: Pod
      max:
        cpu: "8"
        memory: 16Gi
      min:
        cpu: 50m
        memory: 64Mi

    # 3. Per PVC: disk size
    - type: PersistentVolumeClaim
      min:
        storage: 1Gi
      max:
        storage: 200Gi

Differences between the three:

Type Scope Accepts default/defaultRequest Typical use
Container Each container separately Yes The main one: default values and ceilings
Pod The sum of the pod's containers No Preventing pods with 10 giant sidecars
PersistentVolumeClaim Size of the requested disk No Controlling storage cost

The Pod type becomes useful when module 6 arrives with sidecars and init containers: a pod can have three or four containers, each within the Container max, but adding up to an outrageous total. The Pod limit cuts that off.

The PersistentVolumeClaim type will make sense in module 5. With max: storage: 200Gi nobody can ask for a 2 TiB disk through a typo, something that in the cloud translates into an unpleasant bill at the end of the month.

  1. The exact interaction between LimitRange and ResourceQuota

This is the point where everything fits together. The sequence, for a pod arriving with no resources at a namespace holding both objects:

sequenceDiagram
    participant U as kubectl apply
    participant A as apiserver
    participant LR as LimitRanger (mutating)
    participant LV as LimitRanger (validating)
    participant Q as ResourceQuota (validating)
    participant E as etcd

    U->>A: Pod with no resources
    A->>LR: mutating phase
    LR->>LR: injects requests 100m/128Mi<br/>and limits 300m/256Mi
    LR->>LV: validating phase
    LV->>LV: checks min, max and ratio -> OK
    LV->>Q: next validator
    Q->>Q: used + 100m <= hard? -> OK
    Q->>E: pod stored WITH resources
    E-->>U: pod/debugger created

The four consequences of this order:

Consequence 1: the LimitRange saves the ResourceQuota from its own strictness. Without a LimitRange, the quota forces you to declare resources by hand everywhere. With a LimitRange, the default value is injected and the quota tallies it without anyone writing anything.

Consequence 2: injected values consume quota. This is not free and has to be sized. If defaultRequest.memory is 128Mi and somebody launches 20 debugging pods with no resources, they eat 2.5 GiB of the namespace budget. That is why the default values must be modest: they are for pods nobody has calibrated.

Consequence 3: a pod can pass the LimitRange and fail on the quota. They are independent validations and both must be satisfied:

kubectl apply -f large-pod.yaml
Error from server (Forbidden): error when creating "large-pod.yaml": pods "analytics" is forbidden:
  exceeded quota: quota-dev, requested: requests.memory=1Gi,
  used: requests.memory=3600Mi, limited: requests.memory=4Gi

That pod met the LimitRange max (2Gi), but it did not fit in what was left of the quota.

Consequence 4: changing a LimitRange does not affect existing pods. Admission acts only at creation. If you raise defaultRequest, the pods already running keep what was injected into them. To apply the new values you have to recreate them:

kubectl rollout restart deploy -n rutas-norte-dev

And a consistency rule between the two objects that avoids an unpleasant problem: the LimitRange max must never exceed the namespace quota. If max.memory is 8Gi but the requests.memory quota is 4Gi, somebody can write a manifest that passes the LimitRange and is impossible to deploy. It is better for the rejection to come with the LimitRange's clear message ("maximum memory usage per Container is 2Gi") than with the quota's, which is harder to interpret.

  1. The three QoS classes and the rule that determines them

We change subject and reach the most interesting part. When a pod is created, Kubernetes automatically assigns it a quality of service class in status.qosClass. It is not declared: it is deduced from how the resources are set.

The exact rule, in evaluation order:

flowchart TB
    A["Pod created"] --> B{"Do all containers<br/>declare requests AND limits<br/>for CPU AND memory?"}
    B -->|"No"| E{"Does any container<br/>declare any request<br/>or any limit?"}
    B -->|"Yes"| C{"For each container:<br/>requests == limits<br/>for CPU AND memory?"}
    C -->|"Yes"| D["Guaranteed"]
    C -->|"No"| F["Burstable"]
    E -->|"Yes"| F
    E -->|"No: nothing at all"| G["BestEffort"]

In precise words:

Guaranteed — assigned if and only if, for every container in the pod (init containers included):

  • CPU and memory limits are declared.
  • CPU and memory requests are declared (or omitted, in which case they equal the limits).
  • requests is exactly equal to limits for both resources.

BestEffort — assigned if no container in the pod declares any request or any limit, for any resource.

Burstable — everything else. It is the default bucket: at least one container declares something, but the strict Guaranteed condition is not met.

Examples that clarify the borderline cases:

# --- Guaranteed ---
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              cpu: 500m
              memory: 512Mi
# --- ALSO Guaranteed (limits only: the requests are copied) ---
          resources:
            limits:
              cpu: 500m
              memory: 512Mi
# --- Burstable: the memory matches but the CPU does not ---
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              cpu: 500m
              memory: 512Mi
# --- Burstable: the CPU limit is missing ---
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              memory: 512Mi
# --- Burstable: requests only ---
          resources:
            requests:
              memory: 512Mi
# --- BestEffort: nothing at all ---
          resources: {}

Three traps worth memorising:

Trap 1: one container is enough to ruin the class. If a pod has the application with requests == limits and a logging sidecar with no resources, the whole pod is Burstable. The class belongs to the pod, not to the container.

Trap 2: with a LimitRange active, BestEffort is almost impossible. The LimitRanger injects default values, so no pod ever reaches BestEffort. It is an important and generally desirable side effect.

Trap 3: defaultRequest different from default prevents Guaranteed. If the LimitRange injects requests: 100m and limits: 300m, every pod with no resources will be Burstable. To make a pod Guaranteed you have to declare it explicitly.

  1. Checking a pod's class

kubectl get pod bookings-postgres-5d8f6b9c4-k2mnp -n rutas-norte-pro \
  -o jsonpath='{.status.qosClass}'; echo
Guaranteed

For every pod in the namespace at a glance:

kubectl get pods -n rutas-norte-pro \
  -o custom-columns='NAME:.metadata.name,QOS:.status.qosClass,\
CPU_REQ:.spec.containers[0].resources.requests.cpu,\
CPU_LIM:.spec.containers[0].resources.limits.cpu,\
MEM_REQ:.spec.containers[0].resources.requests.memory,\
MEM_LIM:.spec.containers[0].resources.limits.memory'
NAME                                    QOS         CPU_REQ  CPU_LIM  MEM_REQ  MEM_LIM
bookings-api-7f4b8c9d6-2xkqp            Burstable   300m     1        512Mi    768Mi
bookings-api-7f4b8c9d6-8mvtr            Burstable   300m     1        512Mi    768Mi
bookings-postgres-5d8f6b9c4-k2mnp       Guaranteed  1        1        2Gi      2Gi
redis-cache-6c9d8f7b5-vn4qx             Burstable   50m      200m     256Mi    320Mi
web-store-5b7c9f4d8-h7pnk               Burstable   50m      200m     64Mi     128Mi
notifications-worker-6f7d9c8b4-lm3pt    Burstable   100m     800m     320Mi    512Mi

A quick count by class:

kubectl get pods -A -o json | jq -r '.items[] | .status.qosClass' | sort | uniq -c
     28 Burstable
      3 Guaranteed
      6 BestEffort

Those six BestEffort pods deserve a look: in a production cluster they are the first candidates to die, so it is worth knowing who they are and whether that is intentional.

kubectl get pods -A -o json \
  | jq -r '.items[] | select(.status.qosClass=="BestEffort") | "\(.metadata.namespace)/\(.metadata.name)"'
kube-system/coredns-7db6d8ff4d-2vqxn
rutas-norte-dev/debugger
...

describe shows it too:

kubectl describe pod bookings-postgres-5d8f6b9c4-k2mnp -n rutas-norte-pro | grep -i qos
QoS Class:  Guaranteed

  1. Consequence 1: oom_score_adj and the kernel's OOM killer

Here is the first real consequence of the class, and it is literally a matter of life and death.

When the Linux kernel runs out of memory, it invokes the OOM killer, which picks a victim. The choice is based on a per-process score, oom_score, which combines the memory being consumed with a manual adjustment called oom_score_adj, a value between -1000 and 1000. The higher it is, the sooner you die.

The kubelet sets that adjustment according to the QoS class:

QoS class oom_score_adj Order of sacrifice
Guaranteed -997 The last to die
Burstable Between 2 and 999, depending on the memory request In the middle
BestEffort 1000 The first to die

The formula for Burstable is where all the nuance lies:

oom_score_adj = 1000 - (1000 x requests.memory / node allocatable memory)

Read it slowly: the larger your memory request, the lower your score and the later you die. A Burstable pod that asks for a lot of memory is almost as protected as a Guaranteed one; one that asks for very little is almost as exposed as a BestEffort.

Let us check it on a node with 8 GiB allocatable. For bookings-api with requests.memory: 512Mi:

oom_score_adj = 1000 - (1000 x 512 / 8192) = 1000 - 62 = 938
kubectl exec -n rutas-norte-pro deploy/bookings-api -- cat /proc/1/oom_score_adj
kubectl exec -n rutas-norte-pro deploy/bookings-postgres -- cat /proc/1/oom_score_adj
938
-997

Exactly as predicted. And the business conclusion is direct:

When the node runs short of memory, the kernel will kill bookings-api long before bookings-postgres. And that is precisely what we want: losing an API pod means losing a few requests the customer can retry; losing the database means the whole platform stops selling tickets and, in the worst case, that a half-finished transaction corrupts data.

You have to distinguish two different situations that are often confused:

cgroup OOM node OOM
Cause A container exceeds its own limits.memory The sum of everything exceeds the node's memory
Who dies That particular container The process with the highest oom_score on the node
Does the QoS class matter No: you die from your own limit Yes: it is what decides
Looks like OOMKilled on that pod OOMKilled on a pod that "had done nothing"
Prevented by A well-calibrated limits.memory Not overcommitting memory + well-assigned QoS

The second case is the baffling one: a pod dies and its metrics show it was well below its limit. The cause is a neighbour that went out of control, and the QoS class is what determined that the victim was this one and not another.

  1. Consequence 2: the kubelet's eviction order

Before the kernel gets to killing processes, the kubelet has its own, more orderly mechanism: resource pressure eviction.

The kubelet monitors the node's resources every 10 seconds. When a threshold is crossed, it flags the corresponding condition (MemoryPressure, DiskPressure, PIDPressure) and starts evicting pods to reclaim resources.

The default thresholds:

Signal Default threshold Condition it triggers
memory.available < 100Mi MemoryPressure
nodefs.available < 10% DiskPressure
nodefs.inodesFree < 5% DiskPressure
imagefs.available < 15% DiskPressure

And the victim selection criteria, in this strict order:

  1. First, whether the pod exceeds its requests. Those consuming more than they reserved are candidates before those staying within.
  2. Second, the QoS class: BestEffort first, then Burstable, and Guaranteed last.
  3. Third, the PriorityClass, if one is defined.
  4. Fourth, how far usage exceeds the request. Between two equal candidates, the one that went furthest over falls.

Put operationally:

The first pod to fall is a BestEffort. If there is none, the Burstable that has gone furthest over its request falls. A Guaranteed pod staying within its resources is practically untouchable.

An evicted pod looks like this:

kubectl get pods -n rutas-norte-dev
kubectl describe pod ad-hoc-analytics -n rutas-norte-dev | head -14
NAME               READY   STATUS    RESTARTS   AGE
ad-hoc-analytics   0/1     Evicted   0          8m

Name:         ad-hoc-analytics
Namespace:    rutas-norte-dev
Status:       Failed
Reason:       Evicted
Message:      The node was low on resource: memory. Threshold quantity: 100Mi,
              available: 84Mi. Container analytics was using 1204Mi,
              request is 0, which exceeds its request of 0.

That message says it all: request is 0 because it was BestEffort, and therefore any consumption exceeds its reservation. It was the perfect candidate.

Two important practical notes:

Evicted pods do not delete themselves. They stay in Failed, taking up space in etcd. It is worth cleaning them up:

kubectl delete pods -A --field-selector=status.phase=Failed

An evicted pod belonging to a Deployment is recreated. The ReplicaSet from module 2 sees a replica missing and creates another, possibly on a different node. A loose evicted pod, by contrast, never comes back. One more reason not to deploy loose pods.

  1. The QoS class of each Rutas Norte component

Now for the design decision, which is what gives meaning to everything above. The question, for each component, is: what happens if this pod suddenly dies?

Component Class Business justification
bookings-postgres Guaranteed It holds the bookings and the customers' personal data. If it dies, the platform does not sell. A violent death can leave transactions half-finished. It must be the last to fall and its performance must be predictable
bookings-api Burstable It has several replicas and is stateless: if one dies, the Service spreads traffic to the others and the customer retries. It needs CPU burst on bank holidays, and requests == limits would waste capacity 95% of the time
web-store Burstable It serves static assets with minimal, highly variable consumption. Three replicas, stateless, starts in seconds
redis-cache Burstable It has state but it is expendable: if it dies, the cache is lost and bookings-api goes back to querying bookings-postgres. Slower, but correct
notifications-worker Burstable Asynchronous: if it dies mid-batch, the pending notifications are still in the database and the next run picks them up
occupancy-reports Burstable with low resources A nightly task (module 6). If the node is under pressure, let it die: it is retried tomorrow
Ad-hoc analysis BestEffort Exploratory queries by an analyst. It must always be the first thing to fall

The concrete manifests. bookings-postgres as Guaranteed:

        - name: postgres
          image: postgres:16.4
          resources:
            # Guaranteed: requests == limits for CPU and memory.
            # A deliberate decision: this database holds customers' personal
            # data and it is the last pod that should die on the node.
            requests:
              cpu: "1"
              memory: 2Gi
            limits:
              cpu: "1"
              memory: 2Gi
kubectl get pod -l app=bookings-postgres -n rutas-norte-pro -o jsonpath='{.items[0].status.qosClass}'; echo
Guaranteed

bookings-api as Burstable:

        - name: api
          image: registry.rutasnorte.example/bookings-api:2.5.0
          resources:
            # Deliberately Burstable: 4 stateless replicas behind a Service.
            # The CPU limit triples the request to absorb the bank-holiday peaks.
            requests:
              cpu: 300m
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 768Mi

The ad-hoc analysis as BestEffort:

apiVersion: v1
kind: Pod
metadata:
  name: july-occupancy-analysis
  namespace: rutas-norte-dev
  labels:
    app: ad-hoc-analysis
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
  annotations:
    rutasnorte.example/reason: "exploratory occupancy analysis, RN-612"
    rutasnorte.example/owner: "[email protected]"
spec:
  restartPolicy: Never
  containers:
    - name: analysis
      image: postgres:16.4
      command: ["sh", "-c", "psql -h bookings-postgres -c 'SELECT ...' > /tmp/output.csv"]
      resources: {}          # empty ON PURPOSE: BestEffort, the first to fall

Careful: with a LimitRange active in rutas-norte-dev, that resources: {} will not produce BestEffort, because the LimitRanger will inject the default values. To get a genuinely BestEffort pod you need a namespace with no LimitRange, and that is why the experiment in section 12 uses a separate one.

One case deserves comment: redis-cache is Burstable even though it has state. Should it not be protected like bookings-postgres? No, and the difference is the value of the data. redis-cache holds cached seat availability: if it is lost, it is recomputed by querying the database. The impact is a spike in latency and load on bookings-postgres, not a loss of information. What determines the class is not whether the component has state, but what is lost if it dies.

  1. The LimitRange for the three environments

We close the configuration part with the three objects, consistent with the quotas from the previous lesson.

rutas-norte-dev

apiVersion: v1
kind: LimitRange
metadata:
  name: limits-dev
  namespace: rutas-norte-dev
  labels:
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  limits:
    - type: Container
      # Modest default values: they are for uncalibrated pods
      default:
        cpu: 300m
        memory: 256Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      min:
        cpu: 10m
        memory: 32Mi
      # max well below the quota (2 CPU / 4Gi): a single pod cannot exhaust it
      max:
        cpu: "1"
        memory: 1Gi
      maxLimitRequestRatio:
        cpu: "10"          # generous: in dev you want to iterate without fighting the limit
        memory: "4"

rutas-norte-pre

apiVersion: v1
kind: LimitRange
metadata:
  name: limits-pre
  namespace: rutas-norte-pre
  labels:
    app.kubernetes.io/part-of: rutas-norte
    environment: pre
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 200m
        memory: 256Mi
      min:
        cpu: 50m
        memory: 64Mi
      max:
        cpu: "2"
        memory: 4Gi
      maxLimitRequestRatio:
        cpu: "4"           # stricter: pre must resemble pro
        memory: "2"
    - type: Pod
      max:
        cpu: "4"
        memory: 6Gi

rutas-norte-pro

apiVersion: v1
kind: LimitRange
metadata:
  name: limits-pro
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    environment: pro
spec:
  limits:
    - type: Container
      # In pro the defaults are a SAFETY NET, not a convenience:
      # every production component must declare its calibrated resources.
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 200m
        memory: 256Mi
      min:
        cpu: 50m
        memory: 64Mi
      max:
        cpu: "4"
        memory: 8Gi
      maxLimitRequestRatio:
        cpu: "4"
        memory: "2"        # stops reserving little and consuming a lot: avoids evictions
    - type: Pod
      max:
        cpu: "8"
        memory: 16Gi
    - type: PersistentVolumeClaim
      min:
        storage: 1Gi
      max:
        storage: 200Gi     # nobody asks for 2 TiB through an extra zero

A comparison of the decisions:

Parameter dev pre pro Reasoning
defaultRequest.memory 128Mi 256Mi 256Mi In dev there are many small test pods
max.memory (container) 1Gi 4Gi 8Gi In pro bookings-postgres needs room to grow
maxLimitRequestRatio.memory 4 2 2 In pro aggressive overcommitment causes evictions
Pod limit No Yes Yes It kicks in when the module 6 sidecars appear
PVC limit No No Yes Cost control for production storage
kubectl apply -f k8s/environments/dev/limitrange.yaml \
              -f k8s/environments/pre/limitrange.yaml \
              -f k8s/environments/pro/limitrange.yaml
kubectl get limitrange -A
limitrange/limits-dev created
limitrange/limits-pre created
limitrange/limits-pro created

NAMESPACE         NAME         CREATED AT
rutas-norte-dev   limits-dev   2026-08-05T21:14:07Z
rutas-norte-pre   limits-pre   2026-08-05T21:14:07Z
rutas-norte-pro   limits-pro   2026-08-05T21:14:08Z

  1. Experiment: trigger an eviction and see who falls first

Time to check the theory. We are going to create a namespace with no LimitRange (so we can have genuinely BestEffort pods), deploy three pods of the three classes, and squeeze the node's memory until the kubelet starts evicting.

Warning: do this in your practice minikube, never in a shared cluster. We are going to deliberately cause memory pressure on a node.

Step 1: prepare the ground.

kubectl create namespace qos-lab
kubectl get node rutas-norte -o jsonpath='{.status.allocatable.memory}'; echo
namespace/qos-lab created
7937084Ki

About 7.7 GiB allocatable.

Step 2: the three pods.

# qos-lab.yaml
apiVersion: v1
kind: Pod
metadata:
  name: victim-besteffort
  namespace: qos-lab
  labels: {role: victim}
spec:
  containers:
    - name: load
      image: polinux/stress:1.0.4
      command: ["stress"]
      args: ["--vm", "1", "--vm-bytes", "700M", "--vm-hang", "0"]
      # resources omitted on purpose -> BestEffort
---
apiVersion: v1
kind: Pod
metadata:
  name: victim-burstable
  namespace: qos-lab
  labels: {role: victim}
spec:
  containers:
    - name: load
      image: polinux/stress:1.0.4
      command: ["stress"]
      args: ["--vm", "1", "--vm-bytes", "700M", "--vm-hang", "0"]
      resources:
        requests:
          cpu: 100m
          memory: 200Mi        # asks for LITTLE and consumes a LOT: the ideal candidate
        limits:
          cpu: 500m
          memory: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: protected-guaranteed
  namespace: qos-lab
  labels: {role: protected}
spec:
  containers:
    - name: load
      image: polinux/stress:1.0.4
      command: ["stress"]
      args: ["--vm", "1", "--vm-bytes", "700M", "--vm-hang", "0"]
      resources:
        requests:
          cpu: 200m
          memory: 1Gi
        limits:
          cpu: 200m
          memory: 1Gi          # requests == limits -> Guaranteed
kubectl apply -f qos-lab.yaml
sleep 20
kubectl get pods -n qos-lab -o custom-columns='NAME:.metadata.name,QOS:.status.qosClass,STATE:.status.phase'
pod/victim-besteffort created
pod/victim-burstable created
pod/protected-guaranteed created

NAME                   QOS          STATE
protected-guaranteed   Guaranteed   Running
victim-besteffort      BestEffort   Running
victim-burstable       Burstable    Running

The three classes confirmed. Let us also check their oom_score_adj:

for P in victim-besteffort victim-burstable protected-guaranteed; do
  printf '%-22s ' "$P"
  kubectl exec -n qos-lab "$P" -- cat /proc/1/oom_score_adj
done
victim-besteffort      1000
victim-burstable       975
protected-guaranteed   -997

The 975 of victim-burstable comes from the formula in section 8: 1000 - (1000 × 200Mi / 7751Mi) = 1000 - 25 = 975. It asks for very little, so it is almost as exposed as the BestEffort one.

Step 3: squeeze until the pressure appears.

kubectl run pressure --image=polinux/stress:1.0.4 -n qos-lab --restart=Never -- \
  stress --vm 1 --vm-bytes 4500M --vm-hang 0

Step 4: observe.

kubectl get pods -n qos-lab -w
NAME                   READY   STATUS    RESTARTS   AGE
pressure               1/1     Running   0          12s
protected-guaranteed   1/1     Running   0          3m
victim-besteffort      1/1     Running   0          3m
victim-burstable       1/1     Running   0          3m
victim-besteffort      0/1     Evicted   0          3m21s     <-- FIRST
victim-burstable       0/1     Evicted   0          3m48s     <-- SECOND
protected-guaranteed   1/1     Running   0          4m10s     <-- SURVIVES

The order is exactly as predicted: first the BestEffort, then the Burstable that had gone furthest over its request, and the Guaranteed is still standing.

Step 5: read the eviction messages.

kubectl describe pod victim-besteffort -n qos-lab | head -12
Status:   Failed
Reason:   Evicted
Message:  The node was low on resource: memory. Threshold quantity: 100Mi, available: 78Mi.
          Container load was using 712Mi, request is 0, which exceeds its request of 0.
kubectl describe pod victim-burstable -n qos-lab | head -12
Status:   Failed
Reason:   Evicted
Message:  The node was low on resource: memory. Threshold quantity: 100Mi, available: 92Mi.
          Container load was using 705Mi, request is 200Mi, which exceeds its request of 200Mi.

Compare the last two sentences: the BestEffort was exceeding a reservation of 0 (anything it uses exceeds it), and the Burstable was exceeding its 200Mi reservation by 505Mi. Both were candidates; the BestEffort went first by class.

Step 6: the node condition and the events.

kubectl get node rutas-norte -o jsonpath='{.status.conditions[?(@.type=="MemoryPressure")].status}'; echo
kubectl get events -n qos-lab --sort-by=.lastTimestamp | tail -5
True

LAST SEEN   TYPE      REASON     OBJECT                    MESSAGE
2m          Warning   Evicted    pod/victim-besteffort     The node was low on resource: memory
2m          Normal    Killing    pod/victim-besteffort     Stopping container load
94s         Warning   Evicted    pod/victim-burstable      The node was low on resource: memory
94s         Normal    Killing    pod/victim-burstable      Stopping container load

Step 7: clean up.

kubectl delete namespace qos-lab
kubectl get node rutas-norte -o jsonpath='{.status.conditions[?(@.type=="MemoryPressure")].status}'; echo
namespace "qos-lab" deleted
False

What this experiment demonstrates, translated into Rutas Norte terms: if an August bank holiday causes memory pressure on the node where bookings-postgres lives, the database will not be the one to fall. The ad-hoc analysis pods will fall first, then the bookings-api replicas that have gone over their reservation — and the Service will keep spreading traffic among the ones left — and the platform will keep selling tickets. That chain of survival is no accident: you designed it when you assigned the classes.

Common Mistakes and Tips

Mistake Symptom Fix
Quota with no LimitRange Every kubectl run fails Always add a LimitRange alongside the quota
Expecting defaultRequest when there are only limits Far more is reserved than expected The request copies the limit, not the defaultRequest
LimitRange max above the quota Valid but undeployable manifests Keep max clearly below the namespace ceiling
Changing the LimitRange expecting a retroactive effect Old pods keep their values kubectl rollout restart
A sidecar with no resources in a Guaranteed pod The whole pod is Burstable The class belongs to the pod: every container must meet it
Believing Guaranteed immunises against OOMKilled The pod dies anyway Guaranteed protects from the node OOM, not from exceeding your own limit
BestEffort pods in production Unexpected evictions Only for genuinely disposable work
Burstable with a tiny request and a huge limit Constantly evicted Raise the request or lower the maxLimitRequestRatio
Accumulated Evicted pods Noise in kubectl get pods and in etcd kubectl delete pods -A --field-selector=status.phase=Failed
A loose pod evicted It vanishes and never comes back Use Deployments, not loose pods
Confusing quota and LimitRange The wrong object gets created Quota = namespace total; LimitRange = per container
Not checking qosClass after changing resources Guaranteed is lost without anyone noticing Check with -o jsonpath='{.status.qosClass}' in the pipeline

Tips:

  1. A quota and a LimitRange always go together. Treat them as a single decision when creating a namespace.
  2. Modest defaults, generous max. The default value is for the uncalibrated; the max is a safety net against extra zeros.
  3. Guaranteed only for the genuinely critical. It costs real capacity: you reserve the maximum all the time. In Rutas Norte, only bookings-postgres.
  4. Watch the Burstable pods with a high ratio. A pod with a request of 200Mi and a limit of 4Gi is a permanent eviction candidate. maxLimitRequestRatio prevents it from the namespace.
  5. Add the QoS class to your manifest reviews. It is one line in a pull request review and it stops you discovering during an incident that the database was Burstable.

Exercises

Exercise 1: The six LimitRange scenarios

With the limits-dev LimitRange from section 11 applied in rutas-norte-dev:

  1. Create six pods, one per row of the table in section 2 (nothing at all, CPU requests only, memory limits of 1Gi only, both complete, requests.memory: 16Mi, and requests: 128Mi with limits: 1Gi).
  2. For those that get created, show their effective resources and their QoS class.
  3. For those that fail, copy the exact error message.
  4. Explain in particular why the third one ends up with requests.memory: 1Gi instead of 128Mi, and what consequence that has on the namespace quota.
  5. Work out how much quota the six would consume between them if they had all been created.

Exercise 2: Audit and correct the QoS classes

  1. List every pod across the three Rutas Norte namespaces with its QoS class in a single table.
  2. Check whether bookings-postgres is Guaranteed in all three environments. If it is not in one of them, identify why.
  3. Modify the bookings-postgres manifest so that it is Guaranteed in rutas-norte-pro, verify the change and check its oom_score_adj.
  4. Work out by hand the expected oom_score_adj of bookings-api with requests.memory: 512Mi on a node with 7751Mi allocatable, and compare it with the real value.
  5. Add a sidecar with no resources to bookings-postgres and observe what happens to the pod's class. Explain the result and revert the change.

Exercise 3: Reproduce the eviction and reason about the design

  1. Reproduce the experiment from section 12 in your minikube.
  2. Note the exact order of the evictions and the describe messages.
  3. Modify victim-burstable so that its requests.memory is 1Gi (instead of 200Mi) while keeping the same consumption, and repeat the experiment. Does the order change? Explain why using the oom_score_adj formula.
  4. Explain what would have happened if the Rutas Norte bookings-postgres had been on that node as Burstable with requests.memory: 256Mi and a real consumption of 1.8 GiB.
  5. Propose two further measures, beyond the QoS class, to protect bookings-postgres from this scenario, and say in which lesson of the course each one is covered.

Solutions

Solution 1

# 1. Nothing at all
kubectl run p1 --image=busybox:1.36 -n rutas-norte-dev -- sleep 3600

# 2. CPU requests only
kubectl run p2 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p2","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"requests":{"cpu":"200m"}}}]}}'

# 3. Memory limits only
kubectl run p3 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p3","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"limits":{"memory":"1Gi"}}}]}}'

# 4. Both complete and equal
kubectl run p4 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p4","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"requests":{"cpu":"200m","memory":"256Mi"},
  "limits":{"cpu":"200m","memory":"256Mi"}}}]}}'

# 5. Below the minimum
kubectl run p5 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p5","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"requests":{"memory":"16Mi"}}}]}}'

# 6. Excessive ratio
kubectl run p6 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p6","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"requests":{"memory":"128Mi"},
  "limits":{"memory":"1Gi"}}}]}}'
pod/p1 created
pod/p2 created
pod/p3 created
pod/p4 created
Error from server (Forbidden): pods "p5" is forbidden:
  minimum memory usage per Container is 32Mi, but request is 16Mi
Error from server (Forbidden): pods "p6" is forbidden:
  memory max limit to request ratio per Container is 4, but provided ratio is 8.000000
kubectl get pods -n rutas-norte-dev -o custom-columns='N:.metadata.name,QOS:.status.qosClass,\
RC:.spec.containers[0].resources.requests.cpu,RM:.spec.containers[0].resources.requests.memory,\
LC:.spec.containers[0].resources.limits.cpu,LM:.spec.containers[0].resources.limits.memory' \
  | grep -E "^(N|p[0-9])"
N    QOS         RC     RM      LC     LM
p1   Burstable   100m   128Mi   300m   256Mi
p2   Burstable   200m   128Mi   300m   256Mi
p3   Burstable   100m   1Gi     300m   1Gi
p4   Guaranteed  200m   256Mi   200m   256Mi

The p3 case is the interesting one: we declared only limits.memory: 1Gi and the result is requests.memory: 1Gi, not the 128Mi of the defaultRequest. The reason is the resolution order: Kubernetes first applies its general rule of "if there is a limit and no request, the request equals the limit", and the LimitRange's defaultRequest only fills in what is still empty after that.

The consequence for the quota is serious: this pod, which probably consumes 4 MiB of real memory, has committed 1 GiB of the namespace's 4 GiB budget. With four pods like that, rutas-norte-dev runs out of memory quota without anybody using anything. It is a very expensive and very invisible mistake.

Total consumption if all six had been created (only the four valid ones count):

Pod requests.cpu requests.memory
p1 100m 128Mi
p2 200m 128Mi
p3 100m 1024Mi
p4 200m 256Mi
Total 600m of 2000m (30%) 1536Mi of 4096Mi (37.5%)

p3 is only 25% of the pods and consumes 67% of the committed memory.

Solution 2

for NS in rutas-norte-dev rutas-norte-pre rutas-norte-pro; do
  kubectl get pods -n $NS -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,QOS:.status.qosClass' --no-headers
done
rutas-norte-dev   bookings-api-6d8f7c9b-4kx2p            Burstable
rutas-norte-dev   bookings-postgres-5d8f6b9c4-t8wmz      Burstable
rutas-norte-dev   redis-cache-6c9d8f7b5-p2njq            Burstable
rutas-norte-dev   web-store-5b7c9f4d8-h7pnk              Burstable
rutas-norte-pre   bookings-postgres-5d8f6b9c4-v4rbd      Burstable
rutas-norte-pro   bookings-api-7f4b8c9d6-2xkqp           Burstable
rutas-norte-pro   bookings-postgres-5d8f6b9c4-k2mnp      Burstable
rutas-norte-pro   redis-cache-6c9d8f7b5-vn4qx            Burstable

bookings-postgres is not Guaranteed in any environment. The reason:

kubectl get pod -l app=bookings-postgres -n rutas-norte-pro \
  -o jsonpath='{.items[0].spec.containers[0].resources}' | jq .
{
  "limits": { "cpu": "2", "memory": "2Gi" },
  "requests": { "cpu": "500m", "memory": "2Gi" }
}

The memory matches but the CPU does not (500m against 2). One unequal resource is enough to lose Guaranteed. It is a classic mistake: the memory gets equalised with the OOM in mind and the CPU is forgotten.

kubectl patch deployment bookings-postgres -n rutas-norte-pro --type='json' -p='[
  {"op":"replace","path":"/spec/template/spec/containers/0/resources/requests/cpu","value":"1"},
  {"op":"replace","path":"/spec/template/spec/containers/0/resources/limits/cpu","value":"1"}
]'
kubectl rollout status deploy/bookings-postgres -n rutas-norte-pro
kubectl get pod -l app=bookings-postgres -n rutas-norte-pro -o jsonpath='{.items[0].status.qosClass}'; echo
kubectl exec -n rutas-norte-pro deploy/bookings-postgres -- cat /proc/1/oom_score_adj
deployment.apps/bookings-postgres patched
deployment "bookings-postgres" successfully rolled out
Guaranteed
-997

The bookings-api calculation:

oom_score_adj = 1000 - (1000 x 512 / 7751) = 1000 - 66 = 934
kubectl exec -n rutas-norte-pro deploy/bookings-api -- cat /proc/1/oom_score_adj
934

It matches. The 1931-point difference from bookings-postgres is enormous: in practice, the kernel will sacrifice every API replica before touching the database.

The sidecar:

        - name: metrics-exporter
          image: prometheuscommunity/postgres-exporter:v0.15.0
          # no resources
kubectl get pod -l app=bookings-postgres -n rutas-norte-pro -o jsonpath='{.items[0].status.qosClass}'; echo
Burstable

The pod has lost Guaranteed because of a sidecar with no resources. In rutas-norte-pro the LimitRange injects requests: 200m/256Mi and limits: 500m/512Mi, which differ from each other, and that is enough. The fix is to give the sidecar requests == limits too:

        - name: metrics-exporter
          image: prometheuscommunity/postgres-exporter:v0.15.0
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 50m
              memory: 64Mi

With that the pod regains Guaranteed. Lesson: in a pod that must be Guaranteed, every one of its containers must be too, sidecars and init containers included.

Solution 3

The observed order is BestEffortBurstable → the Guaranteed survives, with the messages from section 12.

With requests.memory: 1Gi in victim-burstable and the same 700Mi consumption:

oom_score_adj = 1000 - (1000 x 1024 / 7751) = 1000 - 132 = 868
kubectl exec -n qos-lab victim-burstable -- cat /proc/1/oom_score_adj
kubectl get pods -n qos-lab -w
868

NAME                   READY   STATUS    RESTARTS   AGE
victim-besteffort      0/1     Evicted   0          2m14s
victim-burstable       1/1     Running   0          3m40s     <-- now it SURVIVES
protected-guaranteed   1/1     Running   0          3m40s

The Burstable no longer falls. Two reasons acting at once:

  1. Its oom_score_adj dropped from 975 to 868, so the kernel prioritises it less as a victim.
  2. And above all, it no longer exceeds its request: it consumes 700Mi and it reserved 1Gi. The kubelet's first selection criterion (section 9) is precisely "pods exceeding their requests", and this one is no longer in that group.

The operational moral is emphatic: declaring a realistic memory request is the cheapest protection there is against eviction. It does not change what you consume; it changes whether you are a candidate or not.

  1. If bookings-postgres had been Burstable with requests.memory: 256Mi and a real consumption of 1.8 GiB:
oom_score_adj = 1000 - (1000 x 256 / 7751) = 967

A 967, almost as exposed as a BestEffort, and exceeding its request by more than 1.5 GiB, which puts it in the first group of candidates. It would have been among the first to fall. For Rutas Norte that means: the database goes down at the peak sales moment of a bank holiday, bookings-api starts returning connection errors, web-store shows errors to customers, and in the worst case a half-finished booking transaction leaves seats blocked with no ticket issued. A PostgreSQL Recreate also implies WAL recovery at start-up, with minutes of unavailability.

All of that is avoided by one line: requests == limits.

  1. Two further measures:
Measure What it brings Where it is covered
PodDisruptionBudget Stops a voluntary operation (draining a node for maintenance) leaving bookings-postgres with no available replica 09-05
Taints, tolerations and node affinity Reserving a node for the database, with no noisy neighbours that could generate memory pressure 06-05

And a third, the most important in the medium term: turning bookings-postgres into a StatefulSet with a persistent volume and verified backups, so that the survival of the data does not depend on the survival of the pod. That is the work of modules 5 and 6.

Conclusion

You have closed the two loose ends left by the previous lesson. The LimitRange turns a strict quota into something workable: it injects default and defaultRequest into containers that declare nothing, it sets a floor and a ceiling with min and max, and it limits individual overcommitment with maxLimitRequestRatio. You know that it acts as a mutating admission controller before the ResourceQuota validates, that therefore the injected values consume quota, that it is not retroactive, and that its three types — Container, Pod and PersistentVolumeClaim — cover everything from the single container to the cost of a disk. And you know the rule that costs the most money when ignored: if you declare only limits, the request copies the limit, not the defaultRequest, and with that you reserve far more than you think.

You have also mastered the quality of service classes and the exact rule that determines them: Guaranteed demands requests == limits for CPU and memory on every container in the pod, BestEffort demands that none of them declares anything, and Burstable is everything else. You know how to check it with -o jsonpath='{.status.qosClass}' and, above all, you know what consequences it has: the oom_score_adj the kubelet writes into each process (-997 for Guaranteed, up to 1000 for BestEffort, and a formula proportional to the memory request for Burstable) and the kubelet's eviction order, which looks first at who exceeds their requests and then at the class. You have distinguished the cgroup OOM — you die from your own limit, whatever the class — from the node OOM, where the class decides everything.

Rutas Norte now has a reasoned design: bookings-postgres is Guaranteed because it holds the bookings and the customers' personal data and its death stops sales; bookings-api, web-store, redis-cache and notifications-worker are Burstable because they have replicas, are recoverable and need to burst on bank holidays; and exploratory analysis is BestEffort because it must be the first thing to fall. All three environments have a LimitRange consistent with their quota. And you have verified it by triggering a real eviction in minikube, watching the BestEffort fall first, then the Burstable that had gone over its reservation, and the Guaranteed survive; and discovering along the way that raising a request to a realistic value is the cheapest protection against eviction there is.

One last piece of the module remains, and it is of a different nature. We have given our components their configuration, their credentials and their resources, but we have not given them an identity. Every Rutas Norte pod is right now using the default ServiceAccount of its namespace, with a mounted token none of them needs, and none of them has any way of telling the Kubernetes API who it is. The next lesson, ServiceAccounts and API Access from Pods, solves it: you will see the difference between users and ServiceAccounts, why using the default one is a bad idea, how tokens have changed (from eternal secrets to short-lived projected tokens the kubelet rotates), what exactly lives in /var/run/secrets/kubernetes.io/serviceaccount/, why automountServiceAccountToken: false should be your default choice, and you will talk to the API from inside a pod with curl to see both an authorised response and a well-deserved 403 Forbidden.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved