01. Les données
PostgreSQL en haute disponibilité logique, un pooler en mode transaction, et la contrainte qui remonte jusque dans le moteur SQLAlchemy.
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_managerPourquoi required et pas preferred#
C'est le choix le plus lourd de conséquence du fichier, et il mérite d'être explicite.
preferred | required (retenu) | |
|---|---|---|
| Placement | Le scheduler essaie de répartir | Une instance par nœud, garanti |
| Si moins de 3 nœuds | Tout se schedule quand même | Une instance reste Pending |
| Perte d'un nœud | Peut emporter 2 instances sur 3 — moins de réplicas disponibles | N'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.
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.
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 :
default_pool_sizese compte par pod et par couple (utilisateur, base). Ici l'application n'en utilise qu'un seul,task_managersurtask_manager: 2 pods à 20 donnent donc une capacité de 40 connexions serveur vers le primaire, dimensionnée sous lemax_connectionsde PostgreSQL (100 par défaut). Ce n'est pas une formule universelle : ajouter un second utilisateur ou une seconde base ouvre un pool de plus, etreserve_pool_sizes'y ajoute encore. Passer les poolers à 5 sans y penser, et la base refuse des connexions.- Le Service s'appelle
task-manager-pooler, sans suffixe. Le-rwappartient auClusteret vise le primaire en direct — c'est celui qu'utilisent les migrations, justement.
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.
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()}__"},
)prepared_statement_name_func— asyncpg nomme ses énoncés de façon incrémentale (__asyncpg_stmt_1__…). Deux clients multiplexés sur la même connexion serveur choisiraient le même nom et déclencheraient unDuplicatePreparedStatementError. Un UUID par énoncé supprime la collision.pool_pre_pingteste une connexion à sa sortie du pool SQLAlchemy et remplace celles déjà mortes. Il ne protège ni une transaction en cours ni un arrêt survenant après le ping, et n’est pas spécifique à PgBouncer.
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.
- Alembic ne prend pas automatiquement de verrou consultatif dans ce dépôt.
alembic/env.pyn’en crée aucun. La connexion directe ne sérialise donc pas deux migrateurs : il faut un verrou explicite ou une exclusion au niveau du déploiement si ce cas est possible. CREATE INDEX CONCURRENTLYdoit être exécuté hors bloc transactionnel. Dans PostgreSQL 17,ALTER TYPE … ADD VALUEpeut être exécuté dans une transaction, mais la nouvelle valeur ne peut être utilisée qu’après commit. Voir ALTER TYPE.- les
SETde session (lock_timeout,search_path) ne sont pas garantis d'une requête à la suivante ; - une migration doit viser le primaire, explicitement.
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 :
- Pas de
?prepared_statement_cache_size=0: sans pooler, la contrainte disparaît. L'absence est aussi intentionnelle que la présence ailleurs. APP_INIT_DB=false: le schéma est piloté par Alembic, jamais par uncreate_allau démarrage. Les deux mécanismes existent dans le code (ch. 12) ; en cluster, un seul a le droit de s'exécuter.- Le script attend le Job avant d’appliquer les workloads au premier déploiement. Sur un cluster existant, les pods tournent déjà et un Job
Completedne se relance pas par un simpleapply. Une mise à jour exige une nouvelle exécution des migrations et un schéma compatible avec les versions encore actives.
# 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.yamlLe 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#
- Une base se confie à un opérateur : failover, réplication et Services suivant les rôles ne se réimplémentent pas à la main.
- L'anti-affinité requise échange de la souplesse de placement contre la garantie qu'un nœud perdu n'emporte qu'une instance.
default_pool_sizese compte par pod et par couple (utilisateur, base) : c'est la somme qui doit rester sous lemax_connectionsde PostgreSQL, pas le chiffre du manifeste.- Les contraintes du mode transaction dépendent des fonctionnalités utilisées et de la configuration réelle du pooler ; vérifier ensemble le client, le pooler et le serveur.
- Les migrations contournent le pooler et forment une barrière avant tout pod applicatif.
- Les mots de passe sont générés par les opérateurs, jamais versionnés.