テクノロジー · 実用ガイド
Kubernetes PVC が Terminating のまま?finalizer を外す前に全参照を監査する
検証済みの読み取り専用コレクターで PVC、PV、Pod、StorageClass、VolumeAttachment を一つの証拠台帳にまとめます。

止まった PVC は、まず参照関係の問題として扱います。強制削除より先に API グラフを保存します。
このプログラムは kubectl get だけを実行し、権限不足や壊れた JSON を未解決として残します。
finalizer は根本原因ではなく未完了の契約
PVC を削除すると deletionTimestamp が設定され、finalizer の目的が完了するまで表示されたままです。目的を理解せず手動で外さないよう公式文書は警告しています。
Pod が PVC を参照していれば使用中であり、PV にも独自の保護と再利用ポリシーがあります。まず関係を保存します。
5 種類の 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 と reclaim policy を記録します。Delete や Retain はバックエンド状態の証明ではありません。
VolumeAttachment は重要な API 証拠ですが、ストレージ側の直接証明ではありません。読めない場合やエラー時は CSI とベンダーの手順へ進みます。
強制削除レシピではなく停止マトリクスを使う
Pod または VolumeAttachment の参照、未解決の読み取り、目的不明の finalizer があれば停止します。レビューは担当者を選ぶことであり、自動削除ではありません。
環境ごとに停止原因は異なります。他環境の patch をコピーすると、失敗したコントローラーを示す証拠を消す恐れがあります。
承認された1変更の前後を保存する
担当者が最小の変更を承認したら、古い JSON と操作内容を残し、同じプログラムで新しい台帳を作ります。UID、resourceVersion、参照、finalizer、アタッチ状態を比較します。
API から消えたことはデータ安全性、デタッチ、復旧可能性の証明ではありません。別の正式手順で検証してください。
