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 classes — Guaranteed, 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
- What a LimitRange solves
- The fields:
default,defaultRequest,min,max,maxLimitRequestRatio - Demonstration: a pod with no resources comes out with resources
- The types:
Container,PodandPersistentVolumeClaim - The exact interaction between LimitRange and ResourceQuota
- The three QoS classes and the rule that determines them
- Checking a pod's class
- Consequence 1:
oom_score_adjand the kernel's OOM killer - Consequence 2: the kubelet's eviction order
- The QoS class of each Rutas Norte component
- The LimitRange for the three environments
- Experiment: trigger an eviction and see who falls first
- What a LimitRange solves
The LimitRange is a namespaced object that does two distinct things to every container created in it:
- It injects default values into containers that do not declare
requestsorlimits. - 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:
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: debuggerProblem 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.
- The fields:
default, defaultRequest, min, max, maxLimitRequestRatio
default, defaultRequest, min, max, maxLimitRequestRatioapiVersion: 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:
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 defaultRequestThe 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 |
- 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-devlimitrange/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 4Now 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-devIt works. And the interesting part is seeing what resources it actually has:
{
"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:
{
"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 3GiAnd 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 8MiAll three behaviours verified: it injects, it sets a ceiling and it sets a floor.
- The types:
Container, Pod and PersistentVolumeClaim
Container, Pod and PersistentVolumeClaimA 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: 200GiDifferences 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.
- 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:
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=4GiThat 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:
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.
- 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
limitsare declared. - CPU and memory
requestsare declared (or omitted, in which case they equal thelimits). requestsis exactly equal tolimitsfor 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:
# --- 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: 512MiThree 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.
- Checking a pod's class
kubectl get pod bookings-postgres-5d8f6b9c4-k2mnp -n rutas-norte-pro \
-o jsonpath='{.status.qosClass}'; echoFor 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 512MiA quick count by class:
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)"'describe shows it too:
- Consequence 1:
oom_score_adj and the kernel's OOM killer
oom_score_adj and the kernel's OOM killerHere 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:
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:
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_adjExactly as predicted. And the business conclusion is direct:
When the node runs short of memory, the kernel will kill
bookings-apilong beforebookings-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.
- 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:
- First, whether the pod exceeds its
requests. Those consuming more than they reserved are candidates before those staying within. - Second, the QoS class:
BestEffortfirst, thenBurstable, andGuaranteedlast. - Third, the PriorityClass, if one is defined.
- 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, theBurstablethat has gone furthest over itsrequestfalls. AGuaranteedpod 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 -14NAME 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:
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.
- 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: 2Gikubectl get pod -l app=bookings-postgres -n rutas-norte-pro -o jsonpath='{.items[0].status.qosClass}'; echobookings-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: 768MiThe 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 fallCareful: 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.
- 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: 6Girutas-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 zeroA 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 -Alimitrange/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
- 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}'; echoAbout 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 -> Guaranteedkubectl 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 RunningThe 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
doneThe 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 0Step 4: observe.
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 <-- SURVIVESThe 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.
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.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 -5True
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 loadStep 7: clean up.
kubectl delete namespace qos-lab
kubectl get node rutas-norte -o jsonpath='{.status.conditions[?(@.type=="MemoryPressure")].status}'; echoWhat 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:
- A quota and a LimitRange always go together. Treat them as a single decision when creating a namespace.
- Modest defaults, generous
max. The default value is for the uncalibrated; themaxis a safety net against extra zeros. Guaranteedonly for the genuinely critical. It costs real capacity: you reserve the maximum all the time. In Rutas Norte, onlybookings-postgres.- Watch the
Burstablepods with a high ratio. A pod with arequestof 200Mi and alimitof 4Gi is a permanent eviction candidate.maxLimitRequestRatioprevents it from the namespace. - 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:
- Create six pods, one per row of the table in section 2 (nothing at all, CPU
requestsonly, memorylimitsof 1Gi only, both complete,requests.memory: 16Mi, andrequests: 128Miwithlimits: 1Gi). - For those that get created, show their effective
resourcesand their QoS class. - For those that fail, copy the exact error message.
- Explain in particular why the third one ends up with
requests.memory: 1Giinstead of 128Mi, and what consequence that has on the namespace quota. - 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
- List every pod across the three Rutas Norte namespaces with its QoS class in a single table.
- Check whether
bookings-postgresisGuaranteedin all three environments. If it is not in one of them, identify why. - Modify the
bookings-postgresmanifest so that it isGuaranteedinrutas-norte-pro, verify the change and check itsoom_score_adj. - Work out by hand the expected
oom_score_adjofbookings-apiwithrequests.memory: 512Mion a node with 7751Mi allocatable, and compare it with the real value. - Add a sidecar with no resources to
bookings-postgresand 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
- Reproduce the experiment from section 12 in your minikube.
- Note the exact order of the evictions and the
describemessages. - Modify
victim-burstableso that itsrequests.memoryis 1Gi (instead of 200Mi) while keeping the same consumption, and repeat the experiment. Does the order change? Explain why using theoom_score_adjformula. - Explain what would have happened if the Rutas Norte
bookings-postgreshad been on that node asBurstablewithrequests.memory: 256Miand a real consumption of 1.8 GiB. - Propose two further measures, beyond the QoS class, to protect
bookings-postgresfrom 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.000000kubectl 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 256MiThe 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
donerutas-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 Burstablebookings-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 .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_adjdeployment.apps/bookings-postgres patched
deployment "bookings-postgres" successfully rolled out
Guaranteed
-997The bookings-api calculation:
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:
kubectl get pod -l app=bookings-postgres -n rutas-norte-pro -o jsonpath='{.items[0].status.qosClass}'; echoThe 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: 64MiWith 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 BestEffort → Burstable → the Guaranteed survives, with the messages from section 12.
With requests.memory: 1Gi in victim-burstable and the same 700Mi consumption:
kubectl exec -n qos-lab victim-burstable -- cat /proc/1/oom_score_adj
kubectl get pods -n qos-lab -w868
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 3m40sThe Burstable no longer falls. Two reasons acting at once:
- Its
oom_score_adjdropped from 975 to 868, so the kernel prioritises it less as a victim. - 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 theirrequests", 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.
- If
bookings-postgreshad beenBurstablewithrequests.memory: 256Miand a real consumption of 1.8 GiB:
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.
- 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
