Reported symptoms
Someone deleted the analytics namespace on Tuesday afternoon. The
recovery was supposed to be uneventful: everything in that namespace is
in Git, and re-applying the repository brought back the ConfigMaps, the
Services, the two Deployments and the StatefulSet within a minute.
Everything came back running except the database.
analytics-db-0 has been Pending for forty minutes. The scheduler is
explicit about why:
Warning FailedScheduling pod has unbound immediate PersistentVolumeClaims
That points straight at the claim, so the claim is where the shift starts. And the claim is where it stalls, because the claim has nothing to say.
Three things about the state of the cluster made this harder than it should have been:
- Storage provisioning is obviously fine. A colleague created a throwaway 1Gi claim in a scratch namespace to test, and it bound in under ten seconds.
- The volume is right there.
kubectl get pvlists a 500Gi volume with the capacity, access mode and StorageClass name the claim asks for. - The data is safe. The storage team checked the NFS server directly: the export is healthy and the PostgreSQL data directory is intact.
So there is a claim that wants a volume, a volume that matches the claim, a working control plane, and forty minutes of nothing happening.
Evidence collected
$ kubectl -n analytics get pvc data-analytics-db-0NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
data-analytics-db-0 Pending nfs-retain 41mIllustrative output
$ kubectl -n analytics describe pvc data-analytics-db-0 | tail -4Access Modes:
VolumeMode: Filesystem
Used By: analytics-db-0
Events: <none>Illustrative output
$ kubectl get pv nfs-analytics-500gNAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE
nfs-analytics-500g 500Gi RWO Retain Released analytics/data-analytics-db-0 nfs-retain 288dIllustrative output
$ kubectl get pv nfs-analytics-500g -o custom-columns='PHASE:.status.phase,NS:.spec.claimRef.namespace,NAME:.spec.claimRef.name,UID:.spec.claimRef.uid'PHASE NS NAME UID
Released analytics data-analytics-db-0 0d3f8b21-4c19-4a7e-9d55-5b0a6c1e7f42Illustrative output
$ kubectl -n analytics get pvc data-analytics-db-0 -o jsonpath='{.metadata.uid}'b7c40e9a-2f18-42d6-8a31-19c7f5d0ab63Illustrative output
$ kubectl get storageclassNAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
standard (default) ebs.csi.aws.com Delete WaitForFirstConsumer true 402d
db-ssd ebs.csi.aws.com Retain WaitForFirstConsumer true 402dIllustrative output
Work the evidence before reading on
The scratch claim bound in ten seconds and this one has not bound in forty minutes, so the control plane is working. Something about this pairing is different.
- Read the
STATUScolumn on the volume again, carefully. There are four phases a PersistentVolume can be in. Which one is a candidate for binding? - Compare the uid recorded on the volume with the uid of the claim that exists now. The name and namespace match. Does anything else?
- The claim names
nfs-retainandkubectl get storageclassdoes not list it. What, concretely, is supposed to react to this claim? - The scratch claim bound in ten seconds. Which StorageClass did it use, and what did that class have that this one does not?
Before continuing: the claim has raised no warnings in forty-one minutes. Is that because nothing has gone wrong, or because nobody is trying?
Root cause
1. Retain left the volume in a phase that cannot bind
A PersistentVolume moves through four phases: Available, Bound,
Released and Failed. Available and Bound are the healthy pair.
Released means the claim that owned this volume has been deleted and
the volume has not been reclaimed.
Which of those happens on claim deletion is decided by
persistentVolumeReclaimPolicy. This volume was written by hand for an
existing NFS export and carries Retain, which is the right choice for
a database: it says the data is too important to delete on a controller’s
say-so, and an operator must decide what happens next.
The consequence is the part that surprised the shift. Released is not
a state the control plane resolves on its own, and it is not a state a
volume can bind from. The matching logic only ever looks at Available
volumes. So the 500Gi volume that holds the database is, from the point
of view of the binding logic, not in the pool at all - which is exactly
what Retain is supposed to mean, because the alternative would be to
hand a stranger’s data to whichever claim asked next.
2. The recorded owner is an object that no longer exists
spec.claimRef on a bound volume records which claim owns it: namespace,
name, and uid. The uid is what makes the reference precise. Names are
reused constantly - data-analytics-db-0 is generated from the
StatefulSet’s volumeClaimTemplate and will be identical every time the
StatefulSet is recreated - but a uid is minted per object and never
reused.
The restore created a claim with the same name in the same namespace and a brand new uid. The volume is still pointing at the old one. The two objects agree on everything a human looks at and disagree on the only field the control plane uses to decide they are the same object.
3. Nothing was ever going to raise an event
Both objects name storageClassName: nfs-retain, and there is no
StorageClass object with that name. That is not a mistake; it is the
ordinary shape of static provisioning, where the class name is a label
used to keep hand-written volumes and their claims paired and away from
the dynamic pool.
But it means there is no provisioner in this story. Nothing is being
asked to create a volume, nothing is being refused, nothing is retrying.
A claim waiting on a broken dynamic provisioner accumulates
ProvisioningFailed warnings and tells you where to look. A claim
waiting on a static volume that is not Available produces silence,
forever, and silence is easy to read as “no information” when it is in
fact the diagnosis.
Resolution
- Establish that this volume backs the right export, with the storage team and against the NFS server. The cluster cannot tell you whose data is behind a path; only the backend can. Nothing below is safe until this is settled.
- Agree the acceptance test now, before touching anything: a table list, a row count, a most-recent timestamp - something the application owner will recognise as their data. Write it in the incident channel.
- Record the current state of the volume so the change is reversible:
kubectl get pv nfs-analytics-500g -o yaml > /tmp/pv-before.yaml. Keep it for the duration of the incident. - Rewrite
spec.claimRefon the volume withkubectl edit pv nfs-analytics-500g, keepingnamespaceandnameand removinguidandresourceVersion. This is the documented reservation form: the volume becomes Available, and available only to the claim it names. - Watch the claim rather than the clock. The binding loop reconsiders it within seconds;
kubectl -n analytics get pvc -wshows the transition to Bound as it happens. - Confirm the pairing in both directions before the Pod starts: the claim names the volume in its VOLUME column, and the volume names the claim in its CLAIM column. A one-sided match means something else bound.
- Let the StatefulSet start. It needs no help - the Pod was Pending on the claim and the scheduler reconsiders it as soon as the claim binds.
- Run the acceptance test that was agreed in step two, with the application owner watching. Do not close on the Pod being Ready.
- If ownership of the export cannot be established, hold. A Pending claim is an outage; a claim bound to the wrong export is a StatefulSet writing into data that belongs to another system. Name who owns the hold and when it will be reviewed, and post that rather than leaving it implied.
Verification
- The claim reports Bound and names
nfs-analytics-500g, and the volume reports Bound and namesanalytics/data-analytics-db-0. Check both directions; a match in one is not a binding. - The reservation is what took effect. Read
spec.claimRefback and confirm it now carries a uid matching the live claim, which is the control plane recording the binding it just made. - The database has its own data. Run the acceptance test agreed before the change - table list, row count, most-recent timestamp - and have the application owner confirm it. This is the only check that distinguishes recovery from a clean start on an empty volume.
- No second volume was created while the incident was open.
kubectl get pvshould show one volume for this export, not two, and nothing new in the last hour. - No other volume is stranded. Look for anything else in Released or Failed:
kubectl get pv --field-selector=status.phase=Released. The namespace deletion may have taken more than one claim with it. - The alert would have fired. Create a claim naming a class that does not exist, confirm the Pending alert fires within its threshold, and delete it. An alert nobody has seen fire is an assumption.
- The recovery is written down. The next person to hit this has minutes, not forty of them, and the reservation edit is not something to derive under pressure.
Prevention
- Alert on PersistentVolumes in
ReleasedandFailed. Neither is transient. Both mean a decision is sitting unmade, and this incident is what an unmade decision looks like six weeks later. - Alert on PersistentVolumeClaims that stay
Pendingpast a few minutes. A claim that fails loudly gets attention on its own; a claim that fails silently needs the alert more, not less. - Treat
Retainas a commitment to operational work. It is the right policy for a database and it means someone must act every time a claim is deleted. A cluster withRetainand no cleanup process accumulates stranded volumes until one of them is in the path of a restore. - Know what your restore does not restore. Claims are namespaced and were in Git; volumes are cluster-scoped and were not. Replaying the repository rebuilt one half of a pair. Every namespace with stateful workloads needs a documented path for the cluster-scoped objects its manifests do not contain.
- Make the namespace hard to delete by accident. This incident started
with a
kubectl delete namespacethat should not have been possible against a namespace holding a production database. - Read silence as evidence. An empty
Eventssection on a Pending claim is not missing information - it says no provisioner is involved, which eliminates most of the diagnostic ladder in one step.