OOMKILLED EXIT 137

Chronique nº 003

initdb a bouffé deux de nos trois réplicas Postgres. Celui qu'on avait mis au rebut nous a sauvés.

· SEV-1 · POSTGRES / KUBERNETES / STATEFULSET · 7 min sur la ligne

Les pods sont revenus en bonne santé. C’était ça, le pire.

On avait fait un rolling restart de routine sur un StatefulSet Postgres — changement de config, rien d’exotique. postgres-0 et postgres-1 ont terminé, ont été reschedulés, sont passés au vert. Les readiness probes étaient OK. Et puis l’application s’est mise à cracher des erreurs qui n’avaient aucun sens : les tables n’existaient pas. Pas « permission denied », pas « connection refused ». N’existaient pas.

La base était up. Elle était juste vide. Un cluster flambant neuf, fraîchement initialisé, posé sur les volumes qui, trente minutes plus tôt, contenaient les données de production.

Une base peut être parfaitement saine et parfaitement vide en même temps. Ce ne sont pas des états mutuellement exclusifs — et ta readiness probe te dira avec le sourire que tout va bien.

Ce qu’on faisait tourner

Un StatefulSet Postgres à trois nœuds — postgres-0, -1, -2 — un primary en streaming et deux réplicas, chacun avec son propre PersistentVolumeClaim (data-postgres-0, -1, -2) adossé à des disques locaux aux nœuds. Le pattern standard : identité réseau stable, stockage stable, rollout ordonné.

Quelques semaines plus tôt, on l’avait scalé de 3 à 2. Le troisième nœud, c’était de la marge surprovisionnée qu’on n’utilisait pas, et quelqu’un — moi — voulait récupérer la capacité. kubectl scale statefulset postgres --replicas=2. postgres-2 a été drainé, le pod a disparu, le compte de réplicas est tombé. Propre. Je n’y ai plus repensé.

Cette décision est la seule raison pour laquelle cette histoire finit bien.

Comment un restart devient une réinitialisation

On faisait tourner le chart Bitnami postgresql-ha — Postgres plus repmgr pour la réplication et le failover, avec Pgpool en frontal. La partie qui compte vit dans l’entrypoint du conteneur repmgr, dans une fonction appelée postgresql_repmgr_initialize. Elle s’exécute à chaque démarrage de pod. À chaque fois.

Au démarrage, le conteneur ne se contente pas de lancer Postgres — il détermine qui il est. Il interroge le cluster repmgr pour trouver le primary courant, puis il branche. S’il décide qu’il est le primary et que le data directory a l’air non initialisé, il lance un initdb tout neuf. S’il décide qu’il est un standby, il lance repmgr_clone_primary, qui tente pg_rewind et, quand ça échoue, retombe sur pg_basebackup --force. Ce --force n’a rien de subtil sur ce qu’il fait :

NOTICE: -F/--force provided - deleting existing data directory "/bitnami/postgresql/data"

Il supprime le data directory avant de cloner. C’est voulu.

Maintenant, empile les défaillances. On a redémarré le StatefulSet, donc les deux pods restants sont tombés quasiment en même temps. Quand ils sont revenus, repmgr n’a pas pu joindre de primary dans son timeout de connexion — et le chemin de fallback, c’est celui qui met fin à des carrières :

Can not find new primary ... There are no nodes with primary role. Assuming the primary role

Un nœud qui ne trouve pas de primary part du principe qu’il en est un. Donc postgres-0 s’est bootstrappé comme un primary neuf et vide. postgres-1 est remonté en standby, a tenté pg_rewind — qui échoue d’office sur cette image, parce que la config vit dans /bitnami/postgresql/conf, pas dans le data directory, donc rewind ne trouve pas postgresql.conf — et est tombé tout droit sur pg_basebackup --force. Il a supprimé son propre data directory intact et a cloné le primary vide.

Deux de nos trois copies de la base ont été détruites par la propre logique de démarrage de la base. La réplication ne nous a pas sauvés — elle a fait son boulot à la perfection, et a répliqué du vide.

La copie à laquelle personne n’a touché

Pendant que je calculais à quel point on était finis, quelqu’un a lancé kubectl get pvc et on a tous fixé la sortie :

NAME              STATUS   VOLUME      CAPACITY
data-postgres-0   Bound    pvc-a1b2…   200Gi
data-postgres-1   Bound    pvc-c3d4…   200Gi
data-postgres-2   Bound    pvc-e5f6…   200Gi   # ← toujours là

data-postgres-2 était toujours là. Toujours Bound. Intact depuis le jour où on avait scalé vers le bas.

Voilà le truc que beaucoup de gens ne découvrent qu’au moment où il les sauve ou les brûle : scaler un StatefulSet vers le bas ne supprime pas les PVC. Kubernetes retire le pod et laisse le volume exactement là où il est. (Depuis la 1.27 il y a persistentVolumeClaimRetentionPolicy pour changer ça, et même là, whenScaled est par défaut sur Retain.) On avait dit à Kubernetes de retirer le troisième réplica. Il a discrètement gardé les données du troisième réplica, sur un disque, sur un nœud, pendant des semaines — et cet entrypoint meurtrier ne s’est jamais exécuté contre lui, parce qu’aucun pod ne l’a jamais remonté. Ni initdb ni pg_basebackup --force n’ont jamais eu l’occasion d’y toucher.

Le réplica que j’avais supprimé contenait la seule copie intacte de la base.

La récupération

Comme les volumes étaient locaux aux nœuds, les données n’étaient pas dans une API de block storage abstraite — c’était un répertoire sur une machine précise. Ça a rendu la récupération manuelle, et ça l’a rendue possible.

Le chemin :

  1. Trouver le volume. kubectl get pv pvc-e5f6… nous a donné le node affinity et le chemin sur disque — les volumes locaux sont épinglés à un nœud et vivent à un emplacement connu de son filesystem.
  2. Accéder à ce nœud et confirmer que le PGDATA était intact et dans la bonne version majeure. Il l’était. Les fichiers étaient exactement tels que postgres-2 les avait laissés.
  3. Le remonter en isolation. On a monté ce PVC dans un pod de récupération jetable — pas membre du StatefulSet, pas de script d’init, juste un Postgres tout simple pointant sur le data directory récupéré. Il a démarré proprement et toutes les données étaient là.
  4. Restaurer vers l’avant. pg_dumpall depuis l’instance récupérée, restauration dans un postgres-0 reconstruit comme nouveau primary, puis on a laissé postgres-1 re-cloner à partir de lui via un base backup neuf. Réplication saine, erreurs applicatives disparues.

Des heures, pas des minutes. Mais les données sont revenues entières.

Ce que j’en ai vraiment retiré

La version propre de cette histoire, c’est « Kubernetes nous a sauvés en retenant un PVC ». La version honnête, c’est « on a survécu par chance, et la chance n’est pas une architecture ».

  • Un restart peut être une réinitialisation. Toute logique de démarrage qui choisit le rôle d’un nœud puis lance initdb ou pg_basebackup --force selon ce qu’elle arrive à joindre en quelques secondes est à un hoquet réseau de t’effacer. Le mode de défaillance que tu veux, c’est « refuser de démarrer et réveiller un humain », jamais « partir du principe que je suis primary et repartir de zéro ».
  • Ne redémarre jamais tous les pods d’un coup sur de la HA façon repmgr. Les restarts simultanés, c’est exactement ce qui déclenche le fallback « je ne trouve pas de primary, je serai le primary ». Roule un pod à la fois, attends qu’il rejoigne et rattrape son retard, ensuite touche au suivant. Un kubectl rollout restart sur tout le StatefulSet, c’est le chemin direct vers ce désastre.
  • La réplication n’est pas une sauvegarde. Elle réplique tes erreurs à la vitesse de la lumière. Un primary vide te donne des réplicas vides.
  • Des sauvegardes que tu n’as jamais restaurées ne sont pas des sauvegardes. On avait des sauvegardes. On ne s’est pas jeté dessus en premier parce qu’on n’avait jamais répété une restauration et qu’on ne faisait pas confiance au timing. Ce qui nous a sauvés, c’est un accident, pas le plan de récupération qu’on était censés avoir.
  • Sache où vivent physiquement tes données. Les volumes locaux aux nœuds ont rendu cette récupération manuelle mais faisable. Avec du stockage abstrait, on aurait peut-être eu un chemin plus rapide — ou pas de chemin du tout. Dans tous les cas : savoir exactement où sont les octets, c’est ce qui nous a permis d’aller vite sous pression.
  • persistentVolumeClaimRetentionPolicy est désormais une vraie décision. La rétention qui nous a sauvés est aujourd’hui le défaut, mais c’est configurable. Règle-la exprès. Ne laisse pas « qu’est-ce qui arrive aux données quand je scale vers le bas » être une chose que tu découvres pendant un incident.

On a gardé le troisième PVC par accident et il a ramené la base d’entre les morts. Je préférerais que tu gardes le tien exprès — et que tu testes la restauration avant d’en avoir besoin.


Si tu fais tourner ce chart, le comportement de force-clone-au-restart n’est pas un secret — il est partout sur le issue tracker, et ça vaut le coup de le lire avant qu’il ne te lise : containers#52213 (standby always full-resyncs on restart), charts#20998 (restarting node ignores the new primary and assumes the role), charts#14044 (data loss on Postgres HA).