This guide builds a cluster of three Ubuntu 24.04 (noble) nodes: cluster0 as the control plane, and cluster1 and cluster2 as workers. It covers the same steps as the Alma Linux guide, but pushes them to every node at once with Ansible. The ad-hoc commands come first, so each step stays visible, followed by the same steps as a playbook.
Configure Ansible¶
# inventory.yaml
all:
vars:
ansible_user: <your-ssh-user>
ansible_python_interpreter: /usr/bin/python3
cluster:
hosts:
cluster0:
cluster1:
cluster2:
Test access¶
ansible -i inventory.yaml cluster -m ping
ansible -i inventory.yaml cluster -m shell -a uptime
Every host should return "ping": "pong".
In the commands below, -b runs as root (become) and -K prompts for the sudo password.
Prepare all nodes¶
Kubernetes package repo¶
Pick a supported release from https://kubernetes.io/releases and use it in both URLs:
ansible -i inventory.yaml cluster -bK -m shell \
-a "curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.32/deb/Release.key | gpg --dearmor --yes -o /usr/share/keyrings/kubernetes-archive-keyring.gpg"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.copy \
-a "dest=/etc/apt/sources.list.d/kubernetes.list content='deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.32/deb/ /\n'"
Install and hold kubeadm, kubelet, kubectl¶
ansible -i inventory.yaml cluster -bK -m ansible.builtin.apt \
-a "update_cache=yes name=kubeadm,kubectl,kubelet state=present"
ansible -i inventory.yaml cluster -bK -m shell -a "apt-mark hold kubeadm kubelet kubectl"
Disable swap¶
ansible -i inventory.yaml cluster -bK -m shell -a "swapoff -a"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.replace \
-a "path=/etc/fstab regexp='^([^#].*\sswap\s.*)$' replace='# \1'"
Kernel modules and sysctl¶
ansible -i inventory.yaml cluster -bK -m ansible.builtin.copy \
-a "dest=/etc/modules-load.d/kubernetes.conf content='overlay\nbr_netfilter\n' owner=root group=root mode=0644"
ansible -i inventory.yaml cluster -bK -m shell -a "modprobe overlay && modprobe br_netfilter"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.copy \
-a "dest=/etc/sysctl.d/kubernetes.conf content='net.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\n'"
ansible -i inventory.yaml cluster -bK -m shell -a "sysctl --system"
Install containerd¶
These are the Ubuntu steps from containerd, run across the nodes.
ansible -i inventory.yaml cluster -bK -m ansible.builtin.apt \
-a "name=docker,docker.io,docker-ce,docker-ce-cli,containerd,runc state=absent purge=yes"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.apt \
-a "name=ca-certificates,curl,gnupg state=present update_cache=yes"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.get_url \
-a "url=https://download.docker.com/linux/ubuntu/gpg dest=/etc/apt/keyrings/docker.asc mode=0644"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.apt_repository \
-a "repo='deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable' state=present"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.apt \
-a "name=containerd.io state=present update_cache=yes"
ansible -i inventory.yaml cluster -bK -m shell \
-a "containerd config default > /etc/containerd/config.toml"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.replace \
-a "path=/etc/containerd/config.toml regexp='SystemdCgroup = false' replace='SystemdCgroup = true'"
ansible -i inventory.yaml cluster -bK -m ansible.builtin.systemd \
-a "name=containerd state=restarted enabled=yes"
Restart containerd after the config change. If it isn't restarted, kubeadm init runs against the old config.
Initialize the control plane (cluster0)¶
sudo kubeadm init --pod-network-cidr=10.244.0.0/16
Don't use
192.168.0.0/16as the pod CIDR if the nodes themselves sit on a192.168.x.xLAN. The ranges overlap, and pod traffic gets routed to the wrong place. See CNI plugins.
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
Install a CNI plugin, either Calico (with its CIDR set to 10.244.0.0/16) or Cilium.
Join the workers¶
kubeadm init prints a join command. You can also generate a fresh one at any time:
sudo kubeadm token create --print-join-command
Run the output on cluster1 and cluster2. It looks like this:
sudo kubeadm join <control-plane-ip>:6443 --token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
The token and hash let a machine join the cluster. Treat them as secrets, and don't paste real ones into docs.
The same steps as a playbook¶
Untested: this is the ad-hoc sequence above, collected into one playbook.
---
- hosts: cluster
become: true
vars:
k8s_version: v1.32
tasks:
# ---------------------------------------------------------------
# Kubernetes packages
# ---------------------------------------------------------------
- name: Add Kubernetes apt key
ansible.builtin.shell: |
curl -fsSL https://pkgs.k8s.io/core:/stable:/{{ k8s_version }}/deb/Release.key \
| gpg --dearmor -o /usr/share/keyrings/kubernetes-archive-keyring.gpg
args:
creates: /usr/share/keyrings/kubernetes-archive-keyring.gpg
- name: Add Kubernetes apt repo
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/kubernetes.list
content: |
deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://pkgs.k8s.io/core:/stable:/{{ k8s_version }}/deb/ /
- name: Install kubeadm, kubelet, kubectl
ansible.builtin.apt:
update_cache: true
name: [kubeadm, kubelet, kubectl]
state: present
- name: Hold Kubernetes packages
ansible.builtin.dpkg_selections:
name: "{{ item }}"
selection: hold
loop: [kubeadm, kubelet, kubectl]
# ---------------------------------------------------------------
# Swap, kernel modules, sysctl
# ---------------------------------------------------------------
- name: Disable swap now
ansible.builtin.command: swapoff -a
changed_when: false
- name: Disable swap on boot
ansible.builtin.replace:
path: /etc/fstab
regexp: '^([^#].*\sswap\s.*)$'
replace: '# \1'
- name: Load kernel modules on boot
ansible.builtin.copy:
dest: /etc/modules-load.d/kubernetes.conf
content: |
overlay
br_netfilter
owner: root
group: root
mode: "0644"
- name: Load kernel modules now
community.general.modprobe:
name: "{{ item }}"
state: present
loop: [overlay, br_netfilter]
- name: Kubernetes sysctl settings
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/kubernetes.conf
reload: true
loop:
- { key: net.bridge.bridge-nf-call-iptables, value: "1" }
- { key: net.bridge.bridge-nf-call-ip6tables, value: "1" }
- { key: net.ipv4.ip_forward, value: "1" }
# ---------------------------------------------------------------
# containerd
# ---------------------------------------------------------------
- name: Remove old Docker / runtime packages
ansible.builtin.apt:
name: [docker, docker.io, docker-ce, docker-ce-cli, containerd, runc]
state: absent
purge: true
- name: Install prerequisites
ansible.builtin.apt:
update_cache: true
name: [ca-certificates, curl, gnupg]
state: present
- name: Add Docker apt key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Add Docker apt repo
ansible.builtin.apt_repository:
repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
- name: Install containerd.io
ansible.builtin.apt:
update_cache: true
name: containerd.io
state: present
# containerd.io ships a config.toml with the CRI plugin disabled, so a
# plain `creates: /etc/containerd/config.toml` guard would skip this.
- name: Check whether containerd is already configured
ansible.builtin.command: grep -q 'SystemdCgroup = true' /etc/containerd/config.toml
register: containerd_cfg
failed_when: false
changed_when: false
- name: Generate default containerd config
ansible.builtin.shell: containerd config default > /etc/containerd/config.toml
when: containerd_cfg.rc != 0
- name: Enable SystemdCgroup
ansible.builtin.replace:
path: /etc/containerd/config.toml
regexp: 'SystemdCgroup = false'
replace: 'SystemdCgroup = true'
- name: Restart and enable containerd
ansible.builtin.systemd:
name: containerd
state: restarted
enabled: true
# =================================================================
# Control plane only
# =================================================================
- hosts: cluster0
become: true
tasks:
- name: kubeadm init
ansible.builtin.command: kubeadm init --pod-network-cidr=10.244.0.0/16
args:
creates: /etc/kubernetes/admin.conf
- name: Generate join command
ansible.builtin.command: kubeadm token create --print-join-command
register: join_cmd
changed_when: false
- name: Store join command for workers
ansible.builtin.set_fact:
worker_join: "{{ join_cmd.stdout }}"
- name: Create ~/.kube for the admin user
ansible.builtin.file:
path: "/home/{{ ansible_user }}/.kube"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0700"
- name: Copy kubeconfig to the admin user
ansible.builtin.copy:
src: /etc/kubernetes/admin.conf
dest: "/home/{{ ansible_user }}/.kube/config"
remote_src: true
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0600"
# =================================================================
# Workers only
# =================================================================
- hosts: cluster1,cluster2
become: true
tasks:
- name: Join worker to cluster
ansible.builtin.command: "{{ hostvars['cluster0']['worker_join'] }}"
args:
creates: /etc/kubernetes/kubelet.conf
The playbook needs the community.general and ansible.posix collections (ansible-galaxy collection install community.general ansible.posix). The CNI install isn't in the playbook. Do it by hand afterward, or add a kubectl create -f task on cluster0.
Troubleshooting¶
Can't reach the Ubuntu mirror¶
apt-get update timed out on the regional mirror while the other repos worked:
Err:3 http://ca.archive.ubuntu.com/ubuntu noble InRelease
Could not connect to ca.archive.ubuntu.com:80 (91.189.91.81), connection timed out
W: Failed to fetch http://ca.archive.ubuntu.com/ubuntu/dists/noble/InRelease Could not connect to ca.archive.ubuntu.com:80 ...
Disabling IPv6 (sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1) didn't help. Switching from the country mirror to the main archive fixed it:
sudo sed -i 's|ca.archive.ubuntu.com|archive.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get update
Across all nodes:
ansible -i inventory.yaml cluster -bK -m ansible.builtin.replace \
-a "path=/etc/apt/sources.list.d/ubuntu.sources regexp='ca\.archive\.ubuntu\.com' replace='archive.ubuntu.com'"