A Media Company's Multi-Cluster Migration
This is a composite, illustrative case study, not a real, identifiable company. "Northlight Media" is a stand-in assembled from patterns that repeat across real streaming and broadcast platforms as they outgrow a single Kubernetes cluster — no single company is Northlight, but the shape of the story is real. Northlight ran one cluster for two years: live-event video transcoding, an editorial CMS, and ad-serving, all sharing one control plane and one pool of nodes. That worked until a live match's ingest pipeline starved the CMS during breaking news, a routine upgrade took ad-serving down for forty minutes, an EU licensing deal demanded viewer data never leave the region, and three platform teams started fighting over the same namespaces. This page is the story of what Northlight built in response — a small regional fleet stood up with Cluster API instead of hand-rolled scripts, kept in sync with a fleet-wide GitOps rollout — and the three specific things that broke on the way there.
Imagine a restaurant chain with one giant central kitchen cooking for every location in the country. That works fine for five restaurants — until a huge catering order for one location eats up every burner and the other locations' dinner service starts late too, a new head chef's "small tweak" to the recipe book breaks dessert for everyone at once, and a restaurant opening overseas discovers the health inspector there won't allow ingredients tracked by a kitchen on a different continent. The fix isn't one bigger kitchen — it's a few regional kitchens, each built from the exact same blueprint and stocked automatically from the same recipe book, so a fire in one kitchen doesn't touch the others, and a dish can still be borrowed from a neighboring kitchen's fridge if one runs short.
Northlight Media: what "the cluster" used to mean
☺ Like you're 10: One kitchen, one set of burners, three completely different meals cooking on it at once.
For its first two years on Kubernetes, "the cluster" at Northlight meant exactly one thing: a self-managed cluster, hand-built with kubeadm and a Terraform module nobody had touched since the person who wrote it left, running on AWS in a single region. It carried three genuinely different workloads on the same control plane and the same node pools: a live-event ingest and transcoding pipeline that turned incoming video feeds into the adaptive-bitrate streams viewers actually pulled, an editorial CMS that publishing staff used to write and schedule articles and metadata, and an ad-decision service that picked which ad to serve a given viewer in a given territory. Nothing about that was unreasonable at the time — one cluster is simpler to operate, cheaper to run, and easier to reason about than a fleet, and Anti-Patterns & Pitfalls is right that reaching for extra clusters before you need them is its own failure mode. Northlight's problem wasn't that one cluster was wrong on day one. It was that nobody had named the point at which it would stop being enough.
The triggers: three forces, one bad month
☺ Like you're 10: Not one big disaster — four separate annoyances that all pointed at the same fix.
Multi-Cluster & Fleet Management puts it plainly: never split because "multi-cluster is best practice" — split because you can name the specific force one cluster can't satisfy. Northlight could eventually name four, and they arrived close enough together that the platform team stopped treating them as separate incidents and started treating them as one signal.
| Force | What actually happened |
|---|---|
| Blast-radius isolation | A Saturday night football final pushed the transcoding pipeline's HPA to scale aggressively across the shared node pool. It starved CPU and scheduling headroom from everything else on those nodes — including the CMS, which went unresponsive for eleven minutes in the middle of a breaking-news update about the same match. |
| A shared-fate upgrade | A routine control-plane version bump shipped a webhook regression that intermittently rejected new Pods cluster-wide. It happened to land during a big series-premiere marketing push, and ad-serving — which had nothing to do with the upgrade — was down for forty minutes because it lived on the same control plane as everything else. |
| Data residency for EU expansion | A new licensing agreement for Northlight's European catalog required that EU viewer-activity data and stream logs never leave EU infrastructure or pass through infrastructure operated outside it. A single us-east cluster could not satisfy that — no namespace boundary changes where data is stored. |
| Org growth & namespace sprawl | Three platform-adjacent teams — Streaming Platform, Content/CMS, and Data/Ads — were all deploying into the same cluster with hand-managed ResourceQuotas. Quota fights over the shared node pool became a recurring Monday-morning conversation, and nobody could say with confidence which team's change had caused the previous week's noisy-neighbor incident. |
The instinctive first fix at Northlight was "give the CMS its own dedicated node pool, same cluster." That solved the Saturday-night incident but not the other three — a shared control plane is still a shared control plane no matter how the node pools are split, and it does nothing at all for data residency. Node-pool separation is real, useful isolation for noisy neighbors; it is not a substitute for the control-plane-level isolation a second cluster provides. Know which problem you're actually solving before reaching for either one.
Choosing the topology: named forces, not org chart
☺ Like you're 10: Split by the actual reason you need more kitchens — not by drawing one kitchen per cook.
With four named forces on the table, the platform team resisted the two easiest wrong answers: a cluster per team (which would have meant five-plus clusters solving an org-chart problem, not a technical one, and multiplying the fleet's add-on tax for no isolation benefit) and a single cluster with ever-more-elaborate namespace tooling bolted on (which does nothing for shared-fate upgrades or data residency, no matter how sophisticated the tooling gets). The topology that actually matched the four forces was region × environment: one production cluster per region that needs its own residency or latency boundary, plus a shared non-production cluster per major region for staging and load testing. Three teams could keep sharing a region's production cluster, separated by namespace, RBAC, and NetworkPolicy — soft isolation was genuinely sufficient there, because the three teams trusted each other and none of the four triggers pointed at hard tenancy between them.
The fleet that came out of this exercise was deliberately small: prod-us-east, prod-eu-west, and one non-prod cluster — three clusters, not eight. Every additional cluster is a standing bill in control-plane cost, per-node add-on overhead, and a permanent line in on-call, so Northlight added exactly as many clusters as it had named forces, and used namespace-level isolation for everything else. A fleet sized to the org chart instead of to the forces is how a team ends up paying the multi-cluster tax without getting proportional isolation benefit for it.
Cluster API: turning "build a cluster" into a pull request
☺ Like you're 10: Instead of one person hand-mixing the concrete for every new kitchen, you write the blueprint once and a machine pours every foundation exactly the same way.
The original cluster had been built by hand, and rebuilding that process twice more — once for eu-west, once for non-prod — was exactly how a fleet accumulates snowflakes before it's even finished being built. Northlight adopted Cluster API for lifecycle from the start of the migration: a small management cluster running the CAPI controllers, the AWS infrastructure provider (CAPA) for the actual VMs and load balancers, and KubeadmControlPlane for the control-plane tier. Every new cluster became a reviewed pull request against a shared ClusterClass template instead of a person following a runbook from memory.
apiVersion: cluster.x-k8s.io/v1beta1
kind: Cluster
metadata:
name: prod-eu-west
namespace: fleet
spec:
clusterNetwork:
pods: { cidrBlocks: ["10.32.0.0/16"] } # explicit, unique per cluster — see "what broke" below
controlPlaneRef:
apiVersion: controlplane.cluster.x-k8s.io/v1beta1
kind: KubeadmControlPlane
name: prod-eu-west-cp
infrastructureRef:
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: AWSCluster
name: prod-eu-west
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: MachineDeployment
metadata:
name: prod-eu-west-transcode-pool
namespace: fleet
spec:
clusterName: prod-eu-west
replicas: 12
template:
spec:
clusterName: prod-eu-west
version: v1.30.2
bootstrap:
configRef: { apiVersion: bootstrap.cluster.x-k8s.io/v1beta1, kind: KubeadmConfigTemplate, name: prod-eu-west-transcode-pool }
infrastructureRef:
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: AWSMachineTemplate
name: prod-eu-west-transcode-poolThis page doesn't re-derive how CAPI works — Multi-Cluster & Fleet Management covers the provider split and the MachineDeployment/Deployment symmetry in full, and Platform Engineering's Cluster API tool guide goes deeper still on clusterctl and managed topologies. What matters for this story is what changed operationally: standing up prod-eu-west went from an estimated four days of a senior engineer's undivided attention to a same-day pull request that a controller reconciled unattended.
GitOps rollout: the same addons, every region, one ApplicationSet
☺ Like you're 10: A machine-poured foundation is still an empty room — something has to stock every kitchen with the exact same pans and spice rack, automatically.
Cluster API gets a cluster to "control plane up, nodes joined" and stops there. Filling it with ingress, cert-manager, metrics-server, the Kyverno policy bundle, and Northlight's own transcode-queue operator — and keeping all of that identical across three clusters forever — is GitOps stretched across a fleet. Northlight ran a single Argo CD instance on the management cluster with an ApplicationSet driven by the cluster generator, so registering a cluster with the right labels was the only step needed for it to inherit the entire addon stack:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: platform-addons
namespace: argocd
spec:
generators:
- clusters: { selector: { matchLabels: { fleet: "true" } } }
template:
metadata: { name: 'addons-{{name}}' }
spec:
source:
repoURL: https://git.northlight.example/fleet-config.git
path: 'addons/overlays/{{metadata.labels.region}}'
destination: { server: '{{server}}', namespace: platform-system }
syncPolicy: { automated: { prune: true, selfHeal: true } }This is the same shape the deep-dive page walks through generically — the specific payoff for Northlight was that admission policy stopped being three separate Kyverno installs that could quietly drift apart. "No unsigned images in the transcode namespace" became one rule, shipped as one addon, present on every cluster the moment it registered, whether that cluster existed when the rule was written or not.
What broke: three incidents from the transition
☺ Like you're 10: Building three kitchens from one blueprint doesn't mean nothing ever burns — it means each fire teaches you something the blueprint didn't cover yet.
None of the mechanisms above are new — Cluster API and fleet-wide GitOps are both covered mechanically elsewhere in this course. What a case file can show that a mechanism page can't is what actually goes wrong the first time a team runs them for real. Three incidents did the most to shape Northlight's eventual playbook.
Incident 1: the CIDR collision that mis-routed ads
The prod-eu-west ClusterClass was cloned from an early draft that hadn't yet had its pod CIDR block parameterized, and it defaulted to 10.244.0.0/16 — the exact range still in use by an old hand-built non-prod-us-east-2 cluster that hadn't been decommissioned yet. Nobody noticed at creation time, because within a single cluster an unused CIDR default causes no symptom at all. The problem only surfaced once the ad-decision service was exported through the Multi-Cluster Services API so prod-us-east and prod-eu-west could share ad-inventory lookups: cross-cluster requests routed through the fleet's transit gateway started landing on the wrong cluster's Pods whenever both clusters happened to have a Pod at the same overlapping address. The first visible symptom wasn't a Kubernetes error at all — it was European viewers occasionally seeing US-only ad creative, caught three hours later when ad-revenue reconciliation flagged mismatched territory codes.
# What actually found it: comparing pod CIDR blocks across every Cluster object in the fleet
$ kubectl get clusters.cluster.x-k8s.io -A \
-o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.clusterNetwork.pods.cidrBlocks}{"\n"}{end}'
prod-us-east [10.244.0.0/16]
prod-eu-west [10.244.0.0/16] # <- collision with the still-running non-prod-us-east-2 cluster
non-prod-us-east-2 [10.244.0.0/16]The fix wasn't a policy — it was making CIDR collision structurally impossible to ship. Northlight added a required, parameterized podCidrBlock field to the ClusterClass with no default value, plus a CI check that rejects any new Cluster object whose CIDR block overlaps an existing one in the fleet inventory. A code review catching this by eye doesn't scale past a handful of clusters — non-overlapping CIDRs are a hard prerequisite, not a suggestion, and the only reliable way to enforce a hard prerequisite is to make violating it fail a machine check, not a human one.
Incident 2: GitOps and the HPA, fighting over the same field
The day fleet-wide selfHeal: true went live, the transcoding Deployment's manifest in Git pinned spec.replicas: 12 as a sane baseline. It had an HPA attached that legitimately scaled that same field between 8 and 40 based on queue depth. Every few minutes, Argo CD would notice live replicas didn't match Git's 12 and "self-heal" it back down; the HPA would immediately scale it back up in response to the still-high queue depth it was watching. During a live event, that fight manifested as capacity flapping every three to five minutes — exactly when the transcoding pipeline could least afford it — and a visible backlog of dropped transcode jobs during the flap windows.
The fix is a documented pattern GitOps on Kubernetes describes generically as field-ownership conflict: tell the GitOps controller to stop asserting an opinion about a field another controller legitimately owns.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: addons-prod-us-east
spec:
ignoreDifferences:
- group: apps
kind: Deployment
name: transcode-worker
jsonPointers:
- /spec/replicas # the HPA owns this field; Argo CD must stop fighting it forNorthlight's platform lead added a rule to the fleet's PR template after this one: any Deployment carrying an HPA must ship its ignoreDifferences entry in the same pull request, not as a follow-up once someone notices the flapping.
Incident 3: a MachineHealthCheck loop that made a capacity crunch worse
During the same live event, the transcoding pool's autoscaler tried to add worker capacity in a specific availability zone that had, unluckily, hit an AWS capacity limit that hour. New Machines were provisioned but took longer than the pool's configured MachineHealthCheck nodeStartupTimeout to reach Ready. CAPI's health-check controller did exactly what it was configured to do: it marked each slow Machine unhealthy and remediated it — deleted it and asked the infrastructure provider for a replacement, in the same constrained zone, which then took just as long and got marked unhealthy in turn. The loop burned through cloud spend replacing Machines that were never actually broken, and briefly made the shortage worse instead of better, because none of the churned Machines ever lived long enough to join the pool and take load.
An engineer broke the loop by hand — pausing the MachineHealthCheck, letting the in-flight Machines finish booting on their own, and only then re-enabling remediation. The lasting fix was two changes: a longer nodeStartupTimeout that reflected the pool's real worst-case boot time under load rather than its best-case, and a MachineDeployment spread across two availability zones instead of one, so a single zone's capacity limit no longer had the power to trigger a fleet-wide remediation storm.
What changed
☺ Like you're 10: The kitchens stopped catching each other's fires, and stocking a brand-new one stopped taking a week.
Because Northlight is composite, there is no specific published metric to cite — treat the following as the shape of outcome that recurs across migrations like this one, not a verified number.
- Blast radius actually shrank. A CMS incident in
prod-us-eastno longer has any path to touching ad-serving or transcoding inprod-eu-west— they don't share a control plane, an etcd quorum, or a node. - EU residency became provable, not asserted. European viewer data and stream logs live only on
prod-eu-westinfrastructure, and that boundary is enforced by cluster topology rather than a policy someone has to remember to check. - New-cluster time collapsed. What used to be days of one engineer's undivided, error-prone attention became a same-day, reviewed pull request against a shared
ClusterClasstemplate. - Drift stopped being invisible. Every cluster in the fleet inherits the same addon stack and Kyverno policy bundle from the same ApplicationSet the moment it registers — no more discovering months later that one cluster quietly never got cert-manager installed.
What to steal for your own migration
☺ Like you're 10: Even if you don't run a streaming service, the same handful of tricks make any fleet migration less painful.
- Parameterize CIDR allocation before your second cluster exists, and enforce it in CI. A default value in a shared template is exactly how a collision ships unnoticed — make an overlap a failed check, not a code-review hope.
- Ship
ignoreDifferencesin the same pull request as any Deployment carrying an HPA, VPA, or other field-owning controller. Turning on fleet-wideselfHealwithout it is a guaranteed fight, and it will surface at the worst possible moment, not a quiet one. - Set
MachineHealthChecktimeouts to your real worst-case boot time, and spread pools across zones. A remediation loop that keeps retrying the same failing placement doesn't fix a capacity crunch — it accelerates it. - Split by named force, not by team or by fear. Northlight's fleet stayed at three clusters because that's how many distinct forces it could actually name; a cluster added "just in case" is pure standing cost with no isolation payoff.
- Track every hand-built cluster still running during a migration on an explicit retirement list. The CIDR collision existed only because a "temporary" old cluster was still alive and unaccounted for months after the migration notionally started.
Honest caveats — what doesn't transfer
☺ Like you're 10: This story worked for one kitchen chain with cooks willing to change how they worked. Not every chain will be.
- No number in this case file is a verified, published metric. Treat "what changed" as a pattern this kind of migration tends to produce, not a citation — a real vendor talk quoting a specific percentage belongs to that organization's context, not a guarantee of yours.
- This depends on genuine platform-team capacity to adopt CAPI and GitOps properly. A team without the headcount to build and maintain the
ClusterClasstemplate, the CI CIDR check, and the ApplicationSet correctly will find the fleet drifting anyway — the tooling doesn't run itself. - Multi-cluster is a permanent tax, not a one-time decision. Three clusters means three times the add-on footprint and three times the surface a fleet dashboard has to watch, forever. A smaller media operation with no residency requirement and no history of shared-fate incidents may be entirely correctly served by one well-isolated cluster for years — see Anti-Patterns & Pitfalls on reaching for a cluster before you need one.
- Data-residency requirements vary by jurisdiction and by contract. Northlight's EU reading of its licensing terms is illustrative, not legal guidance — verify what your own regulatory or contractual obligations actually require before assuming a second cluster in a second region automatically satisfies them.
- A legacy estate migrates unevenly. Northlight's old
non-prod-us-east-2cluster was the slowest thing to retire precisely because nobody owned that decision — expect your own least-glamorous cluster to be the last one gone, and put someone's name on retiring it rather than assuming it will happen on its own.
For the mechanisms this case file assumes without re-deriving, see Multi-Cluster & Fleet Management and GitOps on Kubernetes; for the fleet-scale cost tax and topology decision tree in full, see Platform Engineering's Multi-Cluster & Fleet Management.
Reproduce Incident 1 on two local kind clusters. Create both with the same default podSubnet, install a CNI that supports Cluster Mesh (Cilium is the common choice), connect them, and try exporting a throwaway Service with ServiceExport from one cluster and resolving it from the other — watch cross-cluster routing misbehave. Then rebuild the second cluster with a genuinely non-overlapping podSubnet and repeat the export: the same lookup now resolves cleanly. That gap between the two runs is the entire lesson behind Incident 1, made concrete instead of abstract.
Professor Owl: Three clusters, one blueprint each. The question before we build the fourth one is always the same: which named force does it satisfy that the other three don't?
Recon the Robot: And once it exists, it inherits the whole addon stack from the same ApplicationSet as the other three. I don't treat a new cluster as special. I never have.
Gizmo the Gremlin: Boring. Just clone the old cluster's CIDR block again — it worked fine for two years, who's really going to notice a third one? 🤑
Timmy the Turtle: Ads got mis-routed across a continent last time nobody noticed, Gizmo. That's not a rounding error, that's a broken contract with an advertiser. The CIDR field has no default now, on purpose.
Benny the Beaver: And I'm not shipping a Deployment with an HPA attached without its ignoreDifferences entry in the same PR anymore. Learned that one the loud way, during a live match, in front of everyone.
Pip the Hummingbird: The ad-decision service still resolves fine across both prod clusters, by the way — the MCS API was never the problem. The CIDR plan underneath it was.
1. Name the four forces that drove Northlight to split into a fleet, and explain why "multi-cluster is best practice" wasn't one of them. 2. Why did Northlight choose a region-by-environment topology instead of one cluster per team? 3. What actually broke in Incident 1, and why did code review alone fail to catch it before it shipped? 4. In Incident 2, which two controllers were fighting over spec.replicas, and what one line of configuration stopped the fight? 5. In Incident 3, why did the MachineHealthCheck loop make the capacity shortage worse instead of better? 6. Name two caveats that mean this migration's outcome shouldn't be assumed to transfer to every organization.
Check your answers
- Blast-radius isolation (a shared-fate control plane), a shared-fate upgrade taking down unrelated workloads, EU data-residency requirements, and namespace/quota sprawl across three growing teams. "Best practice" wasn't a driver because it names no specific force and doesn't justify the standing cost of an extra cluster — only a concrete, nameable problem does.
- A cluster per team would have meant five-plus clusters solving an org-chart problem rather than a technical one, multiplying the fleet's add-on and on-call tax with no matching isolation benefit; the three teams sharing a region's cluster trusted each other, so namespace-level isolation was genuinely sufficient between them, while region and environment were the boundaries the actual named forces (residency, blast radius) demanded.
- A never-parameterized default pod CIDR in the shared
ClusterClasstemplate collided with an old, still-running non-prod cluster's CIDR, which broke cross-cluster MCS routing for the shared ad-decision service and mis-routed ad creative by territory. Code review missed it because an unused CIDR default causes zero symptoms inside a single cluster — the collision was invisible until two clusters actually needed to route traffic between them. - Argo CD (via GitOps self-heal, trying to hold
spec.replicasat the value pinned in Git) and the HPA (legitimately scaling that same field with real queue depth). AnignoreDifferencesentry for/spec/replicason the affected Deployment told Argo CD to stop asserting an opinion about a field the HPA owns. - The health check correctly marked slow-booting Machines unhealthy, but remediation kept requesting replacement Machines in the same capacity-constrained availability zone, so each replacement took just as long, got marked unhealthy in turn, and none of the churned Machines ever lived long enough to actually add usable capacity — the loop burned spend without solving the shortage.
- Any two of: no number in the case file is a verified published metric; the outcome depends on genuine platform-team capacity to build and maintain the tooling correctly; multi-cluster is a permanent, not one-time, operational tax; data-residency requirements vary by jurisdiction and contract and must be verified independently; a legacy estate migrates unevenly, and the least-owned cluster is typically the last one retired.