
🌐 Aussi en: English · Deutsch · Español
Partie 2 de la visite Ansible. La partie 1 a expliqué comment le dépôt est organisé et ce qui se passe lors d’une exécution ; cette partie commence tout en bas de la pile. Comme toute la série, elle décrit la version v3.2.0, et chaque lien vers un fichier pointe vers elle.
Chaque serveur de cette installation commence comme une simple image cloud AlmaLinux 9 : un Linux minimal, un accès root, rien d’autre. À la fin de cette partie, c’est un système de base patché et réglé, qui sait envoyer des mails, se met à jour tout seul la nuit, fait tourner ses journaux – et exécute un cluster Kubernetes à un seul nœud avec Helm, des certificats Let’s Encrypt automatiques, un contrôleur Ingress et un moteur de règles. C’est la plateforme : tout ce dont le blog et Nextcloud ont besoin en dessous, et presque tout est commun aux deux.
La partie couvre huit rôles. Pour chacun, je parcours pas à pas ce qu’il fait, quelles fonctionnalités d’Ansible il utilise et pourquoi, et ce qui a mal tourné sur le chemin vers la version actuelle – handlers, entrées déclarées dans meta/ et logique des templates compris. Quand il faut un concept Linux ou Kubernetes pour comprendre le code, je l’explique là où il apparaît pour la première fois.
| Rôle | En une phrase | Utilisé par |
|---|---|---|
blog_packages / next_packages | Nom d’hôte, dépôts, mise à jour du système, redémarrage si nécessaire, paquets de base | un par pile |
common_sysctl | Paramètres du noyau pour le réseau et le durcissement | les deux |
common_msmtp | Permet au serveur d’envoyer des mails via un compte SMTP externe | les deux |
common_dnf_automatic | Mises à jour de sécurité chaque nuit, redémarrage si nécessaire | les deux |
common_logrotate | Règles de rotation pour les journaux qui n’en avaient pas | les deux |
common_k3s | K3s, Helm, cert-manager, le contrôleur Ingress | les deux |
common_calico | Fait en sorte que les NetworkPolicies Kubernetes s’appliquent vraiment | les deux |
La place de ces rôles dans le playbook compte, alors voici l’ordre pour mémoire : le rôle des paquets s’exécute juste après le rapport de versions, puis SSH, le pare-feu et common_sysctl (les trois rôles de sécurité sont dans la partie 5), puis le durcissement et SELinux, puis common_k3s et common_calico, puis l’application. common_msmtp, common_dnf_automatic et common_logrotate s’exécutent plus tard, une fois l’application déployée.
🖥️ Avant tout rôle : le serveur lui-même
Ansible a besoin de quelque chose à qui parler : un serveur Linux avec une adresse publique et SSH. Les trois serveurs ici sont des serveurs privés virtuels (VPS, aussi appelés vServer) – des machines virtuelles sur le matériel d’un hébergeur, louées au mois. On obtient une part fixe de cœurs CPU, de mémoire et de disque, une adresse IPv4 et IPv6 publique, un accès root et une console web pour les urgences. On n’obtient personne pour s’occuper du système : mises à jour, sécurité, sauvegardes et surveillance sont votre affaire. C’est exactement le travail de ce dépôt.
Choisir un hébergeur
Le marché est vaste et les offres se ressemblent ; il vaut donc la peine de comparer sur les points qui comptent pour faire tourner un service, pas seulement sur le prix :
| Point | À quoi faire attention |
|---|---|
| Ressources | Nombre de vCPU, RAM, taille et type de disque (SSD/NVMe). Vérifiez si les cœurs sont dédiés ou partagés – les cœurs partagés sont moins chers et suffisent pour un blog, mais peuvent ralentir quand un voisin génère beaucoup de charge. |
| Emplacement et droit | Où se trouve le centre de données et sous quel droit l’hébergeur opère. Pour des données personnelles d’utilisateurs dans l’UE, un centre de données dans l’UE et un contrat de sous-traitance (RGPD art. 28) sont le minimum. |
| Réseau | Une adresse IPv4 fixe et une IPv6 (ou un réseau), le trafic inclus, et si l’hébergeur filtre les attaques (DDoS) en amont. |
| Accès console | Une console web qui fonctionne même quand SSH ne fonctionne pas. Avec SSH et le pare-feu durcis, c’est le chemin du retour après une erreur – testez-la avant d’en avoir besoin. |
| Snapshots | Peut-on prendre une image de tout le disque avant une modification risquée ? Ce n’est pas une sauvegarde (elle reste chez l’hébergeur), mais c’est le retour arrière le plus rapide. |
| Images | L’hébergeur propose-t-il une image propre de la distribution voulue, idéalement minimale ? |
| DNS et DNS inverse | Il vous faut des enregistrements DNS pour votre domaine (quelque part), et un DNS inverse correspondant pour l’adresse du serveur aide quand il envoie des mails. |
| Contrat | Résiliation mensuelle, fonctionnement des évolutions (plus de RAM sans réinstallation ?) et le support qu’on obtient en pratique. |
Les trois serveurs ici sont chez deux hébergeurs différents, tous avec de la virtualisation KVM. Cette répartition est un petit filet de sécurité : une panne ou un problème de compte chez un hébergeur ne met pas tout à terre.
Choisir la distribution
Les rôles sont écrits pour AlmaLinux 9 et pour rien d’autre – noms de paquets, chemins, SELinux et dnf sont supposés partout. Comment je suis passé de Debian à AlmaLinux est une histoire à part ; les raisons qui comptent pour l’exploitation sont :
- Un support long. AlmaLinux est compatible binaire avec Red Hat Enterprise Linux et suit son cycle de versions : chaque version majeure reçoit des mises à jour de sécurité pendant une dizaine d’années. Un serveur installé aujourd’hui n’a pas besoin de grosse mise à niveau pendant des années.
- Les mises à jour de sécurité sont étiquetées. Chaque mise à jour a un avis avec un type. C’est exactement ce qui permet à
dnf-automatic(plus bas) d’installer automatiquement les seuls correctifs de sécurité et de laisser le reste à une exécution planifiée. - SELinux en mode « enforcing » par défaut, avec une politique pour conteneurs que K3s prend en charge. La partie 5 montre pourquoi c’est important.
- Un vaste écosystème. La documentation, le CIS Benchmark et la plupart des outils existent d’abord pour cette famille.
Un serveur Debian ou Ubuntu fonctionnerait tout aussi bien avec d’autres rôles – mais pas avec ceux-ci.
Quelle taille ?
La taille est d’abord une estimation, puis une mesure. Voici les trois serveurs, avec ce qu’ils utilisent réellement un jour calme :
| Serveur | vCPU | RAM | Disque | RAM utilisée (au repos) |
|---|---|---|---|---|
| Blog | 4 | 8 Go | 240 Go | environ 2,5 Go |
| Petit Nextcloud (quelques utilisateurs) | 6 | 12 Go | 200 Go | environ 3 Go |
| Grand Nextcloud (environ 150 utilisateurs) | 8 | 24 Go | 600 Go | environ 4 Go |
Où va la mémoire, mesuré sur les trois : la plateforme de cette partie – K3s lui-même, l’environnement d’exécution des conteneurs, les pods système du cluster, cert-manager et l’Ingress – prend environ 1,3 à 1,5 Go. Prometheus et Grafana ajoutent environ 0,5 Go. Le reste, c’est l’application et le cache de fichiers du système, que Linux remplit avec toute la mémoire libre (c’est une bonne chose, pas un problème).
D’où une règle empirique approximative :
- Un blog comme celui-ci demande au moins 2 vCPU et 4 Go de RAM ; 4 Go, c’est juste avec la supervision, 8 Go, c’est confortable. Disque : quelques gigaoctets pour WordPress, plus les médias, plus de la place pour les sauvegardes de la base de données.
- Nextcloud avec la suite bureautique en ligne en demande plus : chaque document ouvert dans la suite coûte 150 à 200 Mo, et les aperçus, la base de données et PHP veulent tous de la mémoire. 4 vCPU et 8 Go sont le minimum pour un petit groupe, davantage pour beaucoup d’utilisateurs simultanés. Le disque dépend entièrement des données des utilisateurs – prévoyez la croissance et surveillez-la (la partie 6 montre les alertes à 80 et 90 pour cent).
- Pas de swap. Aucun des serveurs n’a de swap. Kubernetes s’attend traditionnellement à ce qu’il soit désactivé, et un serveur qui commence à swapper est déjà trop petit – mieux vaut voir l’alerte mémoire et agrandir.
- Agrandir plutôt que deviner large. La plupart des hébergeurs permettent d’ajouter cœurs et mémoire plus tard. Commencez une taille en dessous, mesurez avec la supervision de la partie 6 et grandissez quand les chiffres le disent.
Ce dont le serveur a besoin avant la première exécution
Très peu, et c’est tout l’intérêt : une image AlmaLinux 9 minimale toute fraîche, un accès root par clé SSH sur le port 22, les adresses et le nom d’hôte du serveur dans l’inventaire, et des enregistrements DNS qui font pointer le domaine vers le serveur (cert-manager ne peut obtenir un certificat que pour un nom qui résout vers lui). La première exécution déplace ensuite SSH sur son nouveau port, active le pare-feu et redémarre une fois – la partie 5 explique comment cela fonctionne sans enfermer Ansible dehors.
📦 blog_packages et next_packages : de l’image cloud au système de base
Ces deux rôles sont la seule paire du dépôt qui fait le même travail en deux variantes. Le blog exécute tout dans des conteneurs, son hôte a donc besoin de très peu. Un hôte Nextcloud exécute en plus MariaDB et Redis directement sur l’hôte (la partie 4 explique pourquoi) et a donc besoin de deux dépôts et de deux services de plus. Tout le reste est identique ; je décris les deux ensemble en signalant les différences.
Étape 1 : un vrai nom d’hôte
- name: Packages | Set system hostname to the public FQDN
ansible.builtin.hostname:
name: "{{ blog_hostname }}"
- name: Packages | Ensure /etc/hosts has a matching entry
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1'
line: "127.0.1.1 {{ blog_hostname }}"Une image cloud toute neuve s’appelle localhost. Ça paraît anodin jusqu’à ce que plusieurs serveurs envoient des mails : chaque rapport de rkhunter, de ClamAV ou de la tâche de mise à jour nocturne met $(hostname -f) dans son objet, et cinq mails de « localhost » ne servent à rien. La toute première tâche définit donc le nom d’hôte public depuis l’inventaire. Le module hostname fait ce que fait hostnamectl – il change le nom courant et le rend permanent.
La deuxième tâche montre un module que vous croiserez souvent : lineinfile. Il gère une ligne dans un fichier. regexp dit de quelle ligne il s’occupe (ici : la ligne qui commence par 127.0.1.1), line dit à quoi elle doit ressembler. S’il existe une ligne correspondante et qu’elle est juste : ok. Si elle existe mais diffère : remplacée. S’il n’y en a pas : ajoutée. C’est idempotent par construction et cela laisse le reste du fichier tranquille – le bon outil quand un fichier appartient au système et qu’Ansible n’en gère qu’une seule ligne.
D’où vient blog_hostname ? Du vars.yml de l’hôte – et le rôle le dit dans meta/argument_specs.yml :
argument_specs:
main:
short_description: Minimal host-level packages for the blog server
options:
blog_hostname:
description: Public hostname of the blog (e.g. www.example.de)
type: str
required: trueAvant que la première tâche s’exécute, Ansible vérifie chaque option listée ici. Un hôte sans blog_hostname échoue exactement ici avec un clair « missing required arguments » – pas trois tâches plus loin avec une erreur de variable non définie dans un template. La variante Nextcloud déclare nextcloud_hostname de la même manière.
Étape 2 : les dépôts – demander d’abord, agir ensuite
Un dépôt est une source depuis laquelle dnf télécharge des paquets. AlmaLinux arrive avec BaseOS et AppStream activés. Beaucoup d’outils de cette installation (fail2ban, Monit, rkhunter, le client Python pour Kubernetes) se trouvent dans EPEL, le dépôt « Extra Packages for Enterprise Linux », donc les deux rôles installent epel-release – un petit paquet dont le seul contenu est la définition du dépôt.
Le rôle Nextcloud en a besoin de deux de plus. CRB (CodeReady Builder) fait partie d’AlmaLinux mais est désactivé, et certains paquets EPEL en dépendent. Il n’existe aucune option de module Ansible pour « active ce dépôt », le rôle utilise donc une commande – et la rend idempotente avec une garde :
- name: Packages | Read list of currently enabled repositories (to check if CRB is active)
ansible.builtin.command: dnf repolist --enabled
register: enabled_repos
changed_when: false
check_mode: false
- name: Packages | Enable CRB repository (only if not already active)
ansible.builtin.command: dnf -y config-manager --set-enabled crb
when: "'crb' not in enabled_repos.stdout"
changed_when: trueCette paire « demander d’abord, agir ensuite » est le motif le plus courant de tout le dépôt, il vaut donc la peine de la lire lentement. La première tâche ne fait que lire : elle exécute dnf repolist et garde le résultat avec register. changed_when: false dit à Ansible que lire ne change rien (sinon une tâche command rapporterait toujours « changed », car Ansible ne peut pas savoir ce que fait une commande). check_mode: false la fait s’exécuter même en simulation – elle est inoffensive, et sans sa sortie la deuxième tâche ne pourrait rien décider. La deuxième tâche ne s’exécute que when CRB manque dans cette sortie, et si elle s’exécute, elle a changé quelque chose (changed_when: true). Résultat : la première exécution signale un changement, toutes les suivantes aucun.
Le troisième dépôt est MariaDB Community. AlmaLinux a son propre MariaDB, mais les hôtes Nextcloud exécutent une série de versions précise directement de MariaDB, épinglée dans versions.yml. Trois étapes :
rpm_keyimporte la clé GPG du projet. Chaque paquet est signé ; avec la clé installée,dnfrefuse les paquets modifiés en route.- Une tâche
copyécrit/etc/yum.repos.d/mariadb.repoavec la série de versions épinglée dans l’URL – la variable figure même dans le nom de la tâche, si bien que la sortie de l’exécution montre quelle version a été configurée. - Le « module » AppStream pour MariaDB est désactivé. Les modules AppStream sont des flux de paquets alternatifs, et un module MariaDB activé masquerait les paquets Community. Encore « demander d’abord, agir ensuite » : d’abord
dnf module list --disabled mariadb,dnf module disableseulement s’il n’est pas encore désactivé.
Encore un détail : EPEL reçoit module_hotfixes=1 dans son fichier de dépôt, via le module community.general.ini_file. Un fichier .repo est un fichier INI, et ini_file définit exactement une option dans une section – l’équivalent INI de lineinfile.
Étape 3 : la mise à jour du système – et pourquoi le noyau reste dehors
- name: Packages | Upgrade all system packages # noqa: package-latest
ansible.builtin.dnf:
name: "*"
state: latest
update_cache: true
exclude: kernel*name: "*" avec state: latest est un dnf upgrade complet. ansible-lint met normalement en garde contre state: latest (cela rend les exécutions non reproductibles), et le commentaire # noqa lui dit qu’ici, une mise à jour complète est justement le but.
La partie intéressante est exclude: kernel*, et pour la comprendre il faut un morceau du durcissement SSH de la partie 5. Le serveur SSH tourne avec un réglage systemd appelé ProtectKernelModules. systemd démarre chaque session SSH dans son propre espace de noms de montage – une vue privée du système de fichiers –, et dans cette vue, le répertoire des modules du noyau /usr/lib/modules est vide et en lecture seule. Personne qui se connecte par SSH ne peut charger un module du noyau, c’est tout le principe. Mais Ansible aussi se connecte par SSH, et installer un nouveau noyau signifie écrire ses modules précisément dans ce répertoire. À l’intérieur de la session, cela échoue toujours.
Les noyaux sont donc exclus ici, et ils arrivent quand même : dnf-automatic (plus loin dans cet article) tourne comme timer systemd, hors de toute session SSH, et installe chaque nuit les mises à jour de sécurité – noyaux compris.
Étape 4 : redémarrer seulement quand c’est vraiment nécessaire
Deux situations exigent un redémarrage avant que le reste du play puisse fonctionner. La première est sournoise : une image cloud a parfois déjà installé un noyau plus récent lorsqu’Ansible se connecte pour la première fois, alors que l’ancien tourne encore. Le répertoire des modules de l’ancien noyau a disparu, et la première tâche qui a besoin d’un module – common_sysctl, qui charge tcp_bbr quelques rôles plus loin – échoue avec un déroutant « No such file or directory ». Mieux vaut le remarquer ici :
- name: Packages | Check whether the running kernel's module directory still exists
ansible.builtin.command: >-
systemd-run --wait --pipe --quiet --
test -d /lib/modules/{{ ansible_facts['kernel'] }}
register: running_kernel_modules
changed_when: false
check_mode: false
failed_when: false
- name: Packages | Reboot – running kernel's modules are gone
ansible.builtin.reboot:
reboot_timeout: 300
when: running_kernel_modules.rc != 0Pourquoi ne pas simplement vérifier le répertoire avec stat ? À cause du même bac à sable SSH : dans la session, /lib/modules paraît toujours vide, et une simple vérification « trouverait » à chaque exécution un noyau manquant et redémarrerait pour rien. systemd-run est l’échappatoire. Il demande à systemd (PID 1) de lancer un service éphémère – une unité transitoire – qui s’exécute hors de l’espace de noms de la session SSH et voit le vrai système de fichiers. --wait attend qu’il ait fini, --pipe renvoie sa sortie et son code de retour. Le durcissement reste entièrement en place ; seule cette commande s’exécute là où elle a le droit de regarder. Vous retrouverez systemd-run dans common_sysctl, pour la même raison.
ansible_facts['kernel'] est l’un des rares facts qu’utilise ce dépôt – la version du noyau en cours, collectée au début du play. failed_when: false transforme « test a renvoyé 1 » en résultat au lieu d’un échec, et le module reboot fait le reste : il redémarre, attend jusqu’à cinq minutes que SSH réponde à nouveau, et le play continue simplement avec la tâche suivante.
Le rôle du blog a une deuxième vérification que le rôle Nextcloud n’a pas : needs-restarting -r (de yum-utils) indique si des bibliothèques système mises à jour comme glibc, systemd ou OpenSSL attendent un redémarrage. Il se termine avec 1 si c’est le cas, et le blog redémarre aussitôt. Le rôle Nextcloud n’a pas cette deuxième vérification ; là-bas, ces redémarrages ont lieu la nuit via la tâche décrite plus bas.
Étape 5 : paquets et services
Viennent ensuite les listes de paquets proprement dites. Sur le blog : nftables, les outils SELinux, container-selinux (la politique SELinux qui autorise les conteneurs à tourner), quelques outils utilitaires et de diagnostic. Sur Nextcloud, en plus : MariaDB-server, redis, rsync et rdiff-backup pour les sauvegardes, et python3-PyMySQL – la bibliothèque Python dont les modules MySQL d’Ansible ont besoin sur l’hôte. Le rôle Nextcloud active et démarre ensuite MariaDB et Redis.
Le dernier bloc désactive ce que ce serveur n’utilise pas. firewalld est arrêté, désactivé et, sur le blog, également masqué – masked: true lie l’unité à /dev/null, si bien qu’elle ne peut plus être démarrée du tout, pas même comme dépendance d’un autre service (le rôle de pare-feu de la partie 5 le masque sur tous les hôtes). Les assistants NFS/RPC et LDAP (rpcbind, sssd) suivent le même chemin, et gssproxy est masqué parce qu’il revient sinon par activation de socket, même désactivé. Toutes ces tâches ont failed_when: false : sur un hôte où le service n’existe pas, « arrête X » ne doit pas interrompre l’exécution.
| Élément | Où | Ce qu’il faut retenir |
|---|---|---|
hostname, lineinfile | Étape 1 | Changer une chose (un nom, une ligne) sans gérer tout le fichier |
argument_specs | meta/ | Les entrées sont vérifiées avant la première tâche |
register + when + changed_when | Étape 2 | « Demander d’abord, agir ensuite » pour les commandes sans module |
rpm_key, ini_file | Étape 2 | Faire confiance à un dépôt, modifier une option INI |
# noqa | Étape 3 | Faire taire une règle de lint là où l’enfreindre est le but |
systemd-run, reboot | Étape 4 | Regarder au-delà du bac à sable SSH ; redémarrer et continuer |
masked: true, failed_when: false | Étape 5 | Rendre un service impossible à démarrer ; ignorer ceux qui manquent |
Pas de handlers, pas de defaults : les listes de paquets sont directement dans les tâches, et chaque service est démarré là où il est installé.
🧮 common_sysctl : paramètres du noyau
Le noyau Linux expose sous /proc/sys des centaines de réglages – comment TCP se comporte, la taille des files d’attente, si le système accepte certains paquets. L’outil pour les définir s’appelle sysctl, et pour les rendre permanents on passe par un fichier dans /etc/sysctl.d/ : chaque fichier y est lu au démarrage. Ce rôle écrit deux de ces fichiers et charge un module du noyau.
Deux templates, deux objectifs
99-webserver.conf concerne la vitesse :
- TCP BBR au lieu de CUBIC par défaut comme algorithme de contrôle de congestion – la partie de TCP qui décide à quelle vitesse envoyer. BBR mesure la bande passante et le temps aller-retour au lieu d’attendre des pertes de paquets, ce qui aide sur les liaisons longues ou avec pertes. Il a besoin de l’ordonnanceur de paquets
fq(fair queueing), défini à la ligne précédente. - TCP Fast Open et pas de redémarrage lent après inactivité : pour un site aux visites irrégulières, une connexion restée calme un instant ne doit pas repartir lentement.
- Des délais plus courts pour les connexions qui se ferment ou sont inactives, et des files plus grandes pour les connexions entrantes, afin qu’un afflux de visiteurs ne soit pas refoulé à la porte.
98-cis-hardening.conf concerne la sécurité – les points réseau du CIS Benchmark (une histoire en soi) : pas de redirections ICMP (un routeur qui dit à l’hôte « envoie plutôt par là » est le moyen par lequel un attaquant sur le chemin détourne le trafic), pas de routage par la source, journalisation des paquets aux adresses d’expéditeur impossibles, SYN cookies contre les inondations SYN, randomisation complète de l’espace d’adressage, ptrace restreint et pas de core dumps des processus privilégiés.
Le plus instructif dans ce fichier, c’est ce qu’il omet. Un profil CIS habituel désactive le transfert IP et active le filtrage strict du chemin retour (reverse path filtering). Sur un nœud Kubernetes, les deux casseraient le cluster : les pods sont joints par du routage sur l’hôte, donc le transfert doit rester actif, et le filtrage strict du chemin retour est une cause connue de trafic de pods rejeté. Le template le dit dans un commentaire, avec la date de la vérification – pour que personne ne le « répare » plus tard en suivant aveuglément le benchmark.
Charger BBR – encore à travers le bac à sable
- name: Sysctl | Load tcp_bbr kernel module
ansible.builtin.command: systemd-run --wait --pipe --quiet -- modprobe tcp_bbr
changed_when: false
- name: Sysctl | Persist tcp_bbr kernel module across reboots
ansible.builtin.copy:
dest: /etc/modules-load.d/tcp_bbr.conf
content: |
tcp_bbrBBR est un module du noyau, et charger des modules est exactement ce que le bac à sable SSH interdit. Le module est donc chargé via systemd-run, et un fichier dans /etc/modules-load.d/ le fait charger par systemd à chaque démarrage. Il existe un module Ansible pour cela, community.general.modprobe, mais il lit d’abord le répertoire des modules pour vérifier l’état – et dans la session, ce répertoire est vide, donc il échoue avec un message trompeur. Deux petites tâches qui font la même chose sont plus honnêtes qu’un module qui ne voit rien.
Appliquer les valeurs : un handler et une tâche
- name: Sysctl | Deploy web server kernel tuning
ansible.builtin.template:
src: 99-webserver.conf.j2
dest: /etc/sysctl.d/99-webserver.conf
notify: apply sysctl
- name: Sysctl | Apply settings immediately
ansible.builtin.command: systemd-run --wait --pipe --quiet -- sysctl --system
changed_when: falseÉcrire un fichier dans /etc/sysctl.d/ ne prend effet qu’au prochain démarrage ; sysctl --system applique immédiatement tous les fichiers. Le rôle le fait volontairement deux fois. Le handler apply sysctl s’exécute à la fin du play si l’un des fichiers a changé – la logique normale des handlers de la partie 1. La tâche sans condition ci-dessus s’exécute à chaque exécution, et elle attrape quelque chose qu’aucun changement de fichier ne remarquerait : que quelqu’un a modifié une valeur à chaud avec sysctl -w. Le fichier est alors toujours juste, aucun handler ne se déclenche, mais le noyau en cours ne l’est pas. Cette tâche le rétablit. Elle ne peut pas savoir si elle a changé quelque chose, elle dit donc changed_when: false. Les deux passent par systemd-run : le bac à sable SSH rend aussi /proc/sys en lecture seule (ProtectKernelTunables).
Transparent Huge Pages
Un réglage n’est pas du tout un sysctl : les Transparent Huge Pages, une fonction mémoire qui regroupe la mémoire en grandes pages. Sur « always », elle provoque des pics de latence dans les bases de données et Redis, elle passe donc sur madvise – seuls les programmes qui le demandent en profitent. La valeur se trouve dans /sys/kernel/mm/, et il n’existe aucun fichier de configuration pour elle. Le rôle écrit donc un minuscule service systemd qui la définit au démarrage (Type=oneshot avec RemainAfterExit=yes : s’exécuter une fois, puis compter comme « actif »), l’active et notifie un deuxième handler qui définit la valeur immédiatement – là encore via systemd-run.
| Élément | Ce qu’il faut retenir |
|---|---|
template vers /etc/sysctl.d/ | Les réglages permanents du noyau ne sont que des fichiers |
notify + une tâche sans condition | Handler pour les changements de fichier, tâche pour les écarts à chaud |
systemd-run | Écrire dans le noyau depuis l’extérieur du bac à sable SSH |
| unité oneshot | Pour les réglages sans fichier de configuration |
✉️ common_msmtp : un serveur qui sait envoyer des mails
La moitié des mécanismes de protection de cette installation se signalent par mail : les scanners nocturnes, la tâche de mise à jour, le chien de garde, l’alerte de connexion SSH. Un vrai serveur de messagerie serait excessif, et une chose de plus à sécuriser. msmtp est l’inverse : pas de service, pas de file d’attente, pas de port ouvert. Un programme qui veut envoyer un mail appelle sendmail (le paquet msmtp fournit cette commande), msmtp se connecte à un compte SMTP externe, remet le mail et se termine.
Le rôle comporte trois tâches – paquet, fichier de configuration, fichier journal –, mais chacune montre quelque chose :
- Les defaults comme couche de traduction.
defaults/main.ymlne contient aucune vraie valeur, seulement des correspondances commemsmtp_host: "{{ mail_smtphost }}". L’inventaire utilise les mêmes nomsmail_*que Nextcloud pour ses propres réglages de messagerie, et le rôle les traduit dans ses propres noms. Un seul jeu de valeurs, plusieurs utilisateurs – Nextcloud, le chien de garde Monit et msmtp. - Les secrets déclarés comme secrets. Les
argument_specsdu rôle marquent l’identifiant et le mot de passe SMTP avecno_log: true, si bien que même la vérification des entrées ne les affiche jamais. no_logsur la tâche de template. Le/etc/msmtprcrendu contient le mot de passe en clair (droits 0600, root seulement). Une simulation avec--diffafficherait le contenu du fichier – la tâche masque donc entièrement sa sortie. Le prix : un contrôle de dérive dit alors seulement « changerait », sans diff. Pour un fichier contenant un mot de passe, c’est le bon compromis.- Un fichier journal sans faux changements.
file: state=touchcrée le journal mais met à jour ses horodatages à chaque exécution, et cela compte comme un changement.modification_time: preserveetaccess_time: preserveconservent les horodatages, si bien que la tâche n’est « changed » que lorsque le fichier a dû être créé.
🌙 common_dnf_automatic : des mises à jour pendant que je dors
dnf-automatic fait partie d’AlmaLinux : un timer systemd (la version systemd d’une tâche cron) qui exécute dnf upgrade sans surveillance. Le rôle l’installe, le limite aux mises à jour de sécurité et – c’est la partie intéressante – l’insère dans un plan pour toute la nuit.
Deux lignes dans un fichier de configuration
Deux tâches lineinfile définissent upgrade_type = security (seulement les paquets marqués comme correctifs de sécurité) et apply_updates = yes (vraiment installer, pas seulement télécharger). Tout le reste reste une étape manuelle délibérée avec le playbook.
La nuit a un plan
Plusieurs tâches tournent la nuit, et leur ordre compte. Voici le plan tel qu’il figure dans le rôle :
| Heure | Quoi | Rôle |
|---|---|---|
| 00:00 | AIDE compare les fichiers système à sa référence | common_cis_hardening (partie 5) |
| 01:00–02:00 | Les mises à jour de sécurité sont installées | common_dnf_automatic |
| 03:30 | Redémarrage si une mise à jour en a besoin | common_dnf_automatic |
| 04:00 | La référence AIDE est renouvelée | common_cis_hardening (partie 5) |
| 04:30 | ClamAV analyse les données des utilisateurs (Nextcloud) | common_clamav (partie 5) |
| 06:00 | Mail d’avertissement si un redémarrage est encore en attente | common_dnf_automatic |
Le timer fourni se déclenche à 06:00 – après la fenêtre de redémarrage et après le renouvellement d’AIDE. La conséquence s’est vue à la dure : chaque mise à jour de sécurité apparaissait le minuit suivant comme alerte AIDE, et un nouveau noyau attendait presque une journée le redémarrage suivant de 03:30. Le rôle déplace donc le timer avec un drop-in :
- name: dnf-automatic | Move the install timer into the 01:00-02:00 window
ansible.builtin.copy:
dest: /etc/systemd/system/dnf-automatic-install.timer.d/schedule.conf
content: |
[Timer]
OnCalendar=
OnCalendar=*-*-* 01:00
RandomizedDelaySec=60m
register: dnf_automatic_timer_dropin
- name: dnf-automatic | Enable and start install timer
ansible.builtin.systemd:
name: dnf-automatic-install.timer
enabled: true
state: "{{ 'restarted' if dnf_automatic_timer_dropin is changed else 'started' }}"
daemon_reload: trueUn drop-in est un fichier dans <unit>.d/ qui surcharge des parties d’une unité sans toucher au fichier fourni – les mises à jour de paquets peuvent remplacer l’original, le drop-in reste. Notez la ligne vide OnCalendar= : pour les réglages de type liste, systemd ajoute chaque affectation. Sans la ligne vide, le timer se déclencherait à 06:00 et à 01:00. Une affectation vide vide d’abord la liste.
La deuxième tâche calcule elle-même son state, avec un if/else Jinja en ligne. Un timer en cours garde son ancien planning jusqu’à son redémarrage, il doit donc être redémarré si – et seulement si – le drop-in a changé. register sur la tâche copy et le test is changed expriment exactement cela, sans handler. (Un handler fonctionnerait aussi, mais ne s’exécuterait qu’à la toute fin du play ; ainsi le timer est juste tout de suite.)
Redémarrage à 03:30, contrôle à 06:00
- name: dnf-automatic | Configure nightly automatic reboot if required
ansible.builtin.cron:
name: "automatic reboot after security update"
hour: "3"
minute: "30"
job: >-
OUT=$(/usr/bin/needs-restarting -r 2>&1);
[ $? -ne 0 ] && { echo "$OUT" | /usr/bin/mail -s "... Rebooting now ..." {{ dnf_automatic_reboot_mail }};
/usr/sbin/shutdown -r now; }Le module cron gère une entrée de crontab, identifiée par son name – si l’on modifie la tâche, la même entrée est mise à jour au lieu qu’une deuxième apparaisse. La tâche demande à needs-restarting -r si un redémarrage est en attente, et seulement dans ce cas envoie la raison par mail et redémarre. >- est du YAML pour « replie ces lignes en une seule » – une entrée de crontab doit tenir sur une ligne. Une deuxième entrée à 06:00 refait la même vérification : si un redémarrage est toujours en attente, le redémarrage automatique a échoué, et un avertissement part. Le destinataire vient de defaults/, où dnf_automatic_reboot_mail pointe simplement vers l’adresse d’administration de l’hôte et peut être surchargé par hôte.
Encore un détail qui fait tenir le plan : les hôtes Nextcloud sont à l’heure allemande, le blog en UTC. Comme les timers et les tâches cron utilisent tous deux l’horloge de l’hôte, l’ordre est partout le même, même si les heures affichées diffèrent.
🗂️ common_logrotate : des journaux qui ne grossissent pas sans fin
logrotate tourne chaque jour via un timer fourni avec le système. Il lit chaque fichier de /etc/logrotate.d/ et fait tourner les journaux qui y sont décrits : renommer le journal courant, en commencer un nouveau, compresser les anciens et finir par les supprimer. Grafana et Prometheus arrivent sans un tel fichier ; fail2ban et rkhunter avec des fichiers incomplets. Le rôle se compose de quatre tâches copy avec content: – le texte du fichier est directement dans la tâche – et d’aucun handler : logrotate lit les fichiers à sa prochaine exécution.
La seule directive qui mérite une explication est postrotate. Un programme en cours garde son fichier journal ouvert. Si logrotate renomme le fichier, le programme continue d’écrire dans le fichier renommé – l’ancien journal grossit, le nouveau reste vide. postrotate dit au programme de rouvrir son journal : Grafana reçoit un SIGHUP, fail2ban un fail2ban-client flushlogs. rkhunter n’a besoin de rien, car il n’écrit que pendant son analyse nocturne et ne garde jamais le fichier ouvert. || true à la fin évite que logrotate signale une erreur si le service ne tourne pas à ce moment-là.
☸️ common_k3s : un cluster Kubernetes sur un seul serveur
Voici maintenant le gros rôle. D’abord, ce qu’il construit. Kubernetes exécute des applications dans des conteneurs et les maintient dans l’état que vous décrivez : « fais tourner un pod WordPress avec ces réglages, joignable sous ce nom ». Cette description s’écrit sous forme d’objets (YAML), on la remet au serveur d’API, et Kubernetes met la réalité en conformité. K3s est une distribution Kubernetes compacte : un binaire qui contient le serveur d’API, l’ordonnanceur, l’agent du nœud (kubelet), un environnement d’exécution de conteneurs et un réseau de pods simple (Flannel). Ici, il tourne comme cluster à un seul nœud – un serveur est tout le cluster. Comment j’en suis arrivé là est une histoire plus ancienne.
Pourquoi Kubernetes sur un seul serveur ? Un blog et un Nextcloud tourneraient aussi comme paquets ordinaires sur l’hôte. Le cluster est rentable de plusieurs façons qui comptent pour l’exploitation : chaque application est décrite comme un ensemble d’objets dans le dépôt et peut être reconstruite à l’identique sur un nouveau serveur ; mettre à jour, c’est changer un tag d’image, et revenir en arrière, c’est le remettre ; chaque application vit dans son propre namespace avec ses propres règles réseau et limites de ressources ; et les deux piles sont déployées de la même manière, si bien que ce qu’on apprend sur l’une vaut pour l’autre. Le prix, c’est la complexité et environ 1,5 Go de mémoire pour la plateforme – c’est pourquoi cette partie y consacre autant de temps.
Sur K3s, le rôle installe trois choses : Helm, un gestionnaire de paquets pour Kubernetes ; cert-manager, qui obtient et renouvelle les certificats Let’s Encrypt ; et le F5 NGINX Ingress Controller, le serveur web en bordure qui reçoit chaque requête HTTPS et la transmet à la bonne application. Le rôle est réparti sur cinq fichiers, inclus dans un ordre fixe :
- name: common_k3s | K3s install/upgrade
ansible.builtin.import_tasks: install_k3s.yml
- name: common_k3s | Helm install/upgrade
ansible.builtin.import_tasks: install_helm.yml
- name: common_k3s | cert-manager + Let's Encrypt ClusterIssuer
ansible.builtin.import_tasks: cert_manager.yml
- name: common_k3s | F5 nginx-ingress controller
ansible.builtin.import_tasks: nginx_ingress.yml
- name: common_k3s | Default ServiceAccount hardening (CIS 5.1.5)
ansible.builtin.import_tasks: default_sa_hardening.ymlimport_tasks est statique : Ansible lit les cinq fichiers au démarrage du playbook, comme s’ils n’en formaient qu’un long. Ainsi, les tags, --list-tasks et les vérifications de syntaxe voient chaque tâche. Le cousin dynamique, include_tasks, ne lit un fichier que lorsque le play l’atteint – utile quand le nom du fichier ou la décision de l’inclure dépend de quelque chose calculé seulement pendant l’exécution. La partie 5 montre le seul endroit où cela compte. Ici, tout est connu à l’avance, la forme statique est donc la bonne.
install_k3s.yml, partie 1 : la configuration avant l’installation
Le premier fichier écrit trois fichiers de configuration avant l’installation ou la mise à jour de K3s – pour que les réglages s’appliquent dès le tout premier démarrage et à chaque exécution suivante :
- Une politique d’audit pour le serveur d’API : qui a fait quoi dans le cluster. Les Secrets et ConfigMaps ne sont journalisés qu’avec leurs métadonnées – qui y a accédé, jamais leur contenu. Les commandes dans les pods (
pods/exec) sont journalisées avec la requête, car c’est la chose la plus puissante qu’on puisse faire avec une application en cours. Les changements de droits (RBAC) sont journalisés intégralement. Le flux bruyant et en lecture seule des « events » est écarté. - Une configuration d’admission qui limite le nombre d’events par seconde acceptés par le serveur d’API, pour qu’un pod en plantage ne puisse pas l’inonder.
config.yaml, les réglages propres à K3s : quels composants intégrés sont désactivés, les paramètres du serveur d’API pour le journal d’audit et les plugins d’admission, et une limite de processus par pod.
- name: K3s | Deploy /etc/rancher/k3s/config.yaml
ansible.builtin.copy:
dest: /etc/rancher/k3s/config.yaml
mode: "0600"
content: |
disable:
- traefik
- servicelb
- local-storage
flannel-backend: vxlan
write-kubeconfig-mode: "0600"
kube-apiserver-arg:
- "audit-log-path=/var/lib/rancher/k3s/server/logs/audit.log"
- "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml"
- "enable-admission-plugins=NodeRestriction,EventRateLimit"
- "admission-control-config-file=/etc/rancher/k3s/admission-control-config.yaml"
kubelet-arg:
- "pod-max-pids=4096"
notify: Restart k3s serviceTrois composants sont désactivés. Traefik est l’Ingress par défaut de K3s ; cette installation utilise NGINX à la place. ServiceLB est un petit répartiteur de charge ; l’Ingress se lie directement aux ports de l’hôte, il n’est donc pas nécessaire. local-storage crée des volumes de stockage à la demande – ici, personne n’en demande, toutes les données résident dans des répertoires fixes sur l’hôte, il ne tournait donc que comme un processus privilégié de plus. Ce qui ne tourne pas ne peut pas être attaqué.
La ligne des plugins d’admission porte l’une des meilleures leçons du dépôt, documentée dans le fichier : dans Kubernetes standard, enable-admission-plugins complète la liste par défaut. Dans K3s, elle la remplace. Ne définir que EventRateLimit a silencieusement retiré NodeRestriction – un plugin qui empêche un agent de nœud compromis de modifier autre chose que ses propres objets. Une nouvelle exécution du vérificateur CIS (kube-bench) l’a trouvé. Depuis, NodeRestriction figure explicitement dans la liste, avec un commentaire expliquant pourquoi. Le fichier explique aussi une recommandation CIS qu’il ne suit volontairement pas (retirer l’image du registre à chaque démarrage de pod) : dans une installation sans locataires séparés, cela n’apporte guère de sécurité, mais transforme chaque courte panne du registre en pods qui échouent.
Les trois tâches notifient le même handler, Restart k3s service – un seul redémarrage, peu importe combien de fichiers ont changé. Et une petite tâche juste après crée le répertoire du journal d’audit, car le serveur d’API ne le crée pas lui-même et ne démarre pas sans lui. Il doit exister avant ce redémarrage.
install_k3s.yml, partie 2 : installer ou mettre à jour, épinglé
Maintenant le binaire. Le rôle compare la version installée à l’épinglage :
- name: K3s | Check installed k3s version
ansible.builtin.shell: |
set -o pipefail
k3s --version 2>/dev/null | awk '{print $3}'
register: k3s_installed_version
changed_when: false
check_mode: false
failed_when: false
- name: K3s | Download k3s binary (install or upgrade to {{ k3s_version }})
ansible.builtin.get_url:
url: "https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/k3s"
dest: /usr/local/bin/k3s
mode: "0755"
when: not k3s_binary.stat.exists or (k3s_installed_version.stdout | trim) != k3s_version
notify: Restart k3s service
timeout: 600C’est le motif du binaire épinglé, et il revient pour Helm, le plugin helm-diff et tous les programmes de supervision : lire la version installée, ne télécharger qu’en cas d’écart, déclencher un redémarrage. Sur un serveur neuf, la version est une chaîne vide, qui ne correspond jamais à l’épinglage – la même tâche couvre donc installation et mise à niveau. Avec K3s, une mise à niveau, c’est littéralement « remplacer le binaire, redémarrer le service » ; les données du cluster sont conservées. set -o pipefail fait échouer le pipe si k3s --version échoue, pas seulement si awk échoue. timeout: 600 est un mot-clé de la tâche, pas une option du module : si le téléchargement se bloque, Ansible abandonne au bout de dix minutes au lieu d’attendre indéfiniment.
Le script d’installation qui crée le service systemd vient du dépôt K3s avec le même tag que le binaire, pas de l’habituel get.k3s.io. Cette adresse sert toujours le script le plus récent, et elle a répondu un moment par HTTP 500 lors de la première installation d’un nouveau serveur, alors que GitHub allait bien. Binaire épinglé plus script épinglé, c’est la même installation à chaque fois. Le tag contient un +, qui doit s’écrire %2B dans une URL – c’est le rôle du filtre urlencode.
- name: K3s | Run K3s installer (skip binary download, flags from config.yaml)
ansible.builtin.command: sh /tmp/k3s-install.sh
environment:
INSTALL_K3S_SKIP_DOWNLOAD: "true"
args:
creates: /etc/systemd/system/k3s.service
when: not k3s_unit.stat.exists
- name: K3s | Apply pending handlers (restart on config/binary change)
ansible.builtin.meta: flush_handlersTrois détails dans deux tâches. environment définit une variable pour cette seule commande : le script d’installation ne doit pas retélécharger le binaire. creates n’exécute la commande que si ce fichier n’existe pas encore – la plus vieille astuce d’idempotence d’Ansible. Et le script est lancé par sh /tmp/k3s-install.sh au lieu d’être exécuté directement, parce que le durcissement CIS monte /tmp avec noexec : les fichiers qui s’y trouvent ne peuvent pas être exécutés, mais peuvent être lus par un shell.
La condition when a elle aussi une histoire. À l’origine, elle vérifiait l’existence du binaire. Puis une première installation a été interrompue après le téléchargement du binaire mais avant le script d’installation – et chaque exécution suivante voyait le binaire, sautait le script d’installation et échouait plus loin parce que le service n’existait pas. Désormais, c’est l’unité systemd qui décide, et une installation interrompue se répare d’elle-même à l’exécution suivante.
Puis meta: flush_handlers. Normalement, les handlers s’exécutent à la fin du play – mais chaque rôle après celui-ci parle à Kubernetes. Si K3s vient d’être mis à jour ou reconfiguré, le redémarrage doit avoir lieu maintenant, pas après que l’application a été déployée contre l’ancienne version. flush_handlers exécute à cet endroit tous les handlers en attente. Le fichier des handlers lui-même ne contient qu’une entrée :
- name: Restart k3s service
ansible.builtin.service:
name: k3s
state: restartedEnsuite, le rôle attend que le serveur d’API réponde – pas avec un sleep fixe, mais en demandant :
- name: K3s | Wait for the API server to become available
ansible.builtin.command: k3s kubectl get nodes
register: k3s_nodes
until: k3s_nodes.rc == 0
retries: 30
delay: 5
changed_when: falseuntil répète la tâche jusqu’à ce que la condition soit vraie, ici au plus 30 fois à 5 secondes d’intervalle. Après un redémarrage rapide, le play continue au bout de quelques secondes ; après un lent, il attend jusqu’à deux minutes et demie ; si K3s ne démarre jamais, l’exécution échoue avec un message clair au lieu d’une erreur déroutante dans le rôle suivant.
Deux petites choses terminent le fichier. K3s écrit ses fichiers de certificats lisibles par tous et en régénère certains au redémarrage. find rassemble tous les fichiers *.crt, et une tâche file avec loop met chacun en 0600 – à chaque exécution, puisque les fichiers reviennent. (loop_control.label n’affiche que le chemin par élément au lieu de toute l’entrée de fichier.) Enfin, la configuration d’administration du cluster est copiée vers /root/.kube/config, pour que kubectl et helm trouvent le cluster dans un shell root. remote_src: true signifie : copier d’un endroit du serveur vers un autre, pas depuis ma machine.
install_helm.yml : Helm et le plugin helm-diff
Helm installe des charts – des paquets d’objets Kubernetes avec des réglages (« values ») – et garde la trace de chaque installation sous forme de release. Le rôle l’installe avec le même motif d’épinglage : vérification de version, téléchargement de l’archive seulement en cas d’écart, décompression, copie vers /usr/local/bin. Deux lignes des tâches de décompression et d’installation méritent un coup d’œil :
when:
- helm_download.changed | default(false)
- not ansible_check_modeUne liste sous when signifie « toutes à la fois ». Première condition : seulement si quelque chose a été téléchargé. La seconde concerne les simulations. En mode check, la tâche de téléchargement ne fait que semblant – elle signale « changerait », mais il n’y a pas de fichier. Décompresser un fichier qui n’existe pas échouerait, le rôle saute donc cette étape en mode check. Ainsi, une simulation sur un serveur au Helm obsolète signale correctement la mise à niveau au lieu de planter. Les deux mêmes lignes protègent chaque archive téléchargée du dépôt.
La seconde moitié installe helm-diff, un plugin qui permet au module Helm de calculer une vraie différence entre la release en cours et celle demandée. La version installée est lue dans helm plugin list, qui affiche un petit tableau, à peu près ainsi :
NAME VERSION TYPE APIVERSION PROVENANCE SOURCE
diff 3.x.x cli/v1 legacy ...…et une seule expression Jinja en extrait la version :
when: >-
(helm_plugins.stdout_lines | default([]) | map('split')
| selectattr('0', 'equalto', 'diff') | map(attribute='1') | first | default('')) != helm_diff_versionLisez-la comme un pipeline de gauche à droite : prendre les lignes de sortie → découper chaque ligne en mots (chaque ligne est maintenant une liste) → ne garder que les lignes dont le premier mot (index 0) est diff → prendre le deuxième mot (index 1) de chacune → en prendre le premier, ou une chaîne vide s’il n’y en a pas. Si ce n’est pas la version épinglée, le block en dessous s’exécute. Un block regroupe des tâches, si bien qu’un when s’applique à toutes.
Dans le bloc, le téléchargement porte une somme de contrôle : checksum: "sha256:{{ helm_diff_sha256 }}". get_url vérifie le fichier après le téléchargement et échoue s’il ne correspond pas. Le plugin est épinglé avec sa somme de contrôle dans versions.yml, et le bot qui propose les mises à jour de versions est configuré pour me rappeler que les deux doivent changer ensemble.
cert_manager.yml : des certificats sans certbot
cert-manager surveille le cluster à la recherche d’objets qui disent « ce nom d’hôte a besoin d’un certificat », en obtient un auprès de Let’s Encrypt, le stocke comme Secret Kubernetes et le renouvelle avant qu’il expire. Let’s Encrypt vérifie avec un challenge HTTP-01 que vous contrôlez un domaine : il donne un jeton à cert-manager, puis interroge http://your-domain/.well-known/acme-challenge/<token>. Si le jeton revient, le domaine est à vous.
- name: cert-manager | Install/upgrade cert-manager via Helm
kubernetes.core.helm:
kubeconfig: "{{ k3s_kubeconfig }}"
name: cert-manager
chart_ref: jetstack/cert-manager
chart_version: "{{ cert_manager_chart_version }}"
release_namespace: cert-manager
create_namespace: true
update_repo_cache: true
values:
crds:
enabled: true
wait: true
timeout: 300sAutrefois, c’était une tâche shell avec helm upgrade --install. Ça fonctionnait, mais signalait « changed » à chaque exécution et créait chaque fois une nouvelle révision Helm – le playbook ne pouvait donc jamais dire « rien à faire ». Le module kubernetes.core.helm compare la version de chart et les values demandées à la release en cours et ne met à jour qu’en cas de vraie différence. wait: true attend que les pods de la release soient prêts.
Vient ensuite une étape qui déroute beaucoup de monde la première fois. cert-manager apporte de nouveaux types d’objets – par exemple ClusterIssuer, « où et comment obtenir des certificats ». Kubernetes ne connaît un tel type qu’une fois sa CustomResourceDefinition (CRD) enregistrée. Appliquer un ClusterIssuer un instant trop tôt échoue avec « no matches for kind ». Le rôle attend donc que le webhook soit disponible et que la CRD soit established, puis applique l’émetteur avec le motif d’application K8s (expliqué en détail dans la section Calico plus bas).
Le template de l’émetteur contient le réglage le plus durement acquis de ce fichier. La requête du challenge doit atteindre le solveur temporaire de cert-manager, mais il existe déjà un Ingress pour ce même nom d’hôte – celui de l’application. Le contrôleur de F5 ignore silencieusement un deuxième Ingress pour le même hôte. La solution tient en deux annotations : l’Ingress du solveur est créé comme minion, que le contrôleur fusionne dans l’Ingress master existant de cet hôte, et il est exclu de la redirection HTTP vers HTTPS, car Let’s Encrypt vérifie en HTTP simple.
nginx_ingress.yml : la porte d’entrée
Le contrôleur Ingress est la seule chose qui répond sur les ports 80 et 443. Il tourne comme DaemonSet (un pod par nœud – ici : exactement un) avec hostNetwork, et se lie donc directement aux ports de l’hôte comme un serveur web ordinaire. Le fichier compte quatre étapes.
Un certificat de secours. Quand le contrôleur reçoit une requête pour un nom d’hôte dont il n’a pas le certificat – DNS erroné, certificat pas encore émis –, le contrôleur de F5 ne présente pas de certificat auto-signé, il ferme simplement la connexion. Dans le navigateur, c’est impossible à distinguer d’un serveur en panne. On l’a remarqué lors d’une erreur DNS, quand « DNS erroné » et « serveur en panne » se ressemblaient trait pour trait. Le rôle génère donc une fois un certificat auto-signé (openssl avec creates:) et l’enregistre comme certificat par défaut. Les vrais certificats ne sont pas concernés : nginx prend celui qui correspond au nom d’hôte.
- name: ingress-nginx | Read default TLS certificate and key
ansible.builtin.slurp:
src: "/etc/ingress-nginx-defaults/{{ item }}"
loop: [tls.crt, tls.key]
register: ingress_default_tls
no_log: true
- name: ingress-nginx | Apply default TLS certificate as a K8s Secret
kubernetes.core.k8s:
definition:
apiVersion: v1
kind: Secret
type: kubernetes.io/tls
metadata: { name: ingress-default-tls, namespace: ingress-nginx }
data:
tls.crt: "{{ ingress_default_tls.results[0].content }}"
tls.key: "{{ ingress_default_tls.results[1].content }}"
no_log: trueslurp lit un fichier sur le serveur et le renvoie encodé en Base64 – ce qui se trouve être exactement le format que Kubernetes attend dans le data d’un Secret. Comme la tâche a une loop, la variable enregistrée possède une liste results, une entrée par élément, dans l’ordre de la boucle : results[0] est le certificat, results[1] la clé. Les deux tâches masquent leur sortie avec no_log : l’une de ces valeurs est une clé privée.
Une migration ponctuelle. Avant tournait le projet communautaire kubernetes/ingress-nginx, qui a été abandonné. Une tâche shell vérifie si ce chart est encore installé et le supprime avant l’installation du chart F5, pour que Helm n’essaie jamais de mettre à jour entre deux charts incompatibles. Elle affiche « uninstalled » ou « skipped », et changed_when cherche le mot – une manière simple de donner un statut honnête à un script.
La release elle-même. Les values Helm viennent d’un template, rendu en mémoire et lu comme YAML : values: "{{ lookup('ansible.builtin.template', 'ingress-nginx-values.yml.j2') | from_yaml }}". Aucun fichier temporaire sur le serveur. C’est dans le fichier de values que la bordure est configurée : uniquement TLS 1.2 et 1.3, une liste fixe de suites de chiffrement, HTTP/2, réutilisation des connexions vers les pods applicatifs, gzip, pas de numéro de version dans l’en-tête Server, et les zones de limitation de débit utilisées par les pages de connexion du blog et de Nextcloud (parties 3 et 4). Il documente aussi ce qui manque volontairement : la compression Brotli (l’image du contrôleur n’inclut pas le module, les réglages étaient sans effet) et l’agrafage OCSP (Let’s Encrypt n’exploite plus OCSP). L’article sur TLS donne le tableau complet.
Attendre. Pour finir, kubectl wait bloque jusqu’à ce que le pod du contrôleur soit prêt, pour que les rôles applicatifs ne déploient pas dans un cluster sans porte d’entrée.
default_sa_hardening.yml : pas de jetons par défaut
Chaque pod dans Kubernetes s’exécute sous un ServiceAccount et reçoit par défaut un jeton monté qui lui permet de parler au serveur d’API. Une application web n’en a pas besoin – et une application compromise ne devrait pas l’avoir. CIS recommande de le désactiver pour le compte default de chaque namespace :
- name: K3s | Find non-kube-system default ServiceAccounts (CIS 5.1.5)
kubernetes.core.k8s_info:
kind: ServiceAccount
field_selectors:
- metadata.name=default
register: k3s_default_sas
- name: K3s | Disable SA token automount on every non-kube-system default ServiceAccount
kubernetes.core.k8s:
definition:
apiVersion: v1
kind: ServiceAccount
metadata:
name: default
namespace: "{{ item }}"
automountServiceAccountToken: false
loop: >-
{{ k3s_default_sas.resources | map(attribute='metadata.namespace')
| reject('equalto', 'kube-system') | list }}k8s_info est l’équivalent en lecture seule de k8s – comme kubectl get. field_selectors filtre côté serveur : seulement les comptes nommés default. La boucle construit sa liste avec une chaîne de filtres : de tous les comptes trouvés, prendre le namespace, laisser de côté kube-system (les composants propres au cluster y ont besoin de leurs jetons) et en faire une liste. La tâche k8s applique ensuite une définition partielle – seulement le champ qui doit changer ; tout le reste du compte reste tel quel. Les namespaces des applications reçoivent le même patch dans leurs propres rôles, car à ce stade d’une installation neuve ils n’existent pas encore.
| Élément | Où | Ce qu’il faut retenir |
|---|---|---|
import_tasks | main.yml | Découper statiquement un gros rôle en fichiers |
| Motif du binaire épinglé | K3s, Helm, helm-diff | Vérification de version → téléchargement seulement en cas d’écart → redémarrage |
notify + meta: flush_handlers | install_k3s | Un seul redémarrage pour de nombreux changements, et maintenant plutôt qu’à la fin |
until / retries / delay | install_k3s | Attendre une condition, pas une durée fixe |
creates, environment | install_k3s, Ingress | Exécuter une fois ; définir une variable pour une commande |
find + loop + loop_control.label | install_k3s | Agir sur une liste de fichiers trouvée seulement pendant l’exécution |
not ansible_check_mode | install_helm | Sauter les étapes qui dépendent de quelque chose qu’une simulation n’a pas fait |
map / selectattr / first | install_helm | Analyser la sortie de commandes avec des filtres Jinja |
get_url avec checksum | install_helm | Vérifier ce qu’on télécharge |
kubernetes.core.helm | cert-manager, Ingress | Des releases Helm idempotentes |
slurp + results[n] + no_log | Ingress | Lire un fichier dans un Secret sans l’afficher |
k8s_info, définitions partielles, reject | default_sa_hardening | Lire des objets, changer un champ |
Le rôle déclare une entrée dans meta/argument_specs.yml : email, l’adresse à laquelle Let’s Encrypt envoie les avertissements d’expiration – marquée no_log comme toute valeur personnelle.
🛡️ common_calico : pour que les NetworkPolicies deviennent réelles
Une NetworkPolicy est un objet Kubernetes qui dit quels pods peuvent parler à quels autres – par exemple : « le pod de base de données n’accepte de connexions que du pod WordPress ». Le blog et Nextcloud s’appuient tous deux sur de telles règles. Il y a un piège facile à manquer : Kubernetes lui-même ne les applique pas. C’est le plugin réseau qui le fait – et Flannel, le plugin intégré à K3s, ne le fait pas. Une règle qui interdit tout est acceptée sans broncher et n’a absolument aucun effet. Cela a été vérifié sur le serveur de test : une règle tout-interdire ne changeait rien jusqu’à l’installation de Calico, puis a pris effet immédiatement.
Calico peut aussi gérer lui-même le réseau, mais tourne ici en mode policy-only : Flannel continue de transporter les paquets, et l’agent de Calico (Felix) se contente de lire les règles dans l’API Kubernetes et d’en faire des règles de filtrage de paquets sur l’hôte. Le manifeste est dérivé du manifeste officiel « Canal » de Calico (Calico pour les règles, Flannel pour le réseau), sans tout ce que K3s apporte déjà : l’installation du plugin réseau, le conteneur Flannel, la configuration réseau. Il reste 34 objets – 23 CRD, les droits, un DaemonSet pour l’agent et un Deployment pour le contrôleur.
Le motif d’application K8s, en entier
Tout le rôle tient en une tâche plus deux attentes, et la tâche est le motif avec lequel 26 tâches du dépôt amènent des objets Kubernetes dans le cluster. Le voici, à lire de l’intérieur vers l’extérieur :
- name: Calico | Apply policy-only manifest
kubernetes.core.k8s:
kubeconfig: "{{ k3s_kubeconfig }}"
state: present
apply: true
definition: "{{ item }}"
loop: "{{ k8s_objects if ansible_diff_mode else [k8s_objects] }}"
loop_control:
label: "{{ (item.kind ~ '/' ~ item.metadata.name) if item is mapping
else ((item | length) ~ ' objects') }}"
vars:
k8s_objects: "{{ lookup('ansible.builtin.template', 'calico-policy-only.yml.j2')
| ansible.builtin.from_yaml_all | select | list }}"
register: calico_applylookup('ansible.builtin.template', ...)rend le template Jinja sur ma machine en une longue chaîne. Rien n’est copié sur le serveur. (Les lookups s’exécutent toujours sur le contrôleur.)from_yaml_alldécoupe cette chaîne – de nombreux documents YAML séparés par---– en une liste d’objets.selectsans argument retire les entrées vides, par exemple issues d’un---final.vars:sur une tâche définitk8s_objectspour cette tâche seulement.kubernetes.core.k8savecstate: presentetapply: truese comporte commekubectl apply: créer ce qui manque, mettre à jour ce qui diffère, laisser le reste – et ne signaler « changed » que si un objet en cours a réellement changé. Il parle directement à l’API via le client Python quecommon_k3sa installé ; pas dekubectl, pas de fichier temporaire.- La boucle est la partie astucieuse. Lors d’une exécution normale, la liste
[k8s_objects]a un élément – toute la liste des 34 objets –, donc le module s’exécute une fois. Avec--diff(ansible_diff_modeest vrai), la boucle parcourt les objets eux-mêmes, un appel de module par objet, car c’est seulement ainsi que le diff montre le changement par objet. Rapide quand ça n’a pas d’importance, détaillé quand je veux voir. loop_control.labelgarde la sortie lisible : « DaemonSet/calico-node » par objet, ou « 34 objects ».
Les deux tâches suivantes attendent avec kubectl rollout status l’agent et le contrôleur – mais seulement when: calico_apply.changed. Une exécution sans changement n’attend pas du tout.
Encore une note de conception tirée du commentaire d’en-tête du rôle, parce que c’est une bonne leçon générale : les droits de l’agent Calico sont repris intégralement d’upstream, pas réduits à ce dont le mode policy-only « devrait » avoir besoin. Plusieurs boucles internes de l’agent tournent quel que soit le mode, et sans leurs droits, elles ne se désactivent pas élégamment – le pod ne devient simplement jamais prêt. Réduire les droits est une bonne pratique, mais pas en dessous de ce dont le logiciel a réellement besoin.
🛠️ En exploitation : la plateforme au quotidien
L’essentiel de cette partie tourne tout seul – mises à jour nocturnes, renouvellement des certificats, rotation des journaux. Ce qui reste à l’administrateur, c’est de savoir où regarder et comment faire un changement planifié.
Vérifier
# The nightly plan: when do the timers fire next?
systemctl list-timers dnf-automatic-install.timer logrotate.timer
# Is a reboot pending? (exit code 1 = yes)
needs-restarting -r
# Does the server send mail? (the address from the inventory)
echo "test from $(hostname -f)" | mail -s "mail test" you@example.org
tail /var/log/msmtp.log
# Kubernetes: the node, all pods, and the Helm releases
k3s kubectl get nodes
k3s kubectl get pods -A
helm list -A
# All certificates: valid, and when they are renewed
k3s kubectl get certificate -A \
-o custom-columns=NAME:.metadata.name,READY:.status.conditions[0].status,RENEWAL:.status.renewalTimeLes mails de cette partie
| L’objet contient | Signification | Que faire |
|---|---|---|
| « Rebooting now after security update » | Un noyau ou une bibliothèque centrale a été mis à jour pendant la nuit ; le serveur a redémarré à 03:30 | Rien – vérifier plus tard que tout est revenu (le chien de garde de la partie 6 se manifeste sinon) |
| « WARNING: reboot still required » | Le redémarrage automatique n’a pas eu lieu | Trouver pourquoi (journalctl -b -1), redémarrer à la main |
Changements planifiés
- Les paquets de l’hôte qui ne sont pas des correctifs de sécurité (un nouveau Grafana par exemple) n’arrivent qu’avec le rôle des paquets :
ansible-playbook blog.yml --limit <host> --tags blog_packages(ounext_packagesavec le playbook Nextcloud). - K3s, Helm, les charts : modifier l’épinglage dans
versions.yml(ou fusionner la pull request du bot), exécuterscripts/check-drift.sh <playbook> <host> --tags common_k3spour voir ce qui se passerait, puis l’exécution réelle avec--tags common_k3s. K3s se met toujours à jour une version mineure à la fois – lisez les notes de version et prenez d’abord un snapshot du serveur. - Calico : pareil avec
--tags common_calico. Ensuite,k3s kubectl -n kube-system get ds calico-nodedoit de nouveau montrer le pod comme prêt. - Paramètres du noyau : modifier le template, exécuter
--tags common_sysctl. Ne « réparez » jamais le transfert IP ni le filtrage du chemin retour selon le manuel – le cluster en a besoin tels qu’ils sont.
🧭 La suite
À ce stade, la plateforme est complète : un système de base patché avec des réglages de noyau raisonnables, du mail, des mises à jour nocturnes et des journaux en rotation, et un cluster Kubernetes avec Helm, des certificats, une porte d’entrée et des règles réseau effectives. La partie 3 y pose le blog – WordPress, MariaDB et Redis en pods, et tout ce que blog_wordpress fait dans l’application en cours. La partie 4 fait de même pour Nextcloud. Les rôles sautés ici – SSH, le pare-feu, le durcissement et SELinux – sont tous dans la partie 5, la partie sécurité.



