Provision Cluster #
Building a Kubernetes cluster from scratch is often considered one of the most challenging rites of passage for a DevOps Engineer or System Administrator. This process involves a series of precise, sequential system-level steps across many servers: disabling swap, loading kernel modules, tuning sysctl parameters, installing the container runtime, installing Kubernetes binaries, initializing the control plane, setting up cluster networking (CNI), and finally joining worker nodes.
Doing these steps manually on 3 or 10 servers isn’t just exhausting — it’s also highly vulnerable to human error. One small mistake like forgetting to enable SystemdCgroup on the container runtime can cause the cluster to mysteriously fail days later. This is where Ansible comes in as a lifesaving solution. By automating the entire cluster bootstrap flow with idempotent Ansible playbooks, we can bring up a consistent, standardized, production-ready Kubernetes cluster with just a single command.
Why Choose Ansible for Cluster Provisioning? #
When we decide to automate the Kubernetes infrastructure lifecycle, we have several tool choices. However, Ansible offers unique advantages for low-level operating system bootstrapping processes.
Manual Bootstrap Challenges:
✗ We must SSH into each server one by one to run OS setup commands.
✗ Manually editing sensitive configuration files like /etc/fstab or config.toml is prone to typos.
✗ kubeadm initialization tokens have a limited validity period (24 hours) — copying these tokens manually between servers is very tedious.
✗ It's hard to replicate the exact same configuration if we want to create identical Staging and Production clusters.
Ease with Ansible Automation:
✓ One Playbook for All Nodes — run all OS preparation tasks in parallel across all servers.
✓ Dynamic Templating — use Jinja2 to generate clean, accurate containerd and sysctl configuration files.
✓ Workflow Orchestration — Ansible can register the kubeadm token on the control plane, store it as a memory variable, then forward it to worker nodes automatically.
✓ Version Control — our entire cluster infrastructure is defined as code (IaC) that can be stored in a Git repository.
Network Architecture Design and Cluster Inventory #
Before starting to write Ansible tasks, we must design our cluster topology first. We’ll build a cluster with one Control Plane (Master Node) and two Worker Nodes. The communication topology between nodes can be visualized through the following diagram:
flowchart TD
subgraph ControlPlane["Control Plane Node"]
M1["k8s-master-01 (10.0.1.10)"]
end
subgraph WorkerNodes["Worker Nodes"]
W1["k8s-worker-01 (10.0.2.10)"]
W2["k8s-worker-02 (10.0.2.11)"]
end
M1 <-->|"Join Token & API Control (Port 6443)"| W1
M1 <-->|"Join Token & API Control (Port 6443)"| W2
W1 <-->|"Pod-to-Pod CNI Overlay Network"| W2To represent the architecture above in Ansible, we create a structured inventory file. This file divides servers by role so we can direct specific tasks to the right server groups.
# inventory/k8s-cluster/hosts.ini
[control_plane]
k8s-master-01 ansible_host=10.0.1.10 ansible_user=ubuntu
[worker_nodes]
k8s-worker-01 ansible_host=10.0.2.10 ansible_user=ubuntu
k8s-worker-02 ansible_host=10.0.2.11 ansible_user=ubuntu
[k8s_cluster:children]
control_plane
worker_nodes
We also define global cluster variables to control package versions and internal cluster network configuration in the group_vars file:
# inventory/k8s-cluster/group_vars/k8s_cluster.yml
---
k8s_version: "1.29.2"
k8s_minor_version: "1.29"
pod_network_cidr: "192.168.0.0/16" # Default CIDR for Calico CNI
control_plane_endpoint: "10.0.1.10:6443"
Step 1: Operating System and Linux Kernel Preparation #
Kubernetes requires very specific low-level operating system configuration before its main components can run. The two most critical configurations are permanently disabling swap and configuring kernel modules to bridge container networking.
Why Does Kubernetes Forbid Swap? #
By default, kubelet refuses to run if swap is active on the server. The main reason is resource allocation guarantees and performance. Kubernetes is designed to schedule applications based on strict resource limits. If the operating system moves Pod memory to disk swap, application performance drops drastically and unpredictably, and Kubernetes can’t accurately calculate the cluster’s real memory usage.
Kernel Modules and Sysctl #
For network traffic from containers inside Pods to be bridged correctly at the host level, we must load the overlay and br_netfilter kernel modules, and configure sysctl parameters so iptables can see Linux network bridge traffic.
Here’s the OS preparation task implementation in our Ansible role:
# roles/k8s-common/tasks/main.yml
---
- name: Disable swap for the current session
command: swapoff -a
changed_when: false
- name: Ensure swap is permanently disabled in fstab
replace:
path: /etc/fstab
regexp: '^([^#].*?\sswap\s+sw\s+.*)$'
replace: '# \1'
- name: Create the kernel module load configuration file
copy:
dest: /etc/modules-load.d/k8s.conf
content: |
overlay
br_netfilter
owner: root
group: root
mode: '0644'
- name: Load the overlay kernel module manually
modprobe:
name: overlay
state: present
- name: Load the br_netfilter kernel module manually
modprobe:
name: br_netfilter
state: present
- name: Configure sysctl parameters for Kubernetes networking
sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
state: present
sysctl_file: /etc/sysctl.d/99-kubernetes-cri.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" }
Step 2: Container Runtime Configuration (containerd) #
Since Kubernetes deprecated the Docker Shim in version 1.20 and removed it entirely in version 1.24, we must use a standalone container runtime compatible with the CRI (Container Runtime Interface). containerd is the very stable and lightweight industry standard choice.
The biggest challenge when deploying containerd for Kubernetes is cgroup driver configuration. By default, Linux uses systemd as the process init system. If containerd is configured with its own built-in cgroup driver (cgroupfs), the system will have two different cgroup managers. This can cause system instability when the server runs out of resources. We must force containerd to use the systemd cgroup driver (SystemdCgroup = true).
# roles/containerd/tasks/main.yml
---
- name: Install initial dependencies for containerd
apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
state: present
update_cache: true
- name: Create the keyrings directory for the Docker repository
file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: Add the official Docker GPG Key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
keyring: /etc/apt/keyrings/docker.gpg
- name: Add the Docker repository for containerd
apt_repository:
repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
filename: docker
- name: Install the containerd.io package
apt:
name: containerd.io
state: present
update_cache: true
- name: Create the containerd configuration directory
file:
path: /etc/containerd
state: directory
mode: '0755'
- name: Generate the default containerd configuration
command: containerd config default
register: containerd_config_raw
changed_when: false
- name: Write the default containerd configuration file
copy:
content: "{{ containerd_config_raw.stdout }}"
dest: /etc/containerd/config.toml
owner: root
group: root
mode: '0644'
- name: Configure containerd to use the SystemdCgroup driver
replace:
path: /etc/containerd/config.toml
regexp: 'SystemdCgroup = false'
replace: 'SystemdCgroup = true'
notify: Restart containerd
- name: Ensure containerd is active and runs at boot
systemd:
name: containerd
state: started
enabled: true
We also create a handler to restart the containerd service if there are configuration changes:
# roles/containerd/handlers/main.yml
---
- name: Restart containerd
systemd:
name: containerd
state: restarted
Step 3: Installing the Kubeadm, Kubelet, and Kubectl Components #
After the container runtime is ready, the next step is installing the core Kubernetes components:
- kubelet: The main agent service responsible for running containers on every node.
- kubeadm: The CLI tool for deploying and initializing Kubernetes clusters following security best practices.
- kubectl: The CLI tool for interacting with the Kubernetes API Server.
[!IMPORTANT] Starting mid-2023, Google officially shut down the legacy APT repository (
apt.kubernetes.io). We must switch to the new community repository atpkgs.k8s.iowith a specific minor version directory naming format.
# roles/k8s-packages/tasks/main.yml
---
- name: Create the keyring directory for Kubernetes
file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: Download the new Kubernetes repository GPG Key
apt_key:
url: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_minor_version }}/deb/Release.key"
state: present
keyring: /etc/apt/keyrings/kubernetes-apt-keyring.gpg
- name: Add the new Kubernetes repository to apt sources
apt_repository:
repo: "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v{{ k8s_minor_version }}/deb/ /"
state: present
filename: kubernetes
- name: Install kubelet, kubeadm, and kubectl together
apt:
name:
- "kubelet={{ k8s_version }}-*"
- "kubeadm={{ k8s_version }}-*"
- "kubectl={{ k8s_version }}-*"
state: present
update_cache: true
- name: Pin package versions to prevent accidental updates during apt upgrade
dpkg_selections:
name: "{{ item }}"
selection: hold
loop:
- kubelet
- kubeadm
- kubectl
Step 4: Control Plane Initialization (Master Node Bootstrap) #
Now, all dependencies are installed on all nodes. It’s time to initialize our first Control Plane node using the kubeadm init command. This task should only run on the first master server (control_plane[0]).
After initialization completes, we must:
- Copy the cluster admin file (
admin.confaka kubeconfig) to our user’s home directory so we can runkubectlcommands without root access. - Create a new dynamic join token for worker nodes to join the cluster securely.
# playbooks/init-control-plane.yml
---
- name: Initialize the Kubernetes Control Plane
hosts: control_plane[0]
become: true
tasks:
- name: Check whether the Control Plane has been initialized before
stat:
path: /etc/kubernetes/admin.conf
register: k8s_init_check
- name: Run the kubeadm initialization on the first master node
command: >
kubeadm init
--pod-network-cidr={{ pod_network_cidr }}
--control-plane-endpoint={{ control_plane_endpoint }}
--kubernetes-version={{ k8s_version }}
register: kubeadm_init_output
when: not k8s_init_check.stat.exists
- name: Configure kubectl Access for the Regular User
hosts: control_plane[0]
become: false
tasks:
- name: Create the .kube directory in the user home
file:
path: "{{ ansible_env.HOME }}/.kube"
state: directory
mode: '0700'
- name: Copy the admin.conf configuration file to the user home directory
copy:
src: /etc/kubernetes/admin.conf
dest: "{{ ansible_env.HOME }}/.kube/config"
remote_src: true
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: '0600'
become: true
- name: Fetch the Join Command Dynamically
hosts: control_plane[0]
become: true
tasks:
- name: Generate a new valid join command
command: kubeadm token create --print-join-command
register: temp_join_command
changed_when: false
- name: Store the join command in the global localhost variable
set_fact:
k8s_join_command: "{{ temp_join_command.stdout }}"
delegate_to: localhost
delegate_facts: true
Step 5: Container Network Interface (CNI) Setup #
Kubernetes assumes every Pod has its own unique IP address that can communicate directly with other Pods in the cluster, even if those Pods are on different physical hosts. To realize this network model, we must install a CNI (Container Network Interface) plugin.
The two most popular CNI options in the industry:
- Flannel: A very simple and lightweight CNI. It uses VXLAN encapsulation to create a basic overlay network. Flannel is great for small clusters or development environments because of its minimal configuration. However, it doesn’t support advanced security features like Network Policies.
- Calico: An advanced CNI very popular at the production level. Calico uses pure BGP protocol without additional encapsulation (when configured that way) to provide high-speed network performance. Additionally, it provides a very powerful security rule enforcement engine (Network Policies).
flowchart TD
subgraph Node_Master["Master Node"]
API_CNI["kube-apiserver"]
end
subgraph Node_Worker1["Worker Node 1"]
PodA["Pod A (192.168.1.10)"]
CNI1["Calico Agent (CNI)"]
PodA --> CNI1
end
subgraph Node_Worker2["Worker Node 2"]
PodB["Pod B (192.168.2.20)"]
CNI2["Calico Agent (CNI)"]
PodB --> CNI2
end
CNI1 <-->|"Overlay Tunnel VXLAN / BGP"| CNI2We’ll install the Calico CNI by deploying its official manifest directly from the master control plane after initialization completes:
# playbooks/install-cni.yml
---
- name: Deploy the Calico CNI Network
hosts: control_plane[0]
become: false
tasks:
- name: Download the official Calico Operator manifest
get_url:
url: https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/tigera-operator.yaml
dest: /tmp/tigera-operator.yaml
mode: '0644'
- name: Apply the Calico Tigera operator to the cluster
command: kubectl create -f /tmp/tigera-operator.yaml
register: apply_operator
failed_when:
- apply_operator.rc != 0
- "'already exists' not in apply_operator.stderr"
changed_when: "'created' in apply_operator.stdout"
- name: Download the Calico Custom Resources configuration manifest
get_url:
url: https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/custom-resources.yaml
dest: /tmp/custom-resources.yaml
mode: '0644'
- name: Adjust the Pod CIDR in the Calico manifest to our variable
replace:
path: /tmp/custom-resources.yaml
regexp: 'cidr: 192.168.0.0/16'
replace: "cidr: {{ pod_network_cidr }}"
- name: Apply the Calico network configuration
command: kubectl create -f /tmp/custom-resources.yaml
register: apply_resources
failed_when:
- apply_resources.rc != 0
- "'already exists' not in apply_resources.stderr"
changed_when: "'created' in apply_resources.stdout"
Step 6: Joining Worker Nodes to the Cluster #
After the Control Plane and CNI network are properly installed, it’s time to join the worker servers so they can receive Pod scheduling instructions. We’ll use the join command variable previously stored on the localhost host.
After running the join command, the best operational task is to ensure and wait until the new node’s status is reported as Ready by the API Server before ending the playbook.
# playbooks/join-workers.yml
---
- name: Join Worker Nodes to the Cluster
hosts: worker_nodes
become: true
tasks:
- name: Check whether this node has been connected before
stat:
path: /etc/kubernetes/kubelet.conf
register: kubelet_conf_check
- name: Run the join command to the Control Plane
command: "{{ hostvars['localhost']['k8s_join_command'] }}"
when: not kubelet_conf_check.stat.exists
- name: Wait for the node status to become 'Ready' on the Control Plane
command: >
kubectl get node {{ ansible_hostname }}
--kubeconfig /etc/kubernetes/admin.conf
-o jsonpath='{.status.conditions[-1].type}'
register: node_status_output
until: node_status_output.stdout == "Ready"
retries: 30
delay: 10
delegate_to: "{{ groups['control_plane'][0] }}"
changed_when: false
Step 7: Fetching the Kubeconfig to the Local Control Node #
The last step of cluster provisioning is copying the secure admin.conf file from the first master server to our local machine (control node). This allows us to manage the Kubernetes cluster remotely using local tools like kubectl, k9s, or our subsequent Ansible playbooks without constantly SSHing into the master server.
# playbooks/fetch-kubeconfig.yml
---
- name: Secure and Fetch the Kubeconfig Locally
hosts: control_plane[0]
become: true
tasks:
- name: Create the local kubeconfig storage directory on the Control Node
file:
path: "{{ playbook_dir }}/kubeconfig"
state: directory
mode: '0700'
delegate_to: localhost
become: false
- name: Copy the admin.conf file to the local playbook directory
fetch:
src: /etc/kubernetes/admin.conf
dest: "{{ playbook_dir }}/kubeconfig/admin.conf"
flat: true
- name: Adjust the API endpoint address in the local kubeconfig file
replace:
path: "{{ playbook_dir }}/kubeconfig/admin.conf"
regexp: 'server: https://127.0.0.1:6443'
replace: "server: https://{{ control_plane_endpoint.split(':')[0] }}:6443"
delegate_to: localhost
become: false
Now we have a secure local cluster configuration file at the kubeconfig/admin.conf path. We can easily use it by setting the environment variable export KUBECONFIG=./kubeconfig/admin.conf in our local terminal.
Summary #
- Disabling swap must be done permanently on all cluster nodes so kubelet can run stably and calculate resources accurately.
- The
overlayandbr_netfilterkernel modules plus the network bridge sysctl parameters must be active so container communication traffic can bridge the Linux host correctly.- Use
containerdwith theSystemdCgroup = trueconfiguration to align the operating system’s cgroup management with the container runtime to avoid OOM crashes.- Move the old Kubernetes repository configuration to the new
pkgs.k8s.iocommunity repository to get minor version updates above version 1.28+.- Use
dpkg_selectionshold on thekubelet,kubeadm, andkubectlpackages so cluster component versions stay locked and don’t accidentally auto-upgrade.- Apply the CNI (like Calico) immediately after the control plane is ready to enable dynamic cross-host Pod-to-Pod network communication.
- Fetch the join command dynamically using host fact memory (
set_factonlocalhost) instead of hardcoding static tokens that quickly expire in inventory files.- Fetch the
admin.conflocally with endpoint IP adjustments so the cluster can be controlled remotely without always needing to SSH into the master server.