Aller au contenu
Mon homelab de zéro (Partie 8) : Ansible et création du cluster, ou pourquoi 'vert' ne veut pas dire 'bon'
Photo by Mohammad Rahmani / Unsplash
  1. Articles/

Mon homelab de zéro (Partie 8) : Ansible et création du cluster, ou pourquoi 'vert' ne veut pas dire 'bon'

·5416 mots·26 mins·
Sommaire
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 8: Cet article

Dans la partie 7, on a installé Proxmox sur les quatre nœuds : disques wipés, ISO auto-installables, bataille du boot UEFI remportée. À la fin, quatre machines tournaient… mais elles ne se connaissaient pas encore entre elles. Quatre îlots isolés.

Cette phase 2, c’est le moment où l’infrastructure devient vraiment as code : on configure les hôtes, on crée les pools ZFS, et surtout on monte le cluster Proxmox à trois nœuds, le tout piloté par Ansible et rejouable depuis GitLab.

Et c’est aussi la phase où j’ai appris, à mes dépens, une leçon que je vais répéter tout au long de cet article : un PLAY RECAP tout vert ne veut pas dire que le résultat est bon. Deux bugs sérieux sont passés sous un playbook « réussi » sans le moindre voyant rouge. On va les décortiquer pour apprendre de ces erreurs.

Un changement de cap : rôles maison plutôt que lae.proxmox
#

Avant d’écrire la moindre ligne, une décision. Dans la partie 5, je prévoyais d’utiliser le rôle communautaire lae.proxmox pour configurer Proxmox. Au moment d’attaquer, j’ai changé d’avis : j’écris mes propres playbooks et rôles.

Pourquoi ? Parce que le but n°1 de ce lab, c’est d’apprendre. Utiliser une boîte noire qui fait tout à ma place (configurer les dépôts, créer le pool ZFS, monter le cluster) m’aurait privé de comprendre ce qui se passe réellement. En écrivant chaque rôle moi-même, je vois et je comprends chaque action. Un peu plus de code à maintenir, oui, mais c’est plus sympa d’apprendre.

note

C’est exactement le genre de situation pour laquelle je documente tous mes choix et surtout les changements quand il y en a. Dans six mois, je saurai pourquoi j’ai fait ce choix. lae.proxmox reste dans un coin de ma tête si un jour le lab doit s’industrialiser ou se simplifier pour une question de maintenabilité.

J’en ai aussi profité pour basculer tout le dépôt en anglais (code, commentaires, messages de commit). Et je m’impose une règle sur les commentaires : expliquer un pourquoi non évident, jamais paraphraser le code.

Étape 1 : les fondations Ansible
#

Tout commence par une arborescence propre :

ansible/
├── ansible.cfg
├── requirements.yml
├── inventory/
│   └── hosts.yml
├── group_vars/
│   ├── all.yml
│   └── pve.yml
└── roles/          # se remplira au fil des étapes 2 à 5
note

Piège de layout, dès le départ. J’avais rangé mes playbooks dans un sous-dossier ansible/playbooks/, bien proprement. Erreur : ansible-lint cherche les rôles à côté des playbooks et m’a sorti syntax-check[specific]: The role was not found. Les playbooks et le dossier roles/ doivent être frères, à la racine d’ansible/.

La configuration
#

Tout le comportement d’Ansible se règle dans ansible.cfg :

# ansible/ansible.cfg
[defaults]
inventory            = inventory/hosts.yml
roles_path           = roles
collections_path     = ~/.ansible/collections
host_key_checking    = False
interpreter_python   = /usr/bin/python3
stdout_callback      = default
result_format        = yaml
bin_ansible_callbacks = True
vars_plugins_enabled = host_group_vars,community.sops.sops

[ssh_connection]
pipelining = True
ssh_args   = -o ControlMaster=auto -o ControlPersist=60s

Rien d’exotique, mais deux-trois réglages méritent un mot. host_key_checking = False m’évite d’avoir à valider l’empreinte SSH de chaque nœud à la première connexion (acceptable sur un LAN de lab que je maîtrise). La section [ssh_connection], avec le pipelining et le ControlPersist, accélère nettement les exécutions en réutilisant les connexions SSH d’une tâche à l’autre. Mais la ligne vraiment importante est la dernière de la section defaults :

vars_plugins_enabled = host_group_vars,community.sops.sops

Elle active le plugin community.sops.sops, qui déchiffre automatiquement les fichiers group_vars/*.sops.yaml au moment de l’exécution. La mécanique posée en phase 0 (SOPS + age) est ainsi prête à l’emploi : le jour où une variable de groupe contiendra un secret, je l’écrirai chiffrée et Ansible la lira en clair à la volée, sans que rien de sensible ne touche le disque. C’est exactement ce que je voulais depuis le début.

Un accroc dès le premier lancement : un callback disparu
#

Ces deux lignes-là (stdout_callback et result_format) ne sont pas arrivées par hasard. Au tout premier lancement, Ansible m’a claqué la porte au nez :

[ERROR] The 'community.general.yaml' callback plugin has been removed.

Le callback qui mettait en forme la sortie en YAML a été retiré de community.general en version 12.0.0. Les collections Ansible évoluent vite et rapatrient (ou suppriment) régulièrement des plugins. Bonne nouvelle : le message indique lui-même la voie de migration - c’est désormais une option du callback natif, d’où les deux lignes ci-dessus.

Le minimum de dépendances
#

Côté collections, requirements.yml reste court :

# ansible/requirements.yml
---
collections:
  - name: community.general
    version: ">=10.0.0"
  - name: community.sops
    version: ">=2.0.0"

Deux collections seulement : community.general (des modules du quotidien) et community.sops (le déchiffrement à la volée, la seule vraiment indispensable ici). On verra à l’étape 4 que j’ai même fait la chasse à d’autres dépendances tentantes pour m’en tenir au cœur d’Ansible partout où c’était possible.

L’inventaire
#

Deux groupes à ce stade : pve (les trois futurs nœuds du cluster) et pbs (le nœud de backup, mis de côté pour l’instant). La variable de groupe pve_cluster_primary désigne le nœud qui portera l’initialisation du cluster.

# ansible/inventory/hosts.yml
---
all:
  children:
    pve:
      hosts:
        pve01:
          ansible_host: 192.168.3.11
        pve02:
          ansible_host: 192.168.3.12
        pve03:
          ansible_host: 192.168.3.13
      vars:
        pve_cluster_primary: pve01

    # Installé mais mis de côté : alim manquante, upgrade RAM en attente
    pbs:
      hosts:
        pbs01:
          ansible_host: 192.168.3.20

Cet inventaire s’étoffera au fil des phases suivantes (DNS interne, runner CI, …), mais pour la phase 2, pve et pbs suffisent.

Les variables de groupe
#

Reste une question que le lecteur attentif se pose peut-être : avec quel utilisateur et quelle clé Ansible se connecte-t-il ? La réponse est dans group_vars/all.yml, qui s’applique à toutes les machines :

# ansible/group_vars/all.yml
---
ansible_user: root
ansible_ssh_private_key_file: "~/.ssh/kentrowlab_ed25519"

lab_domain: ktw.ovh

Ansible se connecte donc en root, avec la clé SSH dédiée au lab générée en phase 1. Pas de mot de passe à gérer : la clé suffit.

Puis group_vars/pve.yml, spécifique aux nœuds du cluster, centralise les variables que les rôles consommeront plus loin :

# ansible/group_vars/pve.yml
---
# Pool name must be identical across nodes: required by ZFS replication
zfs_pool_name: nvme-vm
zfs_pool_device: /dev/nvme0n1
zfs_arc_max_gb: 4
cluster_network: 192.168.3.0/24

On y retrouve le nom du pool ZFS (identique sur les trois nœuds, une contrainte pour la réplication qu’on reverra), le disque cible, le plafond d’ARC et le réseau du cluster. Les définir une fois ici évite de les répéter, et surtout de les désynchroniser, dans chaque rôle.

note

À ce stade, aucune de ces variables n’est sensible : la connexion passe par clé SSH, il n’y a donc pas de mot de passe à protéger dans group_vars. Le seul secret de la phase (les identifiants de bootstrap) vit toujours dans secrets/ depuis la phase 1. Le plugin SOPS, lui, est déjà câblé et n’attend que le premier vrai secret à mettre en variable de groupe.

Validation
#

Le grand classique pour vérifier qu’Ansible parle bien aux trois nœuds : un ping (qui n’est pas un ping ICMP, mais un test de connexion SSH + interpréteur Python distant).

> ansible pve -m ping
pve01 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
pve02 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
pve03 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

Trois pong. Les fondations sont là, on peut commencer à écrire les rôles.

note

Petite subtilité : le result_format = yaml qu’on a réglé plus haut ne s’applique qu’aux exécutions de playbook (via le callback). Une commande ad-hoc comme ansible -m ping sort, elle, toujours en JSON - d’où l’affichage ci-dessus. C’est cosmétique, mais autant ne pas être surpris.

Étape 2 : configurer les hôtes (pve_base)
#

Comme en phase 1, je commence par regarder avant d’agir. J’inspecte un nœud pour écrire un rôle qui vise juste du premier coup :

ansible pve01 -m shell -a "ls -l /etc/apt/sources.list.d/; cat /etc/modprobe.d/zfs.conf; pveversion"

Sur un nœud fraîchement installé (avant que le moindre rôle ne soit passé), ça donne :

pve01 | CHANGED | rc=0 >>
total 12
-rw-r--r-- 1 root root 187 Jul 24 18:02 ceph.sources
-rw-r--r-- 1 root root 365 Jul 24 18:02 debian.sources
-rw-r--r-- 1 root root 204 Jul 24 18:02 pve-enterprise.sources
options zfs zfs_arc_max=3353346048
pve-manager/9.2.2/xxxxxxxxxxxxxxxx (running kernel: 7.0.2-6-pve)

Trois découvertes utiles :

  • PVE 9.2 (basé sur Debian 13) utilise le format deb822 : les dépôts sont des fichiers .sources, plus les vieux .list.
  • Un zfs.conf existe déjà, avec un zfs_arc_max posé par l’installeur (~10 % de la RAM).
  • Le dépôt Ceph est présent alors que je ne l’utilise pas, ce qui pollue chaque apt update.

Le rôle pve_base
#

Le rôle vit dans un dossier bien à lui. À chaque nouveau rôle, je vous montrerai ce petit bout d’arborescence pour qu’on sache toujours où on ajoute des fichiers :

ansible/roles/pve_base/
├── defaults/main.yml   # les variables paramétrables
├── handlers/main.yml   # les actions différées (apt update, initramfs, pveproxy)
└── tasks/main.yml      # le cœur : ce que fait le rôle, dans l'ordre

Les variables par défaut d’abord : tout ce qui se règle sans toucher au code.

# ansible/roles/pve_base/defaults/main.yml
---
pve_base_debian_suite: trixie
pve_base_zfs_arc_max_gb: 4
pve_base_extra_packages:
  - vim
  - htop
  - tmux
  - curl
  - git
  - lsb-release

pve_base_do_upgrade: true
pve_base_remove_subscription_nag: true

Puis les tâches. C’est un peu long, mais chaque bloc est explicite - et c’est précisément l’intérêt de l’avoir écrit à la main plutôt que d’appeler lae.proxmox : rien n’est caché.

# ansible/roles/pve_base/tasks/main.yml
---
- name: Disable enterprise repository
  ansible.builtin.copy:
    dest: /etc/apt/sources.list.d/pve-enterprise.sources
    mode: "0644"
    content: |
      Enabled: no
      Types: deb
      URIs: https://enterprise.proxmox.com/debian/pve
      Suites: {{ pve_base_debian_suite }}
      Components: pve-enterprise
      Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
  notify: Apt update

- name: Disable Ceph repository
  ansible.builtin.copy:
    dest: /etc/apt/sources.list.d/ceph.sources
    mode: "0644"
    content: |
      Enabled: no
      Types: deb
      URIs: https://enterprise.proxmox.com/debian/ceph-squid
      Suites: {{ pve_base_debian_suite }}
      Components: enterprise
      Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
  notify: Apt update

- name: Enable no-subscription repository
  ansible.builtin.copy:
    dest: /etc/apt/sources.list.d/pve-no-subscription.sources
    mode: "0644"
    content: |
      Types: deb
      URIs: http://download.proxmox.com/debian/pve
      Suites: {{ pve_base_debian_suite }}
      Components: pve-no-subscription
      Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
  notify: Apt update

- name: Refresh APT cache
  ansible.builtin.apt:
    update_cache: true

- name: Install extra packages
  ansible.builtin.apt:
    name: "{{ pve_base_extra_packages }}"
    state: present

- name: Upgrade system
  ansible.builtin.apt:
    upgrade: dist
  when: pve_base_do_upgrade | bool

- name: Cap ZFS ARC
  ansible.builtin.copy:
    dest: /etc/modprobe.d/zfs.conf
    mode: "0644"
    content: |
      options zfs zfs_arc_max={{ pve_base_zfs_arc_max_gb * 1024 * 1024 * 1024 }}
  notify: Update initramfs

- name: Remove subscription nag
  ansible.builtin.replace:
    path: /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
    regexp: "Ext\\.Msg\\.show\\(\\{\\s+title: gettext\\('No valid sub"
    replace: "void({ //"
    backup: true
  when: pve_base_remove_subscription_nag | bool
  notify: Restart pveproxy

On retrouve nos trois découvertes : dépôts au format deb822 (.sources), dépôts entreprise et Ceph désactivés (Enabled: no), no-subscription activé, et l’ARC ZFS bridé à 4 Gio. Les notify: renvoient vers des handlers, ces actions différées qui ne s’exécutent qu’à la fin du play :

# ansible/roles/pve_base/handlers/main.yml
---
- name: Apt update
  ansible.builtin.apt:
    update_cache: true

- name: Update initramfs
  ansible.builtin.command: update-initramfs -u -k all
  changed_when: true

- name: Restart pveproxy
  ansible.builtin.systemd:
    name: pveproxy
    state: restarted

Retenez bien ce détail des handlers différés : c’est exactement lui qui a causé le bug le plus sournois de la phase.

Le bug de la phase : cache_valid_time
#

Et là, LE bug.

Le playbook tourne, le PLAY RECAP est entièrement vert sur les trois nœuds. Tout va bien… jusqu’à ce que je vérifie manuellement les versions après reboot :

Nœudpve-manager
pve019.2.2
pve029.2.5
pve039.2.5

Un nœud est resté en 9.2.2 pendant que les autres passaient en 9.2.5. Silencieusement. Et un cluster avec des versions divergentes de pve-manager, c’est la porte ouverte aux problèmes de protocole. Inacceptable avant de créer le cluster.

La cause est vicieuse :

  1. Mes tâches modifient les dépôts APT et notifient un handler Apt update.
  2. Sauf que les handlers ne s’exécutent qu’à la toute fin du play.
  3. Entre-temps, ma tâche Refresh APT cache avait un cache_valid_time: 3600 : « le cache a moins d’une heure, je ne fais rien ».
  4. Du coup, Upgrade system a tourné avec l’ancienne vision du monde - celle où le dépôt no-subscription n’était pas encore actif. Il n’a vu que ce qu’il connaissait déjà.

Un apt-cache policy pve-manager sur pve01 confirmait le diagnostic : Installed: 9.2.2, Candidate: 9.2.5, une vingtaine de paquets en attente.

Le fix est tout bête : rafraîchir le cache sans condition. C’est précisément le Refresh APT cache sans cache_valid_time que vous avez vu dans le rôle plus haut. Le cache est ainsi systématiquement reconstruit juste après la modification des dépôts, avant que l’Upgrade system ne tourne.

note

L’enseignement central de la phase. Quand un playbook vient de modifier les sources APT, le rafraîchissement du cache doit être inconditionnel. Les handlers sont différés, et cache_valid_time masque justement le changement qu’on vient d’appliquer. Le PLAY RECAP reste vert du début à la fin : seule une vérification explicite de l’état obtenu révèle le problème.

Quand l’idempotence n’est pas possible
#

Deuxième surprise, en testant l’idempotence (relancer le playbook jusqu’à obtenir changed=0) : pve01 et pve03 se stabilisaient vite, mais pve02 restait à changed=3 un passage de plus que les autres.

Pourquoi ce seul nœud ? C’est un reliquat direct du bug précédent. À cause de la désynchro des caches, pve02 était en retard d’une mise à jour : il n’a récupéré proxmox-widget-toolkit que pendant ce test d’idempotence, alors que pve01 et pve03 l’avaient déjà absorbé au passage d’avant. Or la mise à jour de ce paquet réécrit proxmoxlib.js… exactement le fichier que je patche pour retirer le bandeau d’abonnement. Résultat, sur ce passage-là : le patch saute, ma tâche le réapplique, pveproxy redémarre → changed. Une fois pve02 rattrapé (au 3ᵉ passage), les trois nœuds convergent enfin sur changed=0.

Autrement dit, pve02 n’avait rien de spécial : c’était juste le retardataire du moment. Le vrai enseignement, lui, est plus général.

note

Toutes les tâches ne peuvent pas être idempotentes. Patcher un fichier qui appartient à un paquet est structurellement fragile : chaque future mise à jour de proxmox-widget-toolkit, sur n’importe quel nœud, défera le patch et fera réapparaître un changed. Il faut le savoir, le documenter (# Cosmetic, reverted by proxmox-widget-toolkit updates) et prévoir un backup: true. Ce n’est pas un bug à corriger, c’est une limite à assumer.

Après un reboot (nécessaire : nouveau noyau, et zfs_arc_max ne prend effet qu’au rechargement du module ZFS), les trois nœuds sont enfin alignés : 9.2.5, noyau identique, ARC ZFS cappé à 4 Gio.

Étape 3 : le pool ZFS sur le NVMe (pve_zfs)
#

Décision de découpage importante ici : je crée uniquement le pool ZFS (l’objet sur le disque), et pas la définition de stockage Proxmox. Pourquoi ? Parce que quand un nœud rejoint un cluster, sa configuration locale est écrasée par celle du cluster. Déclarer le stockage maintenant serait du travail perdu. On le fera après, à l’étape 5. C’est aussi pour ça que le pool doit porter le même nom sur les trois nœuds.

Le rôle est court, mais chaque tâche compte :

ansible/roles/pve_zfs/
├── defaults/main.yml   # variables du rôle (reprises de group_vars)
└── tasks/main.yml      # création du pool + garde-fous + scrub

Les valeurs par défaut du rôle reprennent les variables de groupe définies à l’étape 1 : une seule source de vérité, et le pool porte bien le même nom partout.

# ansible/roles/pve_zfs/defaults/main.yml
---
pve_zfs_pool_name: "{{ zfs_pool_name }}"
pve_zfs_device: "{{ zfs_pool_device }}"

# 4K sectors: correct for every modern SSD, cannot be changed after creation
pve_zfs_ashift: 12
pve_zfs_compression: lz4

Et les tâches :

# ansible/roles/pve_zfs/tasks/main.yml
---
- name: Check whether pool already exists
  ansible.builtin.command: "zpool list -H -o name {{ pve_zfs_pool_name }}"
  register: pve_zfs_existing
  changed_when: false
  failed_when: false

- name: Resolve stable device path
  # by-id survives device renaming across reboots, unlike /dev/nvme0n1
  ansible.builtin.shell: |
    set -o pipefail
    for link in /dev/disk/by-id/nvme-*; do
      case "$link" in *_1|*-part*) continue ;; esac
      if [ "$(readlink -f "$link")" = "{{ pve_zfs_device }}" ]; then
        echo "$link"; exit 0
      fi
    done
    exit 1
  args:
    executable: /bin/bash
  register: pve_zfs_by_id
  changed_when: false
  when: pve_zfs_existing.rc != 0

- name: Fail when target device is not empty
  ansible.builtin.command: "blkid {{ pve_zfs_device }}"
  register: pve_zfs_blkid
  changed_when: false
  failed_when: pve_zfs_blkid.rc == 0
  when: pve_zfs_existing.rc != 0

- name: Create ZFS pool
  ansible.builtin.command: >-
    zpool create
    -o ashift={{ pve_zfs_ashift }}
    -O compression={{ pve_zfs_compression }}
    -O atime=off
    -O xattr=sa
    -O acltype=posixacl
    -m /{{ pve_zfs_pool_name }}
    {{ pve_zfs_pool_name }}
    {{ pve_zfs_by_id.stdout }}
  when: pve_zfs_existing.rc != 0
  changed_when: true

- name: Ensure pool mountpoint is set
  # LXC subvols are datasets Proxmox must mount; mountpoint=none breaks them
  ansible.builtin.command: >-
    zfs set mountpoint=/{{ pve_zfs_pool_name }} {{ pve_zfs_pool_name }}
  register: pve_zfs_mountpoint
  changed_when: false

- name: Enable periodic scrub
  ansible.builtin.systemd:
    name: "zfs-scrub-monthly@{{ pve_zfs_pool_name }}.timer"
    enabled: true
    state: started

Quelques points qui méritent une explication :

  • L’idempotence d’abord : la première tâche regarde si le pool existe déjà (zpool list), et toute la création est conditionnée par when: pve_zfs_existing.rc != 0. Rejouer le rôle sur un nœud déjà configuré ne fait donc rien - et surtout ne détruit rien.
  • ashift=12 (secteurs de 4K) : le seul paramètre impossible à changer après création. Se tromper impose de détruire et recréer le pool. On le fige correctement dès le départ.
  • Chemin by-id plutôt que /dev/nvme0n1 : le nom de périphérique peut changer d’un reboot à l’autre, l’identifiant matériel non. Même principe que le ciblage par numéro de série en partie 7. La tâche Resolve stable device path boucle sur /dev/disk/by-id/nvme-* (en écartant les partitions) pour retrouver le lien stable, un nvme-eui.* (l’EUI-64 gravé dans le firmware).
  • Garde-fou blkid : la tâche échoue volontairement si le disque n’est pas vierge. Protection contre un écrasement accidentel le jour où je rejoue le playbook.
  • Le piège du mountpoint : mon intention de départ était -m none (pool non monté, “Proxmox gère les datasets, pas besoin de point de montage”). Mauvaise idée : mountpoint=none casse les conteneurs LXC. Leurs volumes sont des datasets ZFS que Proxmox doit pouvoir monter, et sans point de montage sur le pool, ça coince. D’où le -m /nvme-vm à la création et la tâche Ensure pool mountpoint is set qui le garantit explicitement. Encore une leçon apprise à la dure.
  • Scrub mensuel : la dernière tâche active le timer systemd zfs-scrub-monthly@nvme-vm.timer, pour que ZFS vérifie tout seul l’intégrité des données une fois par mois.

Résultat, identique sur les trois nœuds : un pool nvme-vm, ONLINE, ~476 Go, monté sur /nvme-vm, prêt à accueillir les VM et les conteneurs. Un coup d’œil sur un nœud le confirme :

  pool: nvme-vm
 state: ONLINE
  scan: scrub repaired 0B in 00:00:11 with 0 errors on Sun Aug  9 00:24:12 2026
config:

        NAME                                         STATE     READ WRITE CKSUM
        nvme-vm                                      ONLINE       0     0     0
          nvme-eui.0000000624xxxxxxxxxxxxxxxxxxxxxx  ONLINE       0     0     0

errors: No known data errors
---
NAME      SIZE  ALLOC   FREE  CKPOINT  EXPANDSZ   FRAG    CAP  DEDUP    HEALTH  ALTROOT
nvme-vm   476G  11.5G   464G        -         -    21%     2%  1.00x    ONLINE  -
---
nvme-vm mountpoint      /nvme-vm        local
nvme-vm compression     lz4     local

On y voit bien le pool en un seul vdev pointant vers le disque par son nvme-eui.* (la résolution by-id a fait son travail), le montage sur /nvme-vm et la compression lz4.

note

Cette sortie est une capture prise un peu plus tard : à la création, le pool était évidemment vide. Ici il a déjà accueilli quelques volumes (~11,5 Go), et surtout un scrub mensuel a déjà tourné - la preuve en passant que le timer systemd qu’on vient d’activer fonctionne.

Étape 4 : créer le cluster (pve_cluster)
#

C’est l’étape la moins réversible de la phase. Défaire un cluster, c’est possible, mais pénible (retirer les nœuds un à un, nettoyer corosync, parfois réinstaller). Comme rien ne tournait encore, le risque restait faible, mais c’est le moment de bien valider le plan avant d’appuyer.

Le rôle traite d’abord trois prérequis :

  1. Résolution de noms : chaque nœud reçoit dans son /etc/hosts les entrées des trois autres. Un cluster ne doit pas dépendre du DNS pour fonctionner (d’autant que mon DNS interne n’existe pas encore).
  2. Confiance SSH entre nœuds : pvecm add se connecte du nœud rejoignant vers le primaire. Je génère une paire de clés root sur chaque nœud et j’échange les clés publiques.
  3. Empreintes SSH pré-acceptées (on va voir pourquoi c’est capital).

Puis vient le vif du sujet : pvecm create sur pve01, et pvecm add sur pve02 puis pve03.

Le rôle pve_cluster
#

ansible/roles/pve_cluster/
├── defaults/main.yml   # le nom du cluster
└── tasks/main.yml      # /etc/hosts, confiance SSH, création et jonction

Un seul paramètre par défaut, le nom du cluster :

# ansible/roles/pve_cluster/defaults/main.yml
---
pve_cluster_name: kentrowlab

Et les tâches, dans l’ordre :

# ansible/roles/pve_cluster/tasks/main.yml
---
- name: Populate /etc/hosts with all cluster nodes
  ansible.builtin.lineinfile:
    path: /etc/hosts
    regexp: '^{{ hostvars[item].ansible_host }}\s'
    line: "{{ hostvars[item].ansible_host }} {{ item }}.{{ lab_domain }} {{ item }}"
    state: present
  loop: "{{ groups['pve'] }}"

- name: Ensure root SSH keypair exists
  ansible.builtin.command: ssh-keygen -t ed25519 -N "" -f /root/.ssh/id_ed25519
  args:
    creates: /root/.ssh/id_ed25519

- name: Read root public key
  ansible.builtin.slurp:
    src: /root/.ssh/id_ed25519.pub
  register: pve_cluster_root_pubkey
  when: not ansible_check_mode

- name: Authorize peer root keys
  ansible.builtin.lineinfile:
    path: /root/.ssh/authorized_keys
    line: "{{ hostvars[item].pve_cluster_root_pubkey.content | b64decode | trim }}"
    state: present
    create: true
    owner: root
    group: root
    mode: "0600"
  loop: "{{ groups['pve'] }}"
  when:
    - not ansible_check_mode
    - item != inventory_hostname

- name: Pre-accept peer SSH host keys
  ansible.builtin.shell: |
    set -o pipefail
    if ssh-keygen -F {{ hostvars[item].ansible_host }} >/dev/null 2>&1; then
      echo "present"
    else
      ssh-keyscan -t ed25519 {{ hostvars[item].ansible_host }} >> /root/.ssh/known_hosts
      echo "added"
    fi
  args:
    executable: /bin/bash
  register: pve_cluster_keyscan
  changed_when: "'added' in pve_cluster_keyscan.stdout"
  loop: "{{ groups['pve'] }}"
  when: item != inventory_hostname

- name: Check current cluster membership
  ansible.builtin.command: pvecm status
  register: pve_cluster_status
  changed_when: false
  failed_when: false

- name: Create cluster on primary node
  ansible.builtin.command: >-
    pvecm create {{ pve_cluster_name }}
    --link0 {{ ansible_host }}
  when:
    - inventory_hostname == pve_cluster_primary
    - pve_cluster_status.rc != 0
  changed_when: true

- name: Wait for cluster to be ready on primary
  ansible.builtin.command: pvecm status
  register: pve_cluster_ready
  until: pve_cluster_ready.rc == 0
  retries: 12
  delay: 5
  changed_when: false
  when: inventory_hostname == pve_cluster_primary

- name: Join cluster
  ansible.builtin.command: >-
    pvecm add {{ hostvars[pve_cluster_primary].ansible_host }}
    --use_ssh
    --link0 {{ ansible_host }}
  register: pve_cluster_join
  failed_when: "'successfully added node' not in pve_cluster_join.stdout"
  when:
    - inventory_hostname != pve_cluster_primary
    - pve_cluster_status.rc != 0
  changed_when: true
  throttle: 1

Au-delà des trois prérequis, quelques détails du rôle valent d’être soulignés :

  • Idempotence : la tâche Check current cluster membership (pvecm status) sert de garde-fou - création et jonction sont conditionnées par pve_cluster_status.rc != 0. Rejouer le rôle sur un cluster déjà monté ne refait rien.
  • --link0 {{ ansible_host }} : on indique explicitement à Corosync le réseau à utiliser pour son trafic (ici le lien du lab). Pas de surprise sur l’interface choisie.
  • Attendre avant de joindre : la tâche Wait for cluster to be ready on primary boucle sur pvecm status (retries) pour être sûr que pve01 est prêt avant que pve02 et pve03 tentent de le rejoindre.
  • throttle: 1 sur la jonction : les nœuds rejoignent un par un, jamais en parallèle, sous peine de corrompre Corosync.

Moins de dépendances, plus de cœur
#

Un détour qui illustre bien la philosophie du projet. Deux modules externes (community.crypto.openssh_keypair, ansible.posix.authorized_key) refusaient de se résoudre correctement malgré une installation censée être bonne. Plutôt que de me battre avec les chemins de collections, je les ai remplacés par des modules du cœur d’Ansible - vous les avez déjà vus dans le rôle : ssh-keygen avec un creates: pour la paire de clés (idempotent sans module dédié), et lineinfile pour les authorized_keys.

note

Préférez ansible.builtin quand il suffit. Une collection externe se justifie quand elle apporte une vraie valeur (comme community.sops), pas pour une tâche qu’un module de base fait très bien. Résultat : mon rôle le plus critique, pve_cluster, n’a aucune dépendance externe. Et bonus, ansible-lint atteint le profil production (le plus strict) avec zéro violation.

Le bug le plus instructif : le succès qui n’en est pas un
#

Rebelote sur le fil rouge, en pire. Le playbook affiche tout en vert, Join cluster: changed sur pve02 et pve03. Youpi. Sauf que :

Name:             kentrowlab
Config Version:   1
Nodes:            1
Expected votes:   1
Quorate:          Yes

pve01 est tout seul (et il s’en trouve très bien : Quorate: Yes, forcément, un nœud seul se suffit à lui-même). Et sur pve02/pve03 :

Error: Corosync config '/etc/pve/corosync.conf' does not exist

Le journal de pve02 ne montre aucune trace d’une tentative de jonction. Et pourtant, pvecm add a bel et bien retourné 0 (succès).

La cause est sournoise : pvecm add a voulu poser une question interactive (l’empreinte SSH de pve01 était inconnue de pve02), a reçu un EOF puisqu’Ansible ne fournit pas de terminal, et a abandonné en retournant 0. Un échec déguisé en succès.

La preuve : en faisant la jonction à la main, elle réussit, précédée d’un Warning: Permanently added '192.168.3.11' (ED25519) to the list of known hosts. C’était donc bien l’empreinte manquante.

Double correctif, tous deux visibles dans le rôle plus haut :

  1. Pré-accepter les empreintes AVANT pvecm add : c’est la tâche Pre-accept peer SSH host keys, qui ajoute l’empreinte de chaque pair dans known_hosts (via ssh-keyscan), mais seulement si elle est absente.
  2. Ne plus faire aveuglément confiance au code retour : la tâche Join cluster ne se fie plus à rc, mais vérifie le contenu de la sortie avec failed_when: "'successfully added node' not in pve_cluster_join.stdout". Un pvecm add qui retourne 0 sans rien faire devient enfin une erreur visible.
note

Le double enseignement le plus important de la phase :

  1. Une commande qui retourne 0 n’a pas forcément fait son travail. Vérifier le contenu de la sortie (failed_when sur le texte attendu) transforme un échec silencieux en erreur bien visible.
  2. Sous Ansible, il faut anticiper tout ce qui pourrait vouloir une interaction. Une simple empreinte SSH inconnue suffit à tout bloquer… sans rien dire.

Un dernier piège d’idempotence
#

Après ce correctif, un changed=1 s’accrochait à chaque passage sur la tâche qui stockait les empreintes. En inspectant le fichier known_hosts, je découvre des lignes de commentaire qui s’accumulaient en triple. Le coupable : ssh-keyscan produit des lignes de commentaire en plus de la clé, et je passais tout ce bloc multi-lignes à lineinfile.

note

lineinfile attend UNE ligne, pas un bloc. Lui donner la sortie brute d’une commande multi-lignes casse silencieusement l’idempotence : il réécrit le bloc à chaque passage et son regexp ne le retrouve jamais. Symptôme : un changed éternel et un fichier qui enfle. Invisible dans le PLAY RECAP, visible seulement en ouvrant le fichier produit. La solution : tester avec ssh-keygen -F et n’ajouter que si l’empreinte est absente.

Après tout ça, le verdict tombe :

Cluster information
-------------------
Name:             kentrowlab
Config Version:   5
Transport:        knet
Secure auth:      on

Quorum information
------------------
Date:             Sat Aug 22 17:29:29 2026
Quorum provider:  corosync_votequorum
Nodes:            3
Node ID:          0x00000001
Ring ID:          1.4b
Quorate:          Yes

Votequorum information
----------------------
Expected votes:   3
Highest expected: 3
Total votes:      3
Quorum:           2
Flags:            Quorate

Membership information
----------------------
    Nodeid      Votes Name
0x00000001          1 192.168.3.11 (local)
0x00000002          1 192.168.3.12
0x00000003          1 192.168.3.13
Le cluster kentrowlab dans l’interface Proxmox avec ses trois nœuds en ligne
Le Datacenter kentrowlab dans l’interface Proxmox : pve01, pve02 et pve03 tous les trois en ligne

L’essentiel est là : Quorate: Yes, avec un quorum de 2 sur 3. La perte d’un nœud ne fait donc pas tomber le cluster - objectif atteint.

note

Cette capture est actuelle, d’où le Config Version: 5. En fin de phase 2, il était à 3 - et ce chiffre raconte quelque chose : la valeur s’incrémente à chaque changement de configuration du cluster, soit ici pvecm create (1) puis les deux pvecm add (2 et 3). Un +1 par jonction, exactement comme attendu.

Étape 5 : le stockage au niveau cluster (pve_storage)
#

Dernière étape, et la plus rapide, parce qu’elle illustre magnifiquement ce qu’apporte un cluster. Le stockage se déclare une seule fois, sur pve01, et le cluster s’occupe du reste.

Le rôle est minuscule :

ansible/roles/pve_storage/
├── defaults/main.yml   # id, pool et type de contenu
└── tasks/main.yml      # un pvesm add idempotent
# ansible/roles/pve_storage/defaults/main.yml
---
pve_storage_zfs_id: "{{ zfs_pool_name }}"
pve_storage_zfs_pool: "{{ zfs_pool_name }}"
pve_storage_zfs_content: "images,rootdir"
# ansible/roles/pve_storage/tasks/main.yml
---
- name: Check whether ZFS storage is declared
  ansible.builtin.command: "pvesm status --storage {{ pve_storage_zfs_id }}"
  register: pve_storage_existing
  changed_when: false
  failed_when: false

- name: Declare ZFS pool as cluster storage
  ansible.builtin.command: >-
    pvesm add zfspool {{ pve_storage_zfs_id }}
    --pool {{ pve_storage_zfs_pool }}
    --content {{ pve_storage_zfs_content }}
    --sparse 1
    --nodes {{ groups['pve'] | join(',') }}
  when: pve_storage_existing.rc != 0
  changed_when: true

Deux options du pvesm add méritent un mot :

  • --sparse 1 : allocation à la demande. Sur un lab avec des VM aux disques généreux mais peu remplis, ça change tout côté espace réellement utilisé.
  • --content images,rootdir : images pour les disques de VM (KVM), rootdir pour les volumes LXC. Le pool sert donc aux deux étages de l’architecture (Dokploy en LXC, Talos en VM).

Le playbook, et un piège de poule et d’œuf
#

C’est l’occasion de montrer enfin un playbook (jusqu’ici on n’avait vu que des rôles). Et il cache un piège que je me suis pris en pleine figure. Mon premier réflexe : cibler directement le nœud primaire.

hosts: "{{ pve_cluster_primary }}"
Error processing keyword 'hosts': 'pve_cluster_primary' is undefined

Le hosts: d’un play est évalué avant la résolution des variables d’inventaire : impossible d’y utiliser une variable de groupe, c’est un vrai problème de poule et d’œuf. La solution : cibler tout le groupe pve, et conditionner la tâche au bon nœud.

# ansible/pve-storage.yml
---
- name: Declare cluster storage
  hosts: pve
  become: false
  gather_facts: false
  tasks:
    - name: Configure storage on primary node
      ansible.builtin.include_role:
        name: pve_storage
      when: inventory_hostname == pve_cluster_primary

Pourquoi une seule fois ? Parce que /etc/pve/storage.cfg vit dans pmxcfs, le système de fichiers répliqué du cluster. J’écris sur pve01, et la configuration se propage toute seule aux trois nœuds. La preuve en interrogeant pve02, alors que je n’ai rien déclaré dessus :

Name           Type     Status     Total (KiB)      Used (KiB) Available (KiB)        %
local           dir     active       102626232        12476980        84889988   12.16%
nvme-vm     zfspool     active       483656464        11896808       471759656    2.46%
note

Écrire une fois, le cluster propage. C’est toute la logique du découpage de cette phase : le pool ZFS se crée par nœud (étape 3, avant le cluster), mais le stockage se déclare une seule fois après le cluster (étape 5). /etc/pve n’est pas un dossier local ordinaire, c’est une base répliquée entre tous les nœuds.

Ce qui n’a pas pu être fait (et pourquoi)
#

Petit rappel : en fin de partie 7, j’annonçais que cette phase inclurait la réplication et la config du serveur de backup. Il a fallu réviser ce plan, et je préfère l’assumer que faire semblant.

La réplication ZFS. J’ai découvert une nuance que je n’avais pas anticipée dans l’ADR-0003 : pvesr réplique des VM, pas des nœuds. Sans la moindre VM à ce stade, il n’y a tout simplement rien à répliquer, aucun job à créer. La réplication n’est pas une configuration d’infrastructure, c’est un attribut par VM. Elle viendra donc naturellement dans les phases 5 et 6 de la feuille de route (celles de Dokploy et des VM Talos). Ce que la phase 2 pouvait faire, elle l’a fait : des pools de même nom sur les trois nœuds, un stockage cluster déclaré. Le terrain est prêt.

Le serveur de backup (pbs01). Il reste sur la touche pour deux raisons très terre-à-terre : il lui manque une alimentation (toutes prises par les PVE) et son upgrade RAM est en attente - la fameuse DDR4 incompatible de la partie 7, ce vieux 9020 étant en DDR3L. Aucun impact sur le cluster : le PBS est par construction indépendant (ADR-0002). Son intégration sera un ajout ultérieur, avec au passage la correction du dimensionnement root laissée en suspens en phase 1.

L’état des lieux après la phase 2
#

ÉlémentÉtat
pve-manager9.2.5 sur les 3 nœuds
Dépôtsno-subscription actif, entreprise et Ceph désactivés
ARC ZFScappé à 4 Gio
Pool ZFSnvme-vm, ONLINE, 476 Go, identique sur les 3
Clusterkentrowlab, 3 nœuds, quorate, config version 3
Stockage clusternvme-vm (zfspool, sparse), actif partout
Idempotencevalidée sur les 4 playbooks
ansible-lintprofil production, 0 violation

Trois machines isolées sont devenues un vrai cluster, entièrement décrit par du code rejouable. Mais surtout, cette phase m’a rappelé une vérité que je ne suis pas près d’oublier : le voyant vert n’est pas une preuve. La seule preuve, c’est l’état réellement obtenu, vérifié à la main.

Et maintenant ?
#

Le cluster tourne, mais tout se fait encore depuis mon poste, à la main. Dans la partie 9, on attaque les fondations day-2 : un token API Proxmox scopé et rangé dans SOPS, un backend de state OpenTofu chiffré côté client, et un GitLab Runner en LXC pour que les pipelines atteignent enfin le réseau local. Autrement dit : le moment où l’automatisation commence à tourner toute seule.

À bientôt !

Kentrow
Auteur
Kentrow
Partage de notes et astuces IT : réseaux, serveurs, DevOps, sécurité, homelab et plus encore.
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 8: Cet article

Articles connexes