From 480415a959464c8303cf11c92fbeb14dd1370595 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 20 Sep 2026 18:30:38 +0200 Subject: [PATCH] =?UTF-8?q?fix(traefik,crowdsec)=20=E2=80=94=20plugin=20bo?= =?UTF-8?q?uncer=20lu=20sur=20disque=20:=20un=20hoquet=20de=2010=20s=20cou?= =?UTF-8?q?pait=20tout=20l'ingress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le 2026-09-20 à 15:00:50, Traefik a redémarré et n'a pas réussi à télécharger le plugin bouncer CrowdSec : unable to install plugin crowdsec-bouncer: ... Get "https://plugins.traefik.io/.../v1.3.3": context deadline exceeded Il ne s'arrête pas pour autant. Il journalise « Plugins are disabled because an error has occurred. » et sert quand même — sans plugin. Le middleware `crowdsec` devient alors « invalid middleware type or middleware does not exist » et Traefik JETTE tous les routeurs qui le référencent, c'est-à-dire presque tout l'ingress. Mesuré : www.arcodange.lab et cms-rec.arcodange.lab en 404 pendant 3 h 18, et zéro pull du bouncer depuis 14:30. Un `kubectl rollout restart` a tout rétabli (404 → 200), ce qui confirme le caractère transitoire : le registre répondait en 214 ms depuis un pod de pi1 au moment du diagnostic. Un hoquet de moins de 10 s a suffi à ouvrir l'edge et à couper les sites. Deux causes, deux correctifs. 1. Le réseau était dans le chemin de démarrage (experimental.localPlugins) `experimental.plugins` fait passer Traefik par RegistryDownloader.Download(), qui appelle plugins.traefik.io à CHAQUE démarrage, avec 10 s de timeout. ⚠ Avoir déjà l'archive en cache NE DISPENSE PAS de l'appel : vérifié dans pkg/plugins/downloader.go (v3.6.2), le cache ne sert qu'à envoyer un hash pour obtenir un 304. Persister /plugins-storage (aujourd'hui un emptyDir) n'aurait donc rien évité — c'était la correction évidente, elle est fausse. `localPlugins` emprunte un autre chemin de code, SetupLocalPlugins, qui ne fait aucune I/O réseau : Yaegi lit les sources sous /plugins-local/src/. Les sources sont posées sur un PVC par un Job Ansible, à la version déclarée. Le réseau ne sert plus qu'au provisionnement, où l'on peut réessayer sans que l'ingress soit en jeu (5 tentatives). Même famille de fragilité que 2026-08-26 (résolution de la forge) : on ne fait pas dépendre l'edge d'un service joignable à la seconde près. 2. L'échec était silencieux (abortOnPluginFailure) Servir l'ingress sans bouncer ni captcha, en ne le disant qu'en INFO dans les logs, n'est pas un mode dégradé acceptable pour un composant de sécurité. Traefik >= 3.3 sait refuser de démarrer : on le lui demande. Maintenant qu'il n'y a plus de réseau au boot, ce drapeau ne peut se déclencher que sur un PVC vide ou corrompu — cas où l'on veut précisément qu'il refuse de démarrer. ⚠ Monter la version du plugin passe désormais par Ansible (`--tags traefik-plugins`) et non plus par un champ que Traefik irait chercher. Le Job est idempotent (marqueur .vendored-version) et tourne sur le nœud du control-plane, donc il peut repeupler le PVC sans couper l'ingress. Vérifié : `helm template` du chart v37.4.0 avec les valeurs réelles du playbook rend bien --experimental.localPlugins.crowdsec-bouncer.moduleName et --experimental.abortonpluginfailure=true, le montage en subPath, et plus aucun emptyDir `plugins`. Co-Authored-By: Claude Opus 5 --- .../factory/playbooks/system/k3s_config.yml | 241 +++++++++++++++++- 1 file changed, 237 insertions(+), 4 deletions(-) diff --git a/ansible/arcodange/factory/playbooks/system/k3s_config.yml b/ansible/arcodange/factory/playbooks/system/k3s_config.yml index 6ecb7e1..4aa7e17 100644 --- a/ansible/arcodange/factory/playbooks/system/k3s_config.yml +++ b/ansible/arcodange/factory/playbooks/system/k3s_config.yml @@ -18,8 +18,22 @@ - name: how to reach k3s hosts: server + + vars: + # Plugin bouncer CrowdSec de Traefik. Ces trois variables sont la SEULE + # source de vérité : elles pilotent à la fois ce que la tâche + # « vendorer le plugin CrowdSec » télécharge et ce que Traefik va lire sur + # disque (cf. experimental.localPlugins plus bas). Monter de version = changer + # `traefik_crowdsec_plugin_version` puis rejouer `--tags traefik-plugins`. + traefik_crowdsec_plugin_module: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + traefik_crowdsec_plugin_version: v1.3.3 + # Sous-dossier du PVC monté sur /plugins-local/src/. Un PVC par + # plugin serait du gaspillage : on range chaque plugin dans son sous-dossier. + traefik_crowdsec_plugin_subpath: crowdsec-bouncer + traefik_plugins_pvc_name: traefik-plugins-local + tasks: - + # Tag `longhorn` : rejouer CETTE tâche seule (`--tags longhorn`). # ⚠ Sans tag, ce playbook exécute aussi « redeploy traefik » sur localhost, # qui SUPPRIME le Deployment traefik dans le contexte kubectl COURANT — @@ -64,6 +78,173 @@ vars: longhorn_helm_values: {} # https://github.com/longhorn/longhorn/blob/master/chart/values.yaml + # Tag `traefik-plugins` : rejouer CES tâches seules (`--tags traefik-plugins`), + # typiquement après avoir monté `traefik_crowdsec_plugin_version`. + # ⚠ Ces tâches DOIVENT rester avant l'écriture du manifeste Traefik : avec + # `abortOnPluginFailure: true`, un Traefik qui redémarre sur un PVC vide refuse + # de démarrer. Peupler d'abord, reconfigurer ensuite. + - name: vendorer le plugin CrowdSec sur un PVC + tags: [traefik-plugins] + delegate_to: localhost + become: false + block: + - name: Créer le PVC des sources de plugins Traefik + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: "{{ traefik_plugins_pvc_name }}" + namespace: kube-system + spec: + accessModes: [ReadWriteOnce] + storageClassName: longhorn + resources: + requests: + # Les sources du bouncer font 574 Ko décompressées (147 fichiers, + # vendor/ inclus). 128 Mi laisse de la place pour un second plugin + # sans avoir à retoucher au PVC. + storage: 128Mi + + - name: Lancer le Job qui pose les sources du plugin + kubernetes.core.k8s: + state: present + definition: + apiVersion: batch/v1 + kind: Job + metadata: + name: vendor-traefik-plugins + namespace: kube-system + spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + # Même nœud que Traefik : le PVC est RWO, donc attaché à UN nœud. + # Plusieurs pods d'un même nœud peuvent le monter, ce qui permet + # de repeupler sans couper l'ingress — Traefik ne lit ses plugins + # qu'au démarrage, écrire pendant qu'il tourne est sans effet + # jusqu'à son prochain redémarrage. + nodeSelector: + node-role.kubernetes.io/control-plane: 'true' + tolerations: + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule + volumes: + - name: plugins + persistentVolumeClaim: + claimName: "{{ traefik_plugins_pvc_name }}" + containers: + - name: vendor + image: alpine:3.20 + env: + - name: MODULE + value: "{{ traefik_crowdsec_plugin_module }}" + - name: VERSION + value: "{{ traefik_crowdsec_plugin_version }}" + - name: SUBPATH + value: "{{ traefik_crowdsec_plugin_subpath }}" + command: + - /bin/sh + - -eu + - -c + - | + DEST="/plugins/$SUBPATH" + + # Idempotence : on ne retélécharge pas si la version posée + # est déjà la bonne ET que le manifeste est là (un PVC + # à moitié écrit ne doit pas passer pour valide). + if [ -f "$DEST/.vendored-version" ] \ + && [ "$(cat "$DEST/.vendored-version")" = "$VERSION" ] \ + && [ -f "$DEST/.traefik.yml" ]; then + echo "déjà vendoré en $VERSION, rien à faire" + exit 0 + fi + + TMP="$(mktemp -d)" + URL="https://plugins.traefik.io/public/download/$MODULE/$VERSION" + + # Ici, et seulement ici, on dépend du réseau. Contrairement + # au téléchargement que faisait Traefik au démarrage, on + # peut réessayer sans que l'ingress soit en jeu. + n=0 + until wget -q -T 30 -O "$TMP/plugin.zip" "$URL"; do + n=$((n + 1)) + if [ "$n" -ge 5 ]; then + echo "échec du téléchargement de $URL après 5 tentatives" >&2 + exit 1 + fi + echo "tentative $n échouée, nouvel essai dans 10 s" >&2 + sleep 10 + done + + mkdir -p "$TMP/x" + unzip -q "$TMP/plugin.zip" -d "$TMP/x" + + # L'archive du registre contient un unique dossier racine + # horodaté (maxlerebourg-...-). On ne devine pas son + # nom : on vérifie qu'il est bien unique. + if [ "$(find "$TMP/x" -mindepth 1 -maxdepth 1 -type d | wc -l)" -ne 1 ]; then + echo "archive inattendue : la racine n'est pas un dossier unique" >&2 + exit 1 + fi + SRC="$(find "$TMP/x" -mindepth 1 -maxdepth 1 -type d)" + + # Sans manifeste, Yaegi ne chargera rien et Traefik + # refusera de démarrer (abortOnPluginFailure). Autant + # s'en apercevoir ici. + if [ ! -f "$SRC/.traefik.yml" ]; then + echo "manifeste .traefik.yml absent de l'archive" >&2 + exit 1 + fi + + rm -rf "$DEST" + mkdir -p "$DEST" + cp -a "$SRC/." "$DEST/" + # Traefik tourne en 65532 et ne fait que lire. + chmod -R a+rX "$DEST" + echo "$VERSION" > "$DEST/.vendored-version" + + echo "plugin $MODULE@$VERSION posé dans $DEST" + ls "$DEST/.traefik.yml" "$DEST/vendor" >/dev/null + volumeMounts: + - name: plugins + mountPath: /plugins + + - name: Attendre la fin du Job + kubernetes.core.k8s_info: + api_version: batch/v1 + kind: Job + name: vendor-traefik-plugins + namespace: kube-system + register: vendor_job + until: >- + (vendor_job.resources[0].status.succeeded | default(0) | int) > 0 + or (vendor_job.resources[0].status.failed | default(0) | int) > 0 + retries: 30 + delay: 10 + + - name: Échouer si le Job n'a pas abouti + ansible.builtin.assert: + that: (vendor_job.resources[0].status.succeeded | default(0) | int) > 0 + fail_msg: >- + Le plugin n'a pas pu être posé sur le PVC. Ne pas reconfigurer Traefik + en l'état : avec abortOnPluginFailure il refuserait de démarrer. + Journal : kubectl logs -n kube-system job/vendor-traefik-plugins + + always: + - name: Nettoyer le Job + delegate_to: localhost + become: false + kubernetes.core.k8s: + state: absent + api_version: batch/v1 + kind: Job + name: vendor-traefik-plugins + namespace: kube-system + - name: customize k3s traefik configuration https://docs.k3s.io/helm block: - name: Get my public IP @@ -200,6 +381,15 @@ volumeMounts: - name: data mountPath: /data + # Sources du plugin CrowdSec, posées sur ce PVC par la tâche + # « vendorer le plugin CrowdSec ». Le chart EXIGE que le volume + # référencé par un localPlugin figure ici, sinon le rendu échoue + # (helper `traefik.volumeExistsInAdditionalVolumes`) — garde-fou + # utile : on ne peut pas déclarer le plugin sans son volume. + additionalVolumes: + - name: plugins-local + persistentVolumeClaim: + claimName: "{{ traefik_plugins_pvc_name }}" # default is https://github.com/traefik/traefik-helm-chart/blob/v25.0.0/traefik/values.yaml <- for v25 (`kubectl describe deployments.apps traefik -n kube-system | grep helm.sh/chart`) # current is https://github.com/traefik/traefik-helm-chart/blob/v37.4.0/traefik/values.yaml nodeSelector: @@ -260,10 +450,53 @@ mountPath: /config type: configMap experimental: - plugins: + # ⚠ PLUGIN LU SUR DISQUE, PLUS TÉLÉCHARGÉ AU DÉMARRAGE. + # + # `experimental.plugins` (registre distant) fait passer Traefik par + # RegistryDownloader.Download(), qui appelle plugins.traefik.io à + # CHAQUE démarrage, avec 10 s de timeout. Avoir déjà l'archive dans + # /plugins-storage ne dispense PAS de l'appel : le cache ne sert qu'à + # envoyer un hash pour obtenir un 304. Persister ce cache (en faire un + # PVC au lieu d'un emptyDir) n'aurait donc rien évité — vérifié dans + # la source de Traefik v3.6.2, pkg/plugins/downloader.go. + # + # Échec mesuré le 2026-09-20 à 15:00:50 : + # unable to install plugin crowdsec-bouncer: ... Get + # "https://plugins.traefik.io/.../v1.3.3": context deadline exceeded + # alors que le registre répondait en 214 ms depuis un pod de pi1 — + # c'était un hoquet, pas une panne. Ça a suffi. + # + # Traefik ne s'arrête pas pour autant : il journalise « Plugins are + # disabled because an error has occurred. » et sert quand même. Le + # middleware `crowdsec` devient alors « invalid middleware type or + # middleware does not exist » et Traefik JETTE tous les routeurs qui + # le référencent — c'est presque tout l'ingress. Mesuré : + # www.arcodange.lab et cms-rec.arcodange.lab en 404 pendant 3 h 18. + # + # `localPlugins` emprunte un autre chemin de code (SetupLocalPlugins), + # qui ne fait AUCUNE I/O réseau : Yaegi lit les sources sous + # /plugins-local/src/. C'est la même famille de fragilité que + # 2026-08-26 (résolution de la forge) : on ne fait pas dépendre l'edge + # d'un résolveur ou d'un registre joignable à la seconde près. + # + # ⚠ Monter la version du plugin ne se fait plus en changeant un champ + # que Traefik irait chercher : il faut REJOUER ce playbook + # (`--tags traefik-plugins`) pour repeupler le PVC. + localPlugins: crowdsec-bouncer: - moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin #https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin - version: v1.3.3 + moduleName: "{{ traefik_crowdsec_plugin_module }}" #https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin + mountPath: "/plugins-local/src/{{ traefik_crowdsec_plugin_module }}" + type: localPath + volumeName: plugins-local + subPath: "{{ traefik_crowdsec_plugin_subpath }}" + # Échouer franchement plutôt que servir sans bouncer. Sans ce drapeau, + # un plugin qui ne charge pas dégrade l'edge EN SILENCE (c'est toute + # l'histoire ci-dessus). Maintenant qu'il n'y a plus de réseau au boot, + # il ne peut se déclencher que si le PVC est vide ou corrompu — et dans + # ce cas on VEUT que Traefik refuse de démarrer plutôt qu'il ouvre + # l'ingress sans filtrage ni captcha. + # Requiert Traefik >= 3.3 (on est en 3.6.2). + abortOnPluginFailure: true additionalArguments: - '--providers.file.filename=/config/dynamic.yaml' - '--providers.kubernetesingress.ingressendpoint.publishedservice=kube-system/traefik' -- 2.54.0