DevOpsIndex

PostgreSQL on Kubernetes (GKE)

Architecture with Patroni (High Availability)

Patroni is the standard solution for PostgreSQL HA on K8s. It uses etcd/Consul/ZooKeeper as a distributed lock to manage leader election.

graph TD
    subgraph K8S["Kubernetes (GKE)"]
        STS["StatefulSet: postgres<br/>replicas: 3"]
        PG0["postgres-0 PRIMARY<br/>Patroni leader<br/>reads + writes"]
        PG1["postgres-1 REPLICA<br/>Patroni follower<br/>streaming replication from primary"]
        PG2["postgres-2 REPLICA<br/>Patroni follower<br/>streaming replication from primary"]
        STS --> PG0 & PG1 & PG2
        PVC0["PVC: data-postgres-0<br/>100Gi GCP SSD"]
        PVC1["PVC: data-postgres-1<br/>100Gi GCP SSD"]
        PVC2["PVC: data-postgres-2<br/>100Gi GCP SSD"]
        PG0 --> PVC0
        PG1 --> PVC1
        PG2 --> PVC2
    end

    ETCD["etcd cluster<br/>(leader lock storage)"]
    PG0 & PG1 & PG2 --> ETCD

    SVC_RW["Service: postgres-primary<br/>ClusterIP<br/>routes to current leader"]
    SVC_RO["Service: postgres-replica<br/>ClusterIP<br/>routes to replicas (load balanced)"]
    SVC_RW --> PG0
    SVC_RO --> PG1 & PG2

Patroni maintains the leader lock in etcd. If the primary fails to renew the lock within ttl seconds, a replica acquires the lock and promotes itself.


Sync vs Async Replication

sequenceDiagram
    participant APP as Application
    participant PRIMARY as postgres-0 (Primary)
    participant REP1 as postgres-1 (Sync Replica)
    participant REP2 as postgres-2 (Async Replica)

    Note over APP,REP2: Synchronous replication (synchronous_standby_names='postgres-1')
    APP->>PRIMARY: INSERT INTO orders VALUES (...)
    PRIMARY->>PRIMARY: write to WAL
    PRIMARY->>REP1: WAL segment (must ACK before commit returns)
    REP1-->>PRIMARY: WAL received + flushed to disk
    PRIMARY-->>APP: COMMIT confirmed
    Note over PRIMARY,REP2: Async to postgres-2 (no wait)
    PRIMARY->>REP2: WAL segment (fire and forget)
    REP2-->>PRIMARY: ACK (whenever)
-- PostgreSQL synchronous replication config
-- postgresql.conf
synchronous_standby_names = 'FIRST 1 (postgres-1)'
-- FIRST 1: wait for at least 1 sync replica to confirm
-- This guarantees zero data loss on postgres-1

-- Check replication lag
SELECT
    client_addr,
    state,
    sent_lsn - write_lsn AS write_lag,
    sent_lsn - flush_lsn AS flush_lag,
    sent_lsn - replay_lsn AS replay_lag
FROM pg_stat_replication;

Trade-off: Synchronous replication adds latency equal to the round trip to the replica. If the sync replica is in a different zone (recommended), that's 1-5ms extra per write.


Automatic Failover with Patroni

sequenceDiagram
    participant P0 as postgres-0 (Primary)
    participant PATRONI0 as Patroni on P0
    participant ETCD2 as etcd
    participant P1 as postgres-1 (Replica)
    participant PATRONI1 as Patroni on P1
    participant SVC2 as K8s Service (postgres-primary)

    Note over P0: Primary crashes (OOM, node failure)
    PATRONI0--xETCD2: Failed to renew leader lock (TTL expires: 30s)

    PATRONI1->>ETCD2: Acquire leader lock
    ETCD2-->>PATRONI1: Lock acquired — I am the new leader
    PATRONI1->>P1: pg_promote() — become writable primary
    P1->>P1: Switches to read-write mode
    PATRONI1->>ETCD2: Update member key: postgres-1 is primary

    Note over SVC2: Patroni updates K8s endpoint labels
    PATRONI1->>SVC2: Label postgres-1 pod with role=master
    SVC2->>P1: Service now routes writes to postgres-1

    Note over P0: postgres-0 recovers
    PATRONI0->>ETCD2: Lock held by postgres-1
    PATRONI0->>P0: pg_rewind() to sync with new primary
    P0->>P1: Start streaming replication as replica

Failover time: ~30 seconds (default TTL). Tune with ttl, loop_wait, retry_timeout in Patroni config.


Patroni Configuration (Helm)

# values.yaml for Patroni
patroni:
  postgresql:
    parameters:
      max_connections: 200
      shared_buffers: "4GB"
      effective_cache_size: "12GB"
      wal_level: replica
      max_wal_senders: 10
      hot_standby: "on"
      synchronous_commit: "remote_write"  # sync to WAL on replica, not full flush

  bootstrap:
    dcs:
      ttl: 30               # seconds before leader lock expires
      loop_wait: 10         # check interval
      retry_timeout: 10     # operation timeout
      maximum_lag_on_failover: 1048576  # 1MB max lag — don't promote if replica is too far behind
      postgresql:
        use_pg_rewind: true  # allow old primary to rejoin as replica without full resync
        use_slots: true      # replication slots (prevent WAL deletion before replica catches up)

Backups and PITR

graph LR
    PG["PostgreSQL Primary"] -->|"continuous WAL archiving<br/>every 5 minutes"| GCS["GCS Bucket<br/>gs://my-pg-wal/"]
    PG -->|"daily pg_basebackup"| GCS
    GCS -->|"pgbackrest restore<br/>--target=2024-01-15T14:30:00"| RESTORED["Restored DB<br/>any point in time"]
# Install pgBackRest (production WAL backup tool)
# Daily full backup
pgbackrest --stanza=main backup --type=full

# WAL archiving (add to postgresql.conf)
archive_mode = on
archive_command = 'pgbackrest --stanza=main archive-push %p'
archive_timeout = 300   # force WAL switch every 5 min

# PITR restore to specific time
pgbackrest --stanza=main restore \
  --target="2024-01-15 14:30:00" \
  --target-action=promote \
  --recovery-option="recovery_target_timeline=latest"

# GKE VolumeSnapshot (consistent disk snapshot while DB is running)
kubectl apply -f - << 'EOF'
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: postgres-snapshot-$(date +%Y%m%d)
spec:
  source:
    persistentVolumeClaimName: data-postgres-0
  volumeSnapshotClassName: csi-gce-pd-vsc
EOF

Master Promotion — Manual Steps

# Check current cluster state
patronictl -c /etc/patroni.yml list
# + Cluster: postgres --------+----+-----------+
# | Member     | Host         | Role   | State   |
# | postgres-0 | 10.0.1.5:5432 | Leader | running |
# | postgres-1 | 10.0.1.6:5432 | Replica| running |
# | postgres-2 | 10.0.1.7:5432 | Replica| running |

# Planned switchover (graceful — zero data loss)
patronictl -c /etc/patroni.yml switchover postgres \
  --master postgres-0 \
  --candidate postgres-1 \
  --force

# Emergency failover (if primary is dead)
patronictl -c /etc/patroni.yml failover postgres \
  --master postgres-0 \
  --candidate postgres-1 \
  --force

# After failover: old primary rejoins as replica automatically
# Verify new cluster state
patronictl -c /etc/patroni.yml list

Connection Pooling with PgBouncer

Each PostgreSQL connection uses ~10MB RAM. 200 app pods × 10 connections = 2000 connections → 20GB RAM just for connections.

graph LR
    APP1["App Pod 1<br/>10 connections"] --> PGB
    APP2["App Pod 2<br/>10 connections"] --> PGB
    APP3["App Pod N<br/>10 connections"] --> PGB
    PGB["PgBouncer<br/>transaction-mode pooling<br/>100 connection pool"] --> PG2["PostgreSQL<br/>max_connections: 100"]
# PgBouncer config
[pgbouncer]
pool_mode = transaction    # connection returned to pool after each transaction
max_client_conn = 10000   # clients can connect freely
default_pool_size = 100    # actual PostgreSQL connections
server_idle_timeout = 600  # close idle server connections

Monitoring

# Replication lag (alert if > 10MB)
pg_replication_slots_lag_bytes > 10485760

# Connection usage (alert if > 80%)
pg_stat_activity_count / pg_settings_max_connections > 0.8

# Long-running transactions (alert if > 5 min)
pg_stat_activity_max_tx_duration > 300

# Dead tuples (needs VACUUM)
pg_stat_user_tables_n_dead_tup > 100000