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

  1. Voluntary and involuntary disruptions
  2. The PodDisruptionBudget: what it is and what it protects
  3. minAvailable versus maxUnavailable
  4. The eviction API and kubectl drain
  5. Demonstration: a drain that keeps waiting
  6. The impossible PDB that blocks maintenance
  7. unhealthyPodEvictionPolicy
  8. The Rutas Norte PDBs, component by component
  9. topologySpreadConstraints in depth
  10. topologySpreadConstraints versus podAntiAffinity
  11. Spreading Rutas Norte across zones and nodes
  12. High availability in the other layers
  13. The node-maintenance procedure
  14. Interaction with the Cluster Autoscaler and with deployments
  15. A simple chaos test
  16. Common Mistakes and Tips
  17. Exercises
  18. Conclusion

  1. 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:

spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 2

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

  1. 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-api

Three fields and nothing else:

  • selector: which pods it covers. Just like the selector of a Service or a Deployment (02-07).
  • minAvailable or maxUnavailable: 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:

  1. Looks for the PDBs whose selector matches that pod.
  2. For each one, computes how many pods are currently available (Ready).
  3. Checks whether evicting this pod would violate the constraint.
  4. If it would, it rejects the request with an HTTP 429 error (TooManyRequests).
  5. 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

  1. minAvailable versus maxUnavailable

The 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."

spec:
  minAvailable: 3
  selector:
    matchLabels:
      app: bookings-api

With 4 replicas: 1 can be evicted (3 remain). With 10 replicas: 7 can be evicted.

maxUnavailable

"At most N pods may be unavailable."

spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: bookings-api

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.

  1. The eviction API and kubectl drain

A 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:

POST /api/v1/namespaces/rutas-norte-pro/pods/bookings-api-7c9d4f8b6d-2mk8p/eviction

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:

kubectl get pdb -n rutas-norte-pro
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                     12d

ALLOWED 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:

kubectl describe pdb bookings-api -n rutas-norte-pro
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

  1. 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=64Mi

A 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-pdb
kubectl apply -f /tmp/pdb-demo.yaml
kubectl get pdb -n rutas-norte-dev
NAME       MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
demo-pdb   3               N/A               0                     8s

ALLOWED DISRUPTIONS: 0. We already know what is going to happen.

The drain

kubectl get pods -n rutas-norte-dev -o wide
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-m03
kubectl drain rutas-norte-m02 --ignore-daemonsets --delete-emptydir-data
node/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:

  1. The node is ALREADY cordoned (cordoned). Even though the drain makes no progress, the node accepts no new pods. That is the first thing drain does.
  2. The message is explicit: Cannot evict pod as it would violate the pod's disruption budget.
  3. drain does 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=4

As 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-dev

Never 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.

  1. 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.

spec:
  replicas: 3        # in the Deployment
---
spec:
  minAvailable: 3    # in the PDB

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.

spec:
  replicas: 1
---
spec:
  minAvailable: 1        # Equivalent to maxUnavailable: 0

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

# Every PDB in the cluster with its allowed disruptions
kubectl get pdb --all-namespaces
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               0

The three zeros have to be analysed one by one:

  • bookings-postgres: a single primary replica. ALLOWED DISRUPTIONS: 0 is 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 == 0

With 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.

  1. unhealthyPodEvictionPolicy

A 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: AlwaysAllow

The 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.

  1. 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: AlwaysAllow

web-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: AlwaysAllow

notifications-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: AlwaysAllow

redis-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: IfHealthyBudget

bookings-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: IfHealthyBudget

Summary 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.

  1. topologySpreadConstraints in depth

PDBs 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-hash

topologyKey — 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.

skew = (pods in the domain with the MOST) - (pods in the domain with the FEWEST)

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.

  1. topologySpreadConstraints versus podAntiAffinity

In 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/hostname

It 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/hostname

The 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

  1. 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/hostname

And 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).

  1. 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: AlwaysAllow

Two decisions that depart from what we said earlier:

  1. DoNotSchedule across 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.
  2. minAvailable: 2 in 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-critical

And its PDB:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: coredns
  namespace: kube-system
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      k8s-app: kube-dns
  unhealthyPodEvictionPolicy: AlwaysAllow

An 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) and bookings-postgres-ro (replicas).
  • Streaming replication between instances.
  • Automatic failover: if the primary goes down, it promotes a replica and repoints the -rw Service 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).

  1. 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

# Do all the PDBs allow at least one eviction?
kubectl get pdb --all-namespaces
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                 1

The two zeros are the deliberate ones from section 8. You have to know which nodes they are on:

kubectl get pods -n rutas-norte-pro -o wide \
  -l 'app in (bookings-postgres,redis-cache)'
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-1b

Those 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-a2
NAMESPACE         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   <- DaemonSet

Three 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 -20

Step 2: cordon

kubectl cordon node-a2
node/node-a2 cordoned

What it does exactly: it sets spec.unschedulable: true on the node and adds the taint node.kubernetes.io/unschedulable:NoSchedule.

kubectl get nodes
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.2

cordon 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=300s

The 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 drained

Look 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-a2
NAMESPACE     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   12d

Only 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-pro

A 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

exit

Step 6: verify the node

kubectl get node node-a2
NAME      STATUS                     ROLES    AGE   VERSION
node-a2   Ready,SchedulingDisabled   <none>   12d   v1.30.3

Ready with the new version. Still cordoned, which is correct.

# Check there are no error conditions
kubectl describe node node-a2 | grep -A10 Conditions

Step 7: uncordon

kubectl uncordon node-a2
node/node-a2 uncordoned
NAME      STATUS   ROLES    AGE   VERSION
node-a2   Ready    <none>   12d   v1.30.3

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 -c
      2 node-a1
      1 node-a3
      2 node-b1
      1 node-b2

After 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:

kubectl drain node-a2 --ignore-daemonsets --delete-emptydir-data
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 drained

Instantaneous. 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.

  1. 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-filler

With 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.

  1. 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-b

A 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: 80
kubectl apply -f /tmp/chaos-demo.yaml
kubectl get pods -n rutas-norte-dev -o wide
NAME                         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-b

A 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'
# Terminal 2: the drain
kubectl drain rutas-norte-m02 --ignore-daemonsets --delete-emptydir-data

Expected result in terminal 1:

OK=847 ERR=0
OK=848 ERR=0
OK=849 ERR=0
...
OK=1204 ERR=0

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.

# Restore
kubectl uncordon rutas-norte-m02

Experiment 2: a node going down (involuntary)

# Simulate a hardware failure: switch the node off abruptly
minikube -p rutas-norte node stop rutas-norte-m03

Let's watch the full sequence:

kubectl get nodes --watch
NAME              STATUS     ROLES    AGE
rutas-norte-m03   Ready      <none>   1h
rutas-norte-m03   NotReady   <none>   1h     <- after ~40 seconds
kubectl get pods -n rutas-norte-dev -o wide --watch
NAME                         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-m04

The 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=68

This 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

minikube -p rutas-norte node start rutas-norte-m03
kubectl delete -f /tmp/chaos-demo.yaml

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 -c
      3 eu-west-1a
      3 eu-west-1b
      2 eu-west-1c

A 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:

kubectl drain node-b1 --ignore-daemonsets --delete-emptydir-data
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               0

The 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-b1

Analyse 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-api
# PDB
spec:
  maxUnavailable: 25%

The 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-exporter

A 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: AlwaysAllow

Block 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: prometheus

Option 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-namespaces
NAMESPACE          NAME                   MAX UNAVAILABLE   ALLOWED DISRUPTIONS
rutas-norte-pro    bookings-api           25%               1
monitoring         prometheus             1                 1

The 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-pro

Structural prevention:

  1. 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"'
  1. A Kyverno policy (08-03) that rejects PDBs with an absolute minAvailable on workloads that have an HPA or a ScaledObject.

  2. A permanent alert:

kube_poddisruptionbudget_status_pod_disruptions_allowed == 0

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:

maxUnavailable: 25% over 11 replicas.
floor(11 x 0.25) = floor(2.75) = 2

ALLOWED DISRUPTIONS: 2

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:

floor(26 x 0.25) = floor(6.5) = 6

ALLOWED DISRUPTIONS: 6

(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-read

Summary 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:

  1. Renegotiating the limit with the bank. It is not technical, it is commercial, and it is the most important one.
  2. Decoupling with a queue, turning a hard constraint into an acceptable latency communicated to the user.
  3. A shared pool instead of a pool per replica, with an intermediate component that multiplexes.
  4. An explicit rate limit in bookings-api that 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 drain goes 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 by kubectl delete pod nor by the Deployment's rolling updates.
  • minAvailable talks about what stays; maxUnavailable about 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 (minAvailable equal 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: AlwaysAllow breaks the circular deadlock of a faulty deployment whose broken pods prevent its own repair. Use it on everything without quorum.
  • topologySpreadConstraints spreads evenly across failure domains. Against the podAntiAffinity of 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 pods Pending forever.
  • The asymmetry between levels is the key decision: DoNotSchedule across zones (a zone outage is catastrophic) and ScheduleAnyway across nodes (blocking scaling would be worse). And matchLabelKeys: ["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 cordondrain → 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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved