How to Implement Persistent Volume Snapshots in Kubernetes

How to Implement Persistent Volume Snapshots in Kubernetes

The first time I lost data on a stateful workload — a Postgres instance running on a PersistentVolume with no snapshot strategy behind it — I learned the hard way that “the cloud provider handles backups” is not actually a plan. Kubernetes has had native volume snapshot support for a while now, and once I actually understood how the snapshot API objects fit together, backing up stateful workloads stopped being scary. This article covers everything from the underlying architecture to production-ready CronJob-driven snapshot automation.

Why Volume Snapshots Matter

Persistent Volumes (PVs) in Kubernetes back long-lived data — databases, message queues, file storage. Unlike stateless pods that can be rescheduled freely, losing a PV means losing data. Volume snapshots give you point-in-time, storage-level backups that you can restore from or clone into new volumes, without needing application-level backup tooling for every single workload.

The Kubernetes Volume Snapshot Architecture

Volume snapshotting is implemented through the Container Storage Interface (CSI), not through core Kubernetes APIs directly. There are three core objects:

  • VolumeSnapshotClass — cluster-scoped, defines which CSI driver handles snapshots and the deletion policy, analogous to StorageClass for PVs.
  • VolumeSnapshot — namespaced, the user-facing request for a snapshot of a specific PVC.
  • VolumeSnapshotContent — cluster-scoped, the actual bound snapshot resource, analogous to a PV — usually created automatically when you create a VolumeSnapshot dynamically.

Internally, the external-snapshotter sidecar (running alongside the CSI driver) watches VolumeSnapshot objects, calls the CSI driver’s CreateSnapshot gRPC method, and the storage backend (EBS, GCE PD, Ceph RBD, Portworx, etc.) performs the actual block-level or filesystem-level snapshot.

VolumeSnapshot (user request)
        │
        ▼
external-snapshotter controller
        │
        ▼
CSI Driver (CreateSnapshot RPC)
        │
        ▼
VolumeSnapshotContent (bound result)
        │
        ▼
Storage backend snapshot (EBS snapshot, PD snapshot, etc.)

Prerequisites

Before you can create snapshots, your cluster needs:

  1. A CSI driver that supports the snapshot feature (most major ones do: ebs-csi-driver, pd-csi-driver, azuredisk-csi-driver, ceph-csi).
  2. The CSI Snapshotter CRDs and controller installed.

Install the snapshot CRDs and controller if they aren’t already present:

kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/release-7.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/release-7.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/release-7.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml

kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/release-7.0/deploy/kubernetes/snapshot-controller/rbac-snapshot-controller.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/release-7.0/deploy/kubernetes/snapshot-controller/setup-snapshot-controller.yaml

Verify:

kubectl get pods -n kube-system | grep snapshot-controller
kubectl get crd | grep snapshot.storage.k8s.io

Creating a VolumeSnapshotClass

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
  name: csi-snapclass
driver: ebs.csi.aws.com     # swap for your CSI driver name
deletionPolicy: Delete

deletionPolicy works the same way as StorageClass’s reclaim policy — Delete removes the underlying storage snapshot when the VolumeSnapshotContent is deleted; Retain keeps it.

kubectl apply -f snapclass.yaml
kubectl get volumesnapshotclass

Taking a Manual Snapshot

Assume I have an existing PVC backing a database:

kubectl get pvc -n production
NAME          STATUS   VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS
postgres-pvc   Bound    pvc-abc123   20Gi       RWO            gp3

Now create a VolumeSnapshot referencing that PVC:

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: postgres-snapshot-2026-08-02
  namespace: production
spec:
  volumeSnapshotClassName: csi-snapclass
  source:
    persistentVolumeClaimName: postgres-pvc
kubectl apply -f postgres-snapshot.yaml
kubectl get volumesnapshot -n production
NAME                            READYTOUSE   SOURCEPVC       SNAPSHOTCLASS    RESTORESIZE   AGE
postgres-snapshot-2026-08-02    true         postgres-pvc    csi-snapclass    20Gi          45s

READYTOUSE: true means the storage backend has confirmed the snapshot completed and it’s restorable.

Restoring a Volume from a Snapshot

Restoring creates a new PVC whose dataSource points at the VolumeSnapshot:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-restored-pvc
  namespace: production
spec:
  storageClassName: gp3
  dataSource:
    name: postgres-snapshot-2026-08-02
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
kubectl apply -f postgres-restored-pvc.yaml
kubectl get pvc postgres-restored-pvc -n production

Mount this new PVC into a fresh pod (or point your StatefulSet at it) and you have a working restore — critically, without downtime on the original volume, since the snapshot operation doesn’t require unmounting the source.

Automating Snapshots with CronJobs

Manual snapshots don’t scale. I automate this with a CronJob that uses a service account with permission to create VolumeSnapshot objects on a schedule:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: snapshot-automation
  namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: snapshot-creator
  namespace: production
rules:
  - apiGroups: ["snapshot.storage.k8s.io"]
    resources: ["volumesnapshots"]
    verbs: ["create", "get", "list", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: snapshot-creator-binding
  namespace: production
subjects:
  - kind: ServiceAccount
    name: snapshot-automation
    namespace: production
roleRef:
  kind: Role
  name: snapshot-creator
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: postgres-snapshot-cron
  namespace: production
spec:
  schedule: "0 2 * * *"    # daily at 2 AM
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: snapshot-automation
          containers:
            - name: snapshotter
              image: bitnami/kubectl:latest
              command:
                - /bin/sh
                - -c
                - |
                  DATE=$(date +%Y%m%d-%H%M%S)
                  cat <<EOF | kubectl apply -f -
                  apiVersion: snapshot.storage.k8s.io/v1
                  kind: VolumeSnapshot
                  metadata:
                    name: postgres-snapshot-$DATE
                    namespace: production
                  spec:
                    volumeSnapshotClassName: csi-snapclass
                    source:
                      persistentVolumeClaimName: postgres-pvc
                  EOF
          restartPolicy: OnFailure

Pair this with a retention job that deletes snapshots older than N days to avoid unbounded storage cost growth.

Monitoring Snapshots

I always add a check that alerts if a scheduled snapshot’s readyToUse field stays false for too long — usually indicates the CSI driver or storage backend is throttled or misconfigured. This can be scraped via a small script exporting a custom metric, or checked through kubectl get volumesnapshot -o json in a health-check Job.

Cloning Volumes Directly (Without a Snapshot)

Related to snapshots but worth distinguishing: Kubernetes also supports PVC cloning, which creates a new volume as a direct copy of an existing PVC without going through the VolumeSnapshot object at all. This is useful for quickly spinning up a dev/test copy of production data:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-dev-clone
  namespace: staging
spec:
  storageClassName: gp3
  dataSource:
    name: postgres-pvc
    kind: PersistentVolumeClaim
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
kubectl apply -f postgres-dev-clone.yaml
kubectl get pvc postgres-dev-clone -n staging

Note that cloning requires the source and destination PVC to be in the same namespace and typically the same StorageClass — this is a meaningful limitation compared to snapshot-based restores, which can cross namespaces more flexibly since the VolumeSnapshot itself can be referenced from a different context.

Troubleshooting Failed Snapshots

VolumeSnapshot stuck with readyToUse: false. Check the VolumeSnapshotContent object it’s bound to for the actual error:

kubectl get volumesnapshot postgres-snapshot-2026-08-02 -n production -o yaml
kubectl get volumesnapshotcontent -o yaml | grep -A 5 "error"

Common causes are IAM/permission issues on the CSI driver’s service account (the controller can’t call CreateSnapshot against the cloud API), or a mismatched VolumeSnapshotClass driver name that doesn’t correspond to any registered CSI driver:

kubectl get csidriver
kubectl get volumesnapshotclass -o yaml

Restore PVC stuck in Pending. This is very often a size mismatch — many CSI drivers require the new PVC’s storage request to be greater than or equal to the snapshot’s restoreSize:

kubectl get volumesnapshot postgres-snapshot-2026-08-02 -n production -o jsonpath='{.status.restoreSize}'

Set the new PVC’s request to at least this value.

Snapshot succeeds but restored data looks inconsistent. This is almost always a crash-consistency vs application-consistency issue — the snapshot was taken mid-write without a quiesce step. For databases, always pause writes or use the database’s native backup-mode hooks before triggering the snapshot.

Security Considerations

Snapshot data is just as sensitive as the source volume — a leaked snapshot is a leaked database. A few things I enforce:

  • Restrict volumesnapshots and volumesnapshotcontents RBAC verbs the same way you’d restrict access to the PVC itself.
  • For cloud-backed snapshots, ensure the underlying storage snapshots (EBS snapshots, GCE PD snapshots) inherit encryption settings from the source volume — don’t assume this happens automatically for every CSI driver.
  • Audit snapshot deletion carefully; a deletionPolicy: Delete VolumeSnapshotClass means deleting the Kubernetes object also deletes your actual backup, which is easy to do by accident during a namespace cleanup.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: snapshot-viewer
  namespace: production
rules:
  - apiGroups: ["snapshot.storage.k8s.io"]
    resources: ["volumesnapshots"]
    verbs: ["get", "list", "watch"]

Production Best Practices

  • Application-consistent snapshots: For databases, take snapshots after a quiesce/flush step (e.g., pg_start_backup() for Postgres) rather than relying purely on crash-consistent storage snapshots, unless your storage backend guarantees write-order consistency.
  • Cross-region copies: Native VolumeSnapshots are typically bound to the same region/zone as the source volume. For disaster recovery, replicate the underlying cloud snapshot (e.g., EBS snapshot copy) to another region using your cloud provider’s native tooling alongside Kubernetes snapshots.
  • RBAC scoping: Restrict volumesnapshots create/delete permissions tightly — a compromised automation account could delete your only backups.
  • Test restores regularly: A snapshot you’ve never restored from is a hypothesis, not a backup.

Common Mistakes

  • Assuming VolumeSnapshot works without the CSI driver explicitly supporting the snapshot feature — check kubectl get csidriver and driver docs first.
  • Forgetting deletionPolicy: Retain for critical snapshot classes, leading to accidental data loss when a namespace is deleted.
  • Not setting resource requests to match restoreSize exactly when the storage backend requires exact size matches.

Summary

Volume snapshots turn Kubernetes-native storage into something you can actually trust for stateful workloads. The CSI-based VolumeSnapshot/VolumeSnapshotContent/VolumeSnapshotClass trio maps closely to the familiar PVC/PV/StorageClass pattern, which makes it approachable once you’ve internalized the architecture. Automate the scheduling, monitor readyToUse status, and — above everything else — actually test your restores.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Kubernetes Monitoring with Kibana

How to Set Up Kubernetes Monitoring with Kibana

Next Post
How to Set Up Pod Disruption Budgets with Prometheus in Kubernetes

How to Set Up Pod Disruption Budgets with Prometheus in Kubernetes

Related Posts