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.