Up to now, throughout the whole course, there has been one cluster. The rutas-norte-dev, rutas-norte-pre and rutas-norte-pro namespaces lived side by side in it, separated by quotas, network policies and RBAC. It is a perfectly defensible architecture and it is where almost everybody starts.
But there comes a point when it stops being so. It might be a control-plane upgrade that affects all three environments at once; it might be the auditor asking why the cluster running rutas-norte-dev has access to the network where production personal data lives; it might be that the company opens operations in another region and the latency from Portugal to eu-west-1 starts showing up in conversion. At Rutas Norte it was the first of those: an EKS upgrade that went wrong in pre also left pro unreachable for nineteen minutes.
This lesson is about what changes when the platform no longer fits into one cluster: why you end up there, what topologies exist, how you work day to day without applying to the wrong cluster, how Argo CD governs a fleet, how clusters are connected to one another, and the problem with no easy solution when you have to recover from a disaster, which is the data.
Compliance warning. Distributing clusters across regions directly affects the residency of the personal data of Rutas Norte's customers. Replicating
bookings-postgresto another region, storing backups outside the main region, or allowing a cluster in one region to query data held in another, are decisions with legal implications that must be reviewed and approved by the compliance officer before being implemented. The examples in this lesson are illustrative.
Contents
- Why a company ends up with several clusters
- Common topologies and which one to choose
- Day-to-day work with several contexts
- Multi-cluster deployment with Argo CD
- Common configuration and policies: the cluster as a product
- Connectivity between clusters
- Disaster recovery
- The Rutas Norte plan
- The cost, the complexity and when not to do it
- Why a company ends up with several clusters
It is not a decision taken one morning. You get there through an accumulation of reasons, and it is worth recognising them so you know which one is pushing you.
1.1. Isolating production from the rest
Namespaces separate workloads, but they do not separate the control plane. They share the API server, etcd, the scheduler, the CNI, the Ingress controller and the add-ons. Everything that is "cluster-wide" is shared: CRDs, ClusterRole, admission webhooks, operator versions.
In practice, this means a developer deploying a malformed CRD in dev, or an operator consuming memory without limit, can degrade production. At Rutas Norte it was a badly configured validating webhook in dev that, by failing to respond, blocked pod creation across the whole cluster for eight minutes, production included.
1.2. Blast radius
With one cluster, the question "what happens if the cluster fails?" has a single, very bad answer: everything goes down. The managed EKS control plane is very reliable, but it is not the only point of failure: an overly broad NetworkPolicy, VPC IP exhaustion, a faulty CNI version or a human error with kubectl delete affect everything inside it.
1.3. Regions and latency
Rutas Norte operates mainly in the north of the Iberian peninsula, but it is looking at expanding into southern France. A request from Toulouse to eu-west-1 (Ireland) adds around 35-45 ms of round trip on the network alone. Over a ticket purchase with six chained calls, that is nearly 300 ms of added latency that the user notices.
1.4. Regulatory data-residency requirements
This is the reason that admits no technical debate: if the applicable regulation, a contract with a corporate customer or a decision by the compliance officer requires that certain customers' personal data must not leave a territory, you need infrastructure in that territory. There is no namespace configuration that solves that.
1.5. Different versions
Kubernetes publishes a minor release roughly every four months, and support for each one lasts around fourteen. Upgrading is compulsory, and testing the upgrade on the same cluster that serves production is impossible by definition. With several clusters you can take dev to the new version, let it break whatever it has to break, and upgrade production weeks later with the lesson learned.
1.6. The cluster gets upgraded too
Related, but distinct: even upgrades that go well have risk windows. Nodes are replaced, add-ons restart, the CNI is upgraded. With a single cluster, every upgrade is a production event. With two, it is a production event once every two upgrades, because the first has been rehearsed.
1.7. And what it costs
| Cost | Detail |
|---|---|
| Control plane | On EKS, about €73/month per cluster; multiplied by the fleet |
| Duplicated add-ons | Prometheus, Grafana, ingress-nginx, cert-manager, Kyverno, Argo CD in each cluster |
| Idle capacity | Each cluster needs its own headroom; it is not shared |
| Operational complexity | Every procedure is run N times, or has to be automated for N |
| Drift between clusters | The real risk: two clusters that should be identical stop being so |
| Cognitive load | "Which cluster am I in?" is a question with consequences |
Drift deserves emphasis: the problem with having several clusters is not having them, it is keeping them identical. A patch applied by hand to one and not the other creates a difference nobody remembers until it causes an incident months later. Everything in section 5 exists to fight that.
- Common topologies and which one to choose
| Topology | Description | Advantages | Drawbacks | When |
|---|---|---|---|---|
| By environment | One cluster for dev+pre, another for pro |
Isolates production's control plane; allows rehearsing upgrades | Duplicates add-ons; does not solve regions | Almost always the first step. It is the best value |
| By region | One cluster per geographical region | Low latency; data residency; tolerance to a regional failure | Data replication between regions (the hard problem) | Presence in several geographies or a regulatory requirement |
| By business unit | One cluster per team or product | Total autonomy; costs clearly attributed | Proliferation; add-ons multiplied; a lot of drift | Large organisations with platform teams per unit |
| By criticality | One for the critical, another for the rest | Protects what matters without duplicating everything | A fuzzy boundary: where do the in-between things go? | When there is a clear difference between the critical and the incidental |
| Management cluster | One that only hosts the tooling (Argo CD, central observability) and governs the rest | A single point for deployment and visibility; it does not compete with the workloads | A single point of failure for governance; oversized for small fleets | From three or four clusters onwards |
2.1. Recommendation
Start with one. A single cluster with well-separated namespaces, quotas, network policies and RBAC solves 80 % of cases and costs a fraction.
The first split should be production against everything else. It gives the most per unit of complexity: it isolates the control plane of the critical thing, allows upgrades to be rehearsed and satisfies any reasonable audit.
The second, if it comes, is imposed by geography or regulation, not by architectural preference.
The management cluster, from the third onwards. With two clusters, Argo CD can live in the production one managing both. With four, you need a neutral place.
graph TB
subgraph fase1["Phase 1 · today"]
C1[Single cluster<br/>ns dev / pre / pro]
end
subgraph fase2["Phase 2 · Rutas Norte now"]
C2A[rutasnorte-pro<br/>eu-west-1]
C2B[rutasnorte-nonpro<br/>dev + pre · eu-west-1]
ACD2[Argo CD in pro] -.manages.-> C2A
ACD2 -.manages.-> C2B
end
subgraph fase3["Phase 3 · if the expansion happens"]
MGT[Management cluster<br/>Argo CD + observability]
C3A[pro eu-west-1]
C3B[pro eu-west-3]
C3C[nonpro]
MGT --> C3A
MGT --> C3B
MGT --> C3C
end
fase1 --> fase2 --> fase3
- Day-to-day work with several contexts
This is where accidents happen. A kubectl delete deploy run in the belief that you are in dev when you are in pro is a real and frequent incident.
3.1. The kubeconfig with several clusters
# ~/.kube/config (excerpt)
apiVersion: v1
kind: Config
current-context: rutasnorte-nonpro
clusters:
- name: rutasnorte-pro
cluster:
server: https://ABC123.gr7.eu-west-1.eks.amazonaws.com
certificate-authority-data: LS0tLS1CRUdJTiBDRVJU...
- name: rutasnorte-nonpro
cluster:
server: https://DEF456.gr7.eu-west-1.eks.amazonaws.com
certificate-authority-data: LS0tLS1CRUdJTiBDRVJU...
users:
- name: joan-pro
exec:
apiVersion: client.authentication.k8s.io/v1
command: aws
args: [eks, get-token, --cluster-name, rutasnorte-pro, --role-arn,
"arn:aws:iam::111122223333:role/rutasnorte-pro-readonly"]
- name: joan-nonpro
exec:
apiVersion: client.authentication.k8s.io/v1
command: aws
args: [eks, get-token, --cluster-name, rutasnorte-nonpro]
contexts:
# The context name is the first line of defence: make it frightening.
- name: PRODUCTION-rutasnorte
context: { cluster: rutasnorte-pro, user: joan-pro, namespace: rutas-norte-pro }
- name: rutasnorte-nonpro
context: { cluster: rutasnorte-nonpro, user: joan-nonpro, namespace: rutas-norte-dev }kubectl config get-contexts
kubectl config use-context rutasnorte-nonpro
kubectl config current-contextCURRENT NAME CLUSTER NAMESPACE
PRODUCTION-rutasnorte rutasnorte-pro rutas-norte-pro
* rutasnorte-nonpro rutasnorte-nonpro rutas-norte-dev3.2. kubectx and kubens
kubectx # lists contexts
kubectx rutasnorte-nonpro # switches context
kubectx - # goes back to the previous one
kubens rutas-norte-pre # switches namespace without touching the contextThey are two small utilities that save a lot of time. Installing them is the first thing anyone working with more than one cluster does.
3.3. The measures for not getting the cluster wrong
Five layers, from the softest to the hardest. The first three are warnings; the last two are real barriers.
Layer 1: the prompt shows the context. This is essential. Without it, everything else is optional.
# ~/.bashrc — context and namespace in the prompt, in red if it is production
k8s_context() {
local ctx ns
ctx=$(kubectl config current-context 2>/dev/null) || return
ns=$(kubectl config view --minify -o jsonpath='{..namespace}' 2>/dev/null)
if [[ "$ctx" == *PRODUCTION* ]]; then
printf '\001\e[41;97m\002 ⚠ %s:%s \001\e[0m\002' "$ctx" "${ns:-default}"
else
printf '\001\e[36m\002(%s:%s)\001\e[0m\002' "$ctx" "${ns:-default}"
fi
}
PS1='$(k8s_context) \w \$ 'A red background when you are in production. It looks trivial and it is the measure that prevents the most accidents.
Layer 2: a context per session, not a global one. The problem with use-context is that it changes the state of every open terminal. A better solution is to isolate the kubeconfig per terminal:
# Aliases that open a session with its own kubeconfig
alias k-pro='export KUBECONFIG=~/.kube/pro.yaml && echo "⚠ PRODUCTION SESSION"'
alias k-dev='export KUBECONFIG=~/.kube/nonpro.yaml'That way, the production terminal is the production terminal, full stop: there is no way for a use-context in another window to change it.
Layer 3: explicit confirmation for destructive verbs.
# kubectl wrapper: asks for confirmation in production for the dangerous verbs
kubectl() {
local ctx; ctx=$(command kubectl config current-context 2>/dev/null)
if [[ "$ctx" == *PRODUCTION* && "$1" =~ ^(delete|drain|cordon|scale|patch|replace|edit)$ ]]; then
echo "⚠ You are about to run '$1' in $ctx"
read -r -p "Type the cluster name to confirm: " r
[[ "$r" == "$ctx" ]] || { echo "Cancelled."; return 1; }
fi
command kubectl "$@"
}Layer 4: read-only contexts by default. This is the first real barrier, because it does not depend on anyone's discipline. The joan-pro user in the kubeconfig assumes the rutasnorte-pro-readonly role, mapped to a read-only ClusterRole:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform-read-pro
subjects:
- kind: Group
name: rutasnorte:platform
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: view # built-in ClusterRole: get, list, watch. Nothing else.
apiGroup: rbac.authorization.k8s.ioWriting to production requires explicitly assuming another role, with a temporary session and a recorded reason. It is a deliberate thirty-second friction that turns every write into a conscious act.
Layer 5: never write to production by hand. The ultimate goal. With GitOps (10-05), the only thing that applies changes in pro is Argo CD. People read, diagnose and propose changes in Git. Manual writing is reserved for incidents, with the role-assumption procedure and its audit log.
| Measure | Implementation cost | Accidents it prevents |
|---|---|---|
| Prompt with the context | 5 minutes | Most slips |
| Kubeconfig per session | 10 minutes | Context switches crossing between terminals |
| Confirmation on destructive verbs | 15 minutes | delete out of habit |
| Read-only contexts | 1 hour + RBAC | Every casual-write accident |
Only GitOps writes to pro |
The whole project | Everything, and it gives traceability too |
- Multi-cluster deployment with Argo CD
Argo CD manages remote clusters from a single installation. Each cluster is registered as a destination and the Application resources point at whichever one applies.
4.1. Registering clusters
# From the cluster where Argo CD lives
argocd cluster add rutasnorte-nonpro --name nonpro \
--label environment=nonpro --label region=eu-west-1
argocd cluster add PRODUCTION-rutasnorte --name pro \
--label environment=pro --label region=eu-west-1 --label critical=true
argocd cluster listSERVER NAME VERSION STATUS LABELS
https://DEF456.gr7.eu-west-1.eks.amazonaws.com nonpro 1.30 Successful environment=nonpro,region=eu-west-1
https://ABC123.gr7.eu-west-1.eks.amazonaws.com pro 1.30 Successful environment=pro,region=eu-west-1,critical=true
https://kubernetes.default.svc in-cluster 1.30 SuccessfulThe labels are the important part: they are what lets you write "deploy this on every production cluster" without listing them.
argocd cluster add creates a ServiceAccount in the target cluster with the necessary permissions and stores its credentials as a Secret in the Argo CD cluster. That Secret is a high-value credential: whoever reads it can deploy to production. It must be protected by strict RBAC and, preferably, encrypted at rest with an externally managed key.
4.2. The ApplicationSet cluster generator
An ApplicationSet generates Application resources automatically from a template and a generator. The cluster generator creates one for each cluster matching a selector.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: platform-base
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- clusters:
# Every cluster registered with this label.
selector:
matchLabels:
rutasnorte.example/managed: "true"
template:
metadata:
# The name includes the cluster: one Application per cluster.
name: 'base-{{.name}}'
spec:
project: platform
source:
repoURL: https://github.com/rutasnorte/manifests
targetRevision: main
path: 'platform/base'
kustomize:
# Each cluster has its patch with its own particularities.
components:
- '../components/{{index .metadata.labels "environment"}}'
destination:
server: '{{.server}}'
namespace: platform
syncPolicy:
automated: { prune: true, selfHeal: true }
syncOptions: [CreateNamespace=true]This deploys the common add-ons (Kyverno, cert-manager, ingress-nginx, node-exporter) on every labelled cluster, with the per-environment differences resolved by Kustomize.
For the business applications, the matrix generator combines clusters with applications:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: rutas-norte-applications
namespace: argocd
spec:
goTemplate: true
generators:
- matrix:
generators:
# Axis 1: production clusters
- clusters:
selector:
matchLabels: { environment: pro }
# Axis 2: the components, read from the repository's directories
- git:
repoURL: https://github.com/rutasnorte/manifests
revision: main
directories:
- path: 'overlays/pro/*'
template:
metadata:
name: '{{.path.basename}}-{{.name}}'
annotations:
# Orders the deployment: the database before the API.
argocd.argoproj.io/sync-wave: '{{if eq .path.basename "bookings-postgres"}}-1{{else}}0{{end}}'
spec:
project: rutas-norte
source:
repoURL: https://github.com/rutasnorte/manifests
targetRevision: main
path: '{{.path.path}}'
destination:
server: '{{.server}}'
namespace: 'rutas-norte-pro'
syncPolicy:
automated: { prune: true, selfHeal: true }With two production clusters and six components, this generates twelve Application resources without writing twelve files. Adding a region means registering a cluster with the right label; adding a component means creating a directory.
4.3. How it combines with Kustomize
The repository structure supports two dimensions: environment and cluster.
manifests/
├── base/
│ ├── bookings-api/
│ ├── web-store/
│ └── bookings-postgres/
├── components/ # reusable variations
│ ├── high-availability/ # more replicas, strict PDB
│ ├── reduced-resources/ # for dev
│ └── read-replica/ # only for secondary clusters
└── overlays/
├── dev/
├── pre/
├── pro-eu-west-1/ # main region
│ ├── kustomization.yaml
│ └── region-patch.yaml
└── pro-eu-west-3/ # secondary region
├── kustomization.yaml
└── region-patch.yaml# overlays/pro-eu-west-3/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: rutas-norte-pro
resources:
- ../../base/bookings-api
- ../../base/web-store
components:
- ../../components/high-availability
- ../../components/read-replica # here PostgreSQL is a replica, not the primary
patches:
- path: region-patch.yaml
target: { kind: Deployment }
images:
# The SAME digest as in eu-west-1: promotion updates both overlays.
- name: bookings-api
newName: registry.rutasnorte.example/rutasnorte/bookings-api
digest: sha256:9a8b7c6d5e4f30291a8b7c6d5e4f30291a8b7c6d5e4f30291a8b7c6d5e4f3029An important rule: what is the same across clusters lives in base; what differs, and only that, in the overlay. Every line in an overlay is a difference that somebody will have to understand six months from now. If an overlay grows a lot, it almost always means the base is badly factored.
- Common configuration and policies: the cluster as a product
With several clusters, the question stops being "how do I deploy my application?" and becomes "how do I guarantee that the N clusters follow the same rules?".
The idea of the cluster as a product is that the platform team does not deliver "access to a cluster", but a product with a contract: a freshly created cluster already comes with observability, security policies, RBAC, quotas, an Ingress controller, certificate management and backups configured. Nobody has to set anything up, and therefore nobody sets it up differently.
5.1. What is part of the product
| Category | Components | Deployed by |
|---|---|---|
| Security | Kyverno with the corporate policies, Pod Security Standards, Falco | ApplicationSet platform-base |
| Identity and access | ClusterRole and bindings per identity-provider group |
ApplicationSet platform-rbac |
| Networking | ingress-nginx, cert-manager with its issuers, a default deny-all NetworkPolicy |
ApplicationSet platform-base |
| Observability | kube-prometheus-stack, Fluent Bit towards the central destination, alerting rules | ApplicationSet platform-observability |
| Costs | OpenCost, mandatory workload labelling | ApplicationSet platform-base |
| Backups | Velero with its destination and its schedule | ApplicationSet platform-base |
| Autoscaling | Karpenter with the approved node classes | Terraform (it is infrastructure, not workload) |
5.2. The policies that guarantee homogeneity
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-corporate-labels
annotations:
policies.kyverno.io/description: >-
Every workload must declare a team and a component. Without this, the
cost allocation in 11-06 and incident response are impossible.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: mandatory-labels
match:
any:
- resources:
kinds: [Deployment, StatefulSet, DaemonSet, CronJob, Rollout]
exclude:
any:
- resources:
namespaces: [kube-system, platform, argocd, monitoring]
validate:
message: "The rutasnorte.example/team and app.kubernetes.io/part-of labels are missing"
pattern:
metadata:
labels:
rutasnorte.example/team: "?*"
app.kubernetes.io/part-of: "?*"5.3. Detecting drift
Even with everything deployed by GitOps, drift appears: add-on versions installed by Terraform, emergency patches, differences in the Kubernetes version itself. A periodic report is worth having.
#!/usr/bin/env bash
# compare-fleet.sh — run weekly by a CronJob
for CTX in rutasnorte-nonpro PRODUCTION-rutasnorte; do
echo "=== $CTX ==="
kubectl --context "$CTX" version -o json | jq -r '.serverVersion.gitVersion'
kubectl --context "$CTX" get clusterpolicies -o name | sort
helm --kube-context "$CTX" list -A -o json | jq -r '.[] | "\(.name)\t\(.chart)"' | sort
doneAnd the check that really matters, done from Argo CD:
# No Application should be OutOfSync without a declared reason
argocd app list -o json | jq -r '.[] | select(.status.sync.status != "Synced")
| "\(.metadata.name)\t\(.spec.destination.name)\t\(.status.sync.status)"'
- Connectivity between clusters
Two clusters are two different pod networks. A pod in eu-west-3 cannot resolve bookings-api.rutas-norte-pro.svc.cluster.local in the eu-west-1 cluster, nor reach its IPs. There are three levels of solution, with very different complexity.
6.1. Level 1: global DNS and load balancing between regions
The simplest, and the one that solves most cases. User traffic is directed to the appropriate region by DNS, and each region is self-sufficient.
www.rutasnorte.example
├── latency policy
├── eu-west-1 → load balancer of cluster pro-1 [health check: /health]
└── eu-west-3 → load balancer of cluster pro-3 [health check: /health]- Latency policy: each user goes to the region that is fastest for them.
- Failover policy: if a region's health check fails, DNS stops resolving to it.
- An important limit: DNS has a TTL. With a 60-second TTL, failover takes between one and several minutes, because some resolvers ignore short TTLs. There is no instantaneous DNS failover.
6.2. Level 2: network connectivity between clusters
When a service in one cluster needs to call another in a different cluster (for example, the PostgreSQL replica following the primary), you need real network connectivity: VPC peering or a transit gateway, non-overlapping CIDR ranges, and explicit firewall rules.
The range requirement deserves attention: if both clusters use 10.244.0.0/16 for pods, routing between them is impossible. You have to plan the addressing before creating the second cluster, and it is a mistake you pay for with a rebuild.
6.3. Level 3: a multi-cluster service mesh
Istio, Linkerd or Cilium Cluster Mesh allow a Service in one cluster to be reachable transparently from another, with mTLS, load balancing across regions, locality failover and unified observability.
What they really solve:
- Transparent discovery:
bookings-api.rutas-norte-pro.svcworks from any cluster in the mesh. - Locality failover: the local instance is preferred and it overflows to the remote one only if the local one is unhealthy.
- mTLS between clusters without touching the application.
- A single view of the traffic topology.
What they cost:
- A sidecar per pod (or eBPF, depending on the implementation), with its CPU and memory consumption.
- One more control plane to operate and upgrade.
- A whole layer of new failure modes, with a considerable learning curve.
- Notably harder debugging: traffic no longer goes where it appears to.
The honest criterion: a multi-cluster mesh is justified when there are many services calling each other across regions. If the communication between clusters comes down to database replication and a couple of calls, VPC peering and a few ExternalName Services solve the same thing at a fraction of the cost.
Rutas Norte does not use a service mesh. With two clusters, six components and a single cross-region conversation (PostgreSQL replication), it is not justified. It will be revisited if a third region appears.
- Disaster recovery
This is where multi-cluster stops being a question of architecture and becomes a question of business survival.
7.1. Active-passive versus active-active
| Aspect | Active-passive | Active-active |
|---|---|---|
| Normal traffic | All to the main region | Split across regions |
| Secondary region | Minimal infrastructure, data replicating | Full capacity serving |
| Additional cost | 20-40 % | 100 % or more |
| Recovery time | Minutes to hours | Seconds (DNS stops sending to the failed one) |
| Data complexity | Replication in one direction | Writes in two places: the hard problem |
| It gets tested | With scheduled drills | Continuously, by construction |
| Risk of surprises | High: the cold path may not work | Low: the path is always warm |
The argument in favour of active-active is not the recovery time, it is that the recovery path is exercised every day. A passive secondary that has never served real traffic is a hypothesis, just like the backup that was never restored in 11-02.
The argument against is twofold: it costs twice as much, and above all it forces you to solve concurrent writes in two regions, which is an application design problem, not an infrastructure one.
7.2. RTO and RPO
The same concepts as in 11-02, now at the scale of a region:
| Scenario | Target RTO | Target RPO | How it is achieved |
|---|---|---|---|
| A pod fails | seconds | 0 | Replicas and probes |
| A node fails | 1-2 min | 0 | Rescheduling and topologySpread |
| A zone fails | 2-5 min | 0 | Multi-zone spread within the cluster |
| The cluster fails | 30 min | < 5 min | A second cluster + replication |
| The region fails | 2 h | < 15 min | A cluster in another region + replicated backups |
7.3. The problem with no easy solution: the data
The manifests replicate with git push. The application starts anywhere in minutes. The data is another matter.
Asynchronous PostgreSQL replication between regions. That is what Rutas Norte does: a replica in eu-west-3 following the eu-west-1 primary by streaming replication. The typical network latency between Ireland and Paris is 25-40 ms, which in practice means a replication lag of under a second under normal load, and a few seconds during the May bank-holiday weekend.
The consequence: if the main region disappears all at once, the committed transactions that had not yet reached the replica are lost. With a 15-minute RPO there is plenty of margin, but it is worth saying plainly: active-passive with asynchronous replication is not zero RPO. There can be bookings confirmed to the customer that do not exist after the failover, and the business has to know what to do about that.
Synchronous replication would give zero RPO, but every transaction commit would wait for a round trip to the other region: 35 ms added to every write. Over the May bank-holiday weekend, that is unaffordable.
graph LR
subgraph W1["eu-west-1 · main"]
P[(bookings-postgres<br/>PRIMARY)]
API1[bookings-api<br/>8-40 replicas]
API1 --> P
end
subgraph W3["eu-west-3 · secondary"]
R[(bookings-postgres<br/>standing REPLICA)]
API3[bookings-api<br/>2 minimum replicas]
API3 -.read-only.-> R
end
S3[(Backups + WAL<br/>replicated to both regions)]
P ==>|asynchronous replication<br/>lag under 1 s| R
P --> S3
DNS[Global DNS<br/>latency + health] --> API1
DNS -.if eu-west-1 fails.-> API3
And the even worse problem: coming back. When the main region recovers, its database is in a state divergent from the one that has been serving. You cannot simply switch back: you have to rebuild the old main region as a replica of the new one and then switch over cleanly. That procedure has to be written down before you need it.
- The Rutas Norte plan
8.1. Current state: two clusters, same region
| Cluster | Region | Contains | Nodes |
|---|---|---|---|
rutasnorte-nonpro |
eu-west-1 | rutas-norte-dev, rutas-norte-pre, the ephemeral environments from 11-03 |
3-8 (Karpenter, mostly spot) |
rutasnorte-pro |
eu-west-1 | rutas-norte-pro, Argo CD, observability |
6-30 (Karpenter, on-demand base + spot for the tolerant workloads) |
The split solved the problem that motivated it: EKS upgrades are rehearsed in nonpro, and a webhook misconfigured by development can no longer affect ticket sales.
8.2. The approved plan: a second region
After the risk analysis, the board approved a secondary region in eu-west-3 (Paris) in active-passive mode, with these decisions:
| Decision | Value | Reason |
|---|---|---|
| Mode | Active-passive | Active-active would require solving multi-region writes in bookings-api, a project of months |
| Standby capacity | 2 replicas of bookings-api and web-store, always on |
A secondary at zero is a secondary you do not know works |
| Database | A CloudNativePG replica following the primary | Real RPO of seconds |
| Backups | Replicated to both regions, with immutability | A disaster cannot take the region and the backups at once |
| Manifests | The same repository, pro-eu-west-3 overlay |
A single source of truth |
| Argo CD | An instance in pro in eu-west-1 and a second, inactive instance in eu-west-3 |
If the main region goes down, so does the Argo CD that governs it |
| DNS | Records with health checks, TTL 60 s | Failover in 1-3 minutes |
| Estimated additional cost | ~34 % on top of the current bill | Approved by the board |
The Argo CD point deserves attention. It is a classic mistake: putting the tool that deploys inside the cluster that may disappear. Rutas Norte solves it with a second instance in eu-west-3 pointing at the same repository, with syncPolicy.automated disabled under normal conditions; it is enabled as part of the failover procedure.
8.3. Failover procedure
# ===== FAILOVER PROCEDURE TO eu-west-3 =====
# Run ONLY on the decision of the incident commander (11-06).
# Total target time: 30 minutes.
# --- Step 1 (2 min). Confirm it is a regional disaster, not a local fault.
# Check the region's status on the provider's dashboard and from an external network.
# --- Step 2 (3 min). Freeze writes to the failed region if it is still reachable,
# to avoid the split-brain scenario.
kubectl --context PRODUCTION-rutasnorte scale deploy/bookings-api --replicas=0 || true
# --- Step 3 (5 min). Promote the PostgreSQL replica in eu-west-3.
kubectl --context rutasnorte-pro-w3 -n rutas-norte-pro \
cnpg promote bookings-postgres bookings-postgres-1
kubectl --context rutasnorte-pro-w3 -n rutas-norte-pro get cluster bookings-postgres
# Note the last applied LSN: it defines the real data loss.
# --- Step 4 (5 min). Activate the secondary Argo CD and scale the application.
kubectl --context rutasnorte-pro-w3 -n argocd patch application bookings-api-w3 \
--type merge -p '{"spec":{"syncPolicy":{"automated":{"selfHeal":true,"prune":true}}}}'
kubectl --context rutasnorte-pro-w3 -n rutas-norte-pro scale deploy/bookings-api --replicas=12
kubectl --context rutasnorte-pro-w3 -n rutas-norte-pro rollout status deploy/bookings-api
# --- Step 5 (3 min). Layer-by-layer verification from 11-01 against the internal URL.
curl -sf https://api-w3.rutasnorte.example/ready
npm run test:smoke -- --base-url https://api-w3.rutasnorte.example
# --- Step 6 (2 min). Switch the DNS over.
aws route53 change-resource-record-sets --hosted-zone-id Z123 \
--change-batch file://failover-to-w3.json
# --- Step 7 (10 min). Watch and communicate.
# Grafana dashboard for the secondary region. Communication to the business: which
# bookings may have been lost (based on the LSN from step 3).8.4. The annual drill
A procedure written down and never executed is an essay, not a plan. Rutas Norte runs a full annual drill in October, outside peak season:
| Phase | Content |
|---|---|
| Preparation (2 weeks before) | Review the procedure, notify the business, define success and abort criteria |
| Execution | A real failover to eu-west-3 with production traffic, during low-demand hours |
| Dwell | 4 hours serving from the secondary region |
| Return | Rebuild eu-west-1 as a replica and switch back cleanly |
| Postmortem | Real times for each step, what failed, improvement actions with an owner and a date |
Results of the October 2025 drill, the first one: a real RTO of 71 minutes against the 30-minute target. The findings were revealing and none of them would have surfaced without running it:
- The TLS certificate for
eu-west-3had expired: cert-manager could not complete the ACME challenge because DNS did not point to that region. It was fixed by using the DNS-01 challenge instead of HTTP-01. - The ExternalSecrets in
eu-west-3pointed at a store that was not replicated to that region. - Nobody could remember where the DNS failover procedure was written down.
- The PostgreSQL replica had not replicated for eleven days because of a firewall rule added in an unrelated change. Nobody knew, because there was no alert on cross-region replication lag.
That last finding, on its own, justified the entire drill: in a real disaster, eleven days of bookings would have been lost.
- The cost, the complexity and when not to do it
9.1. What it really costs
| Item | Estimated annual cost at Rutas Norte |
|---|---|
| Control plane for the second cluster | ~€880 |
Control plane for the third (eu-west-3) |
~€880 |
| Minimum capacity in the secondary region | ~€9,400 |
| Cross-region PostgreSQL replica | ~€4,200 |
| Inter-region traffic (replication) | ~€1,800 |
| Duplicated add-ons (compute) | ~€3,100 |
| Infrastructure total | ~€20,260 |
| Platform time (implementation, ~6 person-weeks) | ~€24,000 |
| Ongoing maintenance (~15 % of one person) | ~€9,000/year |
It is a serious investment. The question that justifies it is not technical: how much does one hour of downtime during the May bank-holiday weekend cost Rutas Norte? If it is €40,000 of lost sales plus the reputational damage, the investment pays for itself by avoiding a single regional incident. If the business can absorb half a day down without serious consequences, it does not.
9.2. When NOT to do it
- When one cluster still works. If namespaces, quotas and network policies cover your isolation needs, adding clusters only adds drift.
- When there is no GitOps. With manual deployments, N clusters mean N opportunities to diverge. GitOps is a prerequisite, not a complement.
- When there is no centralised observability. Diagnosing an incident by hopping between the dashboards of three clusters is unworkable. The logs and metrics of the whole fleet must be visible in one place.
- When the team cannot cope with one. Multiplying the infrastructure does not multiply the team's capacity, it divides it.
- When the reason is "just in case". With no concrete scenario to solve, you pay for the complexity and get nothing back.
- When the data cannot be replicated. If the application does not tolerate eventual consistency and there is no budget to redesign it, a second cluster gives a false sense of security.
9.3. The right order of investment
Before considering a second cluster, it is worth having the earlier things sorted, because almost all of them improve reliability more for less money:
- Tested backups and a timed restore (11-02).
- GitOps with everything in Git (10-05).
- Observability and alerts with runbooks (07-04, 11-06).
- High availability within the cluster: multi-zone, PDB,
topologySpread(09-05). - Safe deployments with a canary (11-04).
- And then, yes, the second cluster.
A team that jumps to step 6 without the previous five ends up with two fragile clusters instead of one.
Common Mistakes and Tips
- Creating the second cluster with the same CIDR range as the first. It makes routing between them impossible and forces a rebuild. Plan the addressing for the whole fleet before creating the second.
- Putting Argo CD only inside the production cluster. When that cluster disappears, so does the ability to deploy anywhere. A secondary instance or a separate management cluster.
- Assuming DNS fails over instantly. Short TTLs are not always honoured; count on one to three minutes as a minimum.
- A secondary at zero replicas. You do not know whether a cold path works. Permanent minimum capacity and real traffic, even a little.
- Not alerting on cross-region replication lag. This is exactly the failure Rutas Norte discovered in the drill: eleven days without replicating and nobody knew.
- Duplicating the whole overlay when adding a region. Only what differs goes in the overlay; if it grows a lot, the base is badly factored.
- Installing a multi-cluster service mesh by default. It is one of the most complex pieces in the ecosystem. It is only justified with a lot of cross-region communication.
- Tip: put the context in your prompt today, with a red background for production. It is the highest-return-per-minute measure in the whole lesson.
- Tip: make production contexts read-only by default. Writing should require a deliberate, recorded act.
- Tip: write the failover procedure and the one for coming back. Coming back is harder than going, and almost nobody documents it.
Exercises
Exercise 1: deciding whether to split
A company has one cluster with dev, pre and pro in separate namespaces, GitOps with Argo CD, backups tested monthly and centralised observability. It has suffered two incidents in six months: one caused by an operator installed by development that consumed all the memory on a shared node, and another by a cluster version upgrade that left pro degraded for 25 minutes. The platform team is two people. Recommend a topology, justify it and state explicitly what you do NOT recommend and why.
Exercise 2: writing the ApplicationSet
Rutas Norte adds the rutasnorte-pro-w3 cluster with the labels environment=pro, region=eu-west-3 and role=secondary. Write an ApplicationSet that deploys bookings-api on every cluster with environment=pro, using the overlays/pro-{{region}} overlay, and that on clusters with role=secondary starts with fewer replicas. Also state what has to be true in the repository for it to work.
Exercise 3: analysing the drill
In the annual drill, a team gets: step 3 (PostgreSQL promotion) 4 min, step 4 (Argo CD and scaling) 22 min, step 5 (verification) 6 min, step 6 (DNS) 14 min. Total RTO 46 min against a 30-minute target. Identify the two problematic steps, propose a likely cause for each and a concrete corrective action.
Solutions
Solution 1. Recommendation: topology by environment, two clusters — one for pro and another for dev+pre. Justification: the two incidents suffered are exactly the ones this topology solves; the first is resource and control-plane contention between environments, the second is the impossibility of rehearsing the upgrade before applying it to production. Besides, the prerequisites are covered: there is GitOps, tested backups and centralised observability, so drift is manageable. The incremental cost for two people is bearable because most of the operation is already automated.
What is not recommended: (a) one cluster per business unit, because with two platform people the proliferation is unaffordable and there is no problem motivating it; (b) a second region, because none of the incidents was regional and the cost (infrastructure plus data replication) is not justified by the available evidence; (c) a dedicated management cluster, unnecessary with only two clusters, where Argo CD can live in the production one managing both.
Solution 2.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: bookings-api-fleet
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- clusters:
selector:
matchLabels: { environment: pro }
template:
metadata:
name: 'bookings-api-{{index .metadata.labels "region"}}'
spec:
project: rutas-norte
source:
repoURL: https://github.com/rutasnorte/manifests
targetRevision: main
path: 'overlays/pro-{{index .metadata.labels "region"}}'
kustomize:
components:
# An additional component only on the secondaries.
- '{{if eq (index .metadata.labels "role") "secondary"}}../../components/reduced-capacity{{else}}../../components/full-capacity{{end}}'
destination:
server: '{{.server}}'
namespace: rutas-norte-pro
syncPolicy:
automated: { prune: true, selfHeal: true }Requirements in the repository: (a) the overlays/pro-eu-west-1 and overlays/pro-eu-west-3 directories must exist; (b) the reduced-capacity and full-capacity components must exist; (c) every target cluster must be registered in Argo CD with the environment, region and role labels; (d) the rutas-norte AppProject must have both servers in its list of permitted destinations, or Argo CD will reject the generated Applications. One additional note: since the HPA governs the replicas, the component should adjust the HPA's minReplicas/maxReplicas, not the Deployment's replicas field.
Solution 3. Problematic steps: 4 (22 min against an expected 5 or so) and 6 (14 min against 2).
Step 4: a likely cause is that the secondary region had no node capacity available and it was necessary to wait for the autoscaler to provision new nodes, including pulling images that were not cached on those nodes. Corrective action: keep permanent minimum capacity and pre-warm the images (for example, with a DaemonSet that pulls them, or by reserving nodes with the images already present). A complementary alternative: over-provision with low-priority pods that Karpenter evicts when scaling (09-03).
Step 6: fourteen minutes for a DNS change indicates it was not automated; somebody probably edited records by hand in a web console under pressure, or had to hunt for the credentials. Corrective action: script the change with the change file already written and versioned in the repository, runnable with a single command, and test it outside the drill. Also check that the records' TTL is 60 seconds and not the default value, which is usually much higher.
Conclusion
We have seen why a company ends up with several clusters — control-plane isolation, blast radius, latency across regions, data residency, differing versions and the simple fact that a cluster gets upgraded too — and what that really costs. We went through the common topologies with the recommendation to start with one and to split production from the rest first; the five layers of defence for never applying to the wrong cluster, of which the best value is putting the context in the prompt and the most effective is making production read-only by default; governing the fleet with the ApplicationSet cluster generator combined with Kustomize overlays; the idea of the cluster as a product to fight drift; the three levels of connectivity between clusters and the honest criterion for when a service mesh is justified; and disaster recovery, where the hard part is never the manifests but the data, with its asynchronous replication, its non-zero RPO and the return procedure that almost nobody writes.
The Rutas Norte plan, with two clusters today and a secondary region approved, illustrates the essential point: the decision is not taken for architectural elegance, but by comparing the cost of redundancy with the cost of an hour of downtime over the May bank-holiday weekend. And the October drill, with its real RTO of 71 minutes and its eleven days of broken replication that nobody had detected, leaves the most important lesson: a procedure that has not been executed is not a plan.
We now have the platform deployed, with state, with a pipeline, with safe deployments and with a governed fleet. What is missing is what no tutorial covers and what actually fills the working day: keeping it alive. In the last lesson of the module, Production Operations: Incidents, Runbooks and Costs, we will see how an incident is managed, how a useful runbook is written, how the error budget decides what the team does next month, how capacity for the May bank-holiday weekend is planned, and why the Kubernetes bill goes through the roof and what to do about it.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
