Skip to main content

Kubernetes 1.36's unusedSince Field Turns Orphaned-Volume Hunting Into a Query

9 min readDora NodaDora Noda
Share

A PersistentVolumeClaim doesn't know how to tell you it's been abandoned. A tenant's worker Deployment gets deleted, a StatefulSet gets scaled down, a debugging Pod gets torn away from the PVC it was mounted to — and the volume just sits there, Bound, billed, and silent. Until Kubernetes 1.36, the only way to find one was to write a script that cross-references every PVC against every running Pod and hopes it didn't miss a CronJob that mounts the thing once a week.

KEP-5541, shipped as an alpha feature in Kubernetes v1.36 "Haru," adds a single field — unusedSince — to PersistentVolumeClaimStatus. It sounds small. It replaces that script with a kubectl one-liner, and it's the first primitive that lets a self-hosted platform build automated volume reclamation instead of a manual quarterly audit.


What the field actually does

The PVC protection controller already watches every Pod event in the cluster to enforce the kubernetes.io/pvc-protection finalizer — it has to know when a PVC stops being referenced before it can let a delete through. KEP-5541 just makes that knowledge visible. When the last Pod referencing a PVC is deleted or reaches a terminal state, the controller stamps the current time onto status.unusedSince. The moment a new Pod starts using that PVC again, the field clears back to nil.

That gives every PVC exactly two states, and the field name is deliberately unambiguous about which is which:

  • unusedSince is nil — the PVC is either actively mounted by a running Pod, or it has never been used by any Pod at all. (Kubernetes 1.36 doesn't distinguish "in use" from "never used" — both read as nil, which matters below.)
  • unusedSince is a timestamp — the PVC has had no Pod reference since that moment. The gap between then and now is exactly how long you've been paying for a volume nothing is reading from.

Once every PVC carries that timestamp, "find volumes that have been idle for two weeks" stops being a fleet-wide correlation problem and becomes a field selector:

bash
kubectl get pvc -A -o json | jq -r '
  .items[]
  | select(.status.unusedSince != null)
  | select((now - (.status.unusedSince | fromdateiso8601)) > (14*86400))
  | "\(.metadata.namespace)/\(.metadata.name)\tunused since \(.status.unusedSince)"
'

That's the whole audit. No cross-referencing Pod specs, no separate inventory job scraping every namespace — the answer is sitting in the PVC's own status field the moment KEP-5541's feature gate (PVCUnusedSince) is enabled on the API server.

Why this is worth automating on owned hardware

The waste this catches isn't hypothetical. Industry audits of unaudited clusters put orphaned-storage waste at 5-15% of total storage spend, and the mechanics are mundane rather than exotic: a StatefulSet scaled from 5 replicas to 2 leaves 3 PVCs behind by design (Kubernetes never auto-deletes a StatefulSet's volumes on scale-down, since the whole point is that they survive it), a debug Pod gets deleted but the PVC it was hand-created against doesn't, a tenant's app gets redeployed onto a fresh PVC and nobody cleans up the old one.

On a managed cloud, that waste is annoying but bounded — you're paying a vendor's storage rate on volumes you forgot about. On a self-hosted platform running its own Hetzner-backed CSI storage classes, it's the same waste, just with your own capacity planning behind it instead of a line item you can shrug off. Hetzner Cloud Volumes price at €0.0572/GB/month as of the April 2026 adjustment. A 50GB volume nobody's mounted in three months has quietly cost €8.58 by the time anyone notices — trivial alone, and exactly the kind of trivial that compounds across a fleet nobody's auditing.

Here's what that compounding looks like at a scale a self-hosted PaaS actually runs at, across the 5-15% waste range the industry numbers give:

Fleet size (tenant volumes)Avg. volume sizeTotal provisionedOrphaned at 5%Orphaned at 15%Monthly waste (5%–15%)
10020GB2TB100GB300GB€5.72 – €17.16
50020GB10TB500GB1.5TB€28.60 – €85.80
2,00020GB40TB2TB6TB€114.40 – €343.20

None of those numbers are catastrophic on their own — that's the point. Orphaned-volume waste doesn't page anyone; it's a few dozen euros a month that never shows up as an incident, just as a storage bill that creeps upward independent of how many tenants are actually paying you. At 2,000 tenant volumes it's real money sitting idle with zero signal pointing at it, and it was completely invisible before this field existed — the platform had no query that could answer "how much of what I'm provisioning right now is doing nothing."

From field to reclaim job

unusedSince gives you the signal. Kubernetes 1.36 does not ship a controller that acts on it — that part is still yours to build, which is honest about what an alpha KEP actually delivers. The shape is a small CronJob, not a new control-plane component:

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: pvc-reclaim-audit
  namespace: platform-system
spec:
  schedule: "0 3 * * *"  # daily, 3am
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: pvc-reclaim-auditor
          restartPolicy: OnFailure
          containers:
            - name: reclaim-audit
              image: bitnami/kubectl:1.36
              command:
                - /bin/sh
                - -c
                - |
                  GRACE_DAYS=14
                  kubectl get pvc -A -o json | jq -r --arg grace "$GRACE_DAYS" '
                    .items[]
                    | select(.status.unusedSince != null)
                    | select((now - (.status.unusedSince | fromdateiso8601)) > ($grace | tonumber) * 86400)
                    | "\(.metadata.namespace)/\(.metadata.name)"
                  ' | while read -r pvc; do
                    kubectl annotate pvc "${pvc%%/*}" -n "${pvc%%/*}" \
                      bex.co/reclaim-candidate="true" --overwrite
                  done

A 14-day grace period is a starting point, not a rule — StatefulSet volumes and anything holding a tenant's actual data need a longer window (or an exemption label) than a scratch volume a build step mounted once. The job above only labels candidates; the honest default is a human or a higher-confidence policy reviews the label before anything deletes data, because the field can't distinguish "nobody's using this by accident" from "nobody's using this because the workload is paused for a month." That distinction is exactly what nil for "never used" versus nil for "in use" already blurs at the single-PVC level — don't compound it by wiring straight to kubectl delete pvc on day one.

Why this beats a third-party scanner

Tools like Kor already exist to answer "which PVCs in my cluster look orphaned," and they've been the honest answer to this problem for years. But they all work the same way: list every PVC, list every Pod, cross-reference, and report a PVC as suspect if no running Pod currently references it. That approach has a real blind spot — a PVC that's unused right now because its Pod restarted five minutes ago looks identical to one that's been idle for three months. The scanner has no memory between runs unless it builds its own, which means every third-party tool ends up reinventing a piece of exactly the state Kubernetes now tracks natively in the PVC protection controller.

unusedSince collapses that gap because the timestamp lives in the object's own status, maintained by a controller that was already watching Pod lifecycle events for an unrelated reason (the finalizer). A scanner running once a day can miss a PVC that went unused and got reclaimed by a new Pod in between runs; a status field updated in real time by the API server can't. That doesn't make external tooling obsolete — Kor and similar tools still cover ConfigMaps, Secrets, and Services unusedSince was never scoped to touch — but for the specific "how long has this volume been idle" question, a native field beats a periodic scan built on the same signal from the outside.

What it doesn't solve

Three gaps are worth being explicit about before treating this as a finished cleanup story:

  • It's alpha and off by default. The PVCUnusedSince feature gate has to be explicitly enabled on the API server. Disabling it later doesn't clear existing timestamps — they go stale in etcd rather than disappearing, so a platform that flips the gate on and off needs its reclaim job to also sanity-check "is this timestamp still being maintained," not just "does one exist."
  • It only covers PVCs, not Released PVs. A PV whose reclaim policy is Retain and whose PVC has already been deleted moves to Released phase — it no longer has a PVC to carry the field at all. That's a separate, older cleanup problem (kubectl get pv --field-selector=status.phase=Released), and unusedSince doesn't touch it.
  • StatefulSet scale-down volumes need their own exemption logic, not a shorter grace period. A volume that's "unused" because its StatefulSet replica count dropped from 5 to 3 is working as designed — reclaiming it on the same clock as an abandoned debug PVC would delete data a team scaled back up expecting to still be there.

Where this fits for a fleet that owns its hardware

A self-hosted PaaS running tenant apps on owned Hetzner machines has no vendor absorbing this waste into an opaque bill — the capacity is the platform's own, and until KEP-5541 the only way to find what's idle was a script nobody ran on a schedule. unusedSince doesn't automate cleanup by itself, but it turns "audit storage" from a quarterly chore into a query any CronJob can run nightly, which is the actual precondition for building a "here's what your reclaim job would delete, review before it does" feature into a platform rather than a wiki page telling operators to check manually.


Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, orchestrated by Cluster API. Primitives like unusedSince are exactly the kind of infrastructure-layer signal a self-hosted platform can build tenant-facing storage hygiene on top of, without ever operating a database or storage service itself. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex