Importing Pre-Existing Storage as a Static PersistentVolume
Making Kubernetes mount data it did not create, without letting it delete that data.
The problem
Dynamic provisioning is built on one assumption: a PersistentVolumeClaim asks for storage, a StorageClass’s provisioner creates a fresh, empty volume to satisfy it, and the two are bound. That assumption is wrong the moment the data you need to mount already exists somewhere — a Ceph RBD image left over from a host migration, a CephFS subvolume recovered after a disk failure, an NFS export that predates the cluster entirely. None of that is what a provisioner produces, and a plain PVC has no way to say “bind to this specific thing, don’t make me a new one.”
Write the obvious PVC and Kubernetes does exactly what it is designed to do — provision something new and ignore the existing data completely:
# Broken. This binds to whatever the default StorageClass provisions,
# not to any data that already exists on disk.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: existing-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
There is a second half to this problem, and it is easy to miss until it costs you
something: even once you do get a PVC bound to the right pre-existing volume, the
default reclaim policy for dynamically provisioned storage is Delete. Delete a PVC
under that policy and the underlying volume is deleted with it. For data Kubernetes
did not create, that is not a lifecycle Kubernetes should be trusted with by default —
a kubectl delete pvc typed against the wrong namespace, or run by a script that
assumed the claim was disposable, destroys something that existed before the cluster
did and cannot be regenerated by re-running a manifest.
Working through it
Take the volume out of the provisioner’s reach
Dynamic provisioning is driven by storageClassName. If a PVC’s storage class matches
one with a provisioner configured, that provisioner will try to satisfy the claim by
creating a new volume — full stop. To mount pre-existing data, you need a class name
that no provisioner is watching, or an explicitly empty one (storageClassName: ""),
so the claim can only be satisfied by a PersistentVolume you create by hand. This is
the first piece: nothing should be racing to fill the claim with something new.
Make Retain the thing that protects you, not the binding
persistentVolumeReclaimPolicy: Retain on the PersistentVolume is what actually
answers the “mistaken delete” half of the problem, and it is worth being precise about
what it does. Delete the PVC bound to a Retain volume, and the PV does not disappear —
it moves to a Released state, with the underlying data completely untouched. It also
does not silently become available for the next claim that asks for the same storage
class: a Released PV keeps a claimRef pointing at the PVC that no longer exists, and
won’t bind to anything else until that claimRef is removed by hand. Getting the data
back into use is therefore a deliberate, visible administrative step — someone has to
look at the volume, decide it is safe to reuse, and edit it to make that so. That
friction is not an oversight; it is the entire point. An accidental delete costs you a
Released PV and a five-minute recovery, not the data itself.
Bind the pair to each other explicitly
A shared storageClassName alone isn’t enough to guarantee this PVC binds this PV,
particularly if more than one static PV happens to use the same class name — Kubernetes
will bind the first PVC that fits to the first matching PV it finds, which is exactly
the kind of race you do not want when “the wrong PV” means “the wrong pre-existing
data.” Pin the relationship from both ends: the PVC names its target PV directly via
spec.volumeName, and the PV names its expected claim back via spec.claimRef
(namespace and name). With both sides pointing at each other, there is no ambiguity for
the scheduler to resolve — and no other PVC, however coincidentally similar its request,
can bind to this PV first.
What this looks like against real storage
In infrastructure that already runs Ceph, this pattern is exactly how you adopt an
existing RBD image or CephFS subvolume rather than provisioning a new one: the PV’s
spec.csi.volumeHandle points at the existing image or subvolume identifier instead of
one a CSI driver generated, and everything else — the class name, Retain, the
explicit claimRef — works identically. That example depends on infrastructure a
reader of this article will not have, so the runnable version below uses a hostPath
volume on a local kind cluster instead, standing in for “storage that already has
data on it.” The mechanics are the same regardless of what sits behind the PV.
The solution
Everything below runs on a laptop with kind.
kind create cluster --name static-pv-demo
Simulate pre-existing data by writing it directly onto the node, outside of anything Kubernetes provisioned:
docker exec static-pv-demo-control-plane mkdir -p /mnt/existing-data
docker exec static-pv-demo-control-plane sh -c \
'echo "this file predates the PVC" > /mnt/existing-data/marker.txt'
# pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: existing-data-pv
spec:
capacity:
storage: 5Gi
accessModes: ["ReadWriteOnce"]
persistentVolumeReclaimPolicy: Retain
storageClassName: manual-import
hostPath:
path: /mnt/existing-data
claimRef:
namespace: default
name: existing-data
# pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: existing-data
namespace: default
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: manual-import
resources:
requests:
storage: 5Gi
volumeName: existing-data-pv
# pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: reader
spec:
containers:
- name: reader
image: busybox:1.36
command: ["sleep", "3600"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: existing-data
Apply everything and confirm the pre-existing data is visible without anything having been provisioned:
kubectl apply -f pv.yaml -f pvc.yaml -f pod.yaml
kubectl wait --for=condition=Ready pod/reader --timeout=60s
kubectl exec reader -- cat /data/marker.txt
# this file predates the PVC
Now prove the delete-protection. The reader pod still has the claim mounted, and
Kubernetes will not delete a PVC while a pod is using it — it holds a
kubernetes.io/pvc-protection finalizer that blocks removal until the volume is
released, so running kubectl delete pvc against a claim still in use just hangs.
Delete the pod first, then the claim, and look at what happens to the volume:
kubectl delete pod reader --now
kubectl delete pvc existing-data
kubectl get pv existing-data-pv
# NAME ... STATUS CLAIM STORAGECLASS
# existing-data-pv ... Released default/existing-data manual-import
docker exec static-pv-demo-control-plane cat /mnt/existing-data/marker.txt
# this file predates the PVC
The PV is Released, not gone, and the marker file is still exactly where it was. To
make the volume usable again, the stale claimRef has to be cleared explicitly — this
patch is the deliberate step that stands between “the PVC was deleted” and “the data
was deleted”:
kubectl patch pv existing-data-pv --type=json \
-p '[{"op": "remove", "path": "/spec/claimRef"}]'
After that, re-applying pvc.yaml binds cleanly to the same volume, with the same
data, exactly as before.
Conclusion
Reclaim policy, not the binding mechanics, is what protects you. volumeName and
claimRef control which PVC binds which PV; only persistentVolumeReclaimPolicy:
Retain controls what happens to the data when that claim is deleted. Get the policy
wrong and the most carefully pinned binding still ends in data loss.
Explicit, mutual binding avoids storage-class races. A shared class name is not a guarantee of which PVC gets which PV when more than one candidate exists; pinning both ends removes the ambiguity rather than hoping the scheduler resolves it the way you intended.
This is the general pattern for adopting data Kubernetes did not create — a Ceph
RBD image or CephFS subvolume via csi.volumeHandle, an NFS export, a disk recovered
after a failure. The volume type changes; the shape of the solution does not.
Rebinding a Retain volume is meant to require a human. The manual claimRef patch
is not friction to route around — it is the checkpoint where someone confirms the data
is safe to reuse before Kubernetes is allowed to hand it to something new.