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.
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 à 5Piè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=60sRien 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.sopsElle 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.20Cet 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.ovhAnsible 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/24On 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.
À 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.
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.confexiste déjà, avec unzfs_arc_maxposé 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'ordreLes 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: truePuis 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 pveproxyOn 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: restartedRetenez 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œud | pve-manager |
|---|---|
| pve01 | 9.2.2 |
| pve02 | 9.2.5 |
| pve03 | 9.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 :
- Mes tâches modifient les dépôts APT et notifient un handler
Apt update. - Sauf que les handlers ne s’exécutent qu’à la toute fin du play.
- Entre-temps, ma tâche
Refresh APT cacheavait uncache_valid_time: 3600: « le cache a moins d’une heure, je ne fais rien ». - Du coup,
Upgrade systema 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.
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.
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 + scrubLes 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: lz4Et 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: startedQuelques 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 parwhen: 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-idplutô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, unnvme-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=nonecasse 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 localOn 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.
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 :
- Résolution de noms : chaque nœud reçoit dans son
/etc/hostsles 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). - Confiance SSH entre nœuds :
pvecm addse 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. - 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 jonctionUn seul paramètre par défaut, le nom du cluster :
# ansible/roles/pve_cluster/defaults/main.yml
---
pve_cluster_name: kentrowlabEt 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: 1Au-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 parpve_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: 1sur 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.
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: Yespve01 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 existLe 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 :
- 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 dansknown_hosts(viassh-keyscan), mais seulement si elle est absente. - 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 avecfailed_when: "'successfully added node' not in pve_cluster_join.stdout". Unpvecm addqui retourne 0 sans rien faire devient enfin une erreur visible.
Le double enseignement le plus important de la phase :
- Une commande qui retourne 0 n’a pas forcément fait son travail. Vérifier le contenu de la sortie (
failed_whensur le texte attendu) transforme un échec silencieux en erreur bien visible. - 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.
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
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.
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: trueDeux 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:imagespour les disques de VM (KVM),rootdirpour 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 undefinedLe 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_primaryPourquoi 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%É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-manager | 9.2.5 sur les 3 nœuds |
| Dépôts | no-subscription actif, entreprise et Ceph désactivés |
| ARC ZFS | cappé à 4 Gio |
| Pool ZFS | nvme-vm, ONLINE, 476 Go, identique sur les 3 |
| Cluster | kentrowlab, 3 nœuds, quorate, config version 3 |
| Stockage cluster | nvme-vm (zfspool, sparse), actif partout |
| Idempotence | validée sur les 4 playbooks |
| ansible-lint | profil 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 !




