기술 · 실용 가이드
Kubernetes PVC가 Terminating에서 멈췄나요? finalizer 제거 전 모든 참조를 감사하세요
검증된 읽기 전용 수집기로 PVC, PV, Pod, StorageClass, VolumeAttachment를 하나의 증거 원장에 묶습니다.

멈춘 PVC는 먼저 참조 관계 문제로 다뤄야 합니다. 강제 삭제 전에 API 그래프를 보존하세요.
이 프로그램은 kubectl get만 실행하며 권한 부족이나 손상된 JSON을 미해결 항목으로 남깁니다.
finalizer는 원인이 아니라 완료해야 할 계약입니다
PVC 삭제 요청 뒤 Kubernetes는 deletionTimestamp를 설정하고 finalizer 목적이 끝날 때까지 객체를 남깁니다. 공식 문서는 목적을 이해하고 완료하기 전 수동 제거를 경고합니다.
Pod가 PVC를 참조하면 여전히 사용 중일 수 있고 PV에도 별도 보호와 회수 수명 주기가 있습니다. 메타데이터보다 관계를 먼저 보존하세요.
다섯 가지 API 보기를 수집하세요
대상 PVC, 네임스페이스의 모든 Pod, 연결된 PV, StorageClass, VolumeAttachment를 읽어 UID, resourceVersion, 삭제 시각, finalizer, claimRef와 CSI 식별자를 기록합니다.
읽기 실패는 미해결 증거입니다. RBAC 거부를 빈 목록으로 바꾸거나 잘못된 JSON을 참조 없음으로 해석하지 않습니다.
읽기 전용 수집기를 저장하고 실행하세요
전체 프로그램을 audit_kubernetes_pvc.mjs로 저장합니다. node --check 후 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; } }
8개 합성 시나리오에서 Pod, PV, VolumeAttachment, PV 없음, RBAC 거부, 잘못된 JSON, 위험한 이름, 변경 명령 미실행을 확인했습니다. 실제 클러스터에는 연결하지 않았습니다.
PVC를 건드리기 전에 Pod 참조를 읽으세요
보고서는 spec.volumes가 대상 claim을 가리키는 Pod만 나열합니다. 상위 워크플로가 완료됐다고 보존된 객체의 참조가 사라진 것은 아닙니다.
원장에 나온 Pod를 즉시 삭제하지 말고 컨트롤러, 종료 절차, 데이터 정책을 소유자와 확인하세요.
PV, 회수 정책, CSI 연결을 따로 추적하세요
Bound PV의 claimRef와 PVC UID를 비교하고 StorageClass provisioner와 reclaim policy를 기록합니다. Delete나 Retain은 백엔드 상태를 증명하지 않습니다.
VolumeAttachment는 중요한 API 증거지만 백엔드 직접 증거는 아닙니다. 읽기 실패나 오류가 있으면 CSI 컨트롤러와 공급자 절차로 넘깁니다.
강제 삭제가 아니라 중지 매트릭스를 사용하세요
Pod 또는 VolumeAttachment 참조, 미해결 읽기, 목적을 모르는 finalizer가 있으면 중지합니다. 검토는 다음 책임자를 정하는 것이지 자동 제거가 아닙니다.
환경마다 워크로드, provisioner, 연결, 오래된 객체가 원인일 수 있습니다. 다른 환경의 patch를 복사하면 실패한 컨트롤러를 찾을 증거가 사라질 수 있습니다.
승인된 한 번의 변경 전후 원장을 보존하세요
책임자가 최소 변경을 승인하면 이전 JSON과 작업을 남기고 같은 프로그램으로 새 원장을 만드세요. UID, resourceVersion, 참조, finalizer와 연결 상태를 비교합니다.
API에서 객체가 사라져도 데이터 안전성, 분리, 복구 가능성이 증명되지는 않습니다. 별도 지원 절차로 검증하세요.
