Skip to main content
  1. Posts/

Installing a kubernetes cluster with kubeadm on Ubuntu 24.04 hosts

·6 mins·
Table of Contents

This guide walks through bootstrapping a Kubernetes cluster with kubeadm. The flow is: prepare every host identically, initialize the first control plane node, install a pod network (CNI), and finally join additional control plane and worker nodes.

Prerequisites (on every host)
#

These steps must run on every node, both control plane and workers, because the kubelet expects the same base environment everywhere.

Disable swap memory
#

Kubernetes requires swap to be off (by default). The scheduler makes placement decisions based on real available memory; if the kernel can silently page memory to disk, those guarantees break and the kubelet refuses to start. We disable it both at runtime and persistently so it stays off after a reboot.

# disable swap
sudo swapoff -a

# disable swap persistent (remove form /etc/fstab)
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab

free -h   # swap should be 0

Install containerd
#

Kubernetes does not run containers itself. It talks to a container runtime through the CRI (Container Runtime Interface). containerd is a lightweight, reliable, CRI-native runtime and the common default.

The key detail is the cgroup driver: both the kubelet and the runtime must manage cgroups the same way. On modern systemd-based distros that means SystemdCgroup = true. A mismatch here leads to nodes that become unstable under load. We also pin the pause (sandbox) image, the tiny container that holds the Linux namespaces shared by all containers in a pod.

# update packages in apt package manager 
sudo apt update 

# install containerd using the apt package manager 
# containerd is lightwieght, reliable and fast (CRI native) 
sudo apt-get install -y containerd 

# create /etc/containerd directory for containerd configuration 
sudo mkdir -p /etc/containerd 

# Generate the default containerd configuration 
# Change the pause container to version 3.10 (pause container holds the linux ns for Kubernetes namespaces) 
# Set `SystemdCgroup` to true to use same cgroup drive as kubelet 
containerd config default | sed 's/SystemdCgroup = false/SystemdCgroup = true/' | sed 's|sandbox_image = ".*"|sandbox_image = "registry.k8s.io/pause:3.10"|' | sudo tee /etc/containerd/config.toml > /dev/null 

# Restart containerd to apply the configuration changes 
sudo systemctl restart containerd

Network configuration
#

The Linux kernel does not forward packets between interfaces by default. Pod networking relies on this forwarding so traffic can be routed between pods and nodes, so we enable ip_forward and persist it across reboots via a sysctl drop-in file.

# sysctl params required by setup, params persist across reboots
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.ipv4.ip_forward = 1
EOF

# Apply sysctl params without reboot

sudo sysctl --system

Install kubeadm, kubelet, and kubectl
#

These are the three core tools:

  • kubeadm bootstraps and joins clusters.
  • kubelet is the node agent that runs on every host and starts pods.
  • kubectl is the CLI used to talk to the cluster.

We add the official Kubernetes apt repository (signed with its GPG key for package authenticity) and then apt-mark hold the packages so an unattended apt upgrade can’t skip a minor version and break the cluster. Kubernetes upgrades must be done deliberately, one minor version at a time.


# Update the `apt` package index and install packages needed to use the Kubernetes `apt` repository:
sudo apt update 
# apt-transport-https may be a dummy package; if so, you can skip that package
sudo apt-get install -y apt-transport-https ca-certificates curl gpg 

# Download the public signing key for the Kubernetes package repositories. The same signing key is used for all repositories so you can disregard the version in the URL: 
# If the directory `/etc/apt/keyrings` does not exist, it should be created before the curl command.
sudo mkdir -p -m 755 /etc/apt/keyrings 

sudo curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

# Add the appropriate Kubernetes `apt` repository. Please note that this repository have packages only for Kubernetes 1.36; for other Kubernetes minor versions, you need to change the Kubernetes minor version in the URL to match your desired minor version (you should also check that you are reading the documentation for the version of Kubernetes that you plan to install).
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/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

# (Optional) Enable the kubelet service before running kubeadm
sudo systemctl enable --now kubelet

Initialize the cluster (only from the first control plane)
#

Run this only on the first control plane node. kubeadm init generates the cluster’s certificates, starts the control plane components (API server, controller manager, scheduler, etcd), and prints the kubeadm join commands you will need for the other nodes.

Pre-pulling images first makes the init faster and surfaces any registry/network problems early. The --pod-network-cidr must match what your CNI expects, and 192.168.0.0/16 is Calico’s default. The kubeadm reset line is only needed when you are retrying a failed init on an already-touched node.

# pull images
sudo kubeadm config images pull

# execute before every new try
sudo kubeadm reset --cri-socket=unix:///run/containerd/containerd.sock -f

# initialize controlplane
sudo kubeadm init --pod-network-cidr=192.168.0.0/16 --cri-socket=unix:///run/containerd/containerd.sock

# HOW TO RESET IF NEEDED 
# sudo kubeadm reset --cri-socket=unix:///run/containerd/containerd.sock 
# sudo rm -rf /etc/kubernetes /var/lib/etcd 

After init succeeds, copy the admin kubeconfig into your home directory so kubectl can authenticate as a regular (non-root) user. The admin.conf holds the client certificate that grants cluster-admin access.

# ONLY ON CONTROL PLANE (also in the output of 'kubeadm init' command) 
mkdir -p $HOME/.kube 
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config 
sudo chown $(id -u):$(id -g) $HOME/.kube/config

# set alias
alias k=kubectl

# copy kubeadm join command!!

Tip: Save the kubeadm join command from the init output now. You’ll need it (with a fresh token) to add worker and control plane nodes later.

Install a Container Network Interface (CNI)
#

Right after init, nodes show as NotReady and pods stay Pending because there is no pod network yet. The CNI plugin provides pod-to-pod networking across nodes. Here we use Calico, installed via its Tigera operator. Once the Calico pods are running, the nodes flip to Ready.

kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.24.1/manifests/tigera-operator.yaml
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.24.1/manifests/custom-resources.yaml

# wait for the pods to be ready
k get po -A -w 

Join the cluster with another control plane
#

For a highly available (HA) control plane you add more control plane nodes. Unlike a worker, a new control plane needs a copy of the cluster certificates, so you first re-upload them and get a short-lived certificate key. Join tokens also expire (24h by default), so generate a fresh one with token create.

HA note: A multi control plane setup requires a stable controlPlaneEndpoint (a load balancer / VIP in front of the API servers). If it wasn’t set at init time, add it to the kubeadm-config ConfigMap in the kube-system namespace, e.g. controlPlaneEndpoint: 10.9.137.195:6443.

# create certificate key
sudo kubeadm init phase upload-certs --upload-certs

sudo kubeadm token create --print-join-command
# add the following entry to the kubeadm-config ConfigMap in the kube-system namespace: controlPlaneEndpoint: 10.9.137.195:6443

kubeadm join 192.168.0.200:6443 --token xxxxxx.xxxxxxxxxxxxxxxx \
--discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
--control-plane --certificate-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Join the cluster with worker nodes
#

Worker nodes run your actual workloads. They only need to authenticate to the API server, so the join command is simpler and needs just the token and the CA cert hash (the hash lets the node verify it’s talking to the right cluster). No certificate key and no --control-plane flag here.

kubeadm join 10.9.137.162:6443 --token xxxxxx.xxxxxxxxxxxxxxxx \
        --discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx