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.
Does the postgres-primary Service route to a fixed pod (always postgres-0), or wherever Patroni currently says the leader is?
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;
COMMIT until the replica named in synchronous_standby_names confirms the WAL is received and flushed to disk. Guarantees zero data loss on that replica, at the cost of one network round trip added to every write's latency.
COMMIT to the app without waiting for any acknowledgment. Fastest option, but if the primary crashes before the replica has applied the WAL already sent to it, whatever wasn't yet replicated is gone.
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.
The primary crashes one second after confirming COMMIT to the app. Is the committed row guaranteed to still exist on postgres-1 (sync replica)? What about postgres-2 (async replica)?
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
Walk through the same failover as discrete stages:
ttl (default 30s) passes with no renewal, Patroni on postgres-1 acquires the lock and calls pg_promote(), switching postgres-1 to read-write.
role=master. The postgres-primary Service, which selects on that label, starts routing writes to postgres-1 — no Service or DNS change needed.
pg_rewind to resync its data files against the new timeline, and starts streaming as a replica — it does not resume as primary automatically.
Failover time: ~30 seconds (default TTL). Tune with ttl, loop_wait, retry_timeout in Patroni config.
When postgres-0 recovers after a failover, does it resume as primary since it was the original leader, or come back as a replica?
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
A PITR restore always starts from a full backup, then replays WAL forward to the target — never WAL alone:
archive_command ships every completed WAL segment to GCS as it's generated (at most every archive_timeout seconds), and a full pg_basebackup runs once a day.
--target="2024-01-15 14:30:00".
--target-action=promote, once replay reaches the target time PostgreSQL exits recovery mode and comes up as a normal, writable primary at exactly that point in time.
To restore to an arbitrary point in time, does pgBackRest need only the archived WAL segments, or the WAL plus a base backup?
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
You run patronictl switchover, but the primary is actually already down. Will it behave the same way as failover?
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
With pool_mode = transaction, does one app connection keep the same PostgreSQL server connection for its whole session, or only for the duration of one transaction?
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