
🌐 Aussi en: English · Deutsch · Español
Voici la première partie d’une petite série qui parcourt le code Ansible derrière ce blog et deux serveurs Nextcloud – pas variable par variable, mais au niveau « que se passe-t-il, dans quel ordre, et pourquoi est-ce construit ainsi ». Le code lui-même est public : github.com/aptupgrademe/www_k3s. Toute la série décrit la version v3.1.0 (octobre 2026), et chaque lien vers un fichier pointe vers cette version – les rôles continueront d’évoluer, les liens continueront de montrer le code dont parle le texte.
Il y a quelques mois, j’ai publié la stack en open source, et depuis, le dépôt a mûri : une passe qualité a rendu chaque rôle idempotent, neuf couches de défense ont été ajoutées, et chaque rôle commence désormais par un commentaire qui l’explique à quelqu’un qui ne l’a jamais vu. Il manquait encore la vue d’ensemble. Qui ouvre le dépôt aujourd’hui trouve 34 rôles, quatre playbooks, environ 10 000 lignes de tâches et 12 000 lignes de templates. Par où commencer ?
Ici. Cet article est la carte. Il explique comment le dépôt est organisé, comment un même ensemble de rôles sert deux applications très différentes, ce qui se passe réellement quand je tape ansible-playbook, et quels filets de sécurité rattrapent mes erreurs avant qu’un serveur ne les voie. Les parties suivantes zooment ensuite, du général au particulier – et deux d’entre elles traversent tout pour un seul sujet : la sécurité et la supervision.
🗺️ La visite en un coup d’œil
| Partie | Sujet | Rôles |
|---|---|---|
| 1 | La charpente – dépôt, inventaire, playbooks, une exécution du début à la fin (cet article) | playbooks, common_version_check, les rôles de vérification |
| 2 | La plateforme – choisir le serveur, système de base et Kubernetes, communs aux deux stacks | *_packages, common_sysctl, common_msmtp, common_dnf_automatic, common_logrotate, common_k3s, common_calico |
| 3 | Le blog – WordPress sur K3s | blog_k3s_deploy, blog_wordpress, blog_visitor_stats, blog_verify |
| 4 | Nextcloud – Nextcloud, Collabora, base de données et cache | next_mariadb, next_redis, next_k3s_deploy, next_config, nextcloud_verify |
| 5 | Sécurité informatique – uniquement les rôles de sécurité, pour les deux stacks | common_ssh, common_firewall, common_cis_hardening, common_selinux, common_auditd, common_aide_refresh, common_rkhunter, common_clamav, common_fail2ban |
| 6 | Supervision – métriques, tableaux de bord, alertes, chien de garde | les exporters, common_prometheus, common_grafana, common_monit |
Quelle que soit l’application qui vous intéresse, vous lisez au plus cinq articles : celui-ci, la plateforme, votre application, la sécurité et la supervision.
L’objectif de la série est pratique : quelqu’un qui a des bases en informatique et la lit attentivement doit en comprendre assez pour exploiter un tel environnement en toute sécurité – pas seulement comment fonctionne le code, mais pourquoi chaque élément est là, où regarder et quoi faire quand quelque chose tourne mal. Chaque partie se termine donc par une section « En exploitation » : les commandes, la routine, les mails d’alerte et les premiers gestes quand quelque chose casse.
🧩 Un dépôt, deux applications
Trois serveurs sont gérés depuis ce dépôt : le blog (WordPress) et deux instances Nextcloud. En apparence, ils ont peu en commun – un blog PHP avec MariaDB dans un conteneur, et un cloud de fichiers avec une suite bureautique, sa base de données sur l’hôte et quelques centaines de gigaoctets de données utilisateurs. En dessous, ils sont presque identiques : le même AlmaLinux 9, le même SSH durci, le même pare-feu, le même K3s à un seul nœud avec le même ingress, la même supervision, les mêmes analyses nocturnes.
C’est exactement ainsi que les rôles sont répartis. Le préfixe d’un rôle indique qui l’utilise :
| Préfixe | Utilisé par | Nombre | Exemples |
|---|---|---|---|
common_* | les deux stacks | 23 | common_ssh, common_firewall, common_k3s, common_grafana |
blog_* | le blog seul | 5 | blog_k3s_deploy, blog_wordpress |
next_* / nextcloud_verify | Nextcloud seul | 6 | next_mariadb, next_config |
Deux tiers du code sont partagés. Une correction du pare-feu ou du durcissement SSH arrive sur les trois serveurs à la prochaine exécution, et une leçon apprise sur un Nextcloud protège aussi le blog. Le prix : un rôle partagé doit savoir où il s’exécute – comment il le découvre est l’une des idées centrales du dépôt et a sa propre section plus bas.
📁 Comment le dépôt est organisé
www_k3s/
├── blog.yml full run for the blog
├── blog-update.yml updates only (plugins, core, rebuilt images)
├── nextcloud-k3s.yml full run for Nextcloud
├── nextcloud-update.yml updates only (rebuilt images, occ upgrade, apps)
├── ansible.cfg how Ansible connects and behaves
├── requirements.yml Ansible collections (kubernetes.core, ...)
├── inventory/
│ ├── hosts.yml which host belongs to which group
│ ├── group_vars/all/ values for every host (main.yml, versions.yml)
│ └── host_vars/<host>/ values for one host (vars.yml, vault.yml)
├── roles/<role>/
│ ├── tasks/ what the role does
│ ├── handlers/ what runs once, at the end, if something changed
│ ├── templates/ Jinja templates (config files, manifests)
│ ├── files/ files copied as they are (scripts)
│ ├── defaults/ the role's own default values
│ └── meta/ dependencies + argument_specs (declared inputs)
├── scripts/ drift check, backup and restore helpers
├── tests/ K8s apply test, update scenario test
└── docs/ operations manuals (DE/EN/FR)Rien d’exotique – c’est la structure Ansible standard. Deux points méritent d’être connus, car le reste de la série s’appuie dessus. D’abord, les playbooks sont minces : ils ne contiennent presque aucune tâche, seulement la liste des rôles dans le bon ordre. Tout ce qui se passe vit dans un rôle. Ensuite, un rôle ne contient jamais de valeurs propres à un hôte. Aucune adresse IP, aucun domaine, aucun mot de passe n’apparaît dans roles/. Tout cela vient de l’inventaire.
📒 L’inventaire : le tableau de commande
Si les rôles sont la machinerie, l’inventaire est le tableau de commande qui décide de ce que la machinerie fait sur quel serveur. Il répond à trois questions : quels serveurs existent, quelles valeurs s’appliquent à tous, et lesquelles à un seul. Je ne passerai pas en revue chaque variable – il y en a quelques centaines –, mais la structure vaut la peine d’être comprise, car elle explique comment un seul dépôt peut décrire trois serveurs différents.
Qui appartient où : hosts.yml
hosts.yml ne contient que la topologie – quel hôte appartient à quel groupe :
all:
children:
blog: # WordPress servers
hosts:
www.example.de: {}
test: {}
nextcloud: # Nextcloud servers
hosts:
instance1: {}
instance2: {}
test: {} # shared with the blog test serverLe groupe est la caractéristique la plus importante d’un hôte. blog.yml commence par hosts: blog, nextcloud-k3s.yml par hosts: nextcloud – le playbook choisit ses serveurs par groupe, et à l’intérieur d’un rôle partagé, le groupe décide de ce qui est généré (nous y revenons dans un instant). Notez que même l’adresse IP n’est pas ici : elle se trouve dans les variables propres à l’hôte, et n’existe donc qu’à un seul endroit.
Ce qui vaut pour tous : group_vars/all/
Les valeurs identiques sur chaque serveur se trouvent dans group_vars/all/ – le réseau des pods de K3s, les chemins des répertoires de données, les limites de ressources par défaut. Le fichier le plus important y est versions.yml : chaque version que les playbooks installent ou déploient, dans un seul tableau. K3s, Helm, Calico, les charts Helm, chaque image de conteneur, les binaires de supervision :
# --- Kubernetes platform ---------------------------------------
k3s_version: "v1.xx.x+k3s1" # github.com/k3s-io/k3s/releases
helm_version: "v4.x.x" # github.com/helm/helm/releases
calico_version: "v3.xx.x" # (policy-only, see notes)
# --- WordPress containers --------------------------------------
blog_image_wordpress: "wordpress:x.y.z-php8.4-fpm"
blog_image_mariadb: "mariadb:10.11.xx" # 10.11 LTSTout est figé exactement – jamais latest, jamais un tag flottant. Ce n’est pas du pinaillage : K3s ne télécharge une image que si elle n’est pas encore sur le nœud, donc un tag flottant reste discrètement sur la version qui se trouve en cache. Avec une version exacte, « quelle version tourne où » a une seule réponse, et une mise à jour est une modification d’une ligne qui apparaît dans git log. Un bot (Renovate) surveille les publications des projets et propose ces modifications d’une ligne sous forme de pull requests ; je les relis et les déploie avec le playbook.
Ce qui vaut pour un serveur : host_vars/<host>/
Chaque serveur a un répertoire avec deux fichiers. vars.yml contient les valeurs ordinaires par hôte : adresses, noms d’hôte, dimensionnement (les deux serveurs Nextcloud ont un matériel très différent), interrupteurs comme le filtre SSH par pays. vault.yml contient les secrets – mots de passe de base de données, comptes d’administration, identifiants mail – et est chiffré avec ansible-vault. Ansible le déchiffre en mémoire pendant l’exécution ; sur le disque et dans chaque sauvegarde, c’est du texte chiffré.
Aucun des deux fichiers n’est dans le dépôt public. Ce qui est public, ce sont les fichiers .example à côté : la même structure avec des valeurs fictives, pour que chacun voie de quelles clés un hôte a besoin sans voir les miennes. L’exemple de vault ressemble à ceci, sans les commentaires :
email: ...
mysql_root_password: ...
nextcloud_admin_user: ...
nextcloud_admin_pass: ...
blog_db_pass: ...
blog_admin_pass: ...Qui l’emporte : l’ordre de priorité
La même variable peut être définie à plusieurs endroits. Ansible tranche avec un ordre de priorité fixe ; pour ce dépôt, quatre niveaux comptent, du plus faible au plus fort :
- Role defaults (
roles/<role>/defaults/main.yml) – le point de départ raisonnable du rôle. - group_vars – ce que tous les hôtes partagent.
- host_vars – ce dont un hôte a besoin différemment.
- Facts définis pendant l’exécution (
set_fact) – des valeurs que le rôle calcule lui-même, par exemple après avoir interrogé le serveur.
Un exemple concret : le filtre SSH par pays. Sa valeur par défaut dans common_firewall est une liste vide – désactivé. Un serveur qui ne doit accepter SSH que depuis l’Allemagne définit fw_ssh_geo_countries: [DE] dans son vars.yml, et cela l’emporte. Le rôle lui-même ne change jamais ; l’interrupteur est dans l’inventaire. C’est le schéma derrière la plupart des différences entre hôtes dans ce dépôt.
Le rôle vérifie ses entrées : argument_specs
Une valeur manquante dans l’inventaire se manifestait autrefois à la dure : en pleine exécution, sous la forme d’une erreur de template obscure. Aujourd’hui, 23 rôles déclarent leurs entrées dans meta/argument_specs.yml – nom, type, obligatoire ou non, et s’il s’agit d’un secret. Ansible les vérifie avant la première tâche du rôle. Un server_ipv4 oublié arrête désormais l’exécution au début du rôle de pare-feu avec « required option missing », et non après que le jeu de règles a été à moitié écrit, et une valeur marquée no_log n’apparaît jamais dans la sortie.
🔀 Un rôle, deux stacks : trois schémas
C’est le cœur des rôles partagés. Un rôle common_* tourne sur le blog et sur Nextcloud, mais les deux ont besoin de choses légèrement différentes : Nextcloud a MariaDB et Redis sur l’hôte, le blog les a dans des conteneurs ; le blog analyse les fichiers envoyés, Nextcloud les données des utilisateurs. Trois schémas gèrent cela, et une fois qu’on les connaît, on les retrouve partout.
1. Interroger le groupe
Chaque hôte connaît les groupes auxquels il appartient (group_names). Un template ou une tâche peut simplement le demander. Le jeu de règles du pare-feu, par exemple, n’ouvre les ports de base de données de l’hôte au réseau des pods que sur les serveurs Nextcloud, car c’est seulement là que les pods doivent atteindre une base de données sur l’hôte :
{% if 'nextcloud' in group_names %}
# Allow K3s pods to reach MariaDB on the host
ip saddr {{ k3s_pod_cidr }} tcp dport 3306 accept
# Allow K3s pods to reach Redis on the host
ip saddr {{ k3s_pod_cidr }} tcp dport 6379 accept
{% endif %}Sur le blog, ces lignes n’existent tout simplement pas dans le fichier généré. Même rôle, même template, deux résultats différents – décidés par l’inventaire.
2. Passer un profil depuis le playbook
Certains rôles font le même type de travail sur les deux stacks, seulement avec d’autres chemins. Ils reçoivent un nom de profil comme paramètre de rôle, directement dans le playbook :
# blog.yml
- { role: common_selinux, selinux_target: wordpress }
- { role: common_clamav, clamav_target: wordpress }
# nextcloud-k3s.yml
- { role: common_selinux, selinux_target: nextcloud }
- { role: common_clamav, clamav_target: nextcloud }Dans le rôle, le profil sélectionne un bloc de réglages dans ses valeurs par défaut – pour SELinux, les répertoires montés dans les pods qui doivent porter l’étiquette des conteneurs :
selinux_profiles:
wordpress:
- { label: WordPress www dir, path: "{{ blog_k3s_www_dir }}", owner: 33, group: 33 }
- { label: MariaDB data dir, path: "{{ blog_k3s_db_dir }}", owner: 999, group: 999 }
nextcloud:
- { label: Nextcloud www dir, path: "{{ nextcloud_k3s_www_dir }}", owner: 33, group: 33 }
- { label: Nextcloud data, path: "{{ nextcloud_data_dir }}", owner: 33, group: 33 }Les tâches du rôle bouclent alors sur « les répertoires de mon profil » sans se soucier de la stack concernée. Ajouter un troisième type d’application, ce serait ajouter un profil, pas un rôle.
3. Détecter plutôt que déclarer
Le troisième schéma se passe complètement de variable : le rôle interroge le serveur. Le pare-feu doit savoir si le serveur obtient son adresse par DHCP – si c’est le cas et que les règles DHCP manquent, l’adresse est perdue au prochain renouvellement du bail. Ce pourrait être un interrupteur dans l’inventaire, mais un interrupteur peut être faux ou devenir obsolète quand un hébergeur change quelque chose. Le rôle interroge donc NetworkManager au début de chaque exécution :
- name: Firewall | Detect whether IPv4/IPv6 are configured by DHCP
ansible.builtin.shell: |
dev=$(ip -4 route show default | awk '{print $5; exit}')
con=$(nmcli -g GENERAL.CONNECTION device show "$dev")
nmcli -g ipv4.method,ipv6.method connection show "$con"
register: _fw_ipmethods
changed_when: false
check_mode: false
- name: Firewall | Remember the address configuration for the template
ansible.builtin.set_fact:
fw_dhcp4: "{{ ... == 'auto' }}"La réponse va dans un fact, et le template ne génère les règles DHCP que là où elles sont nécessaires. Sur mes serveurs, le résultat diffère – un hôte obtient son IPv4 par DHCP, les autres sont entièrement statiques –, et personne n’a besoin de se rappeler lequel est lequel. La règle générale de ce dépôt : ce que le serveur peut nous dire, nous le lui demandons ; l’inventaire sert aux décisions, pas aux faits.
📜 Les quatre playbooks
Il y a deux paires : un playbook complet et un playbook de mise à jour par application.
| Playbook | Ce qu’il fait | Quand je le lance |
|---|---|---|
blog.yml / nextcloud-k3s.yml | Le serveur entier, des paquets jusqu’aux réglages de l’application – chaque rôle, dans l’ordre. Sans risque à tout moment : sur un serveur correct, il ne change rien. | Après une modification dans le dépôt, une montée de version dans versions.yml, ou simplement pour vérifier. |
blog-update.yml / nextcloud-update.yml | Seulement l’application : images de conteneurs reconstruites, mises à jour des extensions et des apps, mise à niveau de la base de données de Nextcloud. Ne touche jamais l’hôte (SSH, pare-feu, paquets). | Quand des mises à jour sont disponibles – le travail de routine. |
Le playbook complet : l’ordre compte
Un playbook complet est une liste d’environ 25 rôles. L’ordre n’est pas alphabétique, c’est une chaîne de dépendances. Voici celui du blog, raccourci :
roles:
- common_version_check # report: what is installed, pinned, available?
- blog_packages # repositories, system update, base packages
- common_ssh # SSH on its new port, key-only
- common_firewall # nftables
- common_sysctl # kernel parameters
- common_cis_hardening # CIS benchmark items
- common_selinux # label the data dirs <- before the deploy!
- common_k3s # K3s, Helm, cert-manager, ingress
- common_calico # NetworkPolicy enforcement
- blog_k3s_deploy # all Kubernetes objects of WordPress
# host-side services
- common_node_exporter
- common_prometheus
- common_grafana
- common_fail2ban
- ... # mail, updates, scanners, audit, watchdog
- blog_wordpress # configure WordPress inside the running pod
- blog_verify # smoke tests
- common_aide_refresh # always lastCertaines de ces positions ont une raison qui mérite d’être connue :
- SELinux avant le déploiement. Les pods montent des répertoires de l’hôte. S’ils démarrent avant que ces répertoires portent l’étiquette des conteneurs, SELinux bloque chaque écriture et MariaDB refuse de démarrer.
- Supervision et protection après le déploiement. Ce sont des services de l’hôte, mais plusieurs d’entre eux observent ce que le déploiement vient de créer : Prometheus collecte les exporters des pods applicatifs, fail2ban lit les journaux de l’ingress, Monit vérifie que les pods tournent.
- La configuration de WordPress vers la fin.
blog_wordpressparle à WordPress dans son pod, il lui faut donc un pod en marche. Le rôle de déploiement s’en assure : il attend que MariaDB, Redis et WordPress soient prêts avant de se terminer. - AIDE toujours en dernier. AIDE compare chaque nuit les fichiers système à une référence. Une exécution du playbook modifie volontairement des fichiers surveillés, donc le tout dernier rôle renouvelle la référence – si, et seulement si, cette exécution a changé quelque chose. Sinon, la nuit suivante signalerait mes propres modifications comme une intrusion.
Les tags : un rôle au lieu de tous
Chaque rôle du playbook porte un tag à son propre nom. Cela transforme un playbook complet en outil de précision :
# only the host package updates (e.g. a new Grafana package)
ansible-playbook blog.yml --limit <host> --tags blog_packages
# only the Kubernetes objects (e.g. after an image version bump)
ansible-playbook blog.yml --limit <host> --tags blog_k3s_deployC’est aussi pourquoi les playbooks de mise à jour n’installent pas de paquets sur l’hôte : ils sont volontairement limités à l’application. Un nouveau paquet Grafana arrive avec la prochaine exécution complète, ou immédiatement avec --tags blog_packages – et les mises à jour de sécurité n’attendent ni l’un ni l’autre, dnf-automatic les installe chaque nuit (partie 2).
Les playbooks de mise à jour : d’abord les garde-fous
Les playbooks de mise à jour redémarrent l’application, ils sont donc écrits de manière défensive. Celui de Nextcloud, dans l’ordre :
- Refuser de tourner sans
--limit. Sinon,hosts: nextcloudmettrait à jour toutes les instances à la fois. Les mises à jour sont délibérées, un hôte après l’autre. - Refuser de mettre à jour une instance mal en point. Si Nextcloud est déjà en mode maintenance ou attend une mise à niveau de la base, une mise à jour enterrerait le problème d’origine. Ce garde-fou a sa propre histoire.
- Faire le travail dans
block/rescue. Trouver les images reconstruites en amont, redémarrer seulement ces déploiements, lancer la mise à niveau de la base, mettre à jour les apps. Si une étape échoue, la partierescueaffiche l’état, les pods, les événements et les dernières lignes du journal, ne quitte le mode maintenance que là où c’est sûr, et s’arrête – un chemin d’échec défini au lieu d’une instance à moitié mise à jour.
La version du blog suit la même idée avec les particularités de WordPress : elle lance immédiatement la tâche de mise à jour nocturne, avec un dump de la base au préalable, et ne redémarre que ce qui a réellement changé. La stratégie de mise à jour par couches qui se trouve derrière est décrite dans un article plus ancien ; la partie 3 montre le code actuel.
⚙️ Ce qui se passe pendant une exécution
Passons à la mécanique : ce que fait Ansible entre l’appui sur Entrée et le récapitulatif final. La comprendre explique presque chaque choix de conception dans les rôles.
Se connecter et collecter les facts
Ansible se connecte en SSH en tant que root, avec une clé – SSH n’accepte rien d’autre. Les connexions sont réutilisées d’une tâche à l’autre, les scripts passent par la connexion existante au lieu d’être d’abord copiés, et Ansible ne demande pas de terminal. Ce dernier détail compte pour l’alarme de connexion SSH : sans terminal, une exécution du playbook ne laisse aucune trace dans les journaux de connexion, et l’alarme ne se déclenche que pour de vraies sessions interactives.
Ensuite, Ansible collecte des facts sur le serveur – mais seulement le jeu minimal. Les rôles en utilisent une poignée (distribution, noyau, nom d’hôte, date) ; le jeu complet coûterait du temps pour des interfaces et des détails matériels que personne ne lit.
Un rapport avant tout changement
Le premier rôle, common_version_check, ne change rien. Pour K3s, Helm, l’ingress et cert-manager, il compare trois choses : ce qui est installé sur le serveur, ce que versions.yml fixe, et la dernière version publiée sur GitHub. Le résultat est un petit tableau en couleur – avant chaque exécution, je vois si une version fixée est dépassée ou si le serveur est en retard sur le dépôt. L’intérêt est dans le fonctionnement : deux étapes set_fact, la première ramène tous les formats de version à un seul, la seconde les compare. Collecter, préparer, juger – le même schéma en trois étapes revient à la fin dans les rôles de vérification.
Tâche par tâche, hôte par hôte
Un play s’exécute tâche par tâche : la tâche 1 sur chaque hôte du play, puis la tâche 2 sur chaque hôte, et ainsi de suite. Quand je lance le playbook Nextcloud sans --limit, les deux instances sont configurées en parallèle, étape par étape. Chaque tâche rapporte l’un de trois états, et tout le dépôt est construit pour que ce rapport soit honnête :
| État | Signification | Ce que cela exige du code |
|---|---|---|
| ok | Déjà correct, rien n’a été fait. | La tâche doit reconnaître l’état correct. Des modules comme copy ou template le font eux-mêmes (ils comparent des sommes de contrôle). |
| changed | Quelque chose a été modifié. | Pour command et shell, Ansible ne peut pas le savoir – le rôle doit le dire, avec changed_when. Une simple lecture est changed_when: false. |
| failed | Quelque chose s’est mal passé ; l’hôte s’arrête. | Les codes de sortie non nuls attendus doivent être déclarés avec failed_when, sinon un « introuvable » inoffensif interrompt l’exécution. |
L’objectif s’appelle idempotence : une deuxième exécution juste après la première rapporte changed=0. Cela ressemble à un détail cosmétique, mais c’est ce qui fait du playbook un outil de contrôle. Si un serveur correct rapporte zéro changement, chaque « changed » dans une exécution ultérieure est un vrai écart – quelqu’un a modifié un fichier à la main, un paquet a changé une valeur par défaut, une valeur a dérivé. La passe qualité consistait surtout à en arriver là.
Une forme très courante dans les rôles ressemble donc à ceci (simplifiée) – lire l’état, ne modifier qu’en cas d’écart :
- name: Read the current value
ansible.builtin.command: occ config:system:get trusted_domains
register: current
changed_when: false # reading changes nothing
failed_when: false # "not set yet" is not an error
check_mode: false # also run in --check, it is read-only
- name: Set the value only if it differs
ansible.builtin.command: occ config:system:set ...
when: current.stdout != wanted
changed_when: true # if it runs, it changed somethingLes handlers : une fois, à la fin, seulement si nécessaire
Beaucoup de changements demandent une action de suivi : un nouveau sshd_config demande un redémarrage de sshd, un nouveau jeu de règles du pare-feu un rechargement. Le faire dans la tâche elle-même redémarrerait sshd cinq fois si cinq fichiers changent. La réponse d’Ansible est le handler : une tâche notifie un handler par son nom, et le handler s’exécute une fois, après toutes les tâches du play – et seulement si au moins une tâche notifiante a réellement rapporté « changed ».
# task
- name: Firewall | Upload nftables ruleset
ansible.builtin.template:
src: fw.nft.j2
dest: /etc/sysconfig/nftables.conf
validate: nft -c -f %s
notify: Reload nftables
# handlers/main.yml
- name: Reload nftables
ansible.builtin.service:
name: nftables
state: reloaded
- name: Re-apply fail2ban bans after the nftables reload
ansible.builtin.command: systemctl try-restart fail2ban
listen: Reload nftablesDeux détails de cet exemple se retrouvent dans tout le dépôt. listen permet à un second handler de s’abonner à la même notification : un rechargement du pare-feu vide les listes de bannissement, fail2ban doit donc y remettre ses bannissements – et les handlers s’exécutent dans l’ordre où ils sont écrits, cela arrive donc après le rechargement. Et quand une action de suivi ne peut pas attendre la fin – Redis doit tourner avec son nouveau mot de passe avant que le rôle suivant s’y connecte –, le rôle appelle meta: flush_handlers, qui exécute tous les handlers en attente à cet endroit précis.
L’exécution à blanc : –check –diff
Chaque playbook peut tourner en mode check : Ansible parcourt toutes les tâches, mais ne change rien et montre les différences qu’il ferait. Pour que ce soit utile, les rôles veillent à deux choses. Les simples lectures s’exécutent quand même (check_mode: false), sinon les tâches suivantes compareraient avec du vide. Et les étapes qui dépendent de quelque chose qui n’a pas réellement eu lieu – décompresser une archive qui n’a pas réellement été téléchargée – sont ignorées en mode check au lieu d’échouer.
Au-dessus du mode check se trouve un petit script, scripts/check-drift.sh. Il lance un playbook avec --check --diff et n’affiche que les tâches qui changeraient quelque chose, avec leur diff. Le code de sortie 0 signifie : le serveur est exactement ce que décrit le code. Je le lance avant chaque exécution réelle, et juste après une exécution réelle, il ne doit rien signaler – tout ce qu’il liste alors est une tâche qui n’est pas encore idempotente.
scripts/check-drift.sh nextcloud-k3s.yml <host>
scripts/check-drift.sh blog.yml <host> --tags blog_k3s_deployLe verdict : les rôles de vérification
Une exécution qui se termine par « ok » signifie seulement que chaque tâche a fait ce qu’on lui a demandé. Cela ne signifie pas que le site fonctionne. Chaque playbook se termine donc par un rôle de vérification – blog_verify et nextcloud_verify, tous deux construits sur des contrôles communs dans common_verify. Ils testent de l’extérieur vers l’intérieur : services en marche, pods prêts, HTTPS qui répond avec un certificat valide, en-têtes de sécurité présents, chemins bloqués réellement bloqués, base de données et cache joignables.
Ils suivent volontairement le schéma collecter–préparer–juger de la vérification des versions. Tous les contrôles s’exécutent d’abord et ne font qu’enregistrer leur résultat (failed_when: false). Puis set_fact transforme les résultats bruts en vrai/faux. Puis le rôle affiche un rapport et l’envoie par mail. Ce n’est qu’à la toute fin qu’un assert fait échouer l’exécution – si un contrôle critique est rouge. L’avantage par rapport à un arrêt au premier problème : une exécution montre tous les problèmes d’un coup, pas un par tentative.
🧪 Les filets de sécurité autour du code
Les serveurs sont en production – les fichiers d’autres personnes, un blog avec des lecteurs. Un changement doit donc franchir plusieurs filets avant de les atteindre, du rapide et local au lent et réel :
| Filet | Ce qu’il attrape | Quand |
|---|---|---|
| Hooks pre-commit | Des secrets dans un commit (gitleaks), des erreurs YAML, des remarques d’ansible-lint, des restes de conflits de fusion | Avant chaque commit, sur ma machine |
| CI sur GitHub | Le même lint plus une recherche de secrets dans tout l’historique – aussi pour un commit fait sans les hooks | À chaque push |
Test d’application K8s (tests/k8s) | Des rôles Kubernetes non idempotents, un redémarrage de pod que personne n’a demandé, une montée de version que --check ne remarquerait pas | Avant des modifications des rôles Kubernetes – tourne contre un serveur d’API K3s jetable dans podman, sans vrai serveur |
Test de scénarios de mise à jour (tests/update) | Les playbooks de mise à jour contre des serveurs simulés : instance saine, image reconstruite, échec à chaque étape – le rescue laisse-t-il un état propre ? | Avant des modifications des playbooks de mise à jour |
| Contrôle de dérive | Tout écart entre le code et le serveur | Avant et après chaque exécution réelle |
| Renovate | Les nouvelles versions de tout ce qui figure dans versions.yml | En continu ; arrive sous forme de pull request |
Aucun de ces filets n’a besoin que quelqu’un se souvienne de quoi que ce soit – c’est tout l’intérêt. La seule règle que j’applique à la main est la dernière : les exécutions réelles sur les serveurs, c’est moi qui les lance, un hôte après l’autre, après une exécution à blanc concluante.
🛠️ En exploitation : comment un changement arrive sur un serveur
Voici la routine pour chaque changement, de la montée de version d’une ligne au nouveau rôle. Ce sont chaque fois les mêmes sept étapes, et l’ordre est l’essentiel :
- Modifier le rôle, le template ou
versions.yml. - Lint – tourne de toute façon au commit, mais voir les remarques maintenant va plus vite.
- Tester sans serveur là où un test existe :
tests/k8s/run.shpour les rôles Kubernetes,tests/update/run.shpour les playbooks de mise à jour. - Exécution à blanc :
scripts/check-drift.sh <playbook> <host>. Lisez la liste des changements et demandez-vous : est-ce exactement ce que je voulais ? Tout le reste de la liste est une surprise à comprendre d’abord. - Exécution réelle sur un serveur, limitée avec
--tagsà ce que touche le changement. - Nouveau contrôle de dérive. Il doit maintenant dire « no drift ». Ce qui reste listé est une tâche non idempotente – corrigez-la avant qu’elle ne masque un vrai changement à la prochaine exécution.
- Commit et push. Les hooks vérifient encore le lint et les secrets, la CI tout l’historique.
Deux habitudes complètent le tout. Les secrets ne se modifient qu’avec ansible-vault edit inventory/host_vars/<host>/vault.yml – la commande déchiffre dans un fichier temporaire pour l’éditeur, chiffre de nouveau à l’enregistrement et supprime la copie temporaire. Ce qu’il ne faut pas faire : déchiffrer le fichier sur place avec ansible-vault decrypt et l’oublier – chaque mot de passe se trouve alors en clair sur le disque et finit dans chaque sauvegarde de cette machine. (Dans ce dépôt, les vrais fichiers vault sont en plus tenus hors de git par .gitignore, et la recherche de secrets dans les hooks est un deuxième filet.) Et une mise à jour de version commence par les notes de version du composant, pas par le playbook : la pull request du bot dit qu’il existe une nouvelle version, pas si l’on peut la prendre sans risque.
📚 Le vocabulaire au même endroit
Les parties suivantes utilisent ces termes sans les expliquer à nouveau :
| Terme | En bref |
|---|---|
| rôle | Une unité de travail autonome avec une structure fixe (tâches, handlers, templates, valeurs par défaut). |
| inventaire / groupe / host_vars | Quels serveurs existent, dans quel groupe ils sont, quelles valeurs s’appliquent à tous ou à un seul. |
fact / set_fact | Une valeur qu’Ansible connaît sur un hôte – collectée au début, ou calculée par un rôle pendant l’exécution. |
register | Enregistrer le résultat d’une tâche (sortie, code de sortie, « changed ») pour les tâches suivantes. |
changed_when / failed_when | Dire à Ansible quand une tâche a vraiment changé quelque chose ou a vraiment échoué. |
handler / notify / listen | Des actions de suivi qui s’exécutent une fois, à la fin, seulement après un changement. |
flush_handlers | Exécuter les handlers en attente tout de suite plutôt qu’à la fin. |
| idempotent | Une deuxième exécution ne change rien ; chaque « changed » ultérieur est une vraie dérive. |
| mode check | --check --diff : montrer ce qui changerait, ne rien changer. |
| tag | N’exécuter que certains rôles d’un playbook. |
| profil | Un paramètre de rôle qui sélectionne un bloc de réglages par stack. |
| vault | Fichier de variables chiffré pour les secrets. |
| argument_specs | Les entrées déclarées d’un rôle, vérifiées avant son démarrage. |
🔎 Où voir quelle fonctionnalité d’Ansible
Les parties suivantes expliquent chaque rôle en entier – pas seulement ses tâches, mais aussi ses handlers, les entrées qu’il déclare dans meta/, les interrupteurs de ses valeurs par défaut, la logique de ses templates et les scripts qu’il fournit. Certaines fonctionnalités d’Ansible apparaissent dans de nombreux rôles, mais chacune a un endroit où elle est la plus facile à comprendre. Pour apprendre une fonctionnalité précise, commencez là :
| Fonctionnalité | À quoi elle sert | Le mieux visible dans |
|---|---|---|
handlers, notify, listen | Des actions de suivi une seule fois, à la fin, dans un ordre fixe | common_firewall (partie 5) – rechargement, puis fail2ban réapplique ses bannissements |
meta: flush_handlers | Exécuter les handlers en attente maintenant, pas à la fin | next_redis (partie 4), common_k3s (partie 2) |
meta/argument_specs.yml | Les entrées déclarées et vérifiées d’un rôle | chaque rôle ; expliqué en détail avec common_firewall (partie 5) |
defaults/ + surcharge dans l’inventaire | Un interrupteur désactivé dans le rôle et activé par hôte | le filtre SSH par pays, common_firewall (partie 5) |
| paramètres de rôle / profils | Un rôle, des réglages différents par stack | common_selinux, common_clamav (partie 5) |
template + validate | N’écrire un fichier de configuration que s’il passe un contrôle de syntaxe | common_firewall (nft -c), common_cis_hardening (visudo -cf, partie 5) |
Jinja {% if %} / group_names | Un template, des fichiers différents par hôte | fw.nft.j2 (partie 5) |
register → when | Lire l’état, ne modifier que ce qui diffère | next_config (partie 4), blog_wordpress (partie 3) |
Chaînes de set_fact | Collecter, préparer, juger | common_version_check, les rôles de vérification (cet article), parties 3 et 4 |
changed_when / failed_when | Un état honnête pour command et shell | partout ; les cas les plus délicats dans common_aide_refresh (partie 5) |
check_mode: false / ansible_check_mode | Rendre l’exécution à blanc significative | les exporters et common_k3s (parties 2 et 6) |
block / rescue | Un chemin d’échec défini | les playbooks de mise à jour (parties 3 et 4) |
assert, run_once | Refuser de démarrer dans un mauvais état | les playbooks de mise à jour, next_redis (partie 4) |
import_tasks vs. include_tasks | Découper un rôle en fichiers – de façon statique ou à l’exécution | next_k3s_deploy (partie 4) vs. le redémarrage de la première exécution dans common_firewall (partie 5) |
async / poll | Lancer quelque chose qui coupe sa propre connexion | common_ssh qui redémarre sshd (partie 5) |
until / retries | Attendre un service au lieu de dormir | common_k3s (partie 2), common_grafana (partie 6) |
loop avec zip / selectattr | Combiner et filtrer des listes | common_monit (partie 6) |
lookup('template') + from_yaml_all + kubernetes.core.k8s | Le schéma d’application K8s | common_calico (partie 2), les rôles de déploiement (parties 3 et 4) |
kubernetes.core.helm | Des releases Helm avec un vrai diff | common_k3s (partie 2) |
uri | Parler à une API HTTP, regarder avant de créer | common_grafana (partie 6) |
script | Exécuter un script local sur l’hôte, exploiter sa sortie JSON | common_image_refresh (parties 3 et 4) |
creates: | N’exécuter une commande qu’une fois | common_clamav (partie 5), blog_visitor_stats (partie 3) |
no_log | Tenir les secrets hors de la sortie | common_msmtp (partie 2), next_mariadb (partie 4) |
🧭 La suite de la visite
La partie 2 – la plateforme commence tout en bas : comment un AlmaLinux tout neuf devient un système de base, comment K3s, Helm, cert-manager et l’ingress sont installés avec des versions exactes, et pourquoi Calico tourne à côté de Flannel sans faire lui-même de réseau. Les parties 3 et 4 posent ensuite les applications par-dessus – WordPress et Nextcloud, chacune dans son propre article. La partie 5 réunit chaque rôle de sécurité au même endroit, de SSH aux analyses nocturnes, comme complément détaillé de Defense in Depth. La partie 6 fait de même pour la supervision.
Chacune suit la même grille pour chaque rôle : ce qui se passe, dans quel ordre – pourquoi c’est construit ainsi – et ce qui a mal tourné en chemin. Ce dernier point est souvent le plus utile. La plupart des commentaires de ce dépôt commencent par une date et les mots « found on » (trouvé le).




