DEV Community

Noushad Hasan
Noushad Hasan

Posted on

How I Stitched Two Kubernetes Clusters Together with Karmada, HAProxy, and Azure Pipelines

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 apply twice
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Export everything as YAML from the old cluster
  2. Strip runtime metadata with kubectl-neat (resourceVersion, UID, status — all the stuff that shouldn't travel with the resource)
  3. Dry-run the apply on the new cluster
  4. 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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)