Andrew Mercer
on this page

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/16 as the pod CIDR if the nodes themselves sit on a 192.168.x.x LAN. 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'"