Building Production-Ready Ansible Playbooks for an HA Kubeadm Cluster
A hands-on guide to building modular, reproducible Ansible playbooks for provisioning a highly available Kubernetes cluster with kubeadm, HAProxy, containerd, and Calico
Building Production-Ready Ansible Playbooks for an HA Kubeadm Cluster
Writing Ansible playbooks for Kubernetes looks simple: run a few commands, install binaries, and join nodes. But turning a manual installation into a reproducible, High-Availability (HA) cluster automation tool is a completely different challenge.
I set out to build a hands-on lab environment on Proxmox to experiment with Kubernetes internals using kubeadm. This is the story of how I transitioned from manual node provisioning to a modular Ansible framework.
Phase 1: The Manual Prototype
Before writing a single line of YAML, I needed to understand every manual step required to assemble a cluster.
Using Proxmox, I spun up virtual machines for control planes, workers, and a load balancer node. Following Kubernetes documentation and community guides, I went through the entire lifecycle manually:
- Disabling swap and configuring kernel modules (
overlay,br_netfilter). - Setting up
containerdwith systemd cgroups. - Installing pinned versions of
kubeadm,kubelet, andkubectl. - Provisioning an HAProxy instance to load-balance API server requests across control planes.
- Running
kubeadm init, applying the Calico CNI, and manually executingkubeadm joincommands.
Doing it by hand revealed all the edge cases—like race conditions with background apt/dpkg locks and transient networking issues—that my automation would eventually need to handle.
Phase 2: Structuring the Playbooks
Once the manual steps worked, I began converting my shell history into Ansible playbooks. My goal was clear: maintainability and modularity.
Instead of dumping everything into one monolithic playbook file, I researched community best practices, pulled inspiration from Ansible Galaxy, and structured the repository using a role-based architecture.
├── ansible.cfg # Ansible defaults (inventory path, roles path, fact caching)
├── inventory/ # Cluster inventory configuration
│ ├── host.ini # Active inventory file (referenced by ansible.cfg)
│ ├── hosts.ini.example # Example inventory file layout
│ └── group_vars/all/ # Global playbook configuration variables
│ ├── calico.yml # Tigera operator & CNI settings
│ ├── haproxy.yml # Frontend/backend load balancer configuration
│ ├── hosts.yml # Host-to-IP/domain mapping definitions
│ ├── kubeadm.yml # Kubeadm versions, CIDRs, API endpoint
│ └── project.yml # Project name, domain, kubeconfig paths
├── manifests/ # Kubernetes resource manifests
│ ├── calico_api_server.yml # Calico API server extension manifest
│ ├── calico_installation.yml # Tigera Installation custom resource
│ ├── goldmane.yml # Calico flow aggregation component
│ └── whisker.yml # Calico observability component
├── playbooks/ # Orchestration entry points
│ └── site.yml # Master playbook for end-to-end cluster setup
├── roles/ # Modular Ansible execution tasks
│ ├── bootstrap/ # SSH key deployment, passwordless sudo setup
│ ├── node_prep/ # Kernel modules, sysctl, swap/firewall disable, host entries
│ ├── container_runtime/ # Containerd installation and systemd cgroup config
│ ├── kubernetes/ # Kubernetes APT repository setup, pinned binary install
│ ├── haproxy/ # HAProxy load balancer for control-plane API HA
│ ├── control_plane/ # Kubeadm init, kubectl config, join token generation
│ ├── cni/ # Tigera operator & Calico CNI deployment
│ └── join_node/ # Worker and secondary control-plane join routines
├── scripts/
│ └── cleanup.sh # Full teardown script (reset nodes to pre-install state)
└── tests/
└── requirements.txt # Pinned Python dependencies (ansible-core, molecule, etc.)
Key Design & Architecture Choices
1. Granular Role Separation
Every major layer lives in its own role:
node_prep: Handles baseline OS setup, kernel parameters, and disables swap.container_runtime: Installs and configurescontainerdwith systemd cgroups (SystemdCgroup = true).haproxy: Sets up a dedicated load balancer on port6443to ensure control plane high availability.cni: Deploys the Tigera Operator to manage Calico lifecycle and custom resources.
2. Variable Isolation in group_vars/all
Hardcoding configuration values leads to brittle playbooks. I separated concern variables into dedicated files inside group_vars/all/:
- Network parameters (Pod CIDR
192.168.0.0/16, Service CIDRs) reside inkubeadm.yml. - Versions for
kubeadm(v1.30.14) andcalico(v3.27) are explicitly pinned to ensure reproducible builds.
Code Quality & Testing
Writing Ansible code is one thing; keeping it clean and error-free is another. To maintain high code quality, I integrated ansible-lint into my workflow.
Running static analysis caught several issues early on:
- Unnamed tasks and missing module parameters.
- Deprecated syntax usage.
- Improper handler triggers for service restarts.
# Running quality checks
ansible-lint playbooks/site.yml
Key Takeaways
- Do it manually first: You cannot automate what you don't fully understand. Manual trial runs on Proxmox saved me hours of Ansible debugging.
- Lint early, lint often: Tools like
ansible-lintenforce consistency and catch syntax gotchas before execution. - Keep variables organized: Centralizing configuration settings makes updating cluster versions or changing subnets effortless.