Skip to main content

Kubernetes is an open-source system for running and managing containerized applications across physical machines, virtual machines, and cloud instances.

The reason it exists is simple: once an application is split into many microservices, containers become easy to create but hard to operate. Kubernetes gives us a consistent way to deploy them, recover from failures, scale them, and keep the whole system available.

This note is both a conceptual map of Kubernetes and a record of my first high-availability cluster setup with kubeadm, HAProxy, containerd, and Cilium.

What Kubernetes Tries to Solve

A production system usually needs three things:

  1. High availability: the application should keep running when one machine or process fails.
  2. Scalability: the system should support adding or removing workload capacity.
  3. Disaster recovery: important state should survive node or process failures.

Kubernetes does not make these problems disappear, but it gives us the primitives to manage them explicitly.

Core Building Blocks

Node

A Node is a physical machine, virtual machine, or cloud instance that runs Kubernetes workloads.

Pod

A Pod is the smallest deployable unit in Kubernetes. It wraps one or more containers. In many common applications, one Pod roughly maps to one application container.

Each Pod gets its own IP address, but that IP is not something we should treat as permanent.

Service

Because Pod IP addresses are temporary, Kubernetes uses a Service to provide a stable network endpoint in front of one or more Pods. The lifecycle of a Service is independent from the lifecycle of any individual Pod.

Ingress

An Ingress routes external HTTP or HTTPS requests to internal Services. It is the layer that lets different hostnames or paths reach different services inside the cluster.

ConfigMap and Secret

A ConfigMap stores non-sensitive configuration, such as a database URL or application setting. This avoids rebuilding a container image every time configuration changes.

A Secret is used for sensitive values such as passwords, tokens, and API keys. These should not be placed in a ConfigMap.

Volume

A Pod is disposable. If a Pod is deleted, local data inside that Pod disappears with it. A Volume makes data persistent by connecting the Pod to local or remote storage.

This is especially important for stateful systems such as databases.

Deployment and StatefulSet

A Deployment manages stateless Pods. It creates them, keeps the desired number running, replaces failed Pods, and supports rolling updates.

A StatefulSet manages Pods that need stable identity and persistent storage, such as PostgreSQL, Redis, or other stateful systems. The key difference is that Kubernetes must preserve the relationship between a Pod and its storage.

Namespace

A Namespace is a logical space inside a Kubernetes cluster. It helps isolate and organize resources so that different applications, environments, or teams do not interfere with each other.

Namespaces are useful for:

  1. Structuring cluster resources.
  2. Avoiding naming conflicts between teams.
  3. Separating environments while still allowing selected services to be shared.
  4. Applying access control and resource limits at the namespace level.

Worker Node Processes

A worker node mainly needs three components:

  • kubelet: manages Pods on the node and makes the actual state match what Kubernetes wants.
  • container runtime: runs containers. In this setup, I used containerd.
  • kube-proxy: handles service networking. In many production setups, this responsibility is increasingly handled by Cilium.

Control Plane Processes

The control plane manages the desired state of the cluster and coordinates worker nodes.

Its core components are:

  • API Server: the entry point of the entire Kubernetes control plane.
  • Scheduler: decides which node should run a newly created Pod.
  • Controller Manager: watches the API Server and continuously reconciles actual state with desired state.
  • etcd: the database that stores cluster state.

In a production-like setup, there are usually three control plane nodes. The API Server instances can all serve requests, typically behind a load balancer. For the Scheduler and Controller Manager, only one instance acts as the leader at a time while the others remain on standby.

Each control plane node also runs etcd, and the etcd members synchronize state with one another as a distributed database.

For user-side traffic, the path is different. A request usually enters through an external load balancer, reaches a Kubernetes LoadBalancer Service and Ingress Controller, and is then routed to internal Services.

My Lab Environment

I built the cluster locally with:

  • macOS
  • UTM
  • Ubuntu Server 24.04 LTS
  • ARM64 virtual machines
RoleCPUMemoryDisk
HAProxy22 GB20 GB
Control Plane24 GB30 GB
Worker24 GB30 GB

The final target topology was:

Build Steps

1. Install Ubuntu

For each VM, I installed Ubuntu Server without a GUI and enabled OpenSSH Server.

sudo apt update
sudo apt upgrade -y

2. Set Hostnames

sudo hostnamectl set-hostname <hostname>

Example names:

lb1
cp1
cp2
cp3
worker1
worker2

3. Install containerd

sudo apt install -y containerd

sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml

In /etc/containerd/config.toml, set:

SystemdCgroup = true

Then restart and enable containerd:

sudo systemctl restart containerd
sudo systemctl enable containerd

4. Disable Swap

sudo swapoff -a

Then comment out the swap entry in /etc/fstab so it does not return after reboot.

5. Configure Kernel Modules and sysctl

Load these modules in /etc/modules-load.d/k8s.conf:

overlay
br_netfilter

Enable the required networking settings:

net.bridge.bridge-nf-call-iptables=1
net.bridge.bridge-nf-call-ip6tables=1
net.ipv4.ip_forward=1

Apply them:

sudo sysctl --system

6. Install Kubernetes Tools

Install kubeadm, kubelet, and kubectl, then enable kubelet:

sudo systemctl enable kubelet

7. Clone Virtual Machines

After preparing the base VM, I cloned it to create cp1, cp2, cp3, worker1, and worker2.

For each cloned VM, I updated the hostname and machine ID.

8. Configure HAProxy

On the load balancer node:

sudo apt update
sudo apt install -y haproxy

Example configuration:

global
    log /dev/log local0
    daemon

defaults
    log global
    mode tcp
    timeout connect 10s
    timeout client 1m
    timeout server 1m

frontend kubernetes-api
    bind *:6443
    mode tcp
    option tcplog
    default_backend kubernetes-control-plane

backend kubernetes-control-plane
    mode tcp
    balance roundrobin
    option tcp-check

    server cp1 192.168.2.191:6443 check
    server cp2 192.168.2.192:6443 check
    server cp3 192.168.2.193:6443 check

Validate and reload:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy

During bootstrap, I only added cp1 to the backend first. After cp2 and cp3 successfully joined the cluster, I added them back into HAProxy.

9. Initialize the Cluster

On the first control plane node:

sudo kubeadm init   --control-plane-endpoint="<LB-IP>:6443"   --upload-certs

Save the generated certificate key, control-plane join command, and worker join command.

10. Configure kubectl

mkdir -p ~/.kube
sudo cp /etc/kubernetes/admin.conf ~/.kube/config
sudo chown $$(id -u):$$(id -g) ~/.kube/config

Repeat this on every control-plane node where kubectl is needed.

11. Install Cilium

cilium install
cilium status

12. Join Additional Control Plane Nodes

Generate or refresh the certificate key and join command:

sudo kubeadm init phase upload-certs --upload-certs
kubeadm token create --print-join-command

On cp2 and cp3:

sudo kubeadm join <LB-IP>:6443   --token <token>   --discovery-token-ca-cert-hash sha256:<hash>   --control-plane   --certificate-key <certificate-key>

13. Join Worker Nodes

On each worker node:

sudo kubeadm join <LB-IP>:6443   --token <token>   --discovery-token-ca-cert-hash sha256:<hash>

14. Validate the Cluster

kubectl get nodes
kubectl get pods -A
cilium status

The Most Important Lesson

Do Not Add Unjoined Control Planes to HAProxy Too Early

The most confusing issue I hit was not caused by Cilium, even though it first looked like a Cilium problem.

After installing Cilium, this command:

cilium status --wait

returned errors like:

unable to retrieve cilium status
error sending request
EOF

kubectl also became unstable:

Unable to connect to the server: EOF
TLS handshake timeout

Some Kubernetes API requests succeeded, while others failed.

Root Cause

I had configured HAProxy with all three control plane nodes before cp2 and cp3 had actually joined the cluster.

HAProxy

├── cp1 ✅
├── cp2 ❌
└── cp3 ❌

At that point, only cp1 was serving the Kubernetes API. cp2 and cp3 did not yet have working API Server instances. HAProxy still routed some requests to them, which caused intermittent API failures.

Because the Cilium CLI talks to the Kubernetes API Server, the symptom appeared during Cilium installation. The real problem was the unstable API endpoint.

Fix

Bootstrap the cluster incrementally.

Start with only cp1 in HAProxy:

HAProxy

└── cp1

After cp2 joins successfully:

HAProxy

├── cp1
└── cp2

After cp3 becomes healthy:

HAProxy

├── cp1
├── cp2
└── cp3

After every HAProxy change, validate and reload:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy

Takeaway

Building Kubernetes from scratch makes the control plane feel much less magical. The key mental model is that Kubernetes is a reconciliation system built around a stable API Server and a persistent state store.

In a high-availability setup, the load balancer in front of the API Server is part of the control plane reliability story. If that endpoint is unstable, everything above it becomes noisy: kubectl, Cilium, node joins, and even basic health checks.

The practical rule I will remember is simple: during bootstrap, only route traffic to control plane nodes that are already serving the Kubernetes API successfully.