Kubernetes HA with kubeadm: Three Control Planes, HAProxy, and Cilium

admineci

admineci

Author

1623 words

Three control planes, HAProxy, and Cilium on kubeadm. This guide covers what changes from a single CP: etcd quorum, the two join commands, and what survives a failure.

Scope. This article builds a highly available Kubernetes 1.36 cluster with three control-plane nodes, two workers, and HAProxy as the API load balancer. It assumes you have already built a single control-plane cluster following the basic kubeadm guide. The focus is on what changes for HA: the load balancer, the etcd quorum, the two different join commands, and the failure modes.

What Changes from a Single Control Plane

A single control-plane cluster has one API server, one etcd instance, one scheduler, and one controller manager. If that node goes down, the entire control plane is lost. The worker nodes keep running their pods, but no new scheduling, scaling, or API access is possible until the control plane recovers.

A highly available cluster distributes the control plane across three nodes. Each runs its own API server, its own etcd member, and its own scheduler and controller manager (with active-standby election). A load balancer sits in front of the three API servers and routes traffic to whichever is available. If one control-plane node fails, the remaining two maintain etcd quorum and API availability.

The rest of the cluster — containerd, kubelet, Cilium, the kubeadm packages — is installed exactly as in the basic guide. This article covers only the differences.

Why Three, Not Two

Etcd requires a majority of its members to agree on every write. This is the Raft consensus protocol. With two members, the majority is two — both must be available for any write to succeed. Losing one member loses the majority and the cluster becomes read-only.

With three members, the majority is two. One node can fail and the remaining two still form a majority. This is the smallest topology that provides meaningful fault tolerance.

Five members tolerate two failures but add latency to every write (three nodes must acknowledge). For most clusters, three control planes is the right number.

The Architecture

Six nodes total:

Role Count Purpose
Load balancer 1 HAProxy, forwards port 6443 to the three API servers
Control plane 3 API server, etcd, scheduler, controller manager
Worker 2 Kubelet, pod scheduling

All five Kubernetes nodes (three CP, two workers) need the same base preparation from the basic kubeadm guide: swap disabled, kernel modules loaded, containerd with SystemdCgroup, and the kubeadm/kubelet/kubectl packages pinned.

The load balancer node runs only HAProxy. It does not run kubelet and is not part of the Kubernetes cluster.

HAProxy: What It Balances and What It Does Not

HAProxy listens on port 6443 and forwards TCP connections to the three API servers using round-robin with health checks. The configuration is minimal:

frontend k8s_api
    bind *:6443
    mode tcp
    default_backend k8s_api_backend

backend k8s_api_backend
    mode tcp
    balance roundrobin
    option tcp-check
    default-server inter 3s fall 3 rise 2
    server cp01 CP01_IP:6443 check
    server cp02 CP02_IP:6443 check
    server cp03 CP03_IP:6443 check

Replace CP01_IP, CP02_IP, and CP03_IP with the actual IP addresses of your control-plane nodes.

HAProxy balances the Kubernetes API only. It does not balance pod traffic, NodePort services, or ingress. Pod-to-pod communication goes through the CNI (Cilium). NodePort traffic goes directly to the worker node’s IP. Ingress traffic goes through an ingress controller deployed inside the cluster. HAProxy handles none of these.

The health check (tcp-check connect port 6443) verifies that the API server is accepting TCP connections. If a control-plane node goes down, HAProxy marks it as failed after three consecutive check failures (9 seconds) and stops sending traffic to it.

Initializing the First Control Plane

This runs only on the first control-plane node.

The kubeadm configuration for HA differs from the basic setup in one critical field: controlPlaneEndpoint points to the load balancer, not to the local node.

apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.4
controlPlaneEndpoint: "HAPROXY_IP:6443"
networking:
  podSubnet: 10.244.0.0/16
  serviceSubnet: 10.96.0.0/12
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd

Replace HAPROXY_IP with the IP address or DNS name of the load balancer. Every component in the cluster will use this address to reach the API. It must resolve correctly from every node before you proceed.

Initialize with certificate upload enabled:

kubeadm init --config=/root/kubeadm-ha.yaml --upload-certs \
  | tee /root/kubeadm-init.out

The --upload-certs flag encrypts the control-plane certificates and stores them in a Kubernetes Secret. The other control-plane nodes will download these certificates during their join. Save the entire output. It contains two different join commands — one for control planes and one for workers.

The kubelet-to-localhost Redirect

After initialization, the kubelet on the first control-plane node is configured to reach the API through HAProxy. This creates a circular dependency: if HAProxy goes down, the kubelet cannot reach the API server that runs on the same node.

The fix is to redirect the kubelet to use the local API server directly:

sudo sed -i 's|server: https://HAPROXY_IP:6443|server: https://127.0.0.1:6443|' \
  /etc/kubernetes/kubelet.conf
sudo systemctl restart kubelet

Apply this on every control-plane node after it joins the cluster. Workers must not be redirected — they do not run an API server, so localhost:6443 would fail. Workers continue to use HAProxy, which is the correct path for them.

This redirect is the single most important HA hardening step that the basic kubeadm guide does not need. Without it, a HAProxy failure takes down all three control planes even though each has a healthy API server.

Joining the Second and Third Control Planes

The init output contains a join command for control-plane nodes. It includes three parameters: the token, the CA certificate hash, and the certificate key:

kubeadm join HAPROXY_IP:6443 --token TOKEN \
  --discovery-token-ca-cert-hash sha256:HASH \
  --control-plane --certificate-key CERT_KEY

Run this command on cp02 and cp03 in sequence — not in parallel. Each join adds an etcd member to the cluster, and etcd needs a stable quorum during membership changes. Joining two nodes simultaneously can split the quorum.

After each join completes, apply the kubelet-to-localhost redirect on that node. Then verify from cp01:

kubectl get nodes

After all three control planes have joined, three nodes should appear with the control-plane role. They will show NotReady until Cilium is deployed.

The Certificate Key Expires in Two Hours

The join token expires after 24 hours, as in the basic setup. But the certificate key expires after only two hours. If you wait longer than two hours between the init and the control-plane join, the join fails with a certificate error.

To regenerate an expired certificate key from the first control plane:

kubeadm init phase upload-certs --upload-certs

This prints a new certificate key. Use it with the existing token and CA hash. If the token has also expired, regenerate both:

kubeadm token create --print-join-command --certificate-key $(kubeadm init phase upload-certs --upload-certs 2&1 | tail -1)

This prints a complete join command with fresh credentials.

Deploying Cilium and Joining Workers

Deploy Cilium from the first control-plane node exactly as in the basic guide:

helm repo add cilium https://helm.cilium.io/
helm repo update
helm install cilium cilium/cilium \
  --namespace kube-system \
  --set ipam.mode=cluster-pool \
  --set ipam.operator.clusterPoolIPv4PodCIDRList=10.244.0.0/16

Wait for Cilium to deploy on all control-plane nodes before joining workers. Cilium agents must be running on every node for the cluster to function.

Join the workers using the worker join command from the init output (without --control-plane and without --certificate-key):

kubeadm join HAPROXY_IP:6443 --token TOKEN \
  --discovery-token-ca-cert-hash sha256:HASH

After both workers join, label them:

kubectl label node WORKER01 node-role.kubernetes.io/worker=""
kubectl label node WORKER02 node-role.kubernetes.io/worker=""

What Survives a HAProxy Failure

Understanding the failure modes is the point of an HA cluster. With the kubelet-to-localhost redirect in place:

Component HAProxy down One CP down Two CPs down
Control-plane kubelets Keep running (localhost) Remaining two run One runs, no quorum
Etcd quorum Intact (3/3) Intact (2/3) Lost (1/3)
API availability Lost for workers/external Intact via HAProxy Lost
Running pods Continue running Continue running Continue running
New scheduling Works on CPs only Works Stopped

The critical insight: running pods are not affected by control-plane failures. The kubelet on each worker continues to run its pods independently. What stops is scheduling, scaling, and API access. A brief HAProxy outage is invisible to workloads. A prolonged one requires direct API access to a control plane for recovery.

For clusters where control-plane monitoring and incident response are part of the operational baseline, our monitoring and operational support practice covers the alerting and escalation chain. For the orchestration platform itself, our cloud-native orchestration practice handles the deployment and lifecycle.

Validating the HA Cluster

Five checks confirm the cluster is truly HA:

kubectl get nodes

Five nodes, all Ready — three control-plane, two worker.

kubectl get pods -n kube-system -l component=etcd

Three etcd pods, all Running, one per control-plane node.

kubectl get pods -A -l k8s-app=cilium -o wide

Five Cilium agents, one per node.

Deploy a test workload and verify it runs on a worker. Delete it afterward.

The Pod Security Standards article explains what your kubeadm cluster, single or HA, does not enforce by default — and what you need to enable before running production workloads.

What This Article Does Not Cover

This article covers the kubeadm HA topology with an external load balancer. It does not cover DNS configuration with Bind9 — the support material details that step, and /etc/hosts entries work for a lab. It does not cover alternatives to HAProxy (keepalived+VIP, kube-vip, cloud load balancers). It does not cover etcd backup and restore, which is essential for production HA but is a separate operational topic. It does not cover stacked vs external etcd topologies — this guide uses the stacked topology where etcd runs on the control-plane nodes. And it does not cover upgrading an HA cluster, which follows a specific node-by-node sequence starting with the first control plane.

References

Kubernetes Project. Creating Highly Available Clusters with kubeadm. kubernetes.io, 2026.

Kubernetes Project. Options for Highly Available Topology. kubernetes.io, 2026.

HAProxy Technologies. HAProxy Configuration Manual — TCP Mode. haproxy.com, 2026.

EC INTELLIGENCE. Formation Administration Kubernetes — Exercice d’Installation d’un Cluster HA. 2026.

Share this article

Do you have a similar project?

Our experts are there to support you in your cloud and infrastructure projects.