A Kubernetes cluster on your own VMs needs a way to give services an address that other machines can reach. This lab uses kubeadm to build the cluster, Calico for pod networking, and MetalLB for LoadBalancer services.
The setup had three Ubuntu VMs: one control plane node and two workers. These notes keep the 2023 versions, including Kubernetes 1.26.4 and Calico 3.25.1. Some steps were missing from the first version, especially the MetalLB network configuration. I explain those gaps below, but the complete setup still needs a fresh test.
Prepare the same runtime on all three nodes
The node preparation runs on every VM. Start with the package update from the original lab:
sudo apt update
sudo apt upgrade
The lab uses containerd as the container runtime. Load the kernel modules for this network setup, and arrange to load them again after a reboot:
cat <<EOF | sudo tee /etc/modules-load.d/containerd.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
Enable forwarding and bridge filtering:
cat <<EOF | sudo tee /etc/sysctl.d/99-kubernetes-cri.conf
net.bridge.bridge-nf-call-iptables = 1
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
sudo sysctl --system
Next comes the original Docker package repository setup for containerd.io. The notes did not record a containerd version. For a new installation, check the Docker Ubuntu repository instructions and select a runtime version compatible with your Kubernetes release. Installing whatever that repository serves today would not reproduce this lab.
The keyring directory and the tools used by these commands must exist first:
sudo apt-get install -y ca-certificates curl gnupg lsb-release
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y containerd.io
On the fresh lab VMs, the next commands generate a default containerd configuration and enable its systemd cgroup driver:
sudo mkdir -p /etc/containerd
sudo containerd config default | sudo tee /etc/containerd/config.toml
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl status containerd
Generating that file replaces any existing configuration, so this step belongs on the fresh lab VMs. Check that the CRI plugin is enabled, too. The Kubernetes runtime documentation shows different configuration sections for containerd 1.x and 2.x. Read the resulting file rather than assuming the old sed command changed the right setting.
This lab disables swap:
sudo swapoff -a
That change lasts until reboot. If the VM enables swap through /etc/fstab, the corresponding swap entry also needs to be disabled for this setup. Other systems may use a different swap service. See the kubeadm prerequisites before preparing new nodes.
The original Kubernetes package repository is gone
The following commands explain how the 2023 lab installed Kubernetes. They no longer form a working package installation path. The Kubernetes project removed its old Google-hosted repositories in March 2024.
sudo apt-get update
sudo apt-get install -y apt-transport-https curl
curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -
cat <<EOF | sudo tee /etc/apt/sources.list.d/kubernetes.list
deb https://apt.kubernetes.io/ kubernetes-xenial main
EOF
sudo apt-get update
sudo apt-get install -y kubelet=1.26.4-00 kubeadm=1.26.4-00 kubectl=1.26.4-00
sudo apt-mark hold kubelet kubeadm kubectl
The hold applies to those three packages, not to every package on the machine. Its purpose was to keep Kubernetes upgrades deliberate. If a package manager is already running, wait for it to finish instead of deleting its lock file.
For a new cluster, follow the current kubeadm installation instructions, including the package repository for your chosen Kubernetes minor release.
Initialize the control plane, then add pod networking
The next commands belong on the control plane node. The original pod network was 172.16.0.0/16; check that it does not overlap your VM network or other networks the cluster must reach.
sudo kubeadm init --pod-network-cidr 172.16.0.0/16 --kubernetes-version 1.26.4
Copy the administrator kubeconfig so your normal user can run kubectl:
mkdir -p "$HOME/.kube"
sudo cp -i /etc/kubernetes/admin.conf "$HOME/.kube/config"
sudo chown "$(id -u):$(id -g)" "$HOME/.kube/config"
That file grants administrative access to the cluster. Keep it private.
The original lab installed Calico from a versioned manifest:
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.25.1/manifests/calico.yaml
Before reproducing the lab, inspect that manifest and verify the resulting Calico IP pool against the pod CIDR. The Calico installation guide explains pod CIDR configuration and the supported installation methods for a new cluster.
Check the node and network pods:
kubectl get nodes
kubectl get pods -n kube-system
A node that stays NotReady needs investigation before you continue. The original notes included a restart of kubelet and containerd, but restarting both services is not a substitute for finding the failure in their logs.
Join the workers with the command from this cluster
On the control plane, print a fresh join command:
sudo kubeadm token create --print-join-command
Run the resulting sudo kubeadm join ... command on each worker. Use the endpoint, token, and CA hash from your own cluster; the dots here are a placeholder, not a complete command. Treat the join token as a credential.
Back on the control plane, check the result:
kubectl get nodes
You should now have three nodes in the Ready state. That is the checkpoint for joining the workers. Testing access from outside the cluster comes later.
Keep MetalLB and its address pool in the same namespace
The original lab used the Bitnami MetalLB chart, version 4.4.1. Its install command used namespace metallb, while the address pool below used metallb-system. Those names must agree.
Here is the historical command with that mismatch corrected:
helm install metallb-system bitnami/metallb \
--namespace metallb-system \
--create-namespace \
--version 4.4.1
This assumes the bitnami repository alias is already configured and that the old chart and images remain available. Their availability has not been verified. For a new installation, start with MetalLB’s own Helm chart and installation instructions; do not substitute a chart version without reviewing its configuration.
The pool tells MetalLB which addresses it may allocate. Save the following historical example as ipaddresspool.yaml:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: first-pool
namespace: metallb-system
spec:
addresses:
- 192.168.20.0/24
The /24 is the original example, not a range you should copy into your network. Reserve unused addresses for MetalLB and keep them separate from DHCP leases, node addresses, and the pod network.
The original apply step was:
kubectl apply -f ipaddresspool.yaml
An address pool still needs an advertisement
This is where the first version stopped too early. An IPAddressPool gives MetalLB addresses to allocate, but those addresses also need to be announced. In Layer 2 mode, associate an L2Advertisement with the pool. A BGP setup needs its own peer and advertisement configuration. The MetalLB configuration guide describes both paths.
The old notes also proposed this router entry:
| Network/host IP | Netmask | Gateway |
|---|---|---|
192.168.20.0 | 255.255.255.0 | <K8s NODE IP> |
Keep that table as a record of the lab, not a general routing recipe. A route through an arbitrary node does not replace an advertisement or establish failover. The intended Layer 2 or BGP topology still needs to be documented and tested.
A rebuilt lab needs a test LoadBalancer service. Check its assigned address, then try reaching the application from another machine. Also test what happens when the node handling traffic becomes unavailable. Those results would make the missing network behavior visible; they are still pending for this article.
