FastAPI Hexagonal Code source

01. Les données

PostgreSQL en haute disponibilité logique, un pooler en mode transaction, et la contrainte qui remonte jusque dans le moteur SQLAlchemy.

TL;DR — Un Cluster CloudNativePG de 3 instances avec anti-affinité requise, un Pooler PgBouncer de 2 pods en mode transaction. Ce mode dicte trois réglages côté application, et impose que les migrations Alembic contournent le pooler.

Pourquoi un opérateur plutôt qu'un StatefulSet#

Une base de données n'est pas un workload comme un autre : elle a un état, un primaire, des réplicas, et un basculement à orchestrer quand le primaire disparaît. Écrire ça soi-même dans un StatefulSet revient à réimplémenter, mal, ce qu'un opérateur fait déjà.

CloudNativePG prend en charge la réplication en flux, l'élection d'un nouveau primaire, les Services -rw / -ro qui suivent les rôles, et le pooler. On lui décrit l'état voulu, il s'occupe du reste.

# k8s/database.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: task-manager-db
  namespace: task-manager
spec:
  instances: 3                    # 1 primaire + 2 réplicas
  affinity:
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname
    podAntiAffinityType: required # une instance par nœud, sans exception
  storage:
    size: 1Gi
  bootstrap:
    initdb:
      database: task_manager
      owner: task_manager

Pourquoi required et pas preferred#

C'est le choix le plus lourd de conséquence du fichier, et il mérite d'être explicite.

preferredrequired (retenu)
PlacementLe scheduler essaie de répartirUne instance par nœud, garanti
Si moins de 3 nœudsTout se schedule quand mêmeUne instance reste Pending
Perte d'un nœudPeut emporter 2 instances sur 3 — moins de réplicas disponiblesN'emporte qu'une instance

L’anti-affinité stricte évite de perdre plusieurs instances avec un seul nœud. Le failover dépend aussi de la disponibilité du contrôleur, du contrôle Kubernetes et d’un réplica suffisamment à jour. PostgreSQL en réplication asynchrone ne requiert pas un quorum de deux instances pour servir : ne pas le confondre avec le consensus d’une quorum queue RabbitMQ.

Réplication asynchrone, par défaut — et les deux chiffres qui vont avec

Le manifeste laisse la réplication en asynchrone : priorité à la latence et à la disponibilité des écritures. Un bloc postgresql.synchronous commenté permet de passer en synchrone pour un RPO proche de zéro — au prix d'écritures qui bloquent si trop peu de réplicas répondent. Le compromis est posé dans le fichier, pas caché.

Ce sont les deux questions à poser avant de choisir. Le RPO (recovery point objective) : combien de données accepte-t-on de perdre ? En asynchrone, ce n'est pas zéro — une transaction commitée sur le primaire qui meurt avant de répliquer est perdue. Le RTO (recovery time objective) : combien de temps accepte-t-on d'être indisponible ? Un failover n'est jamais instantané : détection, promotion, bascule du Service, reconnexion des clients — et les transactions en vol échouent (ch. 03). Ce dépôt ne se fixe ni l'un ni l'autre : c'est un banc d'essai, pas un engagement de service. Les deux sont néanmoins mesurés au chapitre 05 — un objectif qu'on ne mesure pas n'est qu'un vœu.

« Haute disponibilité » veut dire quelque chose de précis

Trois instances sur trois « nœuds » Kind, ce sont trois conteneurs sur la même machine, le même noyau, le même disque (voir la topologie). Ce montage teste des comportements — élection, promotion, bascule du Service, rescheduling — il ne teste aucune panne indépendante. Et HA ne se confond ni avec durabilité, ni avec sauvegarde, ni avec reprise après sinistre : ce sont quatre propriétés distinctes, qui se paient séparément (ch. 05).

Le pooler : deux pods, mode transaction#

apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
  name: task-manager-pooler
spec:
  cluster:
    name: task-manager-db
  instances: 2                    # deux processus ; placement sur les nœuds à contrôler
  type: rw
  pgbouncer:
    poolMode: transaction
    parameters:
      max_client_conn: "1000"
      default_pool_size: "20"     # ⚠️ par pod ET par couple (utilisateur, base)

Deux détails qui se paient cher si on les rate :

Ce que le mode transaction impose au code#

Le pooler prête une connexion serveur pendant une transaction. Une nouvelle transaction peut utiliser une autre connexion ; la continuité de session n’est donc pas garantie.

PgBouncer · poolMode: transaction une connexion serveur par transaction, clients multiplexés URL de connexion Moteur SQLAlchemy Migrations prepared_statement _cache_size=0 prepared_statement_name_func pool_pre_ping=True task-manager-db-rw (contourne le pooler) api-deployment.yaml persistence/database.py migrate-job.yaml Une seule décision d'infrastructure, trois fichiers dans trois couches différentes.

1. Le cache d'énoncés préparés, désactivé#

# k8s/api-deployment.yaml
- name: APP_DATABASE_URL
  value: "postgresql+asyncpg://task_manager:$(DB_PASSWORD)@task-manager-pooler:5432/task_manager?prepared_statement_cache_size=0"

Le dépôt désactive le cache de requêtes préparées du dialecte SQLAlchemy/asyncpg avec prepared_statement_cache_size=0. Cela ne désactive pas les préparations elles-mêmes. PgBouncer sait gérer les prepared statements nommés au niveau protocole depuis 1.21 si max_prepared_statements est non nul ; PREPARE/EXECUTE SQL restent un autre cas. Voir la FAQ PgBouncer.

2. Un nom d'énoncé unique, et pool_pre_ping#

# backend/src/task_manager/infrastructure/persistence/database.py
return create_async_engine(
    database_url.get_secret_value(),
    pool_pre_ping=True,
    pool_size=pool_size,
    max_overflow=max_overflow,
    connect_args={"prepared_statement_name_func": lambda: f"__asyncpg_{uuid4()}__"},
)
Des réglages à tester, pas une loi universelle. Le cache désactivé et les noms uniques correspondent au choix de ce dépôt. Leur nécessité dépend de la version du pooler et de son suivi des prepared statements. pool_pre_ping répond à un autre problème. Vérifier les paramètres effectifs et tester le chemin SQLAlchemy → PgBouncer → PostgreSQL ; les tests d’intégration actuels visent PostgreSQL directement.

Les migrations contournent le pooler#

Le DDL PostgreSQL est largement transactionnel et peut passer par PgBouncer. Une connexion directe au primaire facilite cependant la maîtrise de la session de migration et de ses opérations hors transaction. C’est le choix du dépôt, pas une obligation générale d’Alembic.

Le Job de migration vise donc PostgreSQL en direct :

# k8s/migrate-job.yaml
- name: APP_DATABASE_URL
  value: "postgresql+asyncpg://task_manager:$(DB_PASSWORD)@task-manager-db-rw:5432/task_manager"
- name: APP_INIT_DB
  value: "false"

Trois points valent d'être notés :

# k8s/deploy.sh — la barrière
kubectl apply -f k8s/migrate-job.yaml
kubectl wait --for=condition=complete job/db-migrate -n task-manager --timeout=180s
kubectl apply -f k8s/api-config.yaml
kubectl apply -f k8s/api-deployment.yaml

Le Job porte backoffLimit: 5 et restartPolicy: OnFailure : une base pas encore prête au premier essai n'échoue pas le déploiement, elle provoque une nouvelle tentative.

Les secrets, générés et jamais écrits#

Le mot de passe de la base n'apparaît nulle part dans le dépôt. CNPG crée le secret task-manager-db-app au bootstrap ; les workloads le référencent :

- name: DB_PASSWORD
  valueFrom:
    secretKeyRef: { name: task-manager-db-app, key: password }
# puis interpolé dans l'URL
- name: APP_DATABASE_URL
  value: "postgresql+asyncpg://task_manager:$(DB_PASSWORD)@task-manager-pooler:5432/..."

Même principe pour RabbitMQ, dont l'opérateur génère rabbitmq-default-user. Le fichier secrets.yaml versionné ne contient que ce qui n'est pas généré — et ses valeurs sont des change-me explicites, pas des secrets déguisés en exemple.

À retenir#