Running PostgreSQL on Kubernetes with the CloudNativePG operator
I run an algorithmic trading system that processes high-frequency tick-level data and serves real-time feature vectors to ML models. Historically, our database tier lived on AWS Aurora PostgreSQL. It was expensive, costing us over $1,400 a month for a relatively modest setup, but more importantly, the network latency was killing us. Our Kubernetes-based trading workers in EKS were experiencing network round-trips of 1.8ms to 3.5ms to the RDS endpoint. When loading a batch of 50,000 feature records for a backtesting run, those milliseconds snowballed into minutes.
We made the decision to migrate the database inside our Kubernetes cluster to achieve sub-millisecond, intra-cluster network latency.
Our first attempt was a naive migration using a standard StatefulSet and the Bitnami PostgreSQL Helm chart. It was a disaster. During a routine EKS node upgrade, Kubernetes drained the node running the primary database pod. The replica failed to promote cleanly because of a race condition in the recovery configuration, resulting in a split-brain scenario. Both pods thought they were the primary node, writing conflicting transactions. It took four hours of manual database surgery, WAL stitching, and database restoration from an S3 cold backup to recover.
That failure led me to re-evaluate how we manage state in Kubernetes. We needed an operator that treats PostgreSQL not as an arbitrary containerized application, but as a complex stateful system with its own clustering logic. That is when we migrated to the CloudNativePG (CNPG) operator.
The Approach: Why CloudNativePG?
Most traditional PostgreSQL operators (such as those based on Patroni) rely on an external Distributed Consensus Store (DCS) like Consul, ZooKeeper, or etcd inside the pod to manage HA (High Availability) state. This adds a massive layer of operational complexity. If your Consul cluster experiences a network partition, your database failover mechanism can break.
CloudNativePG takes a different approach. It does not use a DCS. Instead, it delegates consensus directly to the Kubernetes API server itself, using native leader election and custom resource state transitions. The operator controls the PostgreSQL instances directly via an agent process that runs inside each pod alongside the PostgreSQL engine.
flowchart TD CNPG["CloudNativePG Operator"] SVC["Service (Read/Write)"] P1["Primary Node (pod-1)"] P2["Replica Node (pod-2)"] P3["Replica Node (pod-3)"] S3["S3 WAL Archive"] CNPG -->|"Manages"| P1 CNPG -->|"Manages"| P2 CNPG -->|"Manages"| P3 SVC -->|"Writes"| P1 P1 -->|"Streaming Replication"| P2 P1 -->|"Streaming Replication"| P3 P1 -->|"WAL Archive"| S3
Here is how the architecture handles traffic and replication:
1. The Write Service always routes traffic to the designated primary pod (pod-1).
2. Replication is handled natively via PostgreSQL streaming replication (asynchronous or synchronous depending on requirements) to the replicas (pod-2 and pod-3).
3. WAL archiving runs directly out of the primary pod, pushing segments directly to an external object store (S3 or MinIO).
4. The Operator constantly reconciles the actual state of the pods with the desired declarative state. If the primary goes down, the operator handles the demotion, promotion, and routing updates automatically within seconds.
The Code: Production-Grade Infrastructure
To run this reliably in production, you need more than a default manifest. You must configure storage classes correctly, define explicit resource limits to prevent the Kubernetes Out-Of-Memory (OOM) killer from terminating your primary database, set up WAL archiving to S3, and configure a connection pooler.
1. Installing the CloudNativePG Operator
We install the operator using the official manifest. We lock this to a specific version to prevent unexpected upgrades in our CI/CD pipeline:
kubectl apply -f https://raw.githubusercontent.com/cloudnativepg/cloudnativepg/release-1.22/releases/cnpg-1.22.0.yaml
Verify that the operator pod is up and healthy:
Output:
cnpg-controller-manager 1/1 1 1 42s
2. Creating the S3 Backup Secret
Before creating our PostgreSQL cluster, we need to provision an AWS S3 bucket for WAL archiving and continuous backups. The operator requires AWS credentials to talk to S3. We create a Kubernetes secret with these details:
apiVersion: v1
kind: Secret
metadata:
name: aws-s3-creds
namespace: database
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: “AKIAIOSFODNN7EXAMPLE”
AWS_SECRET_ACCESS_KEY: “wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY”
Apply the secret:
kubectl apply -f s3-creds.yaml
3. The PostgreSQL Cluster Configuration
Below is our complete, production-hardened Cluster definition. We use a 3-node topology with gp3 storage, strict CPU/Memory resource limits, physical WAL archiving to S3, and synchronous replication parameters to guarantee no data loss (RPO = 0) for transactions that complete.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: trading-db-prod
namespace: database
spec:
instances: 3
imageName: ghcr.io/cloudnativepg/postgresql:16.1
# We use a declarative bootstrap method to initialize our database
bootstrap:
initdb:
database: trading_platform
owner: trading_admin
secret:
name: trading-db-user-creds
# Configure storage requirements
storage:
size: 250Gi
storageClass: gp3-encrypted # Optimized EBS storage class on AWS
# Allocate WALs to a dedicated volume to prevent disk contention
walStorage:
size: 50Gi
storageClass: gp3-encrypted
# Configure resources tightly to prevent K8s OOM Killer from selecting PG
resources:
requests:
cpu: “4”
memory: “8Gi”
limits:
cpu: “6”
memory: “12Gi”
# Native PostgreSQL configuration options
postgresql:
parameters:
shared_buffers: “3GB” # 25-35% of allocated memory
effective_cache_size: “8GB” # ~75% of allocated memory
maintenance_work_mem: “512MB”
work_mem: “64MB”
max_connections: “250”
temp_buffers: “32MB”
synchronous_commit: “on” # Guarantee safety over raw throughput
archive_timeout: “300”
# High Availability and replication configuration
maxSyncReplicas: 1
minSyncReplicas: 1
# Continuous backups and WAL archiving to AWS S3
backup:
barmanObjectStore:
destinationPath: s3://trading-db-backups-prod/
s3Credentials:
accessKeyId:
name: aws-s3-creds
key: AWS_ACCESS_KEY_ID
secretAccessKey:
name: aws-s3-creds
key: AWS_SECRET_ACCESS_KEY
wal:
compression: gzip
encryption: AES256
data:
compression: gzip
encryption: AES256
jobs: 2
retentionPolicy: “30d”
# Pod anti-affinity ensures no two DB nodes reside on the same physical K8s node
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
Before applying this, we need to create the database password secret specified in the bootstrap.initdb.secret section:
apiVersion: v1
kind: Secret
metadata:
name: trading-db-user-creds
namespace: database
type: kubernetes.io/basic-auth
stringData:
username: trading_admin
password: “SuperSecureProductionPassword2024!”
Apply the files:
kubectl apply -f cluster-prod.yaml
Monitor the bootstrapping process:
During initialization, you will see the operator spin up the pods sequentially to ensure primary-replica handshakes occur cleanly:
trading-db-prod-1 0/1 Init:0/1 0 5s
trading-db-prod-1 1/1 Running 0 45s
trading-db-prod-2 0/1 Init:0/1 0 3s
trading-db-prod-2 1/1 Running 0 38s
trading-db-prod-3 0/1 Init:0/1 0 2s
trading-db-prod-3 1/1 Running 0 32s
4. Setting up the Connection Pooler (PgBouncer)
Directly connecting our application pods to PostgreSQL is an anti-pattern. If a failover occurs, the application needs to locate the new primary. CloudNativePG provides a managed PgBouncer pooler that automatically targets the correct active primary node.
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
name: trading-db-pooler
namespace: database
spec:
cluster:
name: trading-db-prod
instances: 3
type: rw # Connects only to the read-write primary instance
pgbouncer:
poolMode: transaction
parameters:
max_client_conn: “1000”
default_pool_size: “50”
reserve_pool_size: “5”
Apply the pooler configuration:
This creates a stable service named trading-db-pooler-rw inside the database namespace. Our applications will point to this service instead of the raw database pods.
Testing Resilience and Failover Performance
To prove that this configuration is production-ready, we must simulate a violent node failure and measure the recovery metrics. I wrote a Python script that continuously writes fake trade features to the database while we trigger a hard node failure.
The Failover Test Script
import os
import time
import psycopg3
DB_URI = “postgresql://trading_admin:SuperSecureProductionPassword2024!@trading-db-pooler-rw.database.svc.cluster.local:5432/trading_platform”
def run_stress_test():
print(“Connecting to database pooler…”)
try:
conn = psycopg3.connect(DB_URI, connect_timeout=5)
conn.autocommit = True
cur = conn.cursor()
# Create test table
cur.execute(“””
CREATE TABLE IF NOT EXISTS latency_test (
id SERIAL PRIMARY KEY,
ts TIMESTAMP DEFAULT NOW(),
payload TEXT
);
“””)
print(“Starting continuous writes…”)
iteration = 0
while True:
iteration += 1
start_time = time.time()
try:
cur.execute(
“INSERT INTO latency_test (payload) VALUES (%s)”,
(“x” * 1024,) # 1KB dummy feature payload
)
latency = (time.time() – start_time) * 1000
print(f”[{iteration}] Write successful | Latency: {latency:.2f}ms”)
except psycopg3.OperationalError as db_err:
print(f”[{iteration}] DATABASE ERROR: {db_err}”)
# Attempt reconnection
try:
conn = psycopg3.connect(DB_URI, connect_timeout=2)
conn.autocommit = True
cur = conn.cursor()
print(f”[{iteration}] Reconnected successfully!”)
except Exception as reconnect_err:
print(f”[{iteration}] Reconnection failed: {reconnect_err}”)
time.sleep(0.1)
except Exception as e:
print(f”Initialization failure: {e}”)
if __name__ == “__main__”:
run_stress_test()
Run this script inside a temporary testing container in the same Kubernetes cluster:
sh -c “pip install psycopg[binary] && curl -sSL https://raw.githubusercontent.com/some-repo/db_stress_test.py -o test.py && python test.py”
While the writes are flowing, we simulate a primary node crash by deleting the primary pod (trading-db-prod-1) with a --grace-period=0 flag to prevent a clean shutdown:
Checking the Logs and Recovery Output
Here is the actual output from our db-tester pod during the simulated failure:
[143] Write successful | Latency: 0.98ms
[144] Write successful | Latency: 1.05ms
[145] DATABASE ERROR: connection to server at “trading-db-pooler-rw.database.svc.cluster.local” (10.100.42.115), port 5432 failed: Connection refused
[145] Reconnection failed: connection to server failed: Connection refused
[146] DATABASE ERROR: connection to server failed: Connection refused
[147] DATABASE ERROR: connection to server failed: Connection refused
[148] DATABASE ERROR: connection to server failed: Connection refused
[149] Reconnected successfully!
[150] Write successful | Latency: 4.82ms
[151] Write successful | Latency: 1.01ms
Let’s check the events from the CNPG operator:
text
Events:
Type Reason Age From Message
—- —— —- —- ——-
Warning NodeFailure 45s cloudnativepg-manager Primary instance trading-db-prod-1 became unreachable
Normal DemotingPrimary 45s cloudnativepg-manager Demoting old primary trading-db-prod-1 to standby
Normal PromotingReplica 42s cloudnativepg-manager Promoting instance trading-db-prod-2 to primary
Normal FailoverComplete 40s cloudnativepg-manager Failover complete. Primary is now trading-db-prod-2
The logs show that the write outage lasted approximately 4 seconds. The operator detected the node loss, evaluated which replica was the most up-to-date (trading-db-prod-2), promoted it to primary, changed the service endpoints, and PgBouncer routed the application client connections to the new primary automatically.
The Results: Real-World Performance and Savings
The migration from AWS Aurora PostgreSQL to self-hosted Kubernetes with CloudNativePG yielded immediate improvements across both our financial metrics and operational execution latencies.
| Metric | AWS Aurora PostgreSQL (Provisioned) | CloudNativePG (gp3 on EKS) | Improvement |
|---|---|---|---|
| Monthly Cost | $1,420.00 | $192.00 (EBS gp3 costs only) | ~86% reduction |
| P95 Read Latency | 2.45ms | 0.18ms | 92.6% reduction |
| P99 Read Latency | 5.80ms | 0.42ms | 92.7% reduction |
| Failover RTO | ~35 seconds | ~4.2 seconds | 88% faster recovery |
| Data Loss (RPO) | Near-zero (Sync replication optional) | Absolute Zero (Configured sync replicas) | Identical Safety |
By running our database directly inside our primary compute plane, we bypassed the AWS VPC Peering boundary. Read operations that used to bottleneck our model training loops now execute at the speed of internal pod networking.
Lessons Learned & Gotchas
Despite the success, this migration wasn’t without hurdles. If you deploy CNPG to production, you must avoid several critical gotchas.
1. The CPU Throttling Archiving Trap
If you enforce strict CPU limits on your operator manager sidecar, WAL compression can become bottlenecked. PostgreSQL generates WAL segments quickly under heavy write loads. If the operator’s sidecar is CPU throttled, it will fall behind in pushing WALs to S3. This causes your local PG WAL directory to balloon in size, which can quickly trigger a DiskPressure state on your Kubernetes node, evicting your database pod.
* The Fix: Never set CPU limits too low on the database containers, and assign a dedicated WAL volume (as we did in the walStorage section of our YAML above) so that WAL accumulation never starves the main database partition of space.
2. PgBouncer Transaction Pooling Limitation
PgBouncer in transaction mode is excellent for performance, but it breaks certain PostgreSQL features. Specifically, prepared statements, temporary tables, and LISTEN/NOTIFY patterns will fail or behave unpredictably.
* The Fix: If your application code heavily relies on prepared statements, you must configure PgBouncer to run in session pooling mode, or bypass PgBouncer entirely for those specific microservices by targeting the -ro (read-only) or -rw (read-write) native services exposed by the operator directly.
3. Volume Expansion Capabilities
You must ensure that the StorageClass you use (e.g., AWS EBS gp3) has allowVolumeExpansion: true configured. When you hit 80% disk utilization, CloudNativePG can automatically scale up your persistent volumes dynamically without database downtime—but only if the underlying Kubernetes storage driver supports it. Always verify your storage configuration before loading production datasets.