Introduction
In 2026, Raspberry Pi 5 boards with their ARM64 BCM2712 SoC (2.4 GHz quad-core) are perfect for Kubernetes clusters in homelabs or edge computing. This advanced tutorial guides you through deploying a high-availability (HA) cluster with 3 master nodes + 2 workers, using kubeadm, containerd as the CRI, and MetalLB for load balancing. Why it matters: Kubernetes on ARM slashes costs (RPi5 ~$100/node vs x86 servers) while handling 100+ pods with Prometheus monitoring. We dodge ARM pitfalls like non-multiarch images. By the end, you'll deploy a scalable Nginx app. Estimated time: 2 hours. Scalable to 10+ nodes. Prepare 5 RPi5 boards, stable Ethernet (WiFi is unreliable for K8s).
Prerequisites
- 5x Raspberry Pi 5 (8GB RAM min., 64GB A2-class microSD)
- Raspberry Pi OS Lite 64-bit (bookworm, 2026.01+ update)
- Private Gigabit Ethernet network (192.168.1.0/24), SSH enabled
- Passwordless SSH keys between nodes (Ansible-ready)
- Passwordless
sudoforpiuser - Kubernetes v1.32+ (ARM64 compatible)
- Tools:
curl,jq,apt-transport-https
Update and Install Common Dependencies
#!/bin/bash
set -euo pipefail
# Run on ALL nodes (masters + workers)
apt update && apt upgrade -y
apt install -y curl apt-transport-https ca-certificates gnupg lsb-release jq
# Disable swap (critical for kubelet)
swapoff -a
sed -i '/ swap / s/^/#/' /etc/fstab
# Load kernel modules for CNI networking
cat <<EOF | tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
modprobe overlay
modprobe br_netfilter
# Configure sysctl for Kubernetes
cat <<EOF | tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sysctl --system
rebootThis script prepares all nodes by disabling swap (kubelet won't start otherwise), loading modules for CNI (like Flannel), and configuring sysctl for IP forwarding. Run it via Ansible or manually on each RPi. The final reboot ensures persistence. Pitfall: Forgetting sysctl --system causes pod networking failures.
Installing the Container Runtime (containerd)
Containerd is the recommended CRI for K8s on ARM in 2026 (lightweight, native CRI). Install it from official repos to avoid outdated Apt versions.
Install and Configure containerd
#!/bin/bash
set -euo pipefail
# On ALL nodes
apt install -y containerd.io=1.7.*
# Configure containerd for CRI (sandbox_image = pause:3.10)
mkdir -p /etc/containerd
containerd config default | sed 's/SystemdCgroup = false/SystemdCgroup = true/' | tee /etc/containerd/config.toml
# Pull ARM64 pause image
ctr image pull registry.k8s.io/pause:3.10
systemctl restart containerd
systemctl enable containerd
# Verify
ctr version | head -1
grep -q 'SystemdCgroup = true' /etc/containerd/config.toml && echo 'Config OK'This script installs containerd 1.7 (2026 stable), enables systemd cgroup v2 (required for K8s 1.32+), and pre-pulls the ARM64 pause:3.10 image. The sed fixes the default cgroup setting. Verify with ctr version. ARM pitfall: x86 images fail; always specify ARM64. Containerd outperforms Docker on RPi (lower RAM usage).
Installing Kubernetes Components
Only masters run kubeadm init. Workers: kubelet/kubectl only. Use exact versions to avoid incompatibilities.
Install kubeadm, kubelet, and kubectl
#!/bin/bash
set -euo pipefail
KUBEVER="v1.32.2"
# Add Kubernetes repo (ARM64 auto)
curl -fsSL https://pkgs.k8s.io/core:/stable:/$KUBEVER/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/$KUBEVER/deb/ /' | tee /etc/apt/sources.list.d/kubernetes.list
apt update
apt install -y kubelet=$KUBEVER kubeadm=$KUBEVER kubectl=$KUBEVER
apt-mark hold kubelet kubeadm kubectl
systemctl enable --now kubelet
kubectl version --clientInstalls K8s v1.32.2 from pkgs.k8s.io (official post-deprecation of apt.kubernetes.io). hold prevents auto-upgrades. Client version confirms ARM64 build. Run on all nodes. Pitfall: Old GPG keys cause 404s; always regenerate.
Initialize the Primary Master Node (HA kubeadm init)
#!/bin/bash
set -euo pipefail
POD_CIDR="10.244.0.0/16" # For Flannel
CONTROL_PLANE_ENDPOINT="192.168.1.100:6443" # Virtual load balancer (VIP)
# Reset if needed
kubeadm reset -f
# Init with CRI=containerd, HA certs (cert-manager like)
kubeadm init \
--pod-network-cidr $POD_CIDR \
--control-plane-endpoint $CONTROL_PLANE_ENDPOINT \
--cri-socket unix:///run/containerd/containerd.sock \
--upload-certs \
--certificate-key \
$(kubeadm init phase upload-certs --upload-certs 2>/dev/null | tail -1) \
--kubernetes-version v1.32.2
# Post-init
mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
chown $(id -u):$(id -g) $HOME/.kube/config
# Remove taint for worker-like master
kubectl taint nodes --all node-role.kubernetes.io/control-plane-
kubectl get nodes
kubeadm token create --print-join-commandRun ONLY on FIRST MASTER (192.168.1.100). --upload-certs enables HA (other masters join certs). Save the kubeadm join output for workers. Taint removal allows pods on masters. Pitfall: Wrong --control-plane-endpoint breaks HA; use VIP (keepalived later).
Joining Worker Nodes and Secondary Masters
Copy the kubeadm join --token ... --discovery-token-ca-cert-hash ... --control-plane --certificate-key ... output from the primary master. Run on workers: kubeadm join .... On secondary masters: add --control-plane.
Generic Script to Join Nodes
#!/bin/bash
set -euo pipefail
# Replace with output from kubeadm token create --print-join-command
JOIN_CMD="kubeadm join 192.168.1.100:6443 --token abcdef.1234567890abcdef \
--discovery-token-ca-cert-hash sha256:1234..abcd \
--cri-socket unix:///run/containerd/containerd.sock"
# For worker: $JOIN_CMD
# For HA master: $JOIN_CMD --control-plane --certificate-key XYZ...
kubeadm reset -f
$JOIN_CMD
# On primary master, verify
kubectl get nodes -o wideAdapt $JOIN_CMD from master output. For 2nd/3rd master, add --control-plane --certificate-key (from init). kubectl get nodes shows Ready after ~2min. Pitfall: Token expires in 24h; recreate with kubeadm token create. All nodes Ready = cluster ready.
Deploying CNI and Load Balancer
Flannel for simple CNI (Calico is heavier on RPi). MetalLB for external LB (NodePorts otherwise).
Install Flannel CNI and MetalLB
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: flannel
namespace: kube-system
---
apiVersion: v1
kind: ConfigMap
metadata:
name: flannel
namespace: kube-system
data:
net-conf.json: |
{
"Network": "10.244.0.0/16",
"Backend": { "Type": "vxlan" }
}
cni-conf.json: |
{
"name": "cbr0",
"cniVersion": "0.3.1",
"plugins": [
{"type": "flannel"},
{"type": "portmap"},
{"type": "bandwidth"},
{"type": "firewall"}
]
}
---
# MetalLB (LB for services)
apiVersion: v1
kind: Namespace
metadata:
name: metallb-system
---
apiVersion: v1
kind: Secret
metadata:
namespace: metallb-system
name: memberlist
stringData:
secretkey: "VeryRandomString12345678=="
---
# Apply: kubectl apply -f deploy-cni-metallb.yamlkubectl apply -f this-file from master. Flannel DaemonSet assigns pod IPs (vxlan tunneling efficient on ARM). MetalLB uses IP pool 192.168.1.200-250 (configure for your network). Verify: kubectl get pods -n metallb-system. Pitfall: Wrong IP pool causes 'Pending' services.
Deploy Scalable Example App (Nginx + HPA)
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deploy
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: nginx-svc
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 80
selector:
app: nginx
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: nginx-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: nginx-deploy
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70kubectl apply -f nginx-hpa.yaml. Deploys Nginx with HPA on 70% CPU (scales 3-10 pods). LoadBalancer service exposes via MetalLB (IP ~192.168.1.200). Resources tuned for RPi performance. Test: curl , stress with kubectl run load -i --rm --image=busybox --restart=Never -- wget -qO- . Pitfall: No limits cause OOMKills and RPi crashes.
Best Practices
- HA VIP: Install keepalived on masters (
apt install keepalived, VRRP for 192.168.1.100). - Monitoring: Deploy Prometheus + Grafana (kube-prom-stack Helm chart for ARM).
- Backup: Use
velerofor etcd snapshots (kubeadm certs renewmonthly). - Security: Strict RBAC, NetworkPolicy, PodSecurityStandards admission.
- RPi Perf: Overclock CPU to 2.8GHz, active heatsinks, USB SSD boot.
Common Errors to Avoid
- Swap enabled: Kubelet refuses to start (
journalctl -u kubeletshows error). - x86 images: Pods CrashLoopBackOff; force
imagePullPolicy: Always+ multiarch. - Missing CNI: Pods Pending forever; check
kubectl get pods -n kube-system. - Port 6443 firewall: Joins fail;
ufw allow 6443/tcpor disable iptables.
Next Steps
- Official docs: Kubernetes ARM
- Helm for apps:
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - Longhorn CSI for persistent storage.
- Check out our Learni DevOps training courses for certified CKAD Kubernetes training.