Databases in Containers: StatefulSets, Persistent Volumes, and Backup

Databases in Containers: StatefulSets, Persistent Volumes, and Backup

Running databases in containers was once considered an anti-pattern, but modern orchestration and storage primitives have made it viable for many production workloads. This article covers Kubernetes patterns for stateful databases, storage management, and operational considerations.

StatefulSets vs Deployments

Kubernetes Deployments are designed for stateless applications. StatefulSets are the correct primitive for databases:

apiVersion: apps/v1

kind: StatefulSet

metadata:

name: postgres

spec:

serviceName: postgres

replicas: 3

selector:

matchLabels:

app: postgres

template:

metadata:

labels:

app: postgres

spec:

containers:

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- name: postgres

image: postgres:16

ports:

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- containerPort: 5432

env:

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- name: POSTGRES_PASSWORD

valueFrom:

secretKeyRef:

name: pg-secret

key: password

volumeMounts:

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- name: data

mountPath: /var/lib/postgresql/data

volumeClaimTemplates:

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- metadata:

name: data

spec:

accessModes: [ "ReadWriteOnce" ]

storageClassName: "fast-ssd"

resources:

requests:

storage: 100Gi

StatefulSets provide:

  • Stable, unique network identities (pod-name-0, pod-name-1).

  • Ordered, graceful deployment and scaling.

  • Stable storage with PersistentVolumeClaim templates.

PersistentVolumes and Storage Classes

Database storage requires careful configuration of PersistentVolumes:

apiVersion: storage.k8s.io/v1

kind: StorageClass

metadata:

name: fast-ssd

provisioner: ebs.csi.aws.com

parameters:

type: gp3

iops: "3000"

throughput: "125"

reclaimPolicy: Retain

Access Modes

| Mode | Description | Database Use Case | |------|-------------|-------------------| | ReadWriteOnce | Single node read-write | Primary database | | ReadOnlyMany | Many nodes read-only | Read replicas | | ReadWriteMany | Many nodes read-write | Shared storage (avoid for PostgreSQL) |

CloudNativePG Operator

The CloudNativePG operator is the most mature Kubernetes operator for PostgreSQL:

apiVersion: postgresql.cnpg.io/v1

kind: Cluster

metadata:

name: myapp-db

spec:

instances: 3

imageName: ghcr.io/cloudnative-pg/postgresql:16

storage:

size: 100Gi

storageClass: fast-ssd

backup:

barmanObjectStore:

destinationPath: s3://myapp-backups/

s3Credentials:

accessKeyId:

name: aws-creds

key: access-key

secretAccessKey:

name: aws-creds

key: secret-key

wal:

compression: gzip

retentionPolicy: "30d"

monitoring:

enablePodMonitor: true

resources:

requests:

memory: "4Gi"

cpu: "2"

limits:

memory: "8Gi"

cpu: "4"

Automated Failover

Simulate pod failure to test failover

kubectl delete pod myapp-db-0

Operator automatically:

1\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\. Detects primary is gone

2\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\. Promotes the most advanced replica

3\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\. Updates the service to point to new primary

Typical failover time: 15-30 seconds

Backup in Containers

Volume Snapshots

apiVersion: snapshot.storage.k8s.io/v1

kind: VolumeSnapshot

metadata:

name: postgres-weekly-snapshot

spec:

volumeSnapshotClassName: csi-aws-vsc

source:

persistentVolumeClaimName: data-myapp-db-0

WAL Archiving to S3

apiVersion: postgresql.cnpg.io/v1

kind: ScheduledBackup

metadata:

name: myapp-db-backup

spec:

schedule: "0 0 * * *"

backupOwnerReference: self

cluster:

name: myapp-db

Performance Considerations

Network Overhead

Container networking adds latency vs bare metal:

Use hostNetwork for lowest latency

spec:

template:

spec:

hostNetwork: true

dnsPolicy: ClusterFirstWithHostNet

Resource Limits

Setting proper resource limits prevents CPU throttling:

resources:

requests:

cpu: "4"

memory: "16Gi"

limits:

cpu: "8" # PostgreSQL bursts here normally

memory: "24Gi" # Allocate extra for OS cache (effective_cache_size)

Tuning for Kubernetes

postgresql.conf tuned for container environments

shared_buffers = '4GB' # 25% of container memory limit

effective_cache_size = '12GB' # 75% of container memory limit

work_mem = '64MB'

maintenance_work_mem = '1GB'

wal_buffers = '64MB'

random_page_cost = 1.1 # SSD in containers

Anti-Patterns to Avoid

EmptyDir for Data

WRONG: data lost on pod restart

volumes:

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- name: data

emptyDir: {}

Always use PersistentVolumeClaims.

Multiple Writable Replicas

Without proper clustering (Patroni, Stolon, CNPG), multiple replicas with the same PVC cause data corruption.

Using Deployments

WRONG: Deployments do not guarantee stable identity or storage

apiVersion: apps/v1

kind: Deployment

Always use StatefulSets for databases.

Monitoring

Check pod status

kubectl get pods -l app=postgres

Check PVC status

kubectl get pvc -l app=postgres

Check WAL archiving status

kubectl exec myapp-db-1 -- psql -c "SELECT * FROM pg_stat_archiver;"

View operator logs

kubectl logs -n postgres-operator deployment/postgres-operator

When to Containerize

Containerize your database when :

  • You already run everything else in Kubernetes.

  • You need environment parity and GitOps workflows.

  • You value automated operations provided by operators.

  • Your team has Kubernetes expertise.

Do not containerize when :

  • You lack operational Kubernetes expertise.

  • Your database requires specialized hardware or kernel tuning.

  • You need the simplicity of managed services (RDS, Cloud SQL).

  • Your team prefers a dedicated DBA toolset.

Databases in containers are production-viable with the right operator, storage class, and backup strategy. For most teams, a managed database service is simpler and more cost-effective. Containerization makes sense when you need portability, GitOps-driven management, and full control over the database configuration.