FastAPI Hexagonal Code source

03. Résilience

Trois mécanismes indépendants, trois questions différentes — et un scénario de drain qui révèle une conséquence non évidente.

TL;DR — L'anti-affinité décide où, le HPA décide combien, le PDB décide combien on peut retirer d'un coup. Ils ne se remplacent pas. Et avec une anti-affinité requise sur 3 nœuds, drainer un worker laisse une instance PostgreSQL en Pending — c'est attendu.

Trois mécanismes, trois questions#

MécanismeQuestionAgitProtège de
Anti-affinitéOù placer les pods ?au schedulingla perte d'un nœud
HPACombien de pods ?en continula montée de charge
PodDisruptionBudgetCombien peut-on en retirer ?aux évictions via l'API Evictionnos propres opérations

La confusion la plus fréquente porte sur le dernier : un PDB ne protège pas d'un crash. Si un nœud brûle, les pods meurent, PDB ou pas. Il ne contraint que les évictions qui passent par l'API Eviction — un kubectl drain, un vidage de nœud déclenché par l'autoscaler. C'est déjà énorme : ce sont les perturbations qu'on déclenche soi-même, donc celles qu'on peut rendre sûres.

Un PDB ne protège pas un rolling update

C'est l'erreur qui suit immédiatement. Un rolling update de Deployment et un kubectl rollout restart sont pilotés par le contrôleur du Deployment — maxUnavailable, maxSurge, sondes de readiness — et ne consultent pas le PDB. Une suppression directe de pod ou de Deployment le contourne également. Deux mécanismes distincts, deux réglages distincts : « j'ai un PDB, donc mon déploiement est protégé » est faux.

Anti-affinité : requise pour les données, préférée pour le reste#

WorkloadTypeEffet si le placement est impossible
PostgreSQL (CNPG ×3)requiredLe pod reste Pending
api ×2preferred (poids 100)Le pod se schedule quand même, colocalisé
mail-worker ×2preferredidem
frontend ×2preferredidem
RabbitMQ ×3preferredidem

Le choix required protège la séparation des instances ; preferred privilégie la facilité de placement. Cette décision dépend du stockage, de la réplication, du nombre de nœuds disponibles et des opérations de maintenance, pas seulement du caractère stateful du workload. PostgreSQL asynchrone n’exige pas le quorum de réplicas décrit pour RabbitMQ.

Un choix discutable, et discuté RabbitMQ porte aussi de l'état (les files, persistées sur 1 Gi) mais utilise preferred. C'est un compromis : sur un cluster de développement, un cluster RabbitMQ bloqué en Pending gêne plus qu'il ne protège. Sur un vrai cluster, required se défendrait — la question mérite d'être reposée le jour où l'infrastructure sort du portable.
Un cluster RabbitMQ n'est pas une réplication des messages

La formule « 3 nœuds » induit en erreur. Les nœuds répliquent les métadonnées du cluster ; le contenu d'une queue classique vit sur un seul nœud, et RabbitMQ 4 a supprimé le mécanisme historique des mirrored classic queues. Ici, rien ne demande autre chose : la perte permanente du nœud et de son volume perd les messages ; une interruption temporaire les rend indisponibles jusqu’au retour du stockage.

Une quorum queue réplique ses messages et requiert une majorité de ses membres. Le dépôt utilise des queues classiques durables et des messages persistants. Il a déjà les ACK consumers via incoming.process() et les publisher confirms activés par défaut par aio-pika.channel(). Il manque surtout la réplication des queues et une stratégie d’idempotence. Voir aio-pika et les quorum queues.

HPA : deux métriques, et le plus exigeant l'emporte#

# k8s/hpa.yaml
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
  minReplicas: 2
  maxReplicas: 6
  metrics:
  - type: Resource
    resource:
      name: cpu
      target: { type: Utilization, averageUtilization: 70 }
  - type: Resource
    resource:
      name: memory
      target: { type: Utilization, averageUtilization: 80 }
CibleMin → MaxCPUMémoire
api2 → 670 %80 %
frontend2 → 470 %80 %
mail-workerbloc présent mais commenté — voir plus bas

Trois points à comprendre pour que ces chiffres veuillent dire quelque chose :

Sans Metrics Server, le HPA ne fait rien Il reste sur <unknown> et n'ajuste jamais. Sur Kind, il faut en plus le patcher avec --kubelet-insecure-tls, sinon il refuse les certificats auto-signés des kubelets. deploy.sh fait les deux, en vérifiant d'abord si le drapeau est déjà présent — en sachant que ce drapeau désactive la vérification du certificat kubelet et ne se transpose pas hors d'un cluster local (voir l'encadré du sommaire).

Pourquoi le worker n'est pas autoscalé#

Le bloc existe dans hpa.yaml, entièrement commenté. Le fichier ne dit pas pourquoi ; voici l'argument qui justifie de le laisser ainsi.

Un consommateur de file passe l'essentiel de son temps à attendre — un message, une réponse SMTP. Son CPU reste bas même quand 10 000 messages s'accumulent : autoscaler dessus revient à mesurer la mauvaise chose. Le signal pertinent est la profondeur de la file ou l'âge du plus vieux message, que Kubernetes ne connaît pas nativement — il faut KEDA, ou un adaptateur de métriques externes.

Laisser le bloc commenté plutôt que de le supprimer est un choix défendable : il documente une intention, et rend explicite le fait que replicas: 2 est un dimensionnement manuel assumé. Deux consommateurs concurrents sur la même file, c'est déjà du parallélisme — simplement pas élastique.

PodDisruptionBudget : rendre nos propres opérations sûres#

# k8s/pdb.yaml — un budget par workload applicatif
spec:
  minAvailable: 1
  selector:
    matchLabels: { app: api }        # puis frontend, puis mail-worker

Avec 2 replicas et minAvailable: 1, Kubernetes accepte d'évincer un pod à la fois. Un drain qui voudrait en retirer deux attend. C'est ce qui transforme une mise à jour de nœud d'un « ça devrait passer » en une garantie.

Aucun PDB supplémentaire n’est déclaré ici pour les ressources d’opérateurs. Cela ne signifie pas qu’elles n’en ont pas : CloudNativePG gère des PDB selon sa configuration. Inspecter kubectl get pdb -n task-manager et la procédure de maintenance de la version installée. Un drain peut être bloqué ; l’opérateur n’est pas une garantie que toute éviction est autorisée.

Ce qui se passe vraiment quand on draine un nœud#

1. état normal 2. kubectl drain worker-2 3. après worker-1 worker-2 worker-3 worker-1 worker-2 worker-3 worker-1 worker-2 worker-3 postgres-1 · api · front postgres-2 · api · worker postgres-3 · worker · front postgres-1 · api · front postgres-3 · worker · front postgres-1 · api ·· front postgres-3 · worker · front · api cordonné · éviction 1 par 1 (PDB) vide · non schedulable postgres-2 reste en Pending anti-affinité requise + 3 nœuds : aucun emplacement libre pour la 3ᵉ instance Les 2 autres instances suffisent au quorum ; l'application continue.

Scénario à observer, si les PDB effectifs autorisent les évictions. Un drain peut attendre et ne garantit pas que toutes les étapes suivantes se produisent. Ne pas désactiver les protections pour obtenir artificiellement le résultat attendu.

  1. Le nœud est cordonné : plus aucun nouveau pod n'y est planifié.
  2. Les pods sans état sont évincés un par un, dans le respect des PDB. L'API passe momentanément à 1 replica servant, puis remonte à 2 dès que le remplaçant est prêt sur un autre nœud.
  3. Si l'instance PostgreSQL évincée était le primaire, CNPG promeut un réplica. Le Service -rw suit automatiquement et le pooler rétablit ses connexions ; pool_pre_ping (ch. 01) réduit les erreurs dues aux connexions devenues obsolètes. Il ne rend pas la bascule transparente pour autant : les transactions en vol au moment du failover échouent, et en réplication asynchrone les écritures non encore répliquées sont perdues. C'est à l'application de tolérer ces erreurs et de rejouer — le pooler ne fait que réduire le bruit.
  4. L'instance évincée reste en Pending. Pour deux raisons indépendantes, et il faut lever les deux (ch. 06) : son volume est un répertoire du nœud cordonné, et l'anti-affinité required exige un nœud sans instance PostgreSQL : avec 3 workers dont un cordonné, il n'y en a plus. Elle reviendra au uncordon.
Ce point 4 n'est pas un bug C'est le prix explicitement payé pour la garantie du chapitre 01 : une instance par nœud, sans exception. Pendant le drain, le cluster tourne à 2 instances sur 3 — suffisant pour servir et pour basculer. La leçon générale : une contrainte required se paie en capacité de manœuvre, et il faut le savoir avant de lancer la commande, pas en lisant kubectl get pods.

Ce qui n'est pas couvert#

À retenir#