Multi-Node: From Minikube to a Real Cluster#
Minikube is a single-node cluster - we’ve repeated that warning since the very first v1 notes. For homelab work the standard path to real multi-node Kubernetes is kubeadm - vanilla k8s, everything in your hands, the same tool the managed distros are built on. This note walks through a kubeadm install on VMs, from node prep to a joined, working cluster, with Cilium as the CNI - eBPF datapath, working NetworkPolicy enforcement and no kube-proxy.
The Plan#
On my Proxmox box I spin up 4 Debian 12 VMs, 2 vCPU / 4GB each:
kmaster 192.168.1.200 control plane
kworker1 192.168.1.201 worker
kworker2 192.168.1.202 worker
kworker3 192.168.1.203 workerAdd them to /etc/hosts on all nodes and on your laptop, make sure SSH with key auth works, and keep the k8s minor version pinned identically everywhere - mixed minor versions within one cluster are supported only one step ahead and are a support nightmare.
Prep on All Nodes#
# Disable swap - kubelet refuses to work with it
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
# Kernel modules + sysctl for the pod network
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
cat <<EOF | sudo 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
sudo sysctl --system
net.bridge.bridge-nf-call-iptables = 1looks arcane but matters: without it, traffic through the bridge is not processed by iptables - which breaks Service NAT rules and makes NetworkPolicy enforcement silently unreliable. Cilium’s eBPF datapath needsip_forwardregardless, and even with kube-proxy gone the same prep keeps non-BPF paths (and every other doc in this series) honest. Debian 12’s kernel (6.x) is comfortably above Cilium’s minimum - no kernel surgery needed.
Containerd#
Kubernetes ships its own containerd expectations; the important detail is the cgroup driver:
sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
SystemdCgroup = trueis not optional anymore: kubelet defaults to the systemd cgroup driver, and a mismatch with the container runtime is the classic cause of pods stuck inContainerCreatingor kubelet crash-looping at join time. The v1 troubleshooting checklist applies to nodes too.
K8s Packages (apt, the careful way)#
Same discipline as my Docker hardening doc: verify with GPG, pin versions, avoid surprise upgrades.
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
sudo chmod a+r /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/v1.31:/deb/ /" | \
sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
apt-mark holdis the part everyone skips and then regrets: unattended-upgrades bumping kubelet on one node only is a textbook way to end up with version-skew symptoms that look like random network failures.
Control Plane#
sudo kubeadm init --pod-network-cidr=10.244.0.0/16--pod-network-cidris where kubeadm carves out per-node pod CIDRs from - Cilium reads those fromnode.spec.podCIDR(its defaultipam.mode=kubernetes), so pick this beforeinit, it’s painful to change later- The output prints a
kubeadm join ...command - save it - For anything beyond a lab, add
--control-plane-endpoint=<stable-DNS-or-VIP>- this is what makes a later control-plane HA setup possible without rebuilding everything
Set up kubeconfig:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/configCNI: Cilium#
Flannel is the no-frills homelab CNI - and it implements zero NetworkPolicies, which is the one thing this series kept promising. Cilium is the modern answer: an eBPF-based CNI that enforces NetworkPolicy, replaces kube-proxy entirely (Service load-balancing in eBPF), and throws in Hubble for network observability. The cluster-extensions notes called CNI “the socket” - Cilium is the plugin we now plug into it.
Installed with Helm, the same way we’ll treat every cluster add-on later:
helm repo add cilium https://helm.cilium.io/
helm repo update
helm install cilium cilium/cilium --version 1.16.5 \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=192.168.1.200 \
--set k8sServicePort=6443Three flags worth understanding, because each one is a different way to install a broken cluster:
kubeProxyReplacement=true- Cilium programs Service VIPs in eBPF; kube-proxy becomes redundantk8sServiceHost/k8sServicePort- with kube-proxy gone, Cilium is the thing that has to find the API server; point it atkmaster’s IP (or yourcontrolPlaneEndpointif you set one). Forgetting these leaves every Service unroutable with no error anywhere--version- pin it, same philosophy asapt-mark hold.helm install cilium cilium/ciliumwith no version grabs the latest chart, which is how clusters get surprise minor upgrades
Then remove the now-dead kube-proxy DaemonSet - leaving it running is confusing at best and fighting for iptables at worst. If you’re bootstrapping from scratch, the direct path below skips this whole dance:
kubectl -n kube-system scale daemonset kube-proxy --replicas=0
kubectl -n kube-system get daemonset kube-proxy # 0 desired, confirm nothing breaks
kubectl -n kube-system delete daemonset kube-proxyVerify before joining anything:
cilium status --wait # or: kubectl -n kube-system get pods -l k8s-app=cilium
kubectl get nodes # kmaster flips to ReadyCilium pods are a DaemonSet (
--ignore-daemonsetsin every drain below), and the agent needsCAP_BPF-level access to the kernel - on a plain Debian VM with the prep above that just works. Ifcilium statusshowsController: ... Failing, checkk8sServiceHostfirst,dmesg | grep -i bpfsecond. For a deeper look:cilium sysdumpbundles everything worth reading.
Hubble (Cilium’s network observability layer) is a flag away -
--set hubble.enabled=true --set hubble.relay.enabled=true --set hubble.ui.enabled=true- and turns “which pod is talking to which” from a guessing game intohubble observe. Off by default here to keep the install lean; it’s a per-node sidecar-free design so it costs almost nothing.
(Ben policy enforcement’i artık her yerde deneyebilirim - Cilium varsayılan olarak standardı uygular. eBPF tabanlı ekstra CiliumNetworkPolicy‘ler için NetworkPolicy notuna bak; o nottaki CNI gerçekliği tablosunda flannel’in “policies exist, are ignored” satırının neden bizi Cilium’a getirdiğini göreceksin.)
The Direct Path: init without kube-proxy#
The flow above installs kube-proxy only to kill it a minute later - it works, but it’s a dance. If the cluster doesn’t exist yet, skip the addon/kube-proxy phase of kubeadm init entirely: kube-proxy never appears, Cilium owns Service load-balancing from the first second, and there is nothing to clean up.
sudo kubeadm init --pod-network-cidr=10.244.0.0/16 --skip-phases=addon/kube-proxyEverything else is identical - kubeconfig, then the same Helm install with the same flags:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
helm repo add cilium https://helm.cilium.io/ && helm repo update
helm install cilium cilium/cilium --version 1.16.5 \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=192.168.1.200 \
--set k8sServicePort=6443What changes and what doesn’t:
- Worker joins are unchanged - kube-proxy is an init-time cluster addon (a DaemonSet in
kube-system), not a per-node join phase; workers never run it themselves - Verification is simpler:
kubectl -n kube-system get daemonset kube-proxyreturnsNotFoundimmediately, no “scale to 0, wait, delete” sequence cilium status --wait,kubectl get nodes, Service reachability - same checks as the main path
Dikkat:
kubeadm upgrade applyaddon phase’lerini yeniden çalıştırır - yani atladığın kube-proxy DaemonSet’ini upgrade geri getirir. Upgrade’lerde--skip-phases=addon/kube-proxykullan ya da beliren DS’i tekrar sil. Bu, upgrade dokümanının kubeadm-upgrade-etcd notlarındaki “read the output” kuralının özel bir hali: çıktıyı okursan kube-proxy’nin geri geldiğini zaten görürsün.
Özetle: sıfırdan bootstrap’ta bu varyant tercih edilir; zaten ayakta olan bir cluster’da birinci yol (kur + sil) doğrudur. İkisi de aynı yere çıkar: kube-proxy’suz, eBPF servis yük dengelemeli bir cluster.
Workers Join#
On each worker, run the saved join command. If you lost it:
kubeadm token create --print-join-command # on the control planeVerification - three nodes join, then everything flips to Ready (Cilium first, then nodes):
kubectl get nodes -o wide
kubectl get pods -n kube-systemNote: control-plane nodes carry the taint node-role.kubernetes.io/control-plane:NoSchedule by default - workloads land on workers only. The v1 notes’ control plane vs workers distinction is now something you can literally kubectl describe.
Node Lifecycle#
kubectl cordon <node>- mark unschedulable, running pods keep runningkubectl drain <node> --ignore-daemonsets- evict everything reschedulable (daemonsets excluded, they’re per-node by design)kubectl uncordon <node>- schedulable again- On the node itself:
sudo kubeadm resetcleans up most of the k8s state (PVC data and some config survive - read the warning it prints), thenkubectl delete node <name>on the control plane removes the object
Backups and Upgrades#
Control-plane state lives in etcd. Snapshot it periodically:
sudo etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
snapshot save /backup/etcd-snapshot-$(date +%F).dbProxmox tarafında ayrıca VM-level snapshot almak olabilecek en kolay sigortadır - tabi etcd snapshot’ı deployment’ları da kurtarırken, VM snapshot’ı tüm control-plane’i hepten geri alır; ikisinin yerini tutmaz.
Upgrades: control plane first, one minor at a time, apt-mark unhold before, hold again after, drain per node in between. Never skip minors on kubeadm clusters. The full command-by-command walkthrough - including etcd restore and cert renewal - is in kubeadm-upgrade-etcd; HA topologies that make an upgrade survivable are in ha-control-plane.
Özetle: kubeadm upgrade’i ve Cilium upgrade’i ayrı islerdir. apt-get upgrade cluster’ı hareket ettirir; helm upgrade cilium ... datapath’i. İkisini aynı anda yapma - biri bozulursa hangisinde problem olduğunu bilemezsin.
How to Bootstrap a 4-Node Cluster with kubeadm#
Prep all four VMs (swap, modules, sysctl, containerd, packages) - identical commands everywhere, and verify each step’s output rather than assuming.
On
kmaster, initialize and capture the join command:
sudo kubeadm init --pod-network-cidr=10.244.0.0/16Copy kubeconfig, verify kubectl get nodes shows one NotReady control plane (no CNI yet - that’s expected).
- Install Cilium and watch the control plane flip to
Ready:
helm repo add cilium https://helm.cilium.io/ && helm repo update
helm install cilium cilium/cilium --version 1.16.5 \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=192.168.1.200 \
--set k8sServicePort=6443
cilium status --wait
kubectl get nodes --watchIf the node stays
NotReadyafter the install, checkkubectl get pods -n kube-system-ciliumpods should beRunningon every node andcilium-operatorat least once. CrashLooping cilium pods with no obvious error is almost alwaysk8sServiceHostpointing at the wrong address;NotReady+ cilium Running is usually a CIDR mismatch from step 2 - there’s no clean fix for a wrong--pod-network-cidr, plan the CIDR beforeinit.
How to Join Workers and Verify the Cluster#
On each worker, paste the join command. A successful join ends with
This node has joined the cluster. Three times -kworker1,kworker2,kworker3.From the control plane, confirm the fleet and that the worker role reads as
<none>:
kubectl get nodes -o wideCheck INTERNAL-IP, OS-IMAGE, VERSION columns - and confirm all nodes run the same k8s minor version (the hold pins should guarantee it).
- Confirm the datapath claim: kube-proxy is gone and Services still work:
kubectl -n kube-system get daemonset kube-proxy # NotFound = success
kubectl get svc kubernetes -o wideIf kubernetes ClusterIP answers on :443 from a worker, eBPF is load-balancing - the flag did what it promised.
- Deploy the web app with 3 replicas and watch placement:
kubectl get pods -o widePods landing on different worker nodes is the v1 minikube limits table happening for real.
- Sanity-check the control-plane taint keeps pods off the master:
kubectl describe node kmaster | grep -iA3 taintsHow to Remove and Re-add a Node Safely#
- Evacuate first - drain moves the workloads away before you touch anything:
kubectl drain kworker1 --ignore-daemonsets
kubectl get pods -o wide --watchWatch the web pods reschedule onto remaining nodes (this is also the moment you notice if you lack a spread constraint - everything may pile onto one node). Cilium agents stay - they’re a DaemonSet and per-node by design.
- From the cluster’s view, remove the node object:
kubectl delete node kworker1- On the node itself, clean k8s + Cilium state:
sudo kubeadm reset -f
sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F
sudo rm -rf /etc/cni /var/lib/cni/ /var/lib/kubelet/* /var/run/cilium /var/lib/ciliumThe iptables flush, kubelet cleanup and Cilium state removal are what make a re-join work cleanly; skipping them produces join failures that look like token problems but are actually stale state. Stale Cilium state is especially nasty - the node rejoins, the agent starts, and the datapath silently keeps programming old identities.
- Re-join with a fresh token from the control plane and verify it returns to
Ready:
kubeadm token create --print-join-command # on kmaster
# paste on kworker1
kubectl get nodes --watch
kubectl -n kube-system get pods -l k8s-app=cilium -o wide # a cilium pod for the new nodefor more kubeadm-upgrade-etcd and ha-control-plane