Tecnología · GUÍA PRÁCTICA
¿PVC de Kubernetes atascado en Terminating? Audita cada referencia antes de quitar un finalizer
Un recolector probado y de solo lectura reúne PVC, PV, Pods, StorageClass y VolumeAttachments en un único registro de pruebas.

Un PVC bloqueado es primero un problema de relaciones hasta que las pruebas indiquen otra cosa.
El programa solo ejecuta kubectl get y conserva como incógnitas los permisos faltantes o el JSON inválido.
Un finalizer es un contrato pendiente, no la causa raíz
Al borrar un PVC, Kubernetes fija deletionTimestamp y conserva el objeto mientras se cumple el propósito de sus finalizers. La documentación advierte que no deben quitarse manualmente sin comprender y completar ese propósito.
Si un Pod todavía nombra el PVC, puede seguir en uso; el PV enlazado tiene su propia protección y ciclo de reclamación. Primero conserva las relaciones.
Captura cinco vistas de la API
El auditor lee el PVC, todos los Pods del espacio, el PV enlazado, StorageClass y VolumeAttachments. Registra UID, resourceVersion, tiempos de borrado, finalizers, claimRef, política y datos CSI.
Una lectura fallida es una incógnita. Un rechazo RBAC no se convierte en lista vacía y el JSON mal formado no significa cero referencias.
Guarda y ejecuta el recolector sin escritura
Guarda el programa completo como audit_kubernetes_pvc.mjs. Ejecuta node --check y después node audit_kubernetes_pvc.mjs NAMESPACE PVC_NAME > pvc-before.json.
#!/usr/bin/env node import { spawnSync } from 'node:child_process'; const SAFE_NAME = /^(?=.{1,253}$)[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/; export function validateName(value, label) { if (typeof value !== 'string' || !SAFE_NAME.test(value) || value.startsWith('-')) { throw new Error(`${label} must be a safe lowercase Kubernetes name`); } } function summarizeObject(object) { if (!object) return null; return { apiVersion: object.apiVersion ?? null, kind: object.kind ?? null, name: object.metadata?.name ?? null, namespace: object.metadata?.namespace ?? null, uid: object.metadata?.uid ?? null, resourceVersion: object.metadata?.resourceVersion ?? null, deletionTimestamp: object.metadata?.deletionTimestamp ?? null, finalizers: object.metadata?.finalizers ?? [], }; } export function spawnKubectl(args) { const result = spawnSync('kubectl', args, { encoding: 'utf8', shell: false, timeout: 20_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true, }); return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '', error: result.error?.message ?? null, }; } function readJson(run, args, label, unresolved, { required = false } = {}) { const result = run(args); if (result.error || result.status !== 0) { unresolved.push({ check: label, reason: result.error ?? `kubectl exited ${result.status}`, stderr: String(result.stderr ?? '').trim().slice(0, 1000), }); if (required) throw new Error(`${label} could not be read`); return null; } try { return JSON.parse(result.stdout); } catch (error) { unresolved.push({ check: label, reason: `malformed JSON: ${error.message}` }); if (required) throw new Error(`${label} returned malformed JSON`); return null; } } export function auditPVC(namespace, pvcName, run = spawnKubectl) { validateName(namespace, 'namespace'); validateName(pvcName, 'PVC name'); const unresolved = []; const commands = []; const read = (args, label, options) => { commands.push(['kubectl', ...args]); return readJson(run, args, label, unresolved, options); }; const pvc = read(['get', 'pvc', pvcName, '-n', namespace, '-o', 'json'], 'PVC', { required: true }); const pods = read(['get', 'pods', '-n', namespace, '-o', 'json'], 'Pods'); const pvName = pvc.spec?.volumeName || null; const pv = pvName ? read(['get', 'pv', pvName, '-o', 'json'], 'PV') : null; const storageClassName = pvc.spec?.storageClassName || pv?.spec?.storageClassName || null; const storageClass = storageClassName ? read(['get', 'storageclass', storageClassName, '-o', 'json'], 'StorageClass') : null; const volumeAttachments = read( ['get', 'volumeattachments.storage.k8s.io', '-o', 'json'], 'VolumeAttachments', ); const podReferences = (pods?.items ?? []).flatMap((pod) => { const matchingVolumes = (pod.spec?.volumes ?? []) .filter((volume) => volume.persistentVolumeClaim?.claimName === pvcName) .map((volume) => ({ volumeName: volume.name, readOnly: Boolean(volume.persistentVolumeClaim?.readOnly) })); return matchingVolumes.length ? [{ ...summarizeObject(pod), phase: pod.status?.phase ?? null, nodeName: pod.spec?.nodeName ?? null, matchingVolumes, }] : []; }); const matchingAttachments = (volumeAttachments?.items ?? []) .filter((attachment) => attachment.spec?.source?.persistentVolumeName === pvName) .map((attachment) => ({ ...summarizeObject(attachment), attacher: attachment.spec?.attacher ?? null, nodeName: attachment.spec?.nodeName ?? null, attached: attachment.status?.attached ?? null, attachError: attachment.status?.attachError?.message ?? null, detachError: attachment.status?.detachError?.message ?? null, })); return { schemaVersion: 1, generatedAt: new Date().toISOString(), target: { namespace, pvcName, pvName, storageClassName }, safety: { mode: 'read-only', mutatingCommandsRun: false, notRun: ['patch', 'delete', 'replace', 'edit', 'scale', 'drain', 'detach', 'finalizer removal'], backendContacted: false, }, commands, pvc: { ...summarizeObject(pvc), phase: pvc.status?.phase ?? null, accessModes: pvc.spec?.accessModes ?? [], requestedStorage: pvc.spec?.resources?.requests?.storage ?? null, volumeName: pvName, storageClassName, }, pv: pv ? { ...summarizeObject(pv), phase: pv.status?.phase ?? null, reclaimPolicy: pv.spec?.persistentVolumeReclaimPolicy ?? null, storageClassName: pv.spec?.storageClassName ?? null, claimRef: pv.spec?.claimRef ? { namespace: pv.spec.claimRef.namespace ?? null, name: pv.spec.claimRef.name ?? null, uid: pv.spec.claimRef.uid ?? null, } : null, csi: pv.spec?.csi ? { driver: pv.spec.csi.driver ?? null, volumeHandle: pv.spec.csi.volumeHandle ?? null, fsType: pv.spec.csi.fsType ?? null, } : null, } : null, storageClass: storageClass ? { ...summarizeObject(storageClass), provisioner: storageClass.provisioner ?? null, reclaimPolicy: storageClass.reclaimPolicy ?? null, volumeBindingMode: storageClass.volumeBindingMode ?? null, allowVolumeExpansion: storageClass.allowVolumeExpansion ?? null, } : null, podReferences, volumeAttachments: matchingAttachments, unresolved, decisionBoundary: { stopBeforeMutation: true, reasons: [ ...(podReferences.length ? ['one or more Pods still reference the PVC'] : []), ...(matchingAttachments.length ? ['one or more VolumeAttachments still reference the PV'] : []), ...(unresolved.length ? ['one or more required API reads are unresolved'] : []), ...(pvc.metadata?.finalizers?.length ? ['PVC finalizers require controller-purpose investigation'] : []), ...(pv?.metadata?.finalizers?.length ? ['PV finalizers require controller-purpose investigation'] : []), ], }, }; } if (import.meta.url === `file://${process.argv[1]}`) { const [namespace, pvcName] = process.argv.slice(2); try { const report = auditPVC(namespace, pvcName); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); if (report.unresolved.length) process.exitCode = 2; } catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; } }
Ocho escenarios sintéticos verificaron Pod, PV y adjunto, ausencia de PV, rechazo RBAC, JSON inválido, nombres inseguros y ausencia de verbos mutantes. No se contactó ningún clúster real.
Lee las referencias de Pods antes de tocar el PVC
El informe solo enumera Pods cuyo spec.volumes apunta al claim. Que un flujo superior termine no demuestra que todos los objetos conservados hayan dejado de referenciarlo.
No borres un Pod solo por aparecer en el registro. Identifica el controlador, la parada de la aplicación y la política de datos con su responsable.
Sigue por separado PV, política y adjuntos CSI
Compara claimRef del PV Bound con el UID del PVC y registra el provisioner y la política. Delete o Retain no demuestran que el volumen del proveedor ya se separó o borró.
VolumeAttachment es una prueba de API, no una observación directa del backend. Si no se puede leer o hay errores, conserva el dato y sigue el procedimiento de CSI o del proveedor.
Usa una matriz de parada, no una receta de borrado forzado
Detente si un Pod o VolumeAttachment aún referencia el almacenamiento, falta una lectura o se desconoce el propósito del finalizer. Revisar significa elegir al siguiente responsable, no quitar metadatos automáticamente.
Los entornos pueden esperar a cargas, provisioners, adjuntos u objetos obsoletos. Copiar un patch puede borrar la única pista del controlador que falló.
Conserva registros antes y después de un cambio aprobado
Cuando el responsable apruebe una acción mínima, guarda el JSON anterior, registra la acción y ejecuta de nuevo el colector. Compara UID, resourceVersion, referencias, finalizers y adjuntos.
Que el objeto desaparezca de la API no prueba que los datos estén seguros, separados o recuperables. Valídalos mediante procedimientos independientes.
