The whole module has been about growing so far: more pods, of the right size, on more nodes, and before the traffic arrives. But there is a question we have not asked: what happens when something breaks, or when somebody has to touch the cluster?
Next Tuesday the Kubernetes version on the Rutas Norte nodes is due for an upgrade. Somebody will run kubectl drain on the first node to empty it. If three of bookings-api's four replicas are on that node — because the scheduler put them there and nobody told it not to — that drain will take 75% of the API's capacity away in an instant, and the fourth replica will receive four times its usual load until it falls over too.
And there is a worse variant: the node is not drained by anyone, it simply switches itself off because of a hardware failure in availability zone B, at three in the morning, with no warning.
Both scenarios do the same damage, but Kubernetes can only protect you from one of them. Understanding why, and what to do about the other, is what this lesson is about.
We are going to look at the distinction between voluntary and involuntary disruptions, the PodDisruptionBudget and the eviction API that honours it, the impossible PDBs that block maintenance forever, topology spread with topologySpreadConstraints versus the podAntiAffinity of 06-05, what high availability means in the system's other layers, the complete node-maintenance procedure, and a chaos test to check that it all really works.
Contents
- Voluntary and involuntary disruptions
- The PodDisruptionBudget: what it is and what it protects
minAvailableversusmaxUnavailable- The eviction API and
kubectl drain - Demonstration: a drain that keeps waiting
- The impossible PDB that blocks maintenance
unhealthyPodEvictionPolicy- The Rutas Norte PDBs, component by component
topologySpreadConstraintsin depthtopologySpreadConstraintsversuspodAntiAffinity- Spreading Rutas Norte across zones and nodes
- High availability in the other layers
- The node-maintenance procedure
- Interaction with the Cluster Autoscaler and with deployments
- A simple chaos test
- Common Mistakes and Tips
- Exercises
- Conclusion
- Voluntary and involuntary disruptions
Kubernetes formally distinguishes two classes of disruption, and the distinction is not academic: it determines which protection mechanisms exist.
Involuntary disruptions
They happen without anybody asking for them. They are failures:
| Cause | Example at Rutas Norte |
|---|---|
| Node hardware failure | The power supply of the server hosting three replicas |
| Availability-zone outage | A power cut in the zone B data centre |
| The kernel kills a process for lack of memory | An OOMKilled on bookings-api (module 3) |
| kubelet eviction under resource pressure | The node runs out of disk and evicts BestEffort pods |
| Network partition | The node loses connectivity with the control plane |
| Accidental deletion of a virtual machine | Somebody makes a mistake in the provider console |
Kubernetes cannot prevent any of these. When hardware fails, it fails. All you can do is limit the damage: if the replicas are spread out, losing a node takes a fraction; if they are piled up, it takes everything.
Protection against involuntary disruptions is architectural: enough replicas, spread across independent failure domains. That is section 9 onwards.
Voluntary disruptions
They are caused deliberately by somebody, through the API:
| Cause | Example at Rutas Norte |
|---|---|
| Draining a node for maintenance | Upgrading the kernel or the Kubernetes version |
| Cluster scale-down | The Cluster Autoscaler removes an underutilised node (09-03) |
| Redeploying an application | A rolling update of bookings-api (02-04) |
| The VPA adjusting resources | The updater evicts a pod to recreate it (09-02) |
| Deleting a pod by hand | kubectl delete pod |
| Rescheduling by a descheduler | A tool that rebalances pods across nodes |
Here Kubernetes can intervene, because these actions go through the API server and can be evaluated before they run. The mechanism is called a PodDisruptionBudget, and it is the object that says: "you may do maintenance, but not at the cost of leaving me with no service".
flowchart TD
subgraph INVOL["INVOLUNTARY disruptions"]
I1[Hardware failure]
I2[Zone outage]
I3[OOMKilled]
I4[Network partition]
end
subgraph VOL["VOLUNTARY disruptions"]
V1[kubectl drain]
V2[Cluster Autoscaler scale-down]
V3[Rolling update]
V4[VPA eviction]
end
INVOL -->|Kubernetes CANNOT prevent them| ARQ["ARCHITECTURAL protection:<br/>replicas spread across<br/>failure domains<br/>topologySpreadConstraints"]
VOL -->|They go through the eviction API| PDB["POLICY protection:<br/>PodDisruptionBudget"]
style INVOL fill:#fdd,stroke:#c00
style VOL fill:#ffd,stroke:#c90
style ARQ fill:#cde,stroke:#369
style PDB fill:#cfc,stroke:#393
The two protections are complementary and you need both. A perfect PDB does not save you when the zone holding all your replicas goes down. And a perfect topology spread does not stop a careless kubectl drain from taking out three replicas at once.
An important nuance about the Deployment's maxUnavailable
It is worth clearing up a frequent confusion. The Deployment has its own disruption control during updates:
That governs only the rolling update (02-04). It does not protect you from a kubectl drain, nor from Cluster Autoscaler scale-down, nor from a VPA eviction. They are different mechanisms with different scopes, and you need both.
strategy.rollingUpdate.maxUnavailable |
PodDisruptionBudget | |
|---|---|---|
| Scope | Deployment updates only | Any eviction through the API |
| Enforced by | The Deployment controller | The API server, on the eviction request |
| Protects from | The update itself taking the service down | drain, CA, VPA, descheduler |
| Defined in | The Deployment | A separate PodDisruptionBudget object |
- The PodDisruptionBudget: what it is and what it protects
A PodDisruptionBudget (PDB) is an object that declares: "of this set of pods, there must always be at least N available" (or "at most N unavailable").
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
maxUnavailable: 25%
selector:
matchLabels:
app: bookings-apiThree fields and nothing else:
selector: which pods it covers. Just like the selector of a Service or a Deployment (02-07).minAvailableormaxUnavailable: the constraint. Mutually exclusive: one of the two, never both.- (Optional)
unhealthyPodEvictionPolicy: section 7.
What it does exactly
When somebody asks to evict a pod through the eviction API, the API server:
- Looks for the PDBs whose
selectormatches that pod. - For each one, computes how many pods are currently available (
Ready). - Checks whether evicting this pod would violate the constraint.
- If it would, it rejects the request with an HTTP 429 error (
TooManyRequests). - If not, it accepts it and the pod is deleted.
It is a check at the moment of the request, not a reservation. The client that receives the 429 normally retries, and so the eviction waits until it is safe.
What a PDB does NOT do
This list is as important as the previous one:
| A PDB does not | Explanation |
|---|---|
| Stop a node from going down | That is an involuntary disruption: nobody asks permission |
Stop kubectl delete pod |
Direct deletion does not go through the eviction API |
| Guarantee that there are N replicas | That is the Deployment/ReplicaSet's job |
| Create new pods | It is not a replica controller |
Protect from an OOMKilled |
Involuntary |
| Protect from the kubelet evicting under resource pressure | Involuntary |
The second point surprises a lot of people and deserves emphasis: kubectl delete pod ignores PDBs entirely. Direct deletion is a different operation from eviction. If you want to respect PDBs from the command line, use kubectl drain (which does use the eviction API) or the API itself:
# This IGNORES the PDB
kubectl delete pod bookings-api-7c9d4f8b6d-2mk8p -n rutas-norte-pro
# This RESPECTS the PDB
kubectl drain <node> --pod-selector=app=bookings-api
minAvailable versus maxUnavailable
minAvailable versus maxUnavailableThe two express the same thing from opposite sides, but they behave very differently when the replica count changes, which is exactly what happens when there is an HPA.
minAvailable
"There must always be at least N pods available."
With 4 replicas: 1 can be evicted (3 remain). With 10 replicas: 7 can be evicted.
maxUnavailable
"At most N pods may be unavailable."
With 4 replicas: 1 can be evicted. With 10 replicas: also just 1.
The critical difference with percentages
Here is the detail almost nobody knows and that produces surprising behaviours: the rounding is different in each case.
| Field | Percentage rounding | Reason |
|---|---|---|
minAvailable: 50% |
Up | More conservative: it guarantees more pods |
maxUnavailable: 50% |
Down | More conservative: it allows fewer evictions |
In both cases the rounding favours availability. Let's see it with numbers:
With 5 replicas:
| PDB | Calculation | Result | Evictions allowed |
|---|---|---|---|
minAvailable: 50% |
ceil(5 × 0.5) = 3 |
At least 3 available | 2 |
maxUnavailable: 50% |
floor(5 × 0.5) = 2 |
At most 2 unavailable | 2 |
Here they coincide. But with 7 replicas:
| PDB | Calculation | Result | Evictions allowed |
|---|---|---|---|
minAvailable: 50% |
ceil(7 × 0.5) = 4 |
At least 4 available | 3 |
maxUnavailable: 50% |
floor(7 × 0.5) = 3 |
At most 3 unavailable | 3 |
They coincide again. With 3 replicas:
| PDB | Calculation | Result | Evictions allowed |
|---|---|---|---|
minAvailable: 50% |
ceil(3 × 0.5) = 2 |
At least 2 available | 1 |
maxUnavailable: 50% |
floor(3 × 0.5) = 1 |
At most 1 unavailable | 1 |
For 50% the result is equivalent. The difference shows up with other percentages. With 10 replicas and 20%:
| PDB | Calculation | Result | Evictions allowed |
|---|---|---|---|
minAvailable: 20% |
ceil(10 × 0.2) = 2 |
At least 2 available | 8 |
maxUnavailable: 20% |
floor(10 × 0.2) = 2 |
At most 2 unavailable | 2 |
Radically different. minAvailable: 20% allows evicting 80% of the pods; maxUnavailable: 20% allows evicting 20%.
A mental rule: minAvailable talks about what stays; maxUnavailable talks about what goes.
Which to use: the HPA rule
And now the reason this matters so much in this module. With an HPA, the replica count changes on its own. An absolute-number PDB that was reasonable at 30 replicas becomes impossible at 4.
Recall the case from exercise 2 of 09-03:
During the bank holiday: 30 replicas. Somebody sets minAvailable: 25.
Evictions allowed: 5. Reasonable.
After the bank holiday: the HPA drops to 4 replicas. The PDB still says minAvailable: 25.
Available pods: 4. Minimum required: 25.
We are ALREADY below the minimum.
Evictions allowed: ZERO. Forever.
Any node hosting a bookings-api replica is PINNED.
The Cluster Autoscaler cannot remove it. Maintenance is blocked.That is why:
| Situation | Recommendation |
|---|---|
| A workload with an HPA or KEDA | maxUnavailable as a percentage |
| A fixed, known replica count | Either; minAvailable is more explicit |
| Stateful workloads with quorum (etcd, databases) | maxUnavailable: 1, always |
| A single replica | Neither works well (section 6) |
maxUnavailable: 25% is the sensible default for a workload with an HPA. It adapts on its own: with 4 replicas it allows evicting 1; with 30, it allows evicting 7. The proportion of service protected is constant.
A note on the denominator: the percentage is computed on the number of replicas desired by the controller (the Deployment's spec.replicas), not on the pods that are Ready at that moment. This matters when pods are starting: during an HPA scale-up from 4 to 12, the denominator is already 12 even though only 5 are ready.
- The eviction API and
kubectl drain
kubectl drainA PDB only works if whoever wants to remove a pod asks permission. That mechanism is the eviction API.
How it works
It is a subresource of the pod:
With this body:
{
"apiVersion": "policy/v1",
"kind": "Eviction",
"metadata": {
"name": "bookings-api-7c9d4f8b6d-2mk8p",
"namespace": "rutas-norte-pro"
}
}Possible responses:
| Code | Meaning |
|---|---|
200 OK |
Eviction accepted; the pod is deleted with its grace period |
429 TooManyRequests |
A PDB is blocking it. Retry later |
500 Internal Server Error |
Incoherent configuration (a malformed PDB) |
The 429 includes an explanatory message:
{
"kind": "Status",
"status": "Failure",
"message": "Cannot evict pod as it would violate the pod's disruption budget.",
"reason": "TooManyRequests",
"details": {
"causes": [
{
"reason": "DisruptionBudget",
"message": "The disruption budget bookings-api needs 3 healthy pods and has 3 currently"
}
]
}
}That message — "needs 3 healthy pods and has 3 currently" — is the one you will meet in practice and the one to know how to read.
Who uses the eviction API
| Client | Does it respect PDBs? |
|---|---|
kubectl drain |
Yes |
| Cluster Autoscaler (09-03) | Yes |
| VPA updater (09-02) | Yes |
| Descheduler | Yes |
| Karpenter (consolidation) | Yes |
kubectl delete pod |
No. Direct deletion |
| Deployment controller (rolling update) | No. It uses its own maxUnavailable |
| The kubelet evicting under resource pressure | No. It is involuntary |
| A node switching itself off | No. It is involuntary |
The first four rows are the ones that matter: every automatic mechanism we have added in this module respects PDBs. That is the guarantee that autoscaling will not run over availability.
The disruptionsAllowed field
The PDB publishes in its status how many evictions it allows right now:
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
bookings-api N/A 25% 1 12d
web-store N/A 1 1 12d
bookings-postgres 1 N/A 0 12d
notifications-worker N/A 50% 3 12dALLOWED DISRUPTIONS is the most useful number in the whole lesson. If it is 0, no pod in that set can be evicted right now, and any drain affecting them will sit there waiting.
A 0 can be correct (bookings-postgres with one replica and minAvailable: 1) or a symptom of a problem (an impossible PDB, or pods that are not Ready).
The detailed view:
Name: bookings-api
Namespace: rutas-norte-pro
Max unavailable: 25%
Selector: app=bookings-api
Status:
Allowed disruptions: 2
Current: 8
Desired: 6
Total: 8| Field | Meaning |
|---|---|
Current |
Pods Ready right now |
Desired |
The minimum the PDB requires (computed) |
Total |
Pods covered by the selector |
Allowed disruptions |
Current - Desired |
- Demonstration: a drain that keeps waiting
Nothing teaches better than seeing it. Let's set the scene on minikube.
Preparation
minikube start -p rutas-norte --nodes=3 --cpus=2 --memory=4096
kubectl create namespace rutas-norte-dev
# A Deployment with 3 replicas
kubectl -n rutas-norte-dev create deployment demo-pdb \
--image=registry.k8s.io/pause:3.9 --replicas=3
kubectl -n rutas-norte-dev set resources deployment/demo-pdb \
--requests=cpu=100m,memory=64MiA deliberately restrictive PDB:
# /tmp/pdb-demo.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: demo-pdb
namespace: rutas-norte-dev
spec:
# With 3 replicas, requiring 3 available means ZERO evictions allowed.
minAvailable: 3
selector:
matchLabels:
app: demo-pdbALLOWED DISRUPTIONS: 0. We already know what is going to happen.
The drain
NAME READY STATUS NODE
demo-pdb-6c9f8d7b5-2mkjp 1/1 Running rutas-norte
demo-pdb-6c9f8d7b5-7wqzx 1/1 Running rutas-norte-m02
demo-pdb-6c9f8d7b5-9nvtr 1/1 Running rutas-norte-m03node/rutas-norte-m02 cordoned
evicting pod rutas-norte-dev/demo-pdb-6c9f8d7b5-7wqzx
error when evicting pods/"demo-pdb-6c9f8d7b5-7wqzx" -n "rutas-norte-dev"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod rutas-norte-dev/demo-pdb-6c9f8d7b5-7wqzx
error when evicting pods/"demo-pdb-6c9f8d7b5-7wqzx" -n "rutas-norte-dev"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod rutas-norte-dev/demo-pdb-6c9f8d7b5-7wqzx
error when evicting pods/"demo-pdb-6c9f8d7b5-7wqzx" -n "rutas-norte-dev"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
...The drain gets stuck in an infinite loop, retrying every 5 seconds.
Important observations:
- The node is ALREADY cordoned (
cordoned). Even though the drain makes no progress, the node accepts no new pods. That is the first thingdraindoes. - The message is explicit:
Cannot evict pod as it would violate the pod's disruption budget. draindoes not give up. It retries indefinitely until it is allowed through or until you interrupt it.
This is exactly the desired behaviour: the PDB has stopped maintenance from breaking the service. But it is also exactly the behaviour that blocks maintenance forever if the PDB is wrong, and that is the lesson of section 6.
Unblocking it
Three ways:
# Option A: more replicas. With 4 replicas and minAvailable: 3, 1 eviction is allowed.
kubectl -n rutas-norte-dev scale deployment demo-pdb --replicas=4As soon as the fourth replica is Ready, the drain that was retrying moves on by itself. It is very satisfying to watch.
# Option B: fix the PDB
kubectl -n rutas-norte-dev patch pdb demo-pdb --type merge \
-p '{"spec":{"minAvailable":null,"maxUnavailable":"25%"}}'Careful: minAvailable and maxUnavailable are mutually exclusive, so you have to null one out when setting the other.
# Option C (LAST RESORT, dangerous): force the drain
kubectl drain rutas-norte-m02 --ignore-daemonsets --delete-emptydir-data \
--disable-eviction--disable-eviction uses direct deletion instead of the eviction API, bypassing PDBs. It is the emergency option, and you have to know that it can leave the service with no available replica at all. Use it only when you understand exactly what is going to go down and have decided that it is acceptable.
Cleanup
kubectl uncordon rutas-norte-m02
kubectl delete -f /tmp/pdb-demo.yaml
kubectl delete deployment demo-pdb -n rutas-norte-devNever forget the uncordon. A cordoned node that nobody uncordons is capacity that is paid for and unusable, and the symptom (Pending pods while there are apparently free nodes) is extremely confusing.
- The impossible PDB that blocks maintenance
Let's formalise the problem, because it is the most dangerous trap in this lesson.
The definition
An impossible PDB is one whose constraint cannot be satisfied by evicting a single pod. Its ALLOWED DISRUPTIONS is permanently 0.
The two cases:
Case 1: minAvailable equal to (or greater than) the replica count.
Evicting any pod would leave 2 available, below the minimum of 3. Zero evictions, always.
And the insidious variant: minAvailable: 25 over a Deployment with an HPA that has dropped to 4 replicas. The PDB was reasonable when it was written; the HPA made it impossible without anybody touching it.
Case 2: a single replica with any restrictive PDB.
With one replica, evicting it leaves zero available. Zero evictions, always.
The consequences
| Consequence | Detail |
|---|---|
| Node maintenance is blocked | kubectl drain retries forever |
| The Cluster Autoscaler cannot scale down | Underutilised nodes nobody removes, being paid for (09-03) |
| The VPA cannot apply recommendations | The updater fails to evict (09-02) |
| Cluster upgrades get stuck | A managed provider may abort the upgrade |
| Diagnosis is hard | The symptom (zombie nodes) is far from the cause (a PDB) |
That last point is what makes the problem dangerous: nobody connects a node that will not go away with a PDB written three months ago. The Cluster Autoscaler logs say so (pdb-blocked), but you have to know to look there.
Detecting them
NAMESPACE NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS
rutas-norte-pro bookings-api N/A 25% 2
rutas-norte-pro web-store N/A 1 1
rutas-norte-pro bookings-postgres 1 N/A 0
rutas-norte-pro redis-cache 1 N/A 0
rutas-norte-pro notifications-worker N/A 50% 4
monitoring prometheus 1 N/A 0The three zeros have to be analysed one by one:
bookings-postgres: a single primary replica.ALLOWED DISRUPTIONS: 0is deliberate and correct: we do not want any automatic mechanism moving the database. The price is that its node needs manual intervention for maintenance.redis-cache: the same case, though here it is more arguable: losing the cache for a few minutes is annoying but not catastrophic. There would be a case for allowing eviction.prometheus: if Prometheus has a single replica (as is common), it is the same pattern.
A useful alert for detecting the unintentional impossible ones:
# PDBs that have gone more than an hour without allowing any eviction
kube_poddisruptionbudget_status_pod_disruptions_allowed == 0With an annotation on the deliberate ones to exclude them from the alert.
What to do with single-replica workloads
It is a question that has to be answered explicitly, and there are three legitimate answers:
Option A: no PDB. With no PDB, eviction is always allowed. The service will have an outage during maintenance (the seconds it takes to start on another node), but maintenance flows. It is the right option for non-critical services.
Option B: set maxUnavailable: 1 (equivalent to minAvailable: 0). It always allows eviction. It looks pointless, but it explicitly documents that somebody thought about it and accepts the interruption. Better than the absence of a PDB, which is ambiguous.
Option C: run two replicas. If the service genuinely cannot be interrupted, the solution is not a PDB: it is having more than one replica. A PDB does not create availability, it only protects it.
For bookings-postgres, the right answer is none of the three: it is an operator (06-07) that manages a PostgreSQL cluster with a primary and replicas, and that knows how to promote a replica when the primary has to move. We develop this in section 12.
unhealthyPodEvictionPolicy
unhealthyPodEvictionPolicyA relatively recent field that solves a very specific and very annoying problem.
The problem
Imagine bookings-api has 6 replicas and a PDB of maxUnavailable: 1. A faulty deployment means 4 of the 6 replicas fail the readiness probe (07-01): they are Running but not Ready.
Total replicas: 6
Ready replicas: 2
Not-Ready replicas: 4
PDB: maxUnavailable: 1 -> at least 5 available.
Currently available: 2. We are already BELOW the minimum.
ALLOWED DISRUPTIONS: 0.Now you want to evict one of the broken pods — precisely because it is broken and you want it recreated on another node — and the PDB will not let you. The PDB is protecting pods that are not serving traffic.
It is a circular, absurd situation: the system is broken, and the protection mechanism prevents fixing it.
The solution
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
maxUnavailable: 25%
selector:
matchLabels:
app: bookings-api
# ALWAYS allow evicting pods that are not Ready, without consuming
# disruption budget.
unhealthyPodEvictionPolicy: AlwaysAllowThe two values:
| Value | Behaviour |
|---|---|
IfHealthyBudget (default) |
Unhealthy pods can only be evicted if the PDB has budget available |
AlwaysAllow |
Unhealthy pods (those not Ready) can always be evicted |
With AlwaysAllow, the reasoning is direct: a pod that is not Ready is not serving traffic, so evicting it does not reduce availability. On the contrary: it lets it be recreated, possibly on a healthy node.
When to use it
| Situation | Recommendation |
|---|---|
Stateless applications (bookings-api, web-store, workers) |
AlwaysAllow. Always |
| Quorum-based applications (etcd, Kafka, Zookeeper) | IfHealthyBudget (the default). A not-Ready pod may be recovering and still count towards quorum |
| Databases with replicas | It depends on the system; generally IfHealthyBudget |
The distinction is important: in a quorum system, a member that does not answer the probe may still be participating in consensus. Evicting it blithely can lose quorum. In a stateless application, a not-Ready pod is pure dead weight.
For Rutas Norte, AlwaysAllow on every stateless component. It avoids the circular deadlock and has no downside.
- The Rutas Norte PDBs, component by component
Let's write the definitive manifests, with the justification for each decision.
bookings-api
# k8s/environments/pro/pdb-bookings-api.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
spec:
# A PERCENTAGE, not an absolute number. bookings-api has KEDA/HPA (09-01, 09-04)
# and swings between 4 and 30 replicas across the day. An absolute
# minAvailable would be obsolete the moment the HPA moved, and in the
# night-time trough it would become an IMPOSSIBLE PDB that would block
# maintenance and node scale-down.
#
# With 25%:
# 4 replicas -> allows evicting 1 (floor(4 x 0.25) = 1)
# 12 replicas -> allows evicting 3
# 30 replicas -> allows evicting 7
# The PROPORTION of service protected is constant: always 75%.
maxUnavailable: 25%
selector:
matchLabels:
app: bookings-api
# Pods that fail readiness are not serving traffic: evicting them
# does not reduce availability and it avoids the circular deadlock of a
# faulty deployment that prevents its own repair.
unhealthyPodEvictionPolicy: AlwaysAllowweb-store
# k8s/environments/pro/pdb-web-store.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-store
namespace: rutas-norte-pro
labels:
app: web-store
app.kubernetes.io/part-of: rutas-norte
spec:
# 34%, more permissive than bookings-api. Reason: web-store is nginx serving
# static assets. It starts in ~1 second and has no state and no cache to warm,
# so an evicted replica comes back almost instantly on another node.
# Allowing more simultaneous evictions speeds up maintenance with no real risk.
#
# With 3 replicas -> allows evicting 1 (floor(3 x 0.34) = 1)
# With 15 replicas -> allows evicting 5
maxUnavailable: 34%
selector:
matchLabels:
app: web-store
unhealthyPodEvictionPolicy: AlwaysAllownotifications-worker
# k8s/environments/pro/pdb-notifications-worker.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: notifications-worker
namespace: rutas-norte-pro
labels:
app: notifications-worker
app.kubernetes.io/part-of: rutas-norte
spec:
# 50%, the most permissive on the platform. Justification:
#
# 1. NOBODY IS WAITING. A confirmation email sent 30 seconds later
# has no consequence at all for the user.
# 2. THE QUEUE IS THE BUFFER. If half the workers are evicted, the
# work is not lost: it piles up in the queue and is processed later.
# The in-flight message is redelivered automatically.
# 3. KEDA REACTS. If the queue grows because there are fewer workers, KEDA (09-04)
# will scale up to compensate.
#
# With scale-to-ZERO (minReplicaCount: 0) there is a nuance: when there are 0
# replicas, the PDB covers no pod and ALLOWED DISRUPTIONS will be 0, but
# that is irrelevant because there is nothing to evict.
maxUnavailable: 50%
selector:
matchLabels:
app: notifications-worker
unhealthyPodEvictionPolicy: AlwaysAllowredis-cache
# k8s/environments/pro/pdb-redis-cache.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: redis-cache
namespace: rutas-norte-pro
labels:
app: redis-cache
app.kubernetes.io/part-of: rutas-norte
annotations:
# Annotation to exclude this PDB from the "impossible PDB" alert:
# its ALLOWED DISRUPTIONS is 0 DELIBERATELY.
rutasnorte.example/pdb-intentional-block: >
redis-cache is a single-replica StatefulSet. Evicting it empties the cache
and dumps the whole availability-query load onto
bookings-postgres. It requires manual intervention and a maintenance window.
spec:
# minAvailable: 1 with ONE replica = ZERO evictions allowed.
# It is DELIBERATE. Accepted consequences:
# - The Cluster Autoscaler will not remove the node hosting redis-cache.
# - kubectl drain on that node will sit there waiting.
# - Maintenance on that node requires an explicit human decision.
#
# It is an ACCEPTED trade-off: we prefer manual maintenance to
# an automatic mechanism emptying the cache at the worst possible moment.
minAvailable: 1
selector:
matchLabels:
app: redis-cache
# IfHealthyBudget (the default). We do not use AlwaysAllow: if redis-cache is
# not Ready, it may be loading data or recovering. Evicting it
# would make things worse.
unhealthyPodEvictionPolicy: IfHealthyBudgetbookings-postgres
# k8s/environments/pro/pdb-bookings-postgres.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: bookings-postgres
namespace: rutas-norte-pro
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
annotations:
rutasnorte.example/pdb-intentional-block: >
bookings-postgres is the platform's primary database with a
single replica. NO automatic mechanism may move it. Maintenance
on the node hosting it requires a planned window, with a controlled
shutdown of the application and a backup beforehand. See 05-06.
The definitive solution is a PostgreSQL operator: see 06-07.
spec:
# ZERO evictions. It is the strongest possible protection, and it is intentional.
#
# An automatic eviction of bookings-postgres would mean:
# - Cutting ALL connections from bookings-api.
# - Losing the PostgreSQL page cache (minutes of bad latency).
# - Risk of inconsistency if there are transactions in flight.
# - The entire platform down during start-up.
#
# It holds customers' personal data: any risk is unacceptable.
minAvailable: 1
selector:
matchLabels:
app: bookings-postgres
unhealthyPodEvictionPolicy: IfHealthyBudgetSummary table
| Component | PDB | Evictions at min. replicas | unhealthyPodEvictionPolicy |
Justification |
|---|---|---|---|---|
bookings-api |
maxUnavailable: 25% |
1 of 4 | AlwaysAllow |
With an HPA: a percentage is mandatory. 75% of service guaranteed |
web-store |
maxUnavailable: 34% |
1 of 3 | AlwaysAllow |
Starts in 1 s; more permissive speeds up maintenance |
notifications-worker |
maxUnavailable: 50% |
1 of 2 | AlwaysAllow |
The queue buffers; nobody is waiting |
redis-cache |
minAvailable: 1 |
0 | IfHealthyBudget |
One replica; emptying the cache is expensive. Deliberate block |
bookings-postgres |
minAvailable: 1 |
0 | IfHealthyBudget |
Primary database. Deliberate block |
occupancy-reports |
None | — | — | It is a CronJob. Protected with safe-to-evict: false (09-03) |
Note the last row: Jobs do not get a PDB. It makes no sense: a Job that is evicted restarts and loses its work. Its protection is the cluster-autoscaler.kubernetes.io/safe-to-evict: "false" annotation we saw in 09-03.
topologySpreadConstraints in depth
topologySpreadConstraints in depthPDBs protect from voluntary disruptions. For involuntary ones, the protection is spreading the replicas across independent failure domains. And that brings in the field we mentioned in 06-05 and promised to develop here.
The problem it solves
With no constraints at all, the Kubernetes scheduler places pods wherever they fit, optimising resource usage. Nothing stops it from putting all four bookings-api replicas on the same node, if that is where there is room.
node-1 (zone A): bookings-api x4, web-store x2
node-2 (zone A): bookings-postgres
node-3 (zone B): redis-cache, worker x2
node-4 (zone B): (almost empty)
node-1 goes down -> ZERO bookings-api replicas. Platform down.
Zone A goes down -> ZERO bookings-api replicas AND the database.topologySpreadConstraints tells the scheduler: "spread these pods evenly across these domains".
The fields
spec:
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: bookings-api
minDomains: 3
nodeAffinityPolicy: Honor
nodeTaintsPolicy: Honor
matchLabelKeys:
- pod-template-hashtopologyKey — the node label that defines the domain. The standard values:
| Label | Domain | Protects from |
|---|---|---|
kubernetes.io/hostname |
A node | A server failure |
topology.kubernetes.io/zone |
An availability zone | A data-centre failure |
topology.kubernetes.io/region |
A geographic region | A regional catastrophe |
Your own labels (rack, cabinet) |
Whatever you define | A rack or switch failure |
maxSkew — the maximum allowed difference between the domain with the most pods and the one with the fewest. It is the heart of the mechanism.
With maxSkew: 1, the difference between any pair of domains cannot exceed 1. It is the most balanced spread possible.
whenUnsatisfiable — what to do if it cannot be satisfied:
| Value | Behaviour | When to use it |
|---|---|---|
DoNotSchedule |
The pod stays Pending rather than violate the constraint |
Mandatory spread |
ScheduleAnyway |
It is placed anyway, but the scheduler prefers to satisfy it | Preferred spread |
This is the most consequential decision in the block, and we develop it in section 11.
labelSelector — which pods are counted to compute the skew. Normally the ones from the Deployment itself.
minDomains — the minimum number of domains that must exist. Without it, there is a subtle hole:
Without minDomains, with 3 replicas and only 1 zone with available nodes:
Zone A: 3 pods. Zone B: no nodes. Zone C: no nodes.
EXISTING domains: 1
Skew: 3 - 3 = 0 -> the constraint IS SATISFIED.
Result: all 3 replicas in the same zone. NOT what you wanted.
With minDomains: 3:
The scheduler considers there to be 3 domains, two of them with 0 pods.
Skew: 3 - 0 = 3 > maxSkew 1 -> NOT satisfied.
With DoNotSchedule: the pods stay Pending until there are nodes in other zones.minDomains only has an effect with whenUnsatisfiable: DoNotSchedule. It is the guarantee that the spread across zones is real and not an illusion.
nodeAffinityPolicy and nodeTaintsPolicy — whether to take affinities and taints into account when computing the domains:
| Value | Behaviour |
|---|---|
Honor (default) |
Only counts nodes where the pod could go (respecting affinity and taints) |
Ignore |
Counts every node |
The default Honor is almost always the right thing. If bookings-api has affinity towards the general node group, we do not want the data node (with its taint) counting as an empty domain that can never be filled.
matchLabelKeys — the subtlest field, and the one that solves a real problem during deployments.
matchLabelKeys and the update problem
During a rolling update (02-04), pods of the old and the new version coexist. Without matchLabelKeys, the skew calculation counts them all together:
Update of bookings-api from v1.14 to v1.15. 6 replicas.
Intermediate state:
zone A: 2 pods v1.14 + 1 pod v1.15 = 3 pods
zone B: 2 pods v1.14 = 2 pods
zone C: 1 pod v1.14 = 1 pod
Skew computed over ALL of them: 3 - 1 = 2 > maxSkew 1
-> The scheduler CANNOT place more v1.15 pods in zone A.
-> The update stalls or is spread badly.With matchLabelKeys: ["pod-template-hash"], the scheduler counts only the pods of the same version (pod-template-hash is a label the Deployment controller adds automatically and that identifies the ReplicaSet):
With matchLabelKeys: ["pod-template-hash"]:
To place a v1.15 pod, only v1.15 pods count:
zone A: 1, zone B: 0, zone C: 0
Skew: 1 - 0 = 1 <= maxSkew 1 -> it can be placed in B or C.
The new version spreads itself out correctly.A practical rule: always include matchLabelKeys: ["pod-template-hash"] in a Deployment's constraints. It prevents deployment stalls and has no downside.
The skew calculation, step by step
Let's practise with a concrete case. Three zones, maxSkew: 1, 7 bookings-api replicas.
Placing replica 1:
A: 0, B: 0, C: 0. Any zone works. -> A
State: A:1, B:0, C:0
Placing replica 2:
If it goes to A: A:2, B:0, C:0. Skew = 2-0 = 2 > 1. NOT ALLOWED.
If it goes to B: A:1, B:1, C:0. Skew = 1-0 = 1 <= 1. ALLOWED.
-> B (or C)
State: A:1, B:1, C:0
Placing replica 3:
Only C keeps the skew at 1 or below. -> C
State: A:1, B:1, C:1 (skew 0)
Replicas 4, 5, 6: one per zone.
State: A:2, B:2, C:2 (skew 0)
Placing replica 7:
Any zone: the skew would become 1. Allowed in all three.
-> A (arbitrary)
FINAL state: A:3, B:2, C:2 (skew 1)Result: 3-2-2. The most balanced spread possible with 7 replicas across 3 zones.
And now the availability check:
Zone A goes down: 4 of 7 replicas remain (57%).
Zone B goes down: 5 of 7 replicas remain (71%).
Without topologySpreadConstraints, in the worst case all 7 could be in one zone:
That zone goes down: 0 of 7 remain (0%). Platform down.
topologySpreadConstraints versus podAntiAffinity
topologySpreadConstraints versus podAntiAffinityIn 06-05 we saw pod anti-affinity for separating replicas. Now we have two tools that appear to do the same thing. They are not equivalent, and it is worth knowing when to use each.
Anti-affinity, recalled
affinity:
podAntiAffinity:
# HARD version: it cannot be violated
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: bookings-api
topologyKey: kubernetes.io/hostnameIt means: "do not place this pod on a node that already has another pod with app: bookings-api".
It is a binary constraint: either there is a pod of the same type in the domain, or there is not. There is no notion of balance.
The fundamental difference
podAntiAffinity (required) |
topologySpreadConstraints |
|
|---|---|---|
| Semantics | "At most one per domain" | "Spread evenly across domains" |
| Granularity | Binary: there is or there is not | Numeric: it controls the difference |
| With more replicas than domains | The surplus stays Pending forever |
They spread out evenly |
| Computational cost | High: O(n²) in large clusters | Low |
| "Preferred" version | preferredDuringScheduling with a weight |
whenUnsatisfiable: ScheduleAnyway |
| Multi-level | Hard to express | Natural: several constraints at once |
| Rebalancing after a failure | None | None either, but the initial spread is better |
The case that settles it
bookings-api scales from 4 to 30 replicas and the cluster has 9 nodes.
With required podAntiAffinity on hostname:
Rule: at most 1 bookings-api replica per node.
Nodes: 9.
Maximum placeable replicas: 9.
The HPA asks for 30 replicas.
9 are placed. The other 21 stay Pending FOREVER.
The Cluster Autoscaler sees pending pods and boots nodes... and with each new
node ONE more replica fits. For 30 replicas you would need 30 NODES.
Cost: absurd.With topologySpreadConstraints:
Rule: maxSkew 1 across nodes.
Nodes: 9. Replicas: 30.
Spread: 30 / 9 = 3.33
Result: 3 nodes with 4 replicas, 6 nodes with 3 replicas.
Skew: 4 - 3 = 1. Satisfied.
All 30 replicas are placed. Nothing Pending.This is why topologySpreadConstraints is the right tool for autoscaled workloads. Required anti-affinity and the HPA are incompatible in practice.
When anti-affinity is still better
| Case | Why |
|---|---|
| Components with few fixed replicas and strict separation | Etcd, a control plane: 3 replicas, one per node, no exceptions |
| Anti-affinity between different applications | "bookings-api must not share a node with occupancy-reports": topologySpread cannot express that |
Co-location rules (podAffinity) |
"Put this pod near the cache": there is no topologySpread equivalent |
That second case deserves an example, because it is a legitimate and frequent use:
# bookings-api must NOT share a node with the reports CronJob, which uses
# 4 cores in one go and would throttle the API for 40 minutes.
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: occupancy-reports # ANOTHER application, not the same one
topologyKey: kubernetes.io/hostnameThe recommended combination
For bookings-api at Rutas Norte we use both, each for its own purpose:
spec:
template:
spec:
# 1. Even spread across zones and nodes (availability)
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: bookings-api
matchLabelKeys: ["pod-template-hash"]
- maxSkew: 2
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: bookings-api
matchLabelKeys: ["pod-template-hash"]
# 2. Anti-affinity with ANOTHER application (resource isolation)
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: occupancy-reports
topologyKey: kubernetes.io/hostname
- Spreading Rutas Norte across zones and nodes
Let's apply it to the platform, with the skew calculations.
bookings-api: two levels of spread
# k8s/base/deployment-bookings-api.yaml (fragment)
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
# No replicas: governed by KEDA/HPA (09-01, 09-04)
selector:
matchLabels:
app: bookings-api
template:
metadata:
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
spec:
topologySpreadConstraints:
# LEVEL 1: across AVAILABILITY ZONES. A HARD constraint.
#
# This is the big failure domain: if a whole zone goes down, we must
# guarantee that replicas survive in the other two. It is a
# guarantee we are NOT willing to negotiate, hence DoNotSchedule:
# we prefer a Pending pod to an unbalanced spread.
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: bookings-api
# Always 3 zones, even if one has no nodes right now. Without this,
# if the Cluster Autoscaler only has nodes in 2 zones, a "balanced"
# spread across those 2 would satisfy the constraint and we would lose
# the three-zone guarantee.
minDomains: 3
matchLabelKeys: ["pod-template-hash"]
# LEVEL 2: across NODES. A SOFT constraint.
#
# Spreading across nodes is desirable but NOT at the price of leaving
# pods Pending. During the May bank holiday, with 30 replicas and the
# Cluster Autoscaler booting nodes, a DoNotSchedule here would block
# scaling exactly when it is needed most.
#
# maxSkew 2 (not 1): with 30 replicas and 9 nodes, requiring maxSkew 1
# would be unnecessarily rigid. A 4-3-3-4-3-3-4-3-3 is perfectly healthy.
- maxSkew: 2
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: bookings-api
matchLabelKeys: ["pod-template-hash"]
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:1.14.2
resources:
requests: {cpu: 412m, memory: 800Mi}
limits: {cpu: "1", memory: 1600Mi}The asymmetry between the two levels is the key decision, and it deserves a proper explanation:
| Level | whenUnsatisfiable |
Reasoning |
|---|---|---|
| Zone | DoNotSchedule |
A zone outage is a catastrophic and plausible event. A Pending pod is preferable to losing the guarantee |
| Node | ScheduleAnyway |
A node failure is frequent but of limited impact. Blocking scaling would be worse than an imperfect spread |
The calculation with 4 replicas (the trough)
3 zones, maxSkew 1, minDomains 3.
Spread: 4 / 3 = 1.33
Result: A:2, B:1, C:1. Skew = 2-1 = 1. Satisfied.
Zone A goes down: 2 of 4 remain (50%).
Zone B or C goes down: 3 of 4 remain (75%).
With maxUnavailable: 25% in the PDB, 4 replicas allow 1 eviction.
A zone outage (2 replicas) is INVOLUNTARY: the PDB does not apply.
2 replicas survive -> the service carries on, degraded.
KEDA/HPA will detect the doubled load per replica and scale up.The calculation with 30 replicas (the bank-holiday peak)
ZONE level (maxSkew 1, DoNotSchedule):
30 / 3 = exactly 10.
Result: A:10, B:10, C:10. Skew 0. Perfect.
NODE level (maxSkew 2, ScheduleAnyway), with 9 nodes (3 per zone):
Within each zone, 10 replicas across 3 nodes: 4-3-3.
Global skew across nodes: 4 - 3 = 1 <= 2. Satisfied.
Final spread:
zone A: node-a1:4, node-a2:3, node-a3:3
zone B: node-b1:4, node-b2:3, node-b3:3
zone C: node-c1:4, node-c2:3, node-c3:3
A NODE goes down: 3 or 4 replicas of 30 are lost (10-13%). Imperceptible.
A ZONE goes down: 10 of 30 are lost (33%). The service holds with 20.web-store
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web-store
minDomains: 3
matchLabelKeys: ["pod-template-hash"]
- maxSkew: 1
topologyKey: kubernetes.io/hostname
# maxSkew 1 and ScheduleAnyway: with only 3-15 replicas, a spread
# of 1 per node is achievable and desirable. But we still do not
# block scaling.
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: web-store
matchLabelKeys: ["pod-template-hash"]With 3 replicas and minDomains: 3, the spread is exactly one per zone. It is the minimum acceptable for the platform's front door.
bookings-postgres: the special case
With a single replica, topologySpreadConstraints adds nothing: there is nothing to spread. What does matter is where it lives:
# k8s/base/statefulset-bookings-postgres.yaml (fragment)
spec:
template:
spec:
# It lives in the data node group (09-03), with its taint and its affinity.
tolerations:
- key: role
operator: Equal
value: data
effect: NoSchedule
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: role
operator: In
values: ["data"]
# Do NOT share a node with redis-cache: if that node goes down, we do not
# want to lose the database AND the cache at once, because starting
# the database with an empty cache is the worst possible scenario.
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: redis-cache
topologyKey: kubernetes.io/hostnameAnd a crucial consideration that connects with module 5: bookings-postgres's persistent volume is pinned to a zone. A cloud provider's block-storage PV is only accessible from its own zone. That means:
- The pod can only be scheduled on nodes in that zone.
- If the zone goes down, it cannot be recovered in another zone without restoring from a backup (05-06).
This is not a Kubernetes problem, it is a property of block storage. And it is the fundamental reason why a database's high availability is not solved with Kubernetes objects (section 12).
- High availability in the other layers
So far we have talked about the applications. But high availability is a property of the complete system, and there are three more layers worth reviewing.
The control plane and etcd quorum
Recall 01-02: the control plane is made up of the API server, the scheduler, the controller manager and etcd, the database that holds all the cluster's state.
etcd uses the Raft consensus algorithm, which needs a strict majority to operate. That is where the odd-number rule comes from:
| Members | Quorum (majority) | Failures tolerated |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
| 6 | 4 | 2 |
| 7 | 4 | 3 |
Note that 4 members tolerate the same failures as 3, and 6 the same as 5. An even number adds cost and consensus latency without adding tolerance. Hence the standard configuration is 3 or 5 members, never even.
What happens when quorum is lost:
A 3-member etcd cluster. 2 go down.
Quorum required: 2. Live members: 1. NO QUORUM.
Consequences:
- etcd goes READ-ONLY. It accepts no writes.
- The API server cannot persist changes.
- kubectl apply fails. Objects cannot be created, modified or deleted.
- The HPA cannot change replicas.
- The Cluster Autoscaler can do nothing.
BUT, and this is the important part:
- The pods that were ALREADY running KEEP RUNNING.
- The kubelets keep running their containers.
- The Services keep routing (kube-proxy has its local state).
- THE PLATFORM KEEPS SERVING TRAFFIC.A control plane that is down does not take running applications with it. It is a very valuable design property of Kubernetes: the data plane survives the control plane. What you lose is the ability to change things: to scale, to deploy, to recover from a pod failure.
The recommended control-plane spread:
3 control-plane nodes, one per availability zone:
zone A: control-1 (etcd, api-server, scheduler, controller-manager)
zone B: control-2
zone C: control-3
One zone goes down: 2 of 3 remain. Quorum = 2. IT HOLDS.On managed Kubernetes (10-06) the provider does this and you never see it. It is one of the strongest arguments in favour of a managed cluster: control-plane high availability is hard to get right and adds no differential value.
The Ingress controller
The Ingress controller (04-04) is the front door for all external traffic. If it goes down, the platform is unreachable even if every pod is healthy.
# k8s/environments/pro/deployment-ingress-nginx.yaml (fragment)
apiVersion: apps/v1
kind: Deployment
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
spec:
replicas: 4 # Never 1. Never 2 in the same failure domain.
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
minDomains: 3
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule # Here it IS hard
labelSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ingress-nginx
namespace: ingress-nginx
spec:
minAvailable: 2 # An absolute number: the Ingress replicas are FIXED
selector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
unhealthyPodEvictionPolicy: AlwaysAllowTwo decisions that depart from what we said earlier:
DoNotScheduleacross nodes too. The Ingress has no HPA (its replicas are fixed), so there is no risk of blocking a scale-up. And two Ingress replicas on the same node are two replicas that are lost together.minAvailable: 2in absolute numbers. With fixed replicas, the absolute number is more explicit and easier to reason about than a percentage.
CoreDNS
CoreDNS (04-03) resolves every name in the cluster. If it fails, bookings-api cannot find bookings-postgres, notifications-worker cannot find RabbitMQ, and everything falls over with DNS errors that are extremely misleading.
It is a component almost nobody reviews and that is a silent single point of failure.
# Fragment of the CoreDNS Deployment in kube-system
spec:
replicas: 3
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
k8s-app: kube-dns
priorityClassName: system-cluster-criticalAnd its PDB:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: coredns
namespace: kube-system
spec:
maxUnavailable: 1
selector:
matchLabels:
k8s-app: kube-dns
unhealthyPodEvictionPolicy: AlwaysAllowAn additional and very profitable tip: NodeLocal DNSCache, a DaemonSet that puts a DNS cache on every node. It reduces resolution latency, offloads CoreDNS, and means a temporary CoreDNS failure does not affect cached queries. We will come back to it in 09-06 when we talk about ndots: 5.
bookings-postgres: availability comes from the operator
And we get to the most important case, the one that sums up the whole module's lesson on stateful workloads.
Kubernetes cannot make PostgreSQL highly available. What Kubernetes offers:
- Restarting the pod if the process dies.
- Rescheduling it on another node if the node fails (if the volume is reachable).
- A PDB that prevents automatic evictions.
What Kubernetes does not offer:
| Capability needed | Why Kubernetes does not give it |
|---|---|
| Data replication between instances | It is a PostgreSQL protocol, not a Kubernetes one |
| Automatic promotion of a replica to primary | It requires knowing the replication state |
| Redirecting writes to the new primary | It requires changing the Service at exactly the right moment |
| Avoiding "split brain" (two primaries) | It requires specific consensus logic |
| Guaranteeing no committed transactions are lost | It requires understanding PostgreSQL's WAL |
All of that is provided by an operator (06-07): CloudNativePG, Zalando Postgres Operator, Crunchy PGO. A PostgreSQL operator:
flowchart TD
OP[PostgreSQL operator<br/>a controller with domain knowledge]
P[(postgres-primary<br/>zone A<br/>WRITES)]
R1[(postgres-replica-1<br/>zone B<br/>reads)]
R2[(postgres-replica-2<br/>zone C<br/>reads)]
SVCW[Service: postgres-rw<br/>points at the PRIMARY]
SVCR[Service: postgres-ro<br/>points at the REPLICAS]
OP -->|watches health and replication| P
OP -->|watches| R1
OP -->|watches| R2
P -->|streaming replication| R1
P -->|streaming replication| R2
OP -.->|if the primary fails:<br/>PROMOTES and repoints| SVCW
SVCW --> P
SVCR --> R1
SVCR --> R2
style OP fill:#cde,stroke:#369,stroke-width:2px
style P fill:#cfc,stroke:#393
With an operator, the model changes completely:
# Example with CloudNativePG
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: bookings-postgres
namespace: rutas-norte-pro
spec:
instances: 3 # 1 primary + 2 replicas
primaryUpdateStrategy: unsupervised
storage:
size: 100Gi
storageClass: rutasnorte-fast # From 05-04
# The operator spreads the instances across zones automatically
affinity:
enablePodAntiAffinity: true
topologyKey: topology.kubernetes.io/zone
podAntiAffinityType: required
postgresql:
parameters:
max_connections: "200"
shared_buffers: "1GB"
monitoring:
enablePodMonitor: true # It integrates with Prometheus (07-03)And the operator creates, maintains and manages:
- Three StatefulSets with their PVCs in three zones.
- The Services
bookings-postgres-rw(primary) andbookings-postgres-ro(replicas). - Streaming replication between instances.
- Automatic failover: if the primary goes down, it promotes a replica and repoints the
-rwService within seconds. - Its own PDBs, correctly configured for its quorum model.
- Continuous backups to object storage.
The lesson: for stateful workloads, high availability is delegated to an operator that understands the system. PDBs and topologySpreadConstraints are generic Kubernetes tools; an operator brings the domain-specific knowledge that no generic tool can have.
Applying this at Rutas Norte is the outstanding work taken up in module 11 (11-02).
- The node-maintenance procedure
Let's bring it all together into the complete operational procedure. This is what gets run on Tuesday morning.
The flow
flowchart TD
A[1. Check the state beforehand<br/>PDBs, replicas, alerts] --> B{Do all the PDBs<br/>allow evictions?}
B -->|No| C[Fix the impossible PDBs<br/>or plan a manual intervention]
C --> A
B -->|Yes| D[2. cordon: stop accepting new pods]
D --> E[3. drain: evict the existing pods<br/>respecting the PDBs]
E --> F{Did it complete?}
F -->|No, blocked| G[Diagnose: which PDB is stopping it?]
G --> H[Scale replicas or fix it]
H --> E
F -->|Yes| I[4. Verify: node empty<br/>and service healthy]
I --> J[5. MAINTENANCE<br/>upgrade kernel, kubelet, reboot]
J --> K[6. Verify the node comes back Ready]
K --> L[7. uncordon: accept pods again]
L --> M[8. Verify the spread<br/>and move to the next node]
style D fill:#ffd,stroke:#c90
style E fill:#ffd,stroke:#c90
style L fill:#cfc,stroke:#393
Step 1: the pre-flight check
NAMESPACE NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS
rutas-norte-pro bookings-api N/A 25% 1
rutas-norte-pro web-store N/A 34% 1
rutas-norte-pro bookings-postgres 1 N/A 0 <- ATTENTION
rutas-norte-pro redis-cache 1 N/A 0 <- ATTENTION
rutas-norte-pro notifications-worker N/A 50% 1
ingress-nginx ingress-nginx 2 N/A 2
kube-system coredns N/A 1 1The two zeros are the deliberate ones from section 8. You have to know which nodes they are on:
NAME READY STATUS NODE ZONE
bookings-postgres-0 2/2 Running data-node-1 eu-west-1a
redis-cache-0 1/1 Running data-node-2 eu-west-1bThose two nodes need a different procedure, with a planned maintenance window. The rest can simply be drained.
# What is on the node we are about to drain?
kubectl get pods --all-namespaces -o wide --field-selector spec.nodeName=node-a2NAMESPACE NAME READY STATUS AGE
rutas-norte-pro bookings-api-7c9d4f8b6d-2mkjp 2/2 Running 4h
rutas-norte-pro bookings-api-7c9d4f8b6d-9nvtr 2/2 Running 4h
rutas-norte-pro web-store-6f7d9c4b58-4kjnx 1/1 Running 2d
kube-system fluentd-x7kqp 1/1 Running 12d <- DaemonSet
kube-system falco-2wxkp 1/1 Running 12d <- DaemonSet
kube-system kube-proxy-mn2vp 1/1 Running 12d <- DaemonSetThree application pods and three DaemonSets. DaemonSets are not drained (one per node by definition).
# Are there active alerts? Never do maintenance with open incidents.
kubectl get events --all-namespaces --field-selector type=Warning \
--sort-by=.lastTimestamp | tail -20Step 2: cordon
What it does exactly: it sets spec.unschedulable: true on the node and adds the taint node.kubernetes.io/unschedulable:NoSchedule.
NAME STATUS ROLES AGE VERSION
node-a1 Ready <none> 12d v1.30.2
node-a2 Ready,SchedulingDisabled <none> 12d v1.30.2
node-a3 Ready <none> 12d v1.30.2cordon moves NOTHING. The pods already there keep running. It only stops new ones arriving. It is a safe operation, reversible instantly.
Step 3: drain
kubectl drain node-a2 \
--ignore-daemonsets \
--delete-emptydir-data \
--grace-period=60 \
--timeout=300sThe options, one by one:
| Option | What it does | Needed? |
|---|---|---|
--ignore-daemonsets |
Does not try to evict DaemonSet pods | Yes, always. Without it drain fails |
--delete-emptydir-data |
Accepts losing the contents of emptyDirs |
Yes if there are pods with emptyDir (nginx cache) |
--grace-period=60 |
Seconds for the pod to shut down cleanly | Recommended: gives time for an orderly shutdown |
--timeout=300s |
Gives up after 5 minutes | Highly recommended. Avoids waiting forever |
--force |
Also deletes pods with no controller | Dangerous: those pods do not come back |
--disable-eviction |
Ignores PDBs by using direct deletion | Emergencies only |
--pod-selector |
Drains only the matching pods | Useful for partial drains |
--dry-run=client |
Shows what it would do without doing it | Excellent for checking beforehand |
Expected output:
node/node-a2 already cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/fluentd-x7kqp,
kube-system/falco-2wxkp, kube-system/kube-proxy-mn2vp
evicting pod rutas-norte-pro/bookings-api-7c9d4f8b6d-2mkjp
evicting pod rutas-norte-pro/web-store-6f7d9c4b58-4kjnx
evicting pod rutas-norte-pro/bookings-api-7c9d4f8b6d-9nvtr
error when evicting pods/"bookings-api-7c9d4f8b6d-9nvtr" -n "rutas-norte-pro"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
pod/web-store-6f7d9c4b58-4kjnx evicted
pod/bookings-api-7c9d4f8b6d-2mkjp evicted
evicting pod rutas-norte-pro/bookings-api-7c9d4f8b6d-9nvtr
pod/bookings-api-7c9d4f8b6d-9nvtr evicted
node/node-a2 drainedLook at the second bookings-api pod: the first attempt failed with the PDB message, waited 5 seconds, and succeeded on the retry. What happened in those 5 seconds? The first evicted pod was recreated on another node and became Ready, freeing up disruption budget.
This is the PDB working exactly as it should: it has not prevented maintenance, it has serialised it so that there is never more than one pod down at a time.
Step 4: verify
# The node must be empty of application pods
kubectl get pods --all-namespaces -o wide --field-selector spec.nodeName=node-a2NAMESPACE NAME READY STATUS AGE
kube-system fluentd-x7kqp 1/1 Running 12d
kube-system falco-2wxkp 1/1 Running 12d
kube-system kube-proxy-mn2vp 1/1 Running 12dOnly DaemonSets. Correct.
# And the SERVICE must be healthy: this is what really matters
kubectl get pods -n rutas-norte-pro -l app=bookings-api
kubectl get pdb -n rutas-norte-proA check in Grafana (07-04): p95 latency stable, error rate at zero. If the drain has degraded the service, do not move on to the next node.
Step 5: the maintenance
# Example: upgrading the kubelet on a kubeadm cluster (10-02)
ssh node-a2
sudo apt-get update
sudo apt-get install -y kubeadm=1.30.3-1.1
sudo kubeadm upgrade node
sudo apt-get install -y kubelet=1.30.3-1.1 kubectl=1.30.3-1.1
sudo systemctl daemon-reload
sudo systemctl restart kubelet
exitStep 6: verify the node
Ready with the new version. Still cordoned, which is correct.
Step 7: uncordon
Never forget this step. A node left cordoned is capacity that is paid for and invisible: the Cluster Autoscaler may boot new nodes while this one sits empty and available but rejecting pods.
A surprising detail: uncordon does not rebalance the existing pods. The node is available, but the pods that moved do not come back. Kubernetes does not reschedule running pods. Redistribution happens naturally with the next scale-up or deployment.
If you want to force a rebalance, the tool is the descheduler, a component that evicts pods violating the spread policies so that they get rescheduled better. It respects PDBs, naturally.
Step 8: the next node
# Check the spread before continuing
kubectl get pods -n rutas-norte-pro -l app=bookings-api -o wide \
| awk '{print $7}' | sort | uniq -cAfter the drain, node-a2 is empty and its pods were redistributed. The spread is still reasonable across zones (3 in A, 3 in B... though C is missing: we should check whether minDomains is being satisfied).
The golden rule: one node at a time, with a check between each. Draining two nodes in parallel can violate the PDBs in ways you did not anticipate.
What breaks without PDBs
The thought experiment is worth doing. With no PDB at all:
node/node-a2 cordoned
evicting pod rutas-norte-pro/bookings-api-7c9d4f8b6d-2mkjp
evicting pod rutas-norte-pro/bookings-api-7c9d4f8b6d-9nvtr
evicting pod rutas-norte-pro/web-store-6f7d9c4b58-4kjnx
pod/bookings-api-7c9d4f8b6d-2mkjp evicted
pod/bookings-api-7c9d4f8b6d-9nvtr evicted
pod/web-store-6f7d9c4b58-4kjnx evicted
node/node-a2 drainedInstantaneous. All three pods evicted at once. And at that instant:
bookings-api had 4 replicas: 2 on node-a2, 1 on node-a1, 1 on node-b1.
After the drain: 2 live replicas, 2 starting (25-30 seconds).
For 30 seconds: HALF of the API's capacity.
Each live replica receives DOUBLE the load.
p95 latency: from 180 ms to 900 ms.
Some requests: timeout.
And if 3 of the 4 replicas had been on that node: 25% of capacity.
The surviving replica saturates and falls over. TOTAL OUTAGE.The PDB turns that disaster into a serialised eviction of 90 seconds with no perceptible impact. It is the difference between a maintenance and an incident.
- Interaction with the Cluster Autoscaler and with deployments
Two interactions worth keeping in mind.
With the Cluster Autoscaler (09-03)
The CA uses the eviction API, so it respects PDBs. The consequences:
Consequence 1: an impossible PDB pins the node permanently. We already saw it in 09-03: the logs say pdb-blocked and the node is never removed. With bookings-postgres that is intentional; with a misconfigured PDB, it is money thrown away.
Consequence 2: scale-down is slower with PDBs. The CA has to evict pods one at a time, waiting for them to be recreated. A node with 8 bookings-api pods and maxUnavailable: 25% with 12 total replicas takes several minutes to empty. That is correct and desirable, but you have to know it so as not to think the CA is broken.
Consequence 3: PDBs need reviewing when the maxReplicas changes. If you raise bookings-api's maxReplicas from 30 to 60, maxUnavailable: 25% will allow 15 simultaneous evictions at the peak. Is it acceptable to lose 15 replicas at once? Probably yes, but it is a decision worth thinking about.
And a particularly subtle interaction: the over-provisioning cushion must not have a PDB. The filler pods of 09-03 exist to be evicted; a PDB would protect them and break the whole mechanism. If anything, an explicitly permissive PDB:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: capacity-filler
namespace: rutas-norte-pro
spec:
# 100% unavailability allowed: these pods exist TO be evicted.
# It documents the intent better than the absence of a PDB.
maxUnavailable: 100%
selector:
matchLabels:
app: capacity-fillerWith deployments (02-04)
The Deployment controller does not use the eviction API during a rolling update: it deletes pods directly and applies its own maxUnavailable. PDBs do not intervene.
That means you need to configure the two coherently:
# In the Deployment: it governs the UPDATE
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 25% # Consistent with the PDB
maxSurge: 25%
---
# In the PDB: it governs EVICTIONS (drain, CA, VPA)
spec:
maxUnavailable: 25%If the Deployment allows 50% unavailability and the PDB only 25%, you have an inconsistency: during a deployment the service will drop to 50%, but a drain will only be able to take it to 75%. It is not an error, but it is an inconsistency of criteria worth resolving.
Recommendation: use the same value in both, so that the guaranteed service level is the same whatever happens.
And a scenario to avoid: a deployment and a maintenance at the same time. During an update, half the pods are starting and do not count as available. If you also drain a node, the PDB will block the drain (correctly) and the maintenance will stall. Never do maintenance during a deployment, and vice versa.
- A simple chaos test
Everything above is theory until it is verified. Let's kill a node and see what happens.
Preparation on minikube
minikube start -p rutas-norte --nodes=4 --cpus=2 --memory=4096
# Label the nodes with fictitious zones
kubectl label node rutas-norte topology.kubernetes.io/zone=zone-a
kubectl label node rutas-norte-m02 topology.kubernetes.io/zone=zone-a
kubectl label node rutas-norte-m03 topology.kubernetes.io/zone=zone-b
kubectl label node rutas-norte-m04 topology.kubernetes.io/zone=zone-bA deployment with a spread and a PDB:
# /tmp/chaos-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: chaos-api
namespace: rutas-norte-dev
spec:
replicas: 6
selector:
matchLabels:
app: chaos-api
template:
metadata:
labels:
app: chaos-api
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: chaos-api
matchLabelKeys: ["pod-template-hash"]
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: chaos-api
matchLabelKeys: ["pod-template-hash"]
containers:
- name: web
image: nginx:1.27-alpine
resources:
requests: {cpu: 50m, memory: 32Mi}
readinessProbe:
httpGet: {path: /, port: 80}
initialDelaySeconds: 2
periodSeconds: 3
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: chaos-api
namespace: rutas-norte-dev
spec:
maxUnavailable: 25%
selector:
matchLabels:
app: chaos-api
unhealthyPodEvictionPolicy: AlwaysAllow
---
apiVersion: v1
kind: Service
metadata:
name: chaos-api
namespace: rutas-norte-dev
spec:
selector:
app: chaos-api
ports:
- port: 80NAME READY STATUS NODE ZONE
chaos-api-7d9c5f8b6b-2mkjp 1/1 Running rutas-norte zone-a
chaos-api-7d9c5f8b6b-4nqzx 1/1 Running rutas-norte-m02 zone-a
chaos-api-7d9c5f8b6b-7wtrv 1/1 Running rutas-norte-m02 zone-a
chaos-api-7d9c5f8b6b-9hbcx 1/1 Running rutas-norte-m03 zone-b
chaos-api-7d9c5f8b6b-kp3ln 1/1 Running rutas-norte-m04 zone-b
chaos-api-7d9c5f8b6b-tv6ws 1/1 Running rutas-norte-m03 zone-bA perfect spread: 3 in zone-a, 3 in zone-b. The maxSkew: 1 across nodes is satisfied too: 1-2-2-1.
Experiment 1: planned maintenance (voluntary)
# Terminal 1: generate continuous traffic and count errors
kubectl -n rutas-norte-dev run client --rm -it --restart=Never \
--image=busybox:1.36 -- /bin/sh -c \
'ok=0; err=0; while true; do
if wget -q -T2 -O- http://chaos-api > /dev/null 2>&1; then
ok=$((ok+1)); else err=$((err+1)); fi;
echo "OK=$ok ERR=$err";
sleep 0.2;
done'Expected result in terminal 1:
Zero errors. The PDB serialised the eviction of that node's 2 pods, and the Service (04-02) removed each pod from its endpoints the moment it stopped being Ready.
This is what we are after: maintenance is invisible to the user.
Experiment 2: a node going down (involuntary)
# Simulate a hardware failure: switch the node off abruptly
minikube -p rutas-norte node stop rutas-norte-m03Let's watch the full sequence:
NAME STATUS ROLES AGE
rutas-norte-m03 Ready <none> 1h
rutas-norte-m03 NotReady <none> 1h <- after ~40 secondsNAME READY STATUS NODE
chaos-api-7d9c5f8b6b-9hbcx 1/1 Running rutas-norte-m03
chaos-api-7d9c5f8b6b-tv6ws 1/1 Running rutas-norte-m03
...
(~5 minutes pass)
...
chaos-api-7d9c5f8b6b-9hbcx 1/1 Terminating rutas-norte-m03
chaos-api-7d9c5f8b6b-tv6ws 1/1 Terminating rutas-norte-m03
chaos-api-7d9c5f8b6b-x2klm 0/1 Pending <none>
chaos-api-7d9c5f8b6b-p9wnz 0/1 Pending <none>
chaos-api-7d9c5f8b6b-x2klm 0/1 ContainerCreating rutas-norte-m04
chaos-api-7d9c5f8b6b-p9wnz 1/1 Running rutas-norte-m04The timings are the lesson of the experiment:
| Moment | Event | Elapsed time |
|---|---|---|
| t=0 | The node switches off | 0 |
| t=40 s | The control plane marks it NotReady (node-monitor-grace-period) |
40 s |
| t=40 s | The Service removes its pods from the endpoints. Traffic stops going there | 40 s |
| t=5 min | The NoExecute taints evict the pods (tolerationSeconds: 300) |
5 min |
| t=5 min | The ReplicaSet creates the replacement pods | 5 min |
| t=5 min 20 s | The new pods are Ready |
5 min 20 s |
The critical point is those first 40 seconds: the node is dead but Kubernetes does not know it yet, and the Service keeps sending it traffic. During those 40 seconds, a third of the requests fail.
In terminal 1 you would see something like:
OK=1204 ERR=0
OK=1210 ERR=3 <- the errors start
OK=1218 ERR=11
...
OK=1301 ERR=68 <- ~40 seconds of errors
OK=1409 ERR=68 <- they stop: the Service has excluded the dead node
OK=1520 ERR=68This is the fundamental difference between voluntary and involuntary disruptions, measured with a stopwatch:
Voluntary (drain) |
Involuntary (dead node) | |
|---|---|---|
| The Service excludes the pods | Immediately (readiness fails on SIGTERM) | After ~40 seconds |
| Errors for the user | Zero | ~40 seconds of errors |
| Protection available | A PDB | None; only limiting the damage by spreading |
What to do about those 40 seconds
They cannot be eliminated, but they can be mitigated:
| Measure | Effect |
|---|---|
Spread the replicas properly (topologySpreadConstraints) |
With 2 of 6 replicas on the dead node, only 33% of requests fail, not 100% |
| Client-side retries | web-store retries against another pod; the user never sees the error |
| A service mesh (08-04) | Failure detection and automatic retries, with ejection of failing instances |
Lowering node-monitor-grace-period |
Detects sooner... but produces false positives with network latency |
The last option is tempting and almost always a bad idea: lowering the detection threshold makes a temporary network hiccup look like a dead node, and evicts healthy pods. The default 40 seconds is a well-chosen trade-off.
Cleanup
Scaling up the chaos test
This manual experiment is the most basic level. In a serious environment, it is automated with chaos-engineering tools (Chaos Mesh, LitmusChaos) that let you:
- Kill random pods continuously.
- Inject network latency between services.
- Simulate a whole zone going down.
- Fill a node's disk.
- Run it periodically and alert if the system does not hold up.
But the manual experiment is where you have to start, because it is the one that teaches the real timings. Do it once in rutas-norte-pre before the May bank holiday and you will know exactly what to expect.
Common Mistakes and Tips
Mistake 1: a PDB with an absolute minAvailable on an HPA-driven workload. The most frequent mistake in this module. The HPA lowers the replicas in the trough, the minAvailable becomes unreachable, and the PDB starts blocking all maintenance and all node scale-down. With autoscaling, always percentages.
Mistake 2: believing that a PDB protects from a node going down. It does not. PDBs only intervene in evictions that go through the API. A node that switches itself off does not ask permission. The protection against failures is the topology spread.
Mistake 3: required podAntiAffinity on hostname for an HPA-driven workload. It caps the replicas at the number of nodes. The HPA will ask for 30, 9 will be placed, and 21 will stay Pending forever while the Cluster Autoscaler boots nodes that only fit one at a time. Use topologySpreadConstraints.
Mistake 4: DoNotSchedule across nodes for an autoscaled workload. During the May bank holiday, with the CA booting nodes, a hard constraint across nodes blocks scaling exactly when it is needed most. DoNotSchedule for zones, ScheduleAnyway for nodes.
Mistake 5: forgetting matchLabelKeys: ["pod-template-hash"]. Without it, the skew calculation mixes the old and new versions during a deployment, and the update stalls or spreads badly. Always include it in Deployments.
Mistake 6: forgetting the uncordon. A cordoned and forgotten node is capacity that is paid for and unusable. The symptom — Pending pods while there are apparently healthy nodes — is especially confusing. Put it on the maintenance checklist.
Mistake 7: --disable-eviction to "unstick" a drain. It bypasses PDBs using direct deletion. It can leave a service with no replicas at all. If a drain is blocked, the right answer is to diagnose why, not to switch off the protection.
Mistake 8: not giving PDBs to the infrastructure components. CoreDNS, the Ingress controller and Prometheus are as critical as the application. A drain that takes out both CoreDNS replicas brings down name resolution for the whole cluster, with symptoms nobody connects to the maintenance.
Tip 1: check ALLOWED DISRUPTIONS before every maintenance. A two-second kubectl get pdb --all-namespaces tells you whether the maintenance will flow or stall. It is the most profitable pre-flight check there is.
Tip 2: annotate the deliberately blocking PDBs. bookings-postgres and redis-cache have ALLOWED DISRUPTIONS: 0 on purpose. An annotation explaining it stops somebody "fixing" them without understanding why they are that way, and lets you exclude them from alerts.
Tip 3: alert on non-deliberate blocked PDBs.
# PDBs with no evictions allowed, excluding the intentional ones
kube_poddisruptionbudget_status_pod_disruptions_allowed == 0
unless on(namespace, poddisruptionbudget)
kube_poddisruptionbudget_annotations{annotation_rutasnorte_example_pdb_intentional_block!=""}Tip 4: verify the real spread, not the configured one.
# Replicas per zone
kubectl get pods -n rutas-norte-pro -l app=bookings-api \
-o custom-columns=NODE:.spec.nodeName --no-headers | \
while read n; do kubectl get node "$n" -o jsonpath='{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}'; done | \
sort | uniq -cA well-written constraint that is not being satisfied (because there are no nodes in one zone, for instance) is worse than none at all: it gives a false sense of security.
Tip 5: rehearse the maintenance in rutas-norte-pre. Drain a pre-production node with test traffic and measure the impact. If there are errors in pre, there will be multiplied errors in pro.
Tip 6: run the chaos test at least once. Killing a node and timing the recovery teaches more than any documentation. The 40 seconds of detection is a number you have to have seen with your own eyes.
Tip 7: coordinate the Deployment's maxUnavailable with the PDB's. Use the same value so that the guaranteed service level is the same during a deployment and during a maintenance.
Exercises
Exercise 1: diagnosing a blocked maintenance
It is Tuesday morning and the nodes need upgrading. The first node's drain has been stuck for 20 minutes:
node/node-b1 already cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/fluentd-2wxkp, kube-system/falco-x7kqp
evicting pod monitoring/prometheus-server-0
evicting pod rutas-norte-pro/bookings-api-7c9d4f8b6d-mn2vp
evicting pod rutas-norte-pro/notifications-worker-5f8d9c-4kjnx
pod/notifications-worker-5f8d9c-4kjnx evicted
error when evicting pods/"prometheus-server-0" -n "monitoring"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
error when evicting pods/"bookings-api-7c9d4f8b6d-mn2vp" -n "rutas-norte-pro"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.The state of the PDBs:
NAMESPACE NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS
rutas-norte-pro bookings-api 8 N/A 0
rutas-norte-pro web-store N/A 34% 1
rutas-norte-pro notifications-worker N/A 50% 1
monitoring prometheus 1 N/A 0The state of the pods:
NAMESPACE NAME READY STATUS NODE
rutas-norte-pro bookings-api-7c9d4f8b6d-2mkjp 2/2 Running node-a1
rutas-norte-pro bookings-api-7c9d4f8b6d-9nvtr 2/2 Running node-a2
rutas-norte-pro bookings-api-7c9d4f8b6d-mn2vp 2/2 Running node-b1
rutas-norte-pro bookings-api-7c9d4f8b6d-tv6ws 2/2 Running node-b2
rutas-norte-pro bookings-api-7c9d4f8b6d-x2klm 1/2 Running node-a1
monitoring prometheus-server-0 1/1 Running node-b1Analyse the two blocks separately. For each one: is it legitimate or a mistake? What is the exact cause? How do you unblock it now and how do you stop it happening again? Pay particular attention to the pod x2klm.
Exercise 2: computing the spread and the impact
bookings-api has this configuration:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
minDomains: 3
labelSelector:
matchLabels:
app: bookings-api
- maxSkew: 2
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: bookings-apiThe cluster has 9 nodes: 3 in eu-west-1a, 3 in eu-west-1b, 3 in eu-west-1c.
Compute, for 11 replicas and for 26 replicas:
(a) The spread across zones and the resulting skew. (b) The spread across nodes within each zone. (c) How many replicas are lost if one node goes down and what percentage of capacity remains. (d) How many replicas are lost if a whole zone goes down and what percentage remains. (e) How many simultaneous evictions the PDB allows in each case. (f) Whether draining a node with 26 replicas deployed can empty that node in one go, or whether the PDB serialises it.
Also: what would happen with 2 replicas and minDomains: 3? And with 2 replicas if one zone is left with no available nodes?
Exercise 3: designing high availability for a new component
Rutas Norte is adding payments-gateway, the component that talks to the bank to charge for bookings. Its profile:
- It is a stateless HTTP service on the critical path: if it fails, nobody can buy.
- Each transaction takes between 2 and 8 seconds (waiting on the bank), and it cannot be interrupted midway: a cut transaction can leave a charge with no associated booking.
- The bank limits it to 50 simultaneous connections from the Rutas Norte IP.
- Traffic: 5-40 transactions per second on a normal day; up to 300 at the May bank-holiday peak.
- Start-up: 20 seconds (a mutual TLS session with the bank has to be established and certificates validated).
- The compliance team requires that every transaction be logged before the user is answered.
- It must work even if a whole availability zone goes down.
Design the complete high-availability strategy: replica count, PDB, topologySpreadConstraints, terminationGracePeriodSeconds, and any other mechanism you consider necessary. Justify every decision and write the manifests. Pay special attention to the bank's 50-connection limit and to the requirement not to interrupt transactions.
Solutions
Solution 1
There are three findings, not two.
Block 1: bookings-api with minAvailable: 8 — a configuration ERROR.
Total bookings-api replicas: 5
- 4 with READY 2/2 (healthy)
- 1 with READY 1/2 (the pod x2klm: one container is not ready)
AVAILABLE (Ready) replicas: 4
The PDB requires: minAvailable: 8
4 < 8 -> we are ALREADY below the minimum.
ALLOWED DISRUPTIONS: 0. And it will stay 0 whatever happens.It is the textbook case of an impossible PDB with an HPA: somebody set minAvailable: 8 when there were 12 replicas during a peak, and now the HPA has come down to 5. The PDB did not adjust because it is an absolute number.
Third finding: the pod x2klm is at 1/2.
This is the detail you have to catch. bookings-api has two containers (the API and the metrics exporter). 1/2 means one of them is not Ready.
kubectl describe pod bookings-api-7c9d4f8b6d-x2klm -n rutas-norte-pro
kubectl logs bookings-api-7c9d4f8b6d-x2klm -n rutas-norte-pro -c metrics-exporterA pod that is not fully Ready does not count as available for the PDB. So even if we fixed the PDB, that pod is a separate problem that has to be investigated: it may be the symptom of something more serious.
Immediate unblocking:
kubectl patch pdb bookings-api -n rutas-norte-pro --type merge \
-p '{"spec":{"minAvailable":null,"maxUnavailable":"25%"}}'Immediate effect:
Desired replicas: 5
maxUnavailable: 25% -> floor(5 x 0.25) = 1
Minimum available: 5 - 1 = 4
Currently available: 4
ALLOWED DISRUPTIONS: 0
...Still 0, because of the pod x2klm that is not Ready.The patch alone is not enough. The broken pod has to be dealt with too. Two ways:
# Way A: if the pod is broken beyond repair, delete it so it gets recreated
kubectl delete pod bookings-api-7c9d4f8b6d-x2klm -n rutas-norte-pro
# (kubectl delete does NOT respect PDBs: here that works in our favour)
# Way B: add unhealthyPodEvictionPolicy so that unhealthy pods
# do not consume budget
kubectl patch pdb bookings-api -n rutas-norte-pro --type merge \
-p '{"spec":{"unhealthyPodEvictionPolicy":"AlwaysAllow"}}'Way B is the correct one in the long run, and it is exactly the problem unhealthyPodEvictionPolicy solves (section 7): an unhealthy pod was blocking the system's own repair.
The corrected manifest:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: bookings-api
namespace: rutas-norte-pro
annotations:
rutasnorte.example/note: >
A PERCENTAGE is mandatory: bookings-api has KEDA/HPA and swings between
4 and 30 replicas. An absolute minAvailable becomes impossible in the
trough and blocks all maintenance. See 09-05.
spec:
maxUnavailable: 25%
selector:
matchLabels:
app: bookings-api
unhealthyPodEvictionPolicy: AlwaysAllowBlock 2: prometheus with minAvailable: 1 and one replica — LEGITIMATE but badly resolved.
prometheus-server-0: 1 replica (it is a StatefulSet).
PDB: minAvailable: 1.
Evicting it would leave 0 available.
ALLOWED DISRUPTIONS: 0. Permanent and by design.The block is consistent with the configuration, but the configuration is arguable. You have to decide what you want:
Option A: accept the interruption. Prometheus with one replica and local storage. Losing a few minutes of metrics during maintenance is annoying but not critical: it does not affect the service to users.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: prometheus
namespace: monitoring
spec:
# A single replica: we allow the eviction. We lose a few minutes of
# metrics during the maintenance, which is acceptable: Prometheus
# is not on the critical path of the sale.
maxUnavailable: 1
selector:
matchLabels:
app: prometheusOption B: two replicas. Highly available Prometheus is done with two independent instances scraping the same targets, plus Thanos or Mimir to deduplicate. It is more complex and only justified if the alerts are critical.
A pragmatic decision: option A. Prometheus is not on the path of the sale, and observability can have a three-minute gap during planned maintenance.
An important and counter-intuitive note: losing Prometheus during the maintenance has a side effect from the previous lesson. If bookings-api scales with KEDA on a Prometheus metric (09-04), KEDA will go into fallback mode while Prometheus is away. That is why the fallback of 09-04 matters so much: it covers exactly this case.
The complete unblocking procedure:
# 1. Fix the bookings-api PDB
kubectl patch pdb bookings-api -n rutas-norte-pro --type merge \
-p '{"spec":{"minAvailable":null,"maxUnavailable":"25%","unhealthyPodEvictionPolicy":"AlwaysAllow"}}'
# 2. Fix the prometheus PDB
kubectl patch pdb prometheus -n monitoring --type merge \
-p '{"spec":{"minAvailable":null,"maxUnavailable":1}}'
# 3. Verify
kubectl get pdb --all-namespacesNAMESPACE NAME MAX UNAVAILABLE ALLOWED DISRUPTIONS
rutas-norte-pro bookings-api 25% 1
monitoring prometheus 1 1The drain, which was still retrying in the background, moves on by itself.
# 4. Investigate the pod x2klm (not urgent, but do not forget it)
kubectl describe pod bookings-api-7c9d4f8b6d-x2klm -n rutas-norte-proStructural prevention:
- A mandatory pre-flight check in the maintenance procedure:
kubectl get pdb --all-namespaces \
-o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,ALLOWED:.status.disruptionsAllowed' \
| awk 'NR==1 || $3=="0"'-
A Kyverno policy (08-03) that rejects PDBs with an absolute
minAvailableon workloads that have an HPA or aScaledObject. -
A permanent alert:
With the exclusion annotation for the deliberate blocks (bookings-postgres, redis-cache).
Solution 2
Case A: 11 replicas
(a) Spread across zones (maxSkew: 1, DoNotSchedule, 3 zones):
11 / 3 = 3.67
Most balanced spread possible:
eu-west-1a: 4
eu-west-1b: 4
eu-west-1c: 3
Skew = 4 - 3 = 1 <= maxSkew 1. SATISFIED.(b) Spread across nodes (maxSkew: 2, ScheduleAnyway, 3 nodes per zone):
Zone a (4 replicas, 3 nodes): 2-1-1
Zone b (4 replicas, 3 nodes): 2-1-1
Zone c (3 replicas, 3 nodes): 1-1-1
Global skew across nodes: max 2 - min 1 = 1 <= maxSkew 2. SATISFIED.(c) One node goes down:
Worst case: a node with 2 replicas goes down.
Lost: 2 of 11.
Remaining: 9 of 11 = 81.8% of capacity.(d) One zone goes down:
Worst case: a zone with 4 replicas goes down.
Lost: 4 of 11.
Remaining: 7 of 11 = 63.6% of capacity.(e) Evictions allowed:
Case B: 26 replicas
(a) Spread across zones:
26 / 3 = 8.67
Spread:
eu-west-1a: 9
eu-west-1b: 9
eu-west-1c: 8
Skew = 9 - 8 = 1 <= maxSkew 1. SATISFIED.(b) Spread across nodes:
Zone a (9 replicas, 3 nodes): 3-3-3
Zone b (9 replicas, 3 nodes): 3-3-3
Zone c (8 replicas, 3 nodes): 3-3-2
Global skew across nodes: 3 - 2 = 1 <= maxSkew 2. SATISFIED.(c) One node goes down:
Worst case: a node with 3 replicas goes down.
Lost: 3 of 26.
Remaining: 23 of 26 = 88.5% of capacity.(d) One zone goes down:
Worst case: a zone with 9 replicas goes down.
Lost: 9 of 26.
Remaining: 17 of 26 = 65.4% of capacity.(e) Evictions allowed:
(f) Can a node be emptied in one go with 26 replicas?
A node holds 3 bookings-api replicas.
The PDB allows 6 simultaneous evictions.
3 <= 6 -> YES, all 3 can be evicted at once.
The drain will NOT be serialised by the bookings-api PDB.
It will be almost instantaneous for this component.Let's compare with the 11-replica case:
With 11 replicas, a node holds 2 replicas and the PDB allows 2 evictions.
2 <= 2 -> they also fit in one go, though right at the limit.
If the node held 3 replicas (possible with maxSkew 2), the PDB would serialise:
evict 2, wait for them to be recreated, evict the third.An important observation: maxUnavailable: 25% scales with the replicas, so maintenance gets FASTER with more replicas, not slower. That is exactly what you want: at the peak there is more capacity and you can afford to lose more replicas simultaneously.
Special case: 2 replicas with minDomains: 3
minDomains: 3 means the scheduler considers 3 domains,
even if some of them are empty.
Placing replica 1: zone a. State: a:1, b:0, c:0.
Skew = 1 - 0 = 1 <= maxSkew 1. Satisfied.
Placing replica 2:
If it goes to zone a: a:2, b:0, c:0. Skew = 2 - 0 = 2 > 1. NOT ALLOWED.
If it goes to zone b: a:1, b:1, c:0. Skew = 1 - 0 = 1 <= 1. ALLOWED.
Result: 1 replica in zone a, 1 in zone b, 0 in c.
Zone c is left empty, and that IS CORRECT: with 2 replicas you cannot
cover 3 zones.Final skew: 1. Satisfied. The constraint works fine with fewer replicas than zones.
Special case: 2 replicas if one zone runs out of nodes
Scenario: zone c loses all its nodes (a complete outage).
Available nodes: only in zones a and b.
minDomains: 3 makes the scheduler KEEP COUNTING 3 domains.
State after the outage: a:1, b:1, c:0 (zone c has no nodes).
Skew = 1 - 0 = 1 <= maxSkew 1. SATISFIED. Nothing to do.
But if the HPA wanted to scale to 4 replicas:
Placing replica 3:
If it goes to zone a: a:2, b:1, c:0. Skew = 2 - 0 = 2 > 1. NOT ALLOWED.
If it goes to zone b: a:1, b:2, c:0. Skew = 2 - 0 = 2 > 1. NOT ALLOWED.
If it goes to zone c: THERE ARE NO NODES.
-> Replica 3 stays PENDING FOREVER.This is the danger of minDomains combined with DoNotSchedule. The constraint, designed to guarantee the spread, prevents scaling when a zone is unavailable: exactly the moment when scaling is needed most, because you have lost a third of your capacity.
Possible mitigations:
| Option | Effect | Downside |
|---|---|---|
Remove minDomains |
With 2 live zones, the spread across them is satisfied | You lose the three-zone guarantee in normal operation |
whenUnsatisfiable: ScheduleAnyway on zone |
It never blocks | You lose the hard guarantee |
Raise maxSkew to 2 |
With a:2, b:1, c:0, the skew is 2 <= 2. Allowed | A less balanced spread |
| Accept it and have a procedure | On a zone outage, relax the constraint by hand | Requires human intervention under pressure |
Recommendation for Rutas Norte: maxSkew: 1 with minDomains: 3 and a documented procedure for relaxing it during a zone outage:
# EMERGENCY PROCEDURE: zone outage
# Relax the topology constraint to allow scaling in the surviving zones.
kubectl patch deployment bookings-api -n rutas-norte-pro --type json -p '[
{"op": "replace", "path": "/spec/template/spec/topologySpreadConstraints/0/whenUnsatisfiable",
"value": "ScheduleAnyway"}
]'
# REVERT when the zone comes back.Have it written down in advance, tested, and in the runbook. It is far better than improvising it at three in the morning.
Solution 3
Analysing the requirements and their consequences:
| Requirement | Design consequence |
|---|---|
| Critical path, stateless | At least 3 replicas, spread across 3 zones |
| A 2-8 s transaction that cannot be interrupted | A high terminationGracePeriodSeconds + orderly shutdown |
| 50 maximum connections from the bank | A hard cap on the replica count |
| 5-300 transactions/s | Autoscaling needed, but bounded by the bank's limit |
| 20 s start-up | A conservative scaling target; do not scale to zero |
| Logging before answering | Shutdown must wait for the log to be written |
| Must survive a zone outage | DoNotSchedule across zones, minDomains: 3 |
The 50-connection limit is the dominant constraint, and it has to be worked out before anything else.
Step 1: compute the real replica ceiling
The bank allows 50 simultaneous connections from the Rutas Norte IP.
If each replica keeps a pool of N connections:
Maximum replicas = 50 / N
With N = 5 connections per replica: 10 maximum replicas
With N = 3 connections per replica: 16 maximum replicas
With N = 2 connections per replica: 25 maximum replicas
How many transactions per second does one replica sustain?
Each transaction takes 2-8 s, averaging ~5 s.
With a pool of N concurrent connections:
transactions/s = N / 5
With N = 5: 1 transaction/s per replica
With N = 3: 0.6 transactions/s per replica
For 300 transactions/s at the peak:
With N = 5: 300 replicas. IMPOSSIBLE (30x over the bank's limit).
With N = 3: 500 replicas. Worse.Here there is a serious problem that no Kubernetes configuration solves:
The system's THEORETICAL MAXIMUM capacity:
50 concurrent connections / 5 seconds per transaction = 10 transactions/s
PEAK TARGET: 300 transactions/s
THE SYSTEM CANNOT PROCESS MORE THAN 10 TRANSACTIONS PER SECOND.
The limit is the BANK, not Kubernetes.This is the 09-01 lesson about the real bottleneck, in its purest form. Scaling payments-gateway to 30 replicas does not add a shred of capacity: the 30 replicas would fight over the same 50 connections.
Step 2: the architecture that does work
The solution is not about scaling, it is about architecture:
1. NEGOTIATE a higher limit with the bank. 50 connections is ridiculous for
300 transactions/s. It is the most important action and it is not technical.
2. In the meantime: DECOUPLE with a queue.
- bookings-api queues the charge request and answers the user
"your payment is being processed".
- payments-gateway consumes from the queue at the rate the bank allows.
- The user receives confirmation by push notification or email.
This turns a hard constraint into an acceptable latency, and it fits
perfectly with KEDA (09-04): the queue is the natural scaling signal.
3. A SHARED POOL of connections to the bank, instead of a pool per replica.Since the brief asks us to design high availability, we assume option 3 with a bounded replica count.
Step 3: the manifests
# k8s/environments/pro/deployment-payments-gateway.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-gateway
namespace: rutas-norte-pro
labels:
app: payments-gateway
app.kubernetes.io/part-of: rutas-norte
spec:
# NO replicas field: it is governed by the HPA/KEDA.
selector:
matchLabels:
app: payments-gateway
strategy:
type: RollingUpdate
rollingUpdate:
# Consistent with the PDB (see below). Never lose more than 1 replica.
maxUnavailable: 1
# maxSurge 1, no more: every new replica opens connections to the bank and
# we have a hard limit of 50. A high maxSurge could exhaust it
# during the deployment.
maxSurge: 1
template:
metadata:
labels:
app: payments-gateway
app.kubernetes.io/part-of: rutas-norte
spec:
# A 90-SECOND GRACE PERIOD.
#
# A transaction takes up to 8 seconds and CANNOT be interrupted:
# cutting it could leave a charge with no associated booking, which is the
# worst possible failure in a payment gateway.
#
# 90 s = 8 s (longest transaction) + a wide margin for:
# - draining the internal queue of in-flight transactions
# - writing the compliance log for each of them
# - cleanly closing the TLS connections to the bank
#
# The process MUST handle SIGTERM: stop accepting new requests,
# finish the ones in flight, write the logs, and exit.
terminationGracePeriodSeconds: 90
topologySpreadConstraints:
# ZONES: a HARD constraint. The requirement to survive a zone
# outage is explicit and non-negotiable.
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
minDomains: 3
labelSelector:
matchLabels:
app: payments-gateway
matchLabelKeys: ["pod-template-hash"]
# NODES: a HARD constraint too, and here we CAN afford it.
# With a maximum of 9 replicas and 9 nodes, requiring 1 per node is
# achievable. And two gateway replicas on the same node are
# two replicas lost together: unacceptable on the critical
# path of a payment.
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: payments-gateway
matchLabelKeys: ["pod-template-hash"]
containers:
- name: gateway
image: registry.rutasnorte.example/payments-gateway:2.4.1
# ORDERLY SHUTDOWN: the preStop hook gives the Service time to
# remove this pod from its endpoints BEFORE it starts shutting down.
# Without it, there is a 1-2 second window in which the pod receives
# new requests while it is already shutting down.
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
resources:
requests: {cpu: 200m, memory: 256Mi}
limits: {cpu: 500m, memory: 512Mi}
env:
# A SMALL, EXPLICIT POOL: 5 connections per replica.
# With maxReplicas 9: 9 x 5 = 45 < 50. It leaves 5 connections spare
# for the deployment (maxSurge 1) and for retries.
# THIS NUMBER IS CRITICAL: raising it exhausts the bank's limit.
- name: BANK_POOL_MAX
value: "5"
readinessProbe:
httpGet: {path: /ready, port: 8080}
initialDelaySeconds: 20 # Start-up takes 20 s (mutual TLS)
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet: {path: /health, port: 8080}
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
# STARTUP PROBE: gives up to 60 s for start-up without the
# livenessProbe killing the pod midway through the TLS negotiation.
startupProbe:
httpGet: {path: /health, port: 8080}
periodSeconds: 5
failureThreshold: 12# k8s/environments/pro/pdb-payments-gateway.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: payments-gateway
namespace: rutas-norte-pro
labels:
app: payments-gateway
app.kubernetes.io/part-of: rutas-norte
spec:
# maxUnavailable: 1 AS AN ABSOLUTE NUMBER, not a percentage.
#
# It is the exception to the general rule of section 3, and it is deliberate:
#
# 1. The replica range is NARROW (3 to 9), unlike bookings-api (4 to 30).
# A 25% percentage would give: 3 replicas -> 0 evictions (BLOCKED), and
# 9 replicas -> 2 evictions. The 3-replica case would be an impossible PDB.
#
# 2. Every replica that goes down can take up to 5 in-flight transactions with it,
# each one with a customer's real money. We want evictions to be
# SERIALISED always, whatever the replica count.
#
# 3. With a terminationGracePeriodSeconds of 90 s, each eviction takes up to
# a minute and a half. Serialising them makes maintenance slow but safe,
# which is exactly the trade-off we want in a payment gateway.
maxUnavailable: 1
selector:
matchLabels:
app: payments-gateway
# AlwaysAllow: a pod that fails readiness is not processing payments
# (the Service is no longer sending it traffic), so evicting it puts
# no transaction at risk.
unhealthyPodEvictionPolicy: AlwaysAllow# k8s/environments/pro/scaledobject-payments-gateway.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: payments-gateway
namespace: rutas-norte-pro
spec:
scaleTargetRef:
name: payments-gateway
pollingInterval: 15
cooldownPeriod: 600
# A floor of 3 replicas: ONE PER ZONE. It is the minimum for surviving a
# zone outage with 2 operational replicas. NEVER scale to zero:
# the 20 s start-up (mutual TLS negotiation with the bank) is unacceptable
# with a user sitting on the payment screen.
minReplicaCount: 3
# 9 REPLICAS MAXIMUM. THIS NUMBER DOES NOT COME FROM THE TRAFFIC, IT COMES FROM THE BANK:
# 9 replicas x 5 connections = 45 connections < the limit of 50.
# 5 connections spare for the deployment (maxSurge 1) and for retries.
#
# CRITICAL WARNING: raising this number WITHOUT renegotiating the limit with the
# bank does NOT add capacity. It only makes the replicas compete for
# the same 50 connections and start receiving connection-refused
# errors. The bottleneck is the BANK (see 09-01).
maxReplicaCount: 9
fallback:
failureThreshold: 3
replicas: 6
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
# 30 s of stabilisation (not 0): every new replica takes 20 s to
# be ready and opens 5 connections to the bank. We do not want to create
# replicas wildly and exhaust the connection limit.
stabilizationWindowSeconds: 30
selectPolicy: Max
policies:
- type: Pods
value: 2
periodSeconds: 60
scaleDown:
# 15 minutes. Every replica that shuts down takes up to 90 s to finish
# its transactions. Coming down slowly is essential.
stabilizationWindowSeconds: 900
selectPolicy: Min
policies:
- type: Pods
value: 1
periodSeconds: 300
triggers:
# Scaling on ACTIVE CONNECTIONS TO THE BANK, not on CPU nor on requests.
# It is the metric that reflects the real scarce resource.
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoring.svc.cluster.local:9090
metricName: gateway_bank_connections_active
query: |
sum(payments_gateway_bank_connections_active{namespace="rutas-norte-pro"})
# Target: 3.5 active connections per replica (out of a pool of 5).
# At 70% pool occupancy, we scale.
threshold: "3.5"
activationThreshold: "1"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-read
# Safety net: transactions queued waiting for a free connection.
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoring.svc.cluster.local:9090
metricName: gateway_transactions_waiting
query: |
sum(payments_gateway_transactions_waiting{namespace="rutas-norte-pro"})
threshold: "3"
activationThreshold: "1"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-readSummary of the decisions and their justification:
| Decision | Choice | Justification |
|---|---|---|
minReplicaCount |
3 | One per zone; survives a zone outage with 2 operational |
maxReplicaCount |
9 | 9 × 5 = 45 < the bank's 50 connections. It does not come from the traffic |
| PDB | maxUnavailable: 1 absolute |
A narrow range (3-9); 25% would give 0 evictions with 3 replicas |
topologySpread zone |
DoNotSchedule + minDomains: 3 |
An explicit requirement to survive a zone outage |
topologySpread node |
DoNotSchedule, maxSkew: 1 |
With 9 replicas and 9 nodes it is achievable; two replicas per node is unnecessary risk |
terminationGracePeriodSeconds |
90 s | 8 s of transaction + logging + TLS close, with a wide margin |
preStop |
sleep 10 |
So the Service removes the pod before it starts shutting down |
startupProbe |
60 s of headroom | Mutual TLS takes 20 s; it stops liveness killing the start-up |
| Scaling metric | Active connections to the bank | It is the real scarce resource, not CPU nor requests |
maxSurge |
1 | Every extra replica consumes connections from the bank's limit |
| Scale to zero | No | 20 s of start-up with a user on the payment screen |
The conclusion to take away from the exercise, and it goes beyond the configuration:
payments-gateway's high availability is limited by the bank, not by Kubernetes. No amount of PDBs, topologySpreadConstraints or autoscaling lets you process more than 10 transactions per second with 50 connections and 5 seconds per transaction.
With 300 transactions per second at the May bank-holiday peak, the system will saturate irremediably. The actions that genuinely solve the problem are:
- Renegotiating the limit with the bank. It is not technical, it is commercial, and it is the most important one.
- Decoupling with a queue, turning a hard constraint into an acceptable latency communicated to the user.
- A shared pool instead of a pool per replica, with an intermediate component that multiplexes.
- An explicit rate limit in
bookings-apithat politely rejects payment requests when the gateway is saturated, instead of letting them pile up until they time out.
Designing a component's high availability without understanding its real bottleneck is exactly the mistake 09-01 warned about: scaling the web layer when the limit is elsewhere multiplies the cost without selling a single extra ticket.
Conclusion
High availability is not a Kubernetes object you switch on: it is a property of the system that you build by understanding what can fail and what protection exists for each thing.
The essentials:
- Kubernetes distinguishes voluntary and involuntary disruptions, and it can only protect you from the former. A
kubectl draingoes through the API and can be negotiated; a server switching itself off cannot. - The PodDisruptionBudget declares how much service must remain available in the face of evictions. It is honoured by
kubectl drain, the Cluster Autoscaler, the VPA updater and the descheduler; it is not honoured bykubectl delete podnor by the Deployment's rolling updates. minAvailabletalks about what stays;maxUnavailableabout what goes. Percentages round in opposite directions, always favouring availability. With an HPA or KEDA, always percentages: an absolute number becomes impossible in the trough and blocks all maintenance.- The impossible PDB (
minAvailableequal to the replica count, or a single replica) pins nodes, blocks drains and stops the Cluster Autoscaler from scaling down. Sometimes it is deliberate (bookings-postgres); document it with an annotation so that nobody "fixes" it. unhealthyPodEvictionPolicy: AlwaysAllowbreaks the circular deadlock of a faulty deployment whose broken pods prevent its own repair. Use it on everything without quorum.topologySpreadConstraintsspreads evenly across failure domains. Against thepodAntiAffinityof 06-05, it wins clearly for autoscaled workloads: required anti-affinity caps the replicas at the number of nodes, and with 30 replicas and 9 nodes it leaves 21 podsPendingforever.- The asymmetry between levels is the key decision:
DoNotScheduleacross zones (a zone outage is catastrophic) andScheduleAnywayacross nodes (blocking scaling would be worse). AndmatchLabelKeys: ["pod-template-hash"]always, so that deployments do not stall. - High availability in the other layers matters just as much: three or five etcd members (never even) spread across zones, several replicas of the Ingress controller and of CoreDNS with their PDBs, and for
bookings-postgres, an operator that knows how to promote a replica: Kubernetes does not do that and cannot do it. - The maintenance procedure is
cordon→drain→ upgrade →uncordon, checking the PDBs beforehand and the service between each node. Without PDBs, a drain takes all of a node's replicas in an instant; with PDBs, it serialises them and the user notices nothing. - The chaos test reveals what theory hides: a drain produces zero errors; a node that dies produces around 40 seconds of errors while the control plane finds out. That difference, measured with a stopwatch, is the practical definition of voluntary versus involuntary.
Rutas Norte now has the platform spread across three zones with hard guarantees, PDBs consistent with the autoscaling, and a maintenance procedure that does not interrupt the sale. The replicas grow when needed, they are the right size, nodes appear when they do not fit, scaling anticipates known events, and none of it breaks when somebody upgrades a node or when a server goes down.
And yet, there is a question we have not asked in the whole module: is all of it really necessary?
We have taken as given the numbers we have been carrying along: that one bookings-api replica sustains 85 requests per second, that we need to scale to 30 at the peak, that requests.cpu should be 412m. But nobody has asked why one replica only handles 85 requests per second, nor whether it could handle 200 with the same resources. Nobody has looked at whether the PostgreSQL connection pool is sized properly, whether the queries have the indexes they need, whether redis-cache is really being used, or whether the CPU throttling we saw in module 3 shows up far earlier than we thought.
Scaling is multiplying inefficiency by the number of replicas. If each replica wastes half its capacity, thirty replicas waste fifteen.
In the next lesson, Performance Tuning, we close the module by looking inward: the methodology of measuring, finding the real bottleneck and changing one thing at a time; load tests with k6 simulating the May bank holiday and how to read their results properly; tuning the application layer, which is where the problem almost always is; how a CPU limit translates into a kernel quota and why throttling shows up far before 100%; fast start-up as an autoscaling requirement; cluster tuning, including the effect of ndots: 5 we left pending in 04-03; and a latency budget that turns the business objective into per-component objectives.
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
