
🌐 Aussi en: English · Deutsch · Español
Tout a commencé par une question un peu vaniteuse. Mon dépôt Ansible fait tourner ce blog et deux serveurs Nextcloud : 34 rôles, quatre playbooks, environ 9 000 lignes de YAML, près de 200 commits. La plupart de ces lignes, ce n’est pas moi qui les ai écrites, mais mon co-admin IA (Claude) – moi, j’ai décidé, testé et lancé les playbooks. Alors je lui ai demandé : si je comprends l’essentiel de ce code, suis-je un utilisateur avancé d’Ansible ?
La réponse a été honnête et un peu inconfortable : avancé pour le lire et l’exploiter, oui. Avancé pour l’écrire de zéro, pas encore prouvé. Puis sont venues dix pistes pour améliorer le code lui-même. J’ai répondu : faites les dix. Testez-les sérieusement. Puis écrivez là-dessus. Voici cet article.
Règles du jeu habituelles : tout se passe sur une branche Git séparée, rien ne touche un serveur de production avant que je ne le lance, et chaque affirmation est testée. « Testé » s’est révélé être la partie intéressante.
📋 Les dix points
| # | Amélioration | Pourquoi |
|---|---|---|
| 1 | Objets Kubernetes via kubernetes.core.k8s au lieu de kubectl apply par shell | idempotent, vrais diffs, fonctionne en mode check |
| 2 | Rendre --check vraiment utile | une simulation avant chaque exécution en production |
| 3 | ansible-lint, yamllint et un scanner de secrets en hook pre-commit et en GitHub Action | attraper erreurs et secrets divulgués avant qu’ils n’arrivent |
| 4 | Test d’idempotence automatisé | la deuxième exécution doit afficher changed=0 |
| 5 | Chemins rescue dans les playbooks de mise à jour | une réaction définie quand une mise à jour échoue en cours de route |
| 6 | Découper les gros fichiers de tâches | un fichier de 912 lignes n’est l’ami de personne |
| 7 | meta/argument_specs.yml pour les rôles | une variable manquante échoue au début, pas au milieu |
| 8 | Fusionner les rôles en double | un seul rôle ClamAV au lieu de deux presque identiques |
| 9 | Épingler exactement les versions des collections | >= laisse passer une version majeure incompatible sans qu’on le remarque |
| 10 | Renovate pour les versions épinglées | des propositions de mise à jour en pull request au lieu de recherches à la main |
En chemin, j’ai ajouté un onzième souhait : toutes les versions épinglées au même endroit, pour voir d’un coup d’œil ce qui tourne où. Plus de détails au point 10.
🧪 Premier problème : comment tester du code Kubernetes sans toucher à la production ?
Il y avait autrefois un serveur de test dans l’inventaire. Quand Claude a voulu l’atteindre, SSH a refusé : host key verification failed. L’IP appartient sans doute à quelqu’un d’autre désormais, donc aucune connexion n’a été tentée, volontairement. Et je ne donne pas mon mot de passe de vault dans un chat – aucun playbook ne pouvait donc tourner contre les vrais serveurs non plus.
La solution était élégante : un K3s jetable dans podman rootless sur mon jump host. Un K3s complet a échoué aussitôt (failed to find cpuset cgroup – les conteneurs rootless ne reçoivent pas la délégation de ce contrôleur). Mais pour tester si des manifestes sont appliqués de façon idempotente, inutile d’avoir des pods en marche, le serveur d’API suffit. K3s a une option exactement pour ça :
podman run -d --name k3s-test --privileged -p 127.0.0.1:16443:6443 \
docker.io/rancher/k3s:v1.36.5-k3s1 server --disable-agent \
--disable traefik --disable servicelb --disable metrics-serverUn petit script de substitution joue le rôle de k3s kubectl : il transmet tout au conteneur et répond « OK » aux commandes qui auraient besoin de pods en marche (exec, rollout status, wait). Le test fait ensuite l’essentiel : tout appliquer avec l’ancien code de main (c’est l’état des serveurs), puis lancer le nouveau code par-dessus – simulation, exécution réelle, encore une exécution réelle, encore une simulation.
1️⃣ kubernetes.core au lieu de kubectl apply
L’ancien modèle apparaissait 29 fois : générer un template dans /tmp, kubectl apply -f, supprimer le fichier, et deviner « changed » en cherchant le mot configured dans la sortie. Le nouveau modèle génère en mémoire et parle directement à l’API :
- name: Redis | Apply Deployment + Service
kubernetes.core.k8s:
kubeconfig: "{{ k3s_kubeconfig }}"
state: present
apply: true
definition: "{{ item }}"
loop: "{{ lookup('ansible.builtin.template', 'redis-deployment.yml.j2') | ansible.builtin.from_yaml_all | select | list }}"
loop_control:
label: "{{ item.kind }}/{{ item.metadata.name }}"Trois choses ne sont apparues que grâce au test :
- La première simulation affichait « changed » pour presque tout – sans diff. En fait, le module n’affiche aucun diff quand on lui passe une liste d’objets. Une itération de boucle par objet a réglé le problème, et le diff dit désormais exactement
image: redis:7.4.11-alpine → redis:7.4.12-alpine. - La migration unique ne réécrit que l’annotation
last-applied-configuration(kubectl y écrit un"annotations":{}vide, le module non). Spec et template de pod restent identiques, donc aucun redémarrage de pod – vérifié via les révisions des ReplicaSets, toutes toujours à 1. - Helm :
helm upgrade --installtournait à chaque play et empilait une nouvelle révision à chaque fois.kubernetes.core.helmcompare la version du chart et les valeurs, et ne fait rien si elles correspondent. Piège : Helm 4 n’est pris en charge qu’à partir de la version 6.5.0 de la collection ; le paquet de la distribution fournissait la 6.1.0.
Effet secondaire que j’apprécie beaucoup : les Secrets Kubernetes ne passent plus par un fichier dans /tmp sur le serveur.
2️⃣ Une simulation qui veut dire quelque chose
En mode check, Ansible ignore toutes les tâches shell/command – y compris les inoffensives qui ne font que lire. Les tâches suivantes qui évaluent ces résultats échouent alors ou décident sur du vide. 94 simples requêtes (vérifications de version, occ config:system:get, systemctl is-active …) portent désormais check_mode: false. Tout ce qui écrit reste ignoré – et là où cela masquerait un changement en attente, une petite tâche supplémentaire le signale.
3️⃣ Lint, scan de secrets, hooks
La première exécution d’ansible-lint a remonté 811 constats. Environ 600 étaient des règles de style qui contredisent des choix délibérés (colonnes alignées, noms de variables partagés sans préfixe de rôle) – elles sont désactivées avec une justification écrite. Le reste a été corrigé. Deux vraies trouvailles :
ansible.builtin.ini_filen’existe pas. Il ne fonctionnait que grâce à une ancienne redirection verscommunity.general– une collection qui ne figurait même pas dansrequirements.yml. Sur un contrôleur avec ansible-core seul, le playbook Nextcloud se serait arrêté pile là.- 27 pipes shell sans
pipefail. Le plus souvent sans conséquence, mais dans la vérification de version,k3s --version | awk … || echo 'not installed'ne pouvait jamais afficher « not installed » : sanspipefail, seul le code de sortie d’awkcompte.
gitleaks a analysé les 191 commits de l’historique : un seul résultat, un espace réservé PASTE_CONTENT_OF_id_rsa_HERE entre deux marqueurs de clé dans un vieux fichier d’exemple. Accepté volontairement, documenté. Les hooks tournent désormais à chaque commit – et le premier commit qu’ils ont bloqué, quelques heures plus tard, était celui de Claude (mauvaise indentation YAML dans des fichiers générés). C’est exactement leur rôle.
4️⃣ L’idempotence, testée
Le test podman fait désormais partie du dépôt (tests/k8s/run.sh). Il échoue si la deuxième exécution change encore quelque chose, si un pod serait redéployé, si une release Helm reçoit une nouvelle révision – ou si un tag d’image volontairement modifié n’est pas détecté. Ce dernier point compte : un test qui ne peut jamais échouer ne prouve rien.
Pour les vrais serveurs, il y a scripts/check-drift.sh <playbook> <host> : une simulation qui n’affiche que ce qui changerait, avec un diff lisible. Juste après une exécution réelle, le résultat doit être NO DRIFT.
Au passage, quelques tâches qui affichaient « changed » à chaque exécution ont été corrigées (téléchargement des signatures, un utilisateur de base de données, un module dnf). La trouvaille la plus sournoise était l’inverse : les neuf réglages SMTP de Nextcloud étaient réécrits à chaque exécution et affichaient toujours « ok ». Un mot de passe mail modifié ne serait jamais apparu comme un changement. Désormais, ils sont d’abord lus, comparés, et seules les valeurs différentes sont écrites – transmises via argv, pour qu’un guillemet dans un mot de passe ne puisse pas casser la commande.
5️⃣ Quand une mise à jour échoue en cours de route
La première suggestion de Claude était : « quitter proprement le mode maintenance dans le rescue ». En y réfléchissant mieux, il s’est corrigé lui-même : après un occ upgrade interrompu, désactiver le mode maintenance livre les utilisateurs à une instance à moitié mise à jour. La logique désormais :
- Une pré-vérification hors du block : installé, pas en mode maintenance, aucune mise à jour de base en attente – sinon le playbook refuse de démarrer. Hors du block exprès : le rescue ne doit traiter que des états causés par l’exécution elle-même.
- Le rescue collecte le statut occ, les pods, les événements et les dernières lignes de
nextcloud.log, et ne désactive le mode maintenance que sineedsDbUpgradevaut false. Puis il s’arrête, pour qu’aucun deuxième serveur ne soit touché. - Bonus : les playbooks de mise à jour redémarraient Nextcloud et Collabora à chaque exécution. Un petit nouveau rôle compare le digest de chaque image fraîchement tirée avec ce que les pods exécutent, et ne redémarre que ce qui a réellement été reconstruit – exporters et notify_push compris.
Testé avec des substituts de kubectl/crictl dans 14 scénarios. Puis l’étape importante : casser volontairement le rescue (toujours désactiver le mode maintenance) et relancer le test. Il est passé au rouge exactement sur le bon scénario. Auparavant, le même test avait une fois affiché « tout vert » pour une mauvaise raison – deux scénarios du même nom partageaient un répertoire et mélangeaient leurs logs. Le vert ne veut pas dire grand-chose tant qu’on n’a pas vu le test échouer.
6️⃣ à 9️⃣ Les plus discrets
- 6 – découpage : le rôle WordPress (912 lignes) compte désormais sept fichiers thématiques, Grafana trois, les rôles de vérification deux chacun. Preuve que rien n’a changé :
--list-tasks --list-tagsavant et après, identiques. - 7 – argument specs : 23 rôles déclarent leurs entrées. « Obligatoire » est calculé par type d’hôte : les rôles communs lisent
blog_hostnamesur un serveur etnextcloud_hostnamesur l’autre, aucun des deux ne peut donc être obligatoire. Vérifié contre les vraies variables des trois serveurs. Les valeurs du vault sont enno_log– vérifié qu’elles n’apparaissent ni avec-vvvni dans une erreur de validation. - 8 – fusion : un seul rôle ClamAV et un seul rôle SELinux avec un profil par stack, plus 16 vérifications communes. Piège évité : Ansible identifie les entrées cron par leur nom, les noms devaient donc rester exactement les mêmes – sinon l’ancien job aurait continué à tourner à côté du nouveau.
- 9 – épinglage :
>=3.0.0est devenu3.15.0. La simulation de Renovate a montré pourquoi c’est important : les versions majeures 5.x et 13.x attendaient déjà.
🔟 Renovate, et toutes les versions dans un seul fichier
Toutes les versions épinglées se trouvent désormais dans inventory/group_vars/all/versions.yml : les valeurs de façon compacte en haut, les raisons et l’historique en dessous. Renovate reçoit des regex managers pour exactement ces lignes et propose les mises à jour en pull request – chaque semaine, jamais fusionnées automatiquement. Une simulation locale avec le conteneur Renovate a trouvé les 21 versions suivies et proposé aussitôt nginx 1.31. C’est la branche mainline ; j’utilise la branche stable (mineure paire). D’où des garde-fous :
- nginx reste sur les versions mineures paires.
- Les versions majeures (Nextcloud 35, MariaDB, Redis 8) et les sauts mineurs de K3s/Calico uniquement après approbation dans le dependency dashboard, avec une note sur ce qu’il faut faire.
- Les PR WP-CLI me rappellent de mettre aussi à jour la somme SHA-512 – Renovate ne peut pas la calculer, et sans elle le conteneur d’initialisation du blog refuserait le téléchargement.
📚 Et la documentation
Nouveau dans le dépôt : « Comment fonctionne le code Ansible » en allemand, en anglais et en français – les variables et leur priorité, ce qui se passe à l’exécution, le fonctionnement des modules Kubernetes, les modèles d’idempotence, la gestion des erreurs, les tests et un parcours d’apprentissage. La mise à jour des guides existants a fait apparaître en prime quelques informations périmées : une ancienne version d’image, une description de Calico qui disait encore « Nextcloud uniquement », un tableau de variables pointant vers le mauvais fichier.
🚀 Le déploiement
Les tests locaux étaient verts. J’ai ensuite créé moi-même le fichier du mot de passe de vault dans le terminal (jamais dans le chat), et Claude a lancé la vérification de dérive contre les vrais serveurs. La toute première exécution contre le blog a planté – le propre script de Claude a trébuché sur le module dnf, qui renvoie ses résultats sous forme de liste de chaînes au lieu d’objets. Corrigé, relancé, et les vraies trouvailles sont arrivées. La plupart se trouvaient dans du code qui tournait depuis des mois.
Ce que seuls les vrais serveurs ont montré
- Le rôle Grafana ne pouvait pas du tout faire de simulation. Il demande à l’API « la source de données existe-t-elle ? » via le module
uri– qu’Ansible ignore en mode check – et la tâche suivante évaluait alors un résultat inexistant. Sans conséquence en exécution réelle, fatal pour--check. - Ma propre correction du point 4 était incomplète. J’avais ajouté
creates:pour quefreshclamne tourne qu’une fois – mais laisséchanged_when: true, qui écrase le résultat « ignoré, le fichier existe ». Il affichait toujours « changed » sans rien faire. - Un ping-pong de propriétaire : le playbook mettait
publickey.ascà root, autre chose le remettait à www-data – à chaque exécution, pendant des mois sans que personne ne le remarque. - Les binaires de supervision de Nextcloud étaient retéléchargés après chaque redémarrage. Les archives se trouvent dans
/tmp, qu’un redémarrage vide, et la tâche de téléchargement n’avait aucune condition. Elle vérifie désormais d’abord la version installée. - Un privilège renommé : l’utilisateur de l’exporter MariaDB recevait de nouveau ses droits à chaque exécution. Le code demandait
REPLICATION CLIENT, MariaDB 10.11 affiche le même privilège sous le nomBINLOG MONITOR, et le module voyait une différence qui n’en était pas une. - 46 avertissements de dépréciation pour des facts utilisés comme variables de premier niveau (
ansible_kernelau lieu deansible_facts['kernel']) – sans problème aujourd’hui, cassé dans une future version d’Ansible.
Une question demandait plus qu’un log : le Deployment Nextcloud allait-il changer ? Sa tâche est en no_log parce qu’elle contient des secrets, le diff est donc masqué. Un redémarrage signifie une courte interruption (stratégie Recreate). Claude a comparé l’objet en cours avec une simulation côté serveur de l’apply et n’a affiché que les noms des champs différents, jamais les valeurs : identique à l’annotation last-applied près. Pas de redémarrage.
Plus rapide, tant qu’on y était
J’ai demandé si des rôles pouvaient tourner en parallèle pour gagner du temps. Réponse honnête : pas sur un seul hôte – Ansible traite les tâches d’un hôte l’une après l’autre, et les vraies situations de concurrence seraient les verrous dnf et AIDE/rkhunter, qui doivent voir l’état final. Les gains sûrs étaient ailleurs : AIDE compare et écrit désormais sa base en un seul scan au lieu de deux (de 30 secondes à 3 minutes par exécution sur le blog), et les objets Kubernetes d’un template partent vers l’API en un seul appel lors des exécutions normales – par objet uniquement avec --diff, là où le diff lisible compte. Calico seul est passé d’environ 30 secondes à moins de 2.
Les vraies exécutions
- Blog : MariaDB et Redis intacts, WordPress redémarré une fois – j’ai vérifié pourquoi : la seule différence entre l’ancien et le nouveau template de pod était l’horodatage
restartedAtdu redémarrage voulu après des mises à jour d’extensions. Les releases Helm sont restées à la révision 87 (cert-manager) et 72 (ingress-nginx). Ces chiffres disent tout : l’ancien code ajoutait une révision par exécution. Vérification de dérive ensuite : NO DRIFT. - sofie : aucun pod redémarré, révisions Helm inchangées, auto-test notify_push au vert, NO DRIFT.
- cvjm (le gros Nextcloud) : même tableau – aucun pod redémarré, révisions Helm inchangées, l’utilisateur de l’exporter possède désormais
BINLOG MONITORet ne reçoit plus ses droits à chaque fois, NO DRIFT.
🎯 À retenir
En chiffres : 16 commits, 165 fichiers modifiés, 0 erreur de lint, deux nouvelles suites de tests, trois serveurs convertis – chacun terminant sur NO DRIFT. Mais l’essentiel n’est pas dans les chiffres. La plupart des vraies trouvailles sont venues de tests qui avaient le droit d’échouer : la dépendance cachée à une collection, le mot de passe mail qui n’apparaissait jamais comme un changement, le diff manquant, le test vert pour une mauvaise raison – et, dès que de vrais serveurs sont entrés dans la boucle, une demi-douzaine de petits mensonges dans du code qui tournait tranquillement depuis des mois.
Et ma question de départ ? Le dépôt a maintenant un parcours d’apprentissage. L’étape trois dit : écrire soi-même un rôle, sans modèle. Celle-là, c’est pour moi.





