← Alle artikelen

Een PostgreSQL-backend werd door de OOM-killer gestopt, en systemd stopte daarna de hele Patroni-service. Een root cause analysis over connection pools, page tables en een standaardinstelling die je niet verwacht.

Eén OOM-kill, en Patroni ging mee: systemd OOMPolicy

Op een maandagmiddag meldde de load balancer dat de primaire database onbereikbaar was. Na 25 seconden was hij terug, zonder failover. Impact: 0,08 % foutieve requests en geen meldingen van gebruikers. Toch wilde ik weten wat er gebeurd was, want "het herstelde zich vanzelf" is geen verklaring.

De keten, uit metrics

  • De connection pools van de applicatieservers groeiden binnen twee minuten van 20–40 naar 150–177 verbindingen per server. Niet door meer verkeer, maar doordat requests langer duurden.
  • Daardoor ontstonden ruim 550 backends op een max_connections van 600.
  • Elke backend gebruikte ~110 MiB geheugen plus ~40 MiB aan page tables, omdat er geen huge pages waren. Bij 126 GiB RAM en een gevulde page cache is dat precies het verschil.
  • MemAvailable zakte naar 4,7 GiB, de OOM-killer kwam in actie en killde drie postgres-processen.

Tot zover verwacht. Wat ik niet verwachtte: Patroni zelf werd vier seconden later met SIGKILL beëindigd, en niet door de OOM-killer.

systemd 255: OOMPolicy=stop

PostgreSQL draait als child van Patroni, dus in de cgroup van patroni.service. Sinds systemd 255 is de standaard voor services OOMPolicy=stop: zodra de kernel ergens in de cgroup een proces OOM-killt, stopt systemd de héle unit. Eén gekilde backend is dus genoeg om de clustermanager neer te halen.

memory.events in de cgroup bevestigde het: oom_kill 3. De oplossing is een drop-in:

# /etc/systemd/system/patroni.service.d/oom.conf
[Service]
OOMPolicy=continue

PostgreSQL vangt het verlies van één backend zelf op (crash recovery van een paar seconden). Dat is veel minder erg dan een gestopte Patroni, waardoor het cluster een nieuwe leider moet kiezen.

Meetvalkuilen

  • Sampletijden verschillen per exporter. De node-exporter, de postgres-exporter en de applicatiemetrics worden op andere seconden gescrapet. Met query_range lijkt de volgorde van gebeurtenissen dan verkeerd. Lees ruwe samples (metric[5m]) en zet ze zelf op een tijdlijn.
  • Een gat van tien seconden zie je niet. De metric "is primary" bleef 1, omdat de scrape het moment van de herstart net miste. Logs vulden dat gat.
  • Saniteer logs vóór analyse. Databaselogs bevatten queries met persoonsgegevens. Ik liet een script op de server alleen de structuur overhouden (tijd, proces, foutcode), en analyseerde pas daarna.

Wat er structureel uitkwam

  1. OOMPolicy=continue voor Patroni.
  2. Een connection pooler (PgBouncer in transaction-modus), zodat pieken in applicatiepools niet één-op-één backends worden.
  3. Capaciteit: de replica had minder cores dan de primary en zou na een failover direct vastlopen.
  4. De echte trigger: een abrupte vertraging van statements, zonder lock-waits. Die ligt waarschijnlijk in de applicatie en is als open punt met het ontwikkelteam belegd.

Een incident van 25 seconden, en vier structurele verbeteringen. Daarom schrijf ik ook RCA's voor storingen die "vanzelf over" gingen.