MET : Ansible automation (3/6)

| Digital

Configuring one server manually is acceptable. Configuring ten is tedious. Configuring a hundred is impossible. Ansible solves this problem elegantly.

In the previous article, we saw how Terraform creates our servers. But a freshly created server is “bare”: no Kubernetes, no configuration, nothing. This is where Ansible comes in.

The problem: manual configuration

After creating a server, the task list is long:

  • Update the system
  • Install dependencies
  • Configure the firewall
  • Disable swap (required for Kubernetes)
  • Load kernel modules
  • Install Kubernetes
  • Configure networking

Doing this manually poses several problems:

  • Time: 2-3 hours per server, minimum
  • Errors: One oversight, one typo, and it stops working
  • Drift: Over time, servers diverge (different configurations)
  • Documentation: “How did we configure that again?”

The solution: Ansible

Ansible is an open-source automation tool that allows you to configure servers in a declarative and idempotent manner.

Idempotent: You can run the same playbook 100 times, the result will always be the same. If something is already configured, Ansible doesn’t redo it.

Key advantages:

  • Agentless: Ansible connects via SSH, nothing to install on servers
  • Readable: Playbooks are in YAML, understandable by everyone
  • Idempotent: Safe and repeatable execution
  • Modular: Thousands of modules available

The inventory: defining your servers

Everything starts with the inventory, which lists servers and their characteristics:

# inventory/hosts.yml
all:
  children:
    k8s_cluster:
      children:
        control_plane:
          hosts:
            k8s-master:
              ansible_host: 128.140.xxx.xxx
              private_ip: 10.0.1.10
        workers:
          hosts:
            k8s-worker-1:
              ansible_host: 91.99.xxx.xxx
              private_ip: 10.0.1.11

  vars:
    ansible_user: root
    ansible_ssh_private_key_file: ~/.ssh/id_rsa
    k3s_version: "v1.28.5+k3s1"
    cluster_cidr: "10.42.0.0/16"
    service_cidr: "10.43.0.0/16"

This inventory defines:

  • Groups: control_plane, workers, k8s_cluster
  • Per-host variables: Public and private IPs
  • Global variables: K3s version, network configuration

Playbook 1: Server preparation

Our first playbook prepares servers for Kubernetes:

# playbooks/prepare-servers.yml
---
- name: Server preparation
  hosts: k8s_cluster
  become: true  # Execute as root

  tasks:
    - name: Update packages
      apt:
        update_cache: yes
        cache_valid_time: 3600

    - name: Install dependencies
      apt:
        name:
          - curl
          - wget
          - git
          - htop
          - open-iscsi      # Required for Longhorn
          - nfs-common      # Required for NFS storage
          - cryptsetup      # Encryption
          - jq              # JSON parsing
        state: present

    - name: Disable swap
      shell: swapoff -a
      when: ansible_swaptotal_mb > 0

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

Each task is explicit:

  • name: Readable description of what the task does
  • apt: Ansible module for managing Debian/Ubuntu packages
  • when: Execution condition (here, only if swap exists)

Kernel configuration

Kubernetes requires certain kernel modules and system parameters:

    - name: Configure kernel modules
      copy:
        dest: /etc/modules-load.d/k8s.conf
        content: |
          overlay
          br_netfilter
          ip_vs
          ip_vs_rr
          ip_vs_wrr
          ip_vs_sh
          nf_conntrack

    - name: Load modules
      modprobe:
        name: "{{ item }}"
        state: present
      loop:
        - overlay
        - br_netfilter
        - ip_vs

    - name: Configure sysctl
      sysctl:
        name: "{{ item.key }}"
        value: "{{ item.value }}"
        state: present
        reload: yes
      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' }
        - { key: 'fs.inotify.max_user_watches', value: '524288' }

Note the use of loop to repeat a task with different values. This is cleaner than duplicating code.

Playbook 2: Kubernetes (K3s) installation

Once servers are prepared, we install K3s:

# playbooks/install-k3s.yml
---
- name: K3s Control Plane installation
  hosts: control_plane
  become: true

  tasks:
    - name: Download K3s script
      get_url:
        url: https://get.k3s.io
        dest: /tmp/k3s-install.sh
        mode: '0755'

    - name: Install K3s server
      shell: |
        INSTALL_K3S_VERSION={{ k3s_version }} \
        K3S_TOKEN={{ k3s_token }} \
        INSTALL_K3S_EXEC="server \
          --cluster-init \
          --disable traefik \
          --write-kubeconfig-mode 644 \
          --tls-san {{ ansible_host }} \
          --node-ip {{ private_ip }} \
          --advertise-address {{ private_ip }} \
          --cluster-cidr {{ cluster_cidr }} \
          --service-cidr {{ service_cidr }}" \
        sh /tmp/k3s-install.sh
      args:
        creates: /usr/local/bin/k3s  # Only execute if k3s doesn't exist

    - name: Retrieve kubeconfig
      fetch:
        src: /etc/rancher/k3s/k3s.yaml
        dest: ../kubeconfig
        flat: yes

Important points:

  • creates: Makes the task idempotent (only executes if the file doesn’t exist)
  • fetch: Retrieves the kubeconfig to our local machine
  • –disable traefik: We’ll install our own configured Traefik

Worker installation

- name: K3s Workers installation
  hosts: workers
  become: true

  tasks:
    - name: Install K3s agent
      shell: |
        INSTALL_K3S_VERSION={{ k3s_version }} \
        K3S_URL=https://{{ hostvars['k8s-master']['private_ip'] }}:6443 \
        K3S_TOKEN={{ hostvars['k8s-master']['k3s_join_token'] }} \
        INSTALL_K3S_EXEC="agent \
          --node-ip {{ private_ip }}" \
        sh /tmp/k3s-install.sh
      args:
        creates: /usr/local/bin/k3s

Workers automatically join the cluster using the control plane’s token.

Playbook 3: Base services

After K3s, we install essential services via Helm:

# playbooks/install-core-services.yml
---
- name: Core services installation
  hosts: control_plane
  become: true

  tasks:
    - name: Install Helm
      shell: |
        curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
      args:
        creates: /usr/local/bin/helm

    - name: Add Helm repos
      shell: |
        helm repo add traefik https://traefik.github.io/charts
        helm repo add longhorn https://charts.longhorn.io
        helm repo add jetstack https://charts.jetstack.io
        helm repo add argo https://argoproj.github.io/argo-helm
        helm repo update
      environment:
        KUBECONFIG: /etc/rancher/k3s/k3s.yaml

    - name: Install Traefik
      shell: |
        helm upgrade --install traefik traefik/traefik \
          --namespace infra-system \
          --create-namespace \
          --set ports.web.redirectTo.port=websecure \
          --wait --timeout 5m
      environment:
        KUBECONFIG: /etc/rancher/k3s/k3s.yaml

    - name: Install Cert-Manager
      shell: |
        helm upgrade --install cert-manager jetstack/cert-manager \
          --namespace cert-manager \
          --create-namespace \
          --set installCRDs=true \
          --wait --timeout 5m
      environment:
        KUBECONFIG: /etc/rancher/k3s/k3s.yaml

    - name: Install Longhorn
      shell: |
        helm upgrade --install longhorn longhorn/longhorn \
          --namespace longhorn-system \
          --create-namespace \
          --set defaultSettings.defaultReplicaCount=2 \
          --wait --timeout 10m
      environment:
        KUBECONFIG: /etc/rancher/k3s/k3s.yaml

Executing playbooks

To execute a playbook:

# Test connection to servers
ansible all -i inventory/hosts.yml -m ping

# Execute a playbook
ansible-playbook -i inventory/hosts.yml playbooks/prepare-servers.yml

# Dry-run mode (see what would be done without executing)
ansible-playbook -i inventory/hosts.yml playbooks/install-k3s.yml --check

In our project, we simplified with Make commands:

# Test connection
make ansible-ping

# Install K3s
make ansible-install-k3s

# Install core services
make ansible-core-services

Our Ansible best practices

📁 Organization

ansible/
├── inventory/
│   ├── hosts.yml           # Server inventory
│   └── group_vars/         # Variables per group
├── playbooks/
│   ├── prepare-servers.yml
│   ├── install-k3s.yml
│   └── install-core-services.yml
├── templates/              # Jinja2 configuration files
└── ansible.cfg             # Ansible configuration

🔐 Security

  • Ansible Vault: Encrypt sensitive variables
  • SSH keys: Never plaintext passwords
  • become: Use sudo rather than direct root

✅ Idempotence

  • Use creates or removes for shell commands
  • Prefer Ansible modules over shell commands when possible
  • Test with –check before applying

The result

With our Ansible playbooks:

Before (manual) After (Ansible)
2-3 hours per server 15 minutes for the entire cluster
Separate documentation (often outdated) The code IS the documentation
Different configuration between servers Identical configuration guaranteed
“It worked before…” 100% reproducible

What’s next?

We now have:

  • ✅ Servers created by Terraform
  • ✅ A Kubernetes cluster configured by Ansible
  • ✅ Base services installed (Traefik, Longhorn, Cert-Manager)

In the next article, we’ll see how Kubernetes and Rancher allow us to manage our applications with an intuitive interface and powerful features.

🚀 Spending too much time configuring your servers?

We can help you automate your infrastructure with Ansible. Save time, reduce errors, sleep peacefully.

Let’s discuss your project →