How I deploy Kubernetes clusters easily with Ansible and RKE2.

Lately at $DAYJOB I’ve been tasked with deploying a couple of new Kubernetes clusters. I chose to use RKE2, to keep in line with the other clusters we run.

The problem

I’m lazy and I don’t like to do the same things over and over again because I will inevitably mess something up in the process, so I like to have things automated.

The solution

I’m using Ansible to deploy the clusters. Being even lazier than what you may think, I use the open source RKE2 Ansible Role from the fine folks at Labyrinth Labs, so I just need to write a playbook and an inventory file.

Some notes:

  • We use cilium as CNI, so I added a custom manifest to the role to disable kube-proxy.
  • We use a Load Balancer to expose the Kubernetes API server, so I set rke2_api_ip to its IP address.
  • We use LVM and local-path-provisioner for the storage, maybe you prefer to do things differently.
  • I like k9s to manage my clusters, so I install it by default on the control plane nodes.

Here are an inventory file, a custom manifest for cilium, and a playbook that will deploy a multi-node RKE2 cluster on freshly installed Ubuntu 24.04 servers.

# inventory.yml
[masters]
# The control plane machines
#
# Entry format:
# <env>-cp-<number> ansible_host=<IPv4 address> rke2_type=server
# <env> is the environment name, <number> is the machine number
# <IPv4 address> is the IP address of the machine
# <rke2_type> is either server or agent
env-cp-1 ansible_host=xxx.xxx.xxx.xxx rke2_type=server
env-cp-2 ansible_host=xxx.xxx.xxx.xxx rke2_type=server
env-cp-3 ansible_host=xxx.xxx.xxx.xxx rke2_type=server

[workers]
# The worker machines
#
# Entry format:
# <env>-wo-<number> ansible_host=<IPv4 address> rke2_type=agent
env-wo-1 ansible_host=xxx.xxx.xxx.xxx rke2_type=agent
env-wo-2 ansible_host=xxx.xxx.xxx.xxx rke2_type=agent
env-wo-3 ansible_host=xxx.xxx.xxx.xxx rke2_type=agent

[k8s_cluster:children]
masters
workers

[k8s_cluster:vars]
#A long random token (this is sensitive like a password)
#see https://ranchermanager.docs.rancher.com/how-to-guides/new-user-guides/kubernetes-cluster-setup/rke2-for-rancher#1-install-kubernetes-and-set-up-the-rke2-server
rke2_token=RANDOMLY_GENERATED_TOKEN

# The version of RKE to use
rke2_version=vx.y.z

#IPv4 address of the Load Balancer, maybe you don't need this
rke2_api_ip=xxx.xxx.xxx.xxx

# The file where to store the kubernetes client configuration, in the format <env>.yaml
rke2_download_kubeconf_file_name=env.yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: rke2-cilium
  namespace: kube-system
spec:
  valuesContent: |-
    image:
      tag: "vx.y.z"
    kubeProxyReplacement: true
    k8sServiceHost: {{ rke2_api_ip }}
    k8sServicePort: {{ rke2_apiserver_dest_port }}
    cni:
      chainingMode: "none"
    hubble:
      enabled: true
      ui:
        enabled: true
      relay:
        enabled: true
        image:
          tag: "vx.y.z"
# playbook.yml
- name: Deploy RKE2
  hosts: all
  remote_user: root
  vars:
    rke2_custom_manifests:
      # Add your custom manifests here
      - "{{ playbook_dir }}/manifests/rke2-cilium-config.yaml"

    rke2_server_node_taints:
      - 'CriticalAddonsOnly=true:NoExecute'

    rke2_download_kubeconf: true
    rke2_download_kubeconf_path: "{{ playbook_dir }}/kubeconfs"

    rke2_ha_mode: true

    # We use cilium as CNI, so we disable kube-proxy
    disable_kube_proxy: true

    # Not required for us since we use a Load Balancer, you may need it
    rke2_ha_mode_keepalived: false

    rke2_disable:
      - rke2-canal
      - rke2-ingress-nginx

    rke2_cni:
      - cilium

  pre_tasks:

    - name: Check if ubuntu
      ansible.builtin.fail:
        msg: No ubuntu but {{ ansible_distribution }}
      when: ansible_distribution != 'Ubuntu'

    - name: Check if ubuntu 24
      ansible.builtin.fail:
        msg: No ubuntu 24 but {{ ansible_distribution_major_version }}
      when: ansible_distribution_major_version != '24'

    - name: Gather information about lvm
      stat:
        path: /dev/vg0/root
      register: vg0_root

    - name: Check for /dev/vg0/root
      ansible.builtin.fail:
        msg: No /dev/vg0/root found. LVM configuration seems not ok.
      when: rke2_type == 'agent' and (not vg0_root.stat.exists)

    - name: Check if the NetworkManager directory exists
      ansible.builtin.stat:
        path: "/usr/sbin/NetworkManager"
      register: nwm_dir

    - name: NetworkManager found
      ansible.builtin.fail:
        msg: Network Manager present
      when: nwm_dir.stat.exists

    - name: Disable SWAP
      shell: |
        swapoff -a

    - name: Disable SWAP in fstab
      replace:
        path: /etc/fstab
        regexp: '^([^#].*?\sswap\s+sw\s+.*)$'
        replace: '# \1'

    - name: Set a hostname {{ inventory_hostname }}
      ansible.builtin.hostname:
        name: "{{ inventory_hostname }}"

    - name: Update Timezone to Etc/UTC
      copy: content="Etc/UTC\n" dest=/etc/timezone owner=root group=root mode=0644
      register: timezone

    - name: Reconfigure Timezone Data (if changed)
      shell: dpkg-reconfigure -f noninteractive tzdata
      when: timezone.changed

    #sysctl -w fs.inotify.max_user_instances=100000
    - name: "Set fs.inotify.max_user_instances"
      sysctl:
        name: fs.inotify.max_user_instances
        value: '100000'
        sysctl_set: yes
        state: present
        reload: yes

    #sysctl -w fs.inotify.max_user_watches=1003986
    - name: "Set fs.inotify.max_user_watches"
      sysctl:
        name: fs.inotify.max_user_watches
        value: '1003986'
        sysctl_set: yes
        state: present
        reload: yes

    #sysctl -w vm.max_map_count=262144
    - name: "Set vm.max_map_count"
      sysctl:
        name: vm.max_map_count
        value: '262144'
        sysctl_set: yes
        state: present
        reload: yes

    - name: Add or modify hard nofile limits for wildcard domain
      community.general.pam_limits:
        domain: '*'
        limit_type: hard
        limit_item: nofile
        value: 1000000

    - name: Add or modify soft nofile limits for wildcard domain
      community.general.pam_limits:
        domain: '*'
        limit_type: soft
        limit_item: nofile
        value: 1000000

    - name: Update apt repo and cache on all boxes
      apt: update_cache=yes force_apt_get=yes cache_valid_time=3600

    - name: Upgrade all packages on servers
      apt: upgrade=dist force_apt_get=yes

    - name: Install additional software
      apt:
        update_cache: true
        pkg:
        - linux-headers-generic
        - linux-headers-{{ ansible_kernel }}
        - curl
        - git
        - wget
        - vim
        - nano
        - htop
        - netcat-openbsd
        - net-tools
        - dnsutils
        - jq
        - lvm2

    # I like k9s, you may not
    - name: Install k9s on control plane nodes
      shell: |
        curl -L -O https://github.com/derailed/k9s/releases/latest/download/k9s_linux_amd64.deb && sudo dpkg -i k9s_linux_amd64.deb && rm k9s_linux_amd64.deb
      when: rke2_type == 'server'

    - name: Get lvm information
      shell: |
        ls -la /opt
        cat /etc/fstab
        lsblk -f
        vgdisplay || true
        lvdisplay || true
      register: result

    - name: Print lvm information
      ansible.builtin.debug:
        var: result

    - name: Check for /opt/local-path-provisioner
      stat:
        path: /opt/local-path-provisioner
      register: local_path_provisioner

    - name: Create logical volumes for local path provisioner on server nodes
      shell: |
        lvcreate -n k8s-local-path -L1500G vg0
        mkfs.ext4 /dev/vg0/k8s-local-path
        mkdir /opt/local-path-provisioner
        echo "/dev/vg0/k8s-local-path /opt/local-path-provisioner ext4 defaults,noatime 0 0" >> /etc/fstab
        mount -a
      register: result
      when: rke2_type == 'agent' and (not local_path_provisioner.stat.exists)

    - name: Print lvm create information
      ansible.builtin.debug:
        var: result

    - name: Check if a reboot is needed on all servers
      register: reboot_required_file
      stat: path=/var/run/reboot-required

    - name: Check if the rke2/config.yaml exists
      stat:
        path: /etc/rancher/rke2/config.yaml
      register: rkeconfig_result

    - name: Reboot the box on first install
      reboot:
        msg: "Reboot initiated by Ansible for first install"
        connect_timeout: 5
        reboot_timeout: 300
        pre_reboot_delay: 0
        post_reboot_delay: 30
        test_command: uptime
      #when: reboot_required_file.stat.exists and (not rkeconfig_result.stat.exists)
      when: not rkeconfig_result.stat.exists

    - name: Check SWAP disabled
      shell: |
        LINES=$(cat /proc/swaps | wc -l)
        if [ "$LINES" != "1" ]; then
          # 1 means only the headers and no swap
          echo "LINES is $LINES"
          exit 1
        fi

    - name: Create a networkd.conf.d for drop-ins
      ansible.builtin.file:
        path: /etc/systemd/networkd.conf.d/
        state: directory
        owner: root
        group: root
        mode: '0755'

    - name: Configure systemd Network RoutingPolicyRules
      ansible.builtin.copy:
        dest: /etc/systemd/networkd.conf.d/rke2-network.conf
        content: |
          [Network]
          ManageForeignRoutes=no
          ManageForeignRoutingPolicyRules=no
        owner: root
        group: root
        mode: '0644'
      register: systemdnetwork

    - name: Reload systemd networkd
      ansible.builtin.systemd:
        state: restarted
        daemon_reload: true
        name: systemd-networkd
      when: systemdnetwork.changed

    - name: Add k8s Binaries to PATH
      ansible.builtin.lineinfile:
        path: /root/.bashrc
        line: "{{ item }}"
      loop:
        - "export KUBECONFIG=/etc/rancher/rke2/rke2.yaml"
        - "PATH=$PATH:/var/lib/rancher/rke2/bin"

  roles:
    - role: rke2

As you can see, most of the playbook is dedicated to the initial configuration of the servers (hostname, timezone, networking, LVM, etc.), while the deployment of RKE2 is completely handled by the role.

Feel free to get in touch if you have any questions or suggestions.
Till next time!