One kubectl apply, Two Clusters, Zero Downtime: My Karmada + HAProxy Journey
A few weeks ago I was staring at two separate Kubernetes clusters, each with its own ingress, its own deploy process, and its own quirks — and I was manually deciding which one got which workload. That's not "multi-cluster," that's just "twice the work."
So I set out to actually federate them: one control plane, one CI/CD pipeline, one public entry point, and smart traffic distribution underneath. Here's how the whole thing came together, from joining clusters to Karmada, to migrating live resources, to putting HAProxy in front of everything with weighted and failover routing.
The Problem I Was Solving
I had an cluster-1 and a cluster-2, and I needed:
- A single place to define "what gets deployed where" instead of running
kubectl applytwice - A clean way to migrate existing workloads from the old cluster to the new one without breaking ingress
- A CI/CD pipeline (Azure DevOps, pulling from a self-hosted GitLab repo) that deploys once and lets the platform handle distribution
- A load balancer in front of both clusters' ingress IPs that could do weighted traffic splitting or flip to full failover when one cluster goes down
Karmada turned out to be exactly the right layer for the orchestration half, and HAProxy handled the traffic half beautifully. Here's the build, step by step.
Step 1: Standing Up the Karmada Control Plane
Karmada sits above Kubernetes — it's a control plane for controlling other control planes. I put it on its own dedicated VM running K3s, with the Karmada API server exposed on a NodePort.
curl -sfL https://get.k3s.io | sh -
mkdir -p ~/.kube
cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sed -i 's/127.0.0.1/<control-plane-ip>/g' ~/.kube/config
One thing that tripped me up early: there are two separate kubeconfigs living on this VM — one for K3s itself, one for the actual Karmada API server. Mixing them up wastes a good ten minutes of "why isn't this cluster showing up."
K3s API server: https://<control-plane-ip>:6443
Karmada API server: https://<control-plane-ip>:32443
I also installed the CLI tool, karmadactl, which is what actually joins and unjoins member clusters.
Step 2: Joining Both Clusters
With kubeconfigs copied over from each cluster's master node, joining them to Karmada is refreshingly simple:
karmadactl join cluster-1 \
--cluster-kubeconfig=/home/ibos/cluster-1.config \
--kubeconfig=/etc/karmada/karmada-apiserver.config
karmadactl join cluster-2 \
--cluster-kubeconfig=/home/ibos/cluster-2.config \
--kubeconfig=/etc/karmada/karmada-apiserver.config
A quick get clusters against the Karmada API server confirms both are checked in and healthy. At this point Karmada can see both clusters — but nothing is actually being distributed yet. That's what PropagationPolicies are for.
Step 3: Migrating Live Resources (Carefully)
Before I could let Karmada manage new deployments, I needed to move existing workloads off the old cluster. My approach:
- Export everything as YAML from the old cluster
- Strip runtime metadata with
kubectl-neat(resourceVersion, UID, status — all the stuff that shouldn't travel with the resource) - Dry-run the apply on the new cluster
- Apply for real, then verify
kubectl --kubeconfig=cluster-1.config -n default \
get deploy,svc,ingress,configmap,secret -o yaml \
> resources.yaml
kubectl neat < resources.yaml > resources-clean.yaml
kubectl --kubeconfig=cluster-2.config apply \
-f resources-clean.yaml --dry-run=server
⚠️ The one caution that actually matters here: don't migrate ingress-controller resources themselves (Pods, Deployments, ConfigMaps, Secrets, ClusterRole/ClusterRoleBinding tied to ingress). They simply don't work once moved — each cluster needs its own ingress controller running natively, matched on the same version across both clusters. Migrate your application resources, not the plumbing.
Step 4: Letting Karmada Decide Where Things Live
This is the part that actually removes the manual work. A PropagationPolicy tells Karmada which resource kinds to watch and which clusters to send them to:
apiVersion: policy.karmada.io/v1alpha1
kind: PropagationPolicy
metadata:
name: default-apps-to-both-clusters
namespace: default
spec:
conflictResolution: Overwrite
resourceSelectors:
- apiVersion: apps/v1
kind: Deployment
- apiVersion: v1
kind: Service
- apiVersion: networking.k8s.io/v1
kind: Ingress
- apiVersion: v1
kind: ConfigMap
- apiVersion: v1
kind: Secret
placement:
clusterAffinity:
clusterNames:
- cluster-1
- cluster-2
Apply this once against the Karmada API, and from then on, anything matching those resource kinds gets automatically propagated to both clusters. No more double apply.
Cleanup matters too — when I need to retire a policy, I make sure to delete the ResourceBinding objects it created, not just the policy itself, or Karmada leaves orphaned bindings behind:
kubectl -n staging delete propagationpolicy default-apps-to-both-clusters
kubectl -n staging get resourcebinding
kubectl -n staging delete resourcebinding <binding-name>
Step 5: Wiring In Azure DevOps
The last piece of the orchestration puzzle was making CI/CD talk to Karmada instead of talking to each cluster individually. The trick is exporting the raw Karmada kubeconfig and swapping the private IP for the public one on port 32443:
kubectl --kubeconfig=/etc/karmada/karmada-apiserver.config \
config view --raw
That kubeconfig becomes a Kubernetes service connection in Azure Pipelines, sourced from our self-hosted GitLab repo. Now every pipeline run deploys against the Karmada API server — and Karmada takes it from there, fanning the deployment out to both clusters according to the PropagationPolicy. One pipeline, one target, two clusters updated.
Step 6: HAProxy — the Front Door
Orchestration was solved. Traffic was next. Both clusters expose their ingress on a LoadBalancer IP, and I wanted a single public IP and port (443) fronting both, doing TCP/TLS passthrough — HAProxy never terminates TLS itself, it just forwards the encrypted stream straight to whichever backend cluster it picks.

global
log /dev/log local0
maxconn 50000
ssl-server-verify none
defaults
log global
mode tcp
option tcplog
timeout connect 5s
timeout client 300s
timeout server 300s
frontend https_front
bind 0.0.0.0:443
mode tcp
tcp-request inspect-delay 5s
tcp-request content accept if { req_ssl_hello_type 1 }
default_backend k8s_https_backend
The req_ssl_hello_type 1 check is doing the clever bit — it inspects the TLS ClientHello without decrypting anything, just enough to confirm it's a real TLS handshake before routing it on.
Step 7: Weighted Traffic — and Failover When Things Go Sideways
This is where the backend definition gets interesting, because I don't always want the same distribution strategy.
Balanced, when both clusters are equally sized:
backend k8s_https_backend
mode tcp
balance roundrobin
option tcp-check
server k8s-1 <cluster-1-ip>:443 check inter 3s fall 3 rise 2
server k8s-2 <cluster-2-ip>:443 check inter 3s fall 3 rise 2
Weighted, when one cluster has more headroom than the other (say 70/30):
backend k8s_https_backend
mode tcp
balance roundrobin
option tcp-check
server k8s-1 <cluster-1-ip>:443 weight 70 check inter 3s fall 3 rise 2
server k8s-2 <cluster-2-ip>:443 weight 30 check inter 3s fall 3 rise 2
Failover, when cluster 2 should only take traffic if cluster 1 goes dark:
backend k8s_https_backend
mode tcp
option tcp-check
server k8s-1 <cluster-1-ip>:443 check inter 3s fall 3 rise 2
server k8s-2 <cluster-2-ip>:443 check inter 3s fall 3 rise 2 backup
That backup keyword is doing all the work — HAProxy health-checks cluster 1 every 3 seconds, and only starts sending traffic to cluster 2 after 3 consecutive failed checks (fall 3), switching back once cluster 1 passes 2 consecutive checks again (rise 2). Swapping between weighted and failover mode is a one-line config change and a reload — no redeploying anything downstream.
Finally, DNS for the public-facing domain points straight at the HAProxy VM's IP, so from the outside world it just looks like one healthy endpoint, regardless of what's happening underneath.
What This Actually Bought Me
- One deploy target. The pipeline doesn't know or care there are two clusters — it just talks to Karmada.
- One migration, done safely. Metadata-cleaned exports plus a dry-run kept the cutover boring, which is exactly what you want from infrastructure work.
- Traffic control without redeploying. Shifting from weighted balancing to hard failover is a config-and-reload operation on HAProxy, not a Kubernetes change.
- A real safety net. If cluster 1 disappears, HAProxy's health checks catch it in seconds and traffic shifts to cluster 2 automatically.
If you're managing more than one cluster and still deploying to each one by hand, this combination — Karmada for orchestration, HAProxy for traffic — is worth the afternoon it takes to set up. Happy to answer questions in the comments if you're trying something similar.

Top comments (0)