fix(traefik,crowdsec) — plugin bouncer lu sur disque : un hoquet de 10 s coupait tout l'ingress #62

Merged
arcodange merged 1 commits from arcodange/crowdsec-repair-045e72 into main 2026-09-20 18:39:47 +02:00
@@ -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/<module>. 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-...-<sha>). 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/<module>. 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'