데이터베이스 파드가 재시작된 뒤 주문 데이터가 사라졌다는 알림이 올라왔습니다. 스토리지와 Pod 생명주기를 분리하지 않으면 컨테이너 재생성이 곧 데이터 손실로 이어질 수 있습니다. PV와 PVC는 상태 있는 워크로드를 Kubernetes에서 안전하게 운영하기 위한 핵심입니다.
PersistentVolume — 파드가 재시작해도 살아남는 스토리지
데이터베이스를 파드로 배포하고 데이터를 넣었는데 파드가 재시작하자 모든 데이터가 사라졌습니다. 파드의 컨테이너 레이어는 임시적이어서 파드 종료와 함께 사라집니다. Kubernetes에서 상태가 있는 서비스(데이터베이스, 파일 서버, 메시지 큐)를 운영하려면 파드 생애 주기와 독립적인 영구 스토리지가 필요합니다. PersistentVolume이 이 역할을 합니다. PV→PVC→Pod로 이어지는 3단계 추상화는 처음에는 복잡해 보이지만, 파드가 실제 스토리지의 종류(NFS인지 EBS인지 로컬 디스크인지)를 몰라도 되게 해주는 중요한 설계입니다. StorageClass의 동적 프로비저닝까지 이해하면, 클라우드 스토리지를 선언만으로 자동 생성하는 패턴으로 자연스럽게 연결됩니다.
파드 재시작에도 데이터가 유지되는 영구 스토리지의 구조와 동작 원리를 이해하고, 실제 데이터베이스 배포에 적용합니다.
- 1컨테이너 스토리지의 임시성과 PersistentVolume이 필요한 이유
- 2PV → PVC → Pod 3단계 바인딩 흐름
- 3Access Mode: RWO, ROX, RWX, RWOP 비교
- 4StorageClass와 동적 프로비저닝
- 5reclaimPolicy: Retain vs Delete
- 6TroubleCase: PVC Pending — StorageClass 미설정 또는 용량 부족
minikube는 기본 StorageClass(standard)가 포함되어 있습니다. EKS/GKE/AKS 등 클라우드 클러스터도 기본 StorageClass가 있습니다. 일반 클러스터는 kubectl get storageclass로 확인하세요.
kubectl cluster-infokubectl get storageclasskubectl create namespace storage-labkubectl get pv,pvc -n storage-lab컨테이너 스토리지의 임시성과 PV 3단계 구조
새벽에 배포된 데이터베이스 파드가 OOM으로 재시작됐습니다. 팀이 확인해보니 파드 재시작 순간 수천 건의 주문 데이터가 함께 사라졌습니다. 컨테이너 파일시스템은 파드가 종료되면 그대로 삭제되기 때문입니다. 데이터베이스, 파일 서버, 메시지 큐처럼 상태를 유지해야 하는 서비스는 파드 생애 주기와 완전히 분리된 스토리지가 필요합니다. PersistentVolume은 바로 이 요구를 해결하기 위한 Kubernetes의 핵심 스토리지 추상화입니다. PV→PVC→Pod의 3단계 구조는 복잡해 보이지만, 파드가 실제 스토리지의 위치와 종류를 몰라도 되도록 설계된 것입니다.

왜 일반 볼륨만으로는 부족한가
파드 내부의 컨테이너 파일시스템은 파드가 삭제되면 함께 사라집니다. emptyDir을 사용하면 파드 내 컨테이너 간 파일 공유는 가능하지만, 파드 자체가 삭제되면 데이터가 사라집니다. 데이터베이스처럼 상태를 영구적으로 저장해야 하는 워크로드에는 파드 생애 주기와 완전히 독립적인 스토리지가 필요합니다.
PV → PVC → Pod 3단계 추상화
관리자 또는 StorageClass
│ PV 생성 (물리/클라우드 스토리지 연결)
▼
┌──────────────────────────────────┐
│ PersistentVolume (PV) │
│ - 실제 스토리지 연결 정보 │
│ - 용량, accessMode │
│ - 클러스터 범위 오브젝트 │
└────────────────┬─────────────────┘
│ 바인딩
▼
┌──────────────────────────────────┐
│ PersistentVolumeClaim (PVC) │
│ - 스토리지 요청서 │
│ - "5Gi, ReadWriteOnce 필요" │
│ - 네임스페이스 범위 오브젝트 │
└────────────────┬─────────────────┘
│ 마운트
▼
┌──────────────────────────────────┐
│ Pod │
│ - PVC 이름만 참조 │
│ - 실제 스토리지 위치 모름 │
└──────────────────────────────────┘
이 구조 덕분에 파드 YAML은 스토리지 종류에 무관하게 동일하게 작성할 수 있습니다. 개발 환경(로컬 hostPath)에서 프로덕션(AWS EBS)으로 전환할 때도 파드 YAML은 변경하지 않고 PV/PVC만 바꾸면 됩니다.
Access Mode 비교
| Access Mode | 약어 | 의미 | 지원 스토리지 |
|---|---|---|---|
| ReadWriteOnce | RWO | 단일 노드에서 읽기/쓰기 | AWS EBS, GCP PD, Azure Disk |
| ReadOnlyMany | ROX | 여러 노드에서 읽기 전용 | NFS, GCS |
| ReadWriteMany | RWX | 여러 노드에서 읽기/쓰기 | NFS, AWS EFS, Azure Files |
| ReadWriteOncePod | RWOP | 단일 파드에서만 읽기/쓰기 (K8s 1.22+) | CSI 드라이버 지원 필요 |
주의: PVC에 선언한 accessMode가 볼륨의 마운트 방식을 제어합니다. 하지만 스토리지 백엔드 자체가 해당 모드를 지원해야 합니다. EBS는 RWO만 지원하므로 RWX로 선언해도 단일 노드에서만 동작합니다.
정적 프로비저닝 — PV와 PVC 수동 생성
클러스터에 처음 데이터베이스를 배포하려는데 StorageClass가 없는 환경이거나, 보안상 스토리지 생성을 인프라 팀이 직접 통제해야 하는 경우가 있습니다. 이럴 때는 관리자가 PV를 직접 생성하고 개발팀이 PVC로 요청하는 정적 프로비저닝 방식을 사용합니다. 온프레미스 클러스터나 레거시 스토리지 시스템과 연동할 때 특히 자주 쓰입니다. 수동 생성이지만 PV와 PVC의 바인딩 조건을 정확히 이해해야 Pending 상태 없이 연결됩니다.

Step 1: PersistentVolume 생성
# pv-example.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: my-pv-001
spec:
capacity:
storage: 5Gi # 이 PV가 제공하는 용량
accessModes:
- ReadWriteOnce # 단일 노드 읽기/쓰기
persistentVolumeReclaimPolicy: Retain # PVC 삭제 후에도 데이터 보존
storageClassName: manual # PVC와 매칭할 StorageClass 이름
hostPath: # 테스트용 — 프로덕션에서는 사용하지 않음
path: /data/pv-001
type: DirectoryOrCreate
kubectl apply -f pv-example.yaml
kubectl get pv my-pv-001
# NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS STORAGECLASS
# my-pv-001 5Gi RWO Retain Available manual
# ↑ PVC 연결 대기 중
- NAME—조회 대상 리소스 이름이 예상한 대상과 일치하는지 확인합니다.
- STATUS/READY—Running, Ready, Available처럼 정상 상태를 나타내는 필드가 있는지 봅니다.
- RESTARTS/EVENTS—재시작 횟수나 Warning 이벤트가 증가하지 않는지 확인합니다.
Step 2: PersistentVolumeClaim 생성
# pvc-example.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: my-pvc
namespace: storage-lab
spec:
accessModes:
- ReadWriteOnce
storageClassName: manual # PV의 storageClassName과 일치
resources:
requests:
storage: 3Gi # 요청 용량 (PV 용량 이하여야 바인딩됨)
kubectl apply -f pvc-example.yaml
kubectl get pvc my-pvc -n storage-lab
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
# my-pvc Bound my-pv-001 5Gi RWO manual
# ↑ Bound = PV와 연결됨
kubectl get pv my-pv-001
# STATUS: Bound ← PVC가 연결되면 PV도 Bound로 변경
Step 3: Pod에서 PVC 사용
# pod-with-pvc.yaml
apiVersion: v1
kind: Pod
metadata:
name: pvc-test-pod
namespace: storage-lab
spec:
volumes:
- name: my-storage
persistentVolumeClaim:
claimName: my-pvc # PVC 이름 참조
containers:
- name: app
image: busybox
command: ["sh", "-c", "echo 'hello pv' > /data/test.txt && sleep 3600"]
volumeMounts:
- name: my-storage
mountPath: /data # 컨테이너 내 마운트 경로
kubectl apply -f pod-with-pvc.yaml
# 파드에서 파일 쓰기
kubectl exec pvc-test-pod -n storage-lab -- cat /data/test.txt
# hello pv
# 파드 삭제 후 재생성해도 데이터 유지 확인
kubectl delete pod pvc-test-pod -n storage-lab
kubectl apply -f pod-with-pvc.yaml
kubectl exec pvc-test-pod -n storage-lab -- cat /data/test.txt
# hello pv ← 파드 재생성 후에도 데이터 유지!
StorageClass와 동적 프로비저닝
정적 프로비저닝은 PV를 관리자가 미리 만들어야 해서 번거롭습니다. StorageClass를 사용하면 PVC를 생성할 때 자동으로 PV가 프로비저닝됩니다.
StorageClass 확인 및 이해
# 클러스터의 StorageClass 목록
kubectl get storageclass
# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
# standard (default) k8s.io/minikube-hostpath Delete Immediate
# ↑ (default) = storageClassName 생략 시 자동으로 이 클래스 사용
# StorageClass 상세 확인
kubectl describe storageclass standard
# storageclass-example.yaml (참고용 — minikube에서는 이미 있음)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
annotations:
storageclass.kubernetes.io/is-default-class: "true" # 기본 StorageClass 지정
provisioner: kubernetes.io/aws-ebs # AWS EBS 프로비저너
parameters:
type: gp3 # EBS 볼륨 타입
fsType: ext4
reclaimPolicy: Delete # PVC 삭제 시 PV도 삭제
volumeBindingMode: WaitForFirstConsumer # 파드가 스케줄될 노드에 맞춰 프로비저닝
동적 프로비저닝 실습
# pvc-dynamic.yaml — StorageClass 지정 시 PV 자동 생성
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: dynamic-pvc
namespace: storage-lab
spec:
accessModes:
- ReadWriteOnce
storageClassName: standard # 이 StorageClass가 자동으로 PV 생성
resources:
requests:
storage: 2Gi
kubectl apply -f pvc-dynamic.yaml
# PVC 생성 직후 — 자동으로 PV가 만들어지고 바인딩됨
kubectl get pvc dynamic-pvc -n storage-lab
# NAME STATUS VOLUME CAPACITY
# dynamic-pvc Bound pvc-a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx 2Gi
# ↑ 자동 생성된 PV 이름
kubectl get pv
# NAME CAPACITY STATUS CLAIM
# pvc-a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx 2Gi Bound storage-lab/dynamic-pvc
실제 데이터베이스 배포 예시 — PostgreSQL
# postgres-with-pvc.yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data-pvc
namespace: storage-lab
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
# storageClassName 생략 시 default StorageClass 사용
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: storage-lab
spec:
replicas: 1 # DB는 보통 StatefulSet 사용하지만 학습용으로 Deployment
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-data-pvc
containers:
- name: postgres
image: postgres:15-alpine
env:
- name: POSTGRES_PASSWORD
value: "changeme"
- name: PGDATA
value: "/var/lib/postgresql/data/pgdata"
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
kubectl apply -f postgres-with-pvc.yaml
# PVC 바인딩 확인
kubectl get pvc postgres-data-pvc -n storage-lab
# 파드 실행 확인
kubectl get pods -n storage-lab -l app=postgres
# DB에 접속하여 데이터 입력
kubectl exec -it $(kubectl get pod -n storage-lab -l app=postgres -o name) \
-n storage-lab -- psql -U postgres -c "CREATE TABLE test (id INT, name TEXT);"
kubectl exec -it $(kubectl get pod -n storage-lab -l app=postgres -o name) \
-n storage-lab -- psql -U postgres -c "INSERT INTO test VALUES (1, 'hello');"
# 파드 강제 삭제 후 재생성
kubectl delete pod -n storage-lab -l app=postgres
# Deployment가 자동으로 새 파드 생성
# 새 파드에서 데이터 확인
kubectl exec -it $(kubectl get pod -n storage-lab -l app=postgres -o name) \
-n storage-lab -- psql -U postgres -c "SELECT * FROM test;"
# id | name
# ----+-------
# 1 | hello ← 파드 재생성 후에도 데이터 유지!
문제 상황
kubectl apply -f pvc-dynamic.yaml
kubectl get pvc my-pvc -n storage-lab
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
# my-pvc Pending <empty> <empty> <empty> fast-ssd
# ↑ Pending 상태 — PV가 바인딩되지 않음
진단 1: PVC 이벤트 확인 — 오류 메시지 읽기
kubectl describe pvc my-pvc -n storage-lab
# Events:
# Warning ProvisioningFailed no persistent volumes available for this
# claim and no storage class is set
#
# 또는:
# Warning ProvisioningFailed storageclass.storage.k8s.io "fast-ssd" not found
원인 A: StorageClass가 없는 경우
kubectl get storageclass
# No resources found ← StorageClass 없음
# 해결: 기본 StorageClass 설치 또는 PVC에서 storageClassName 제거
PVC 수정 (storageClassName 생략 → default StorageClass 사용):
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
# storageClassName: fast-ssd ← 삭제 또는 존재하는 StorageClass로 변경
원인 B: 정적 PV와 요구사항 불일치
# PV 목록과 상태 확인
kubectl get pv
# NAME CAPACITY ACCESS MODES STATUS STORAGECLASS
# my-pv-001 3Gi RWO Available manual
# PVC가 요청하는 것 확인
kubectl describe pvc my-pvc -n storage-lab | grep -E "Capacity|Access|StorageClass"
# Capacity: 5Gi ← 5Gi 요청
# Access Modes: RWO
# StorageClass: manual
# 문제: PV는 3Gi인데 PVC는 5Gi 요청 → 바인딩 불가
해결 방법 1: PVC 용량을 PV 이하로 조정
spec:
resources:
requests:
storage: 2Gi # 3Gi PV에 바인딩 가능한 용량으로 조정
해결 방법 2: 더 큰 용량의 PV 생성
spec:
capacity:
storage: 10Gi # PVC 요청 용량보다 크게 설정
원인 C: StorageClass 이름 불일치
# 사용 가능한 StorageClass 확인
kubectl get storageclass
# NAME PROVISIONER AGE
# standard k8s.io/minikube-hostpath 10d
# PVC에서 잘못된 StorageClass 지정
# storageClassName: fast-ssd ← 존재하지 않는 이름
# storageClassName: standard ← 올바른 이름으로 수정
빠른 진단 스크립트
# PVC 상태 전체 확인
echo "=== PVC 상태 ==="
kubectl get pvc -n storage-lab
echo "=== PV 상태 ==="
kubectl get pv
echo "=== StorageClass 목록 ==="
kubectl get storageclass
echo "=== PVC 이벤트 (오류 확인) ==="
kubectl describe pvc -n storage-lab | grep -A5 Events
StatefulSet으로 DB 배포 — Deployment 대신 StatefulSet
# postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: storage-lab
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:15-alpine
env:
- name: POSTGRES_PASSWORD
value: "changeme"
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates: # StatefulSet은 파드마다 PVC를 자동 생성
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
StatefulSet의 volumeClaimTemplates는 파드마다 개별 PVC를 자동으로 만들어줍니다. 레플리카가 3개면 PVC도 3개(data-postgres-0, data-postgres-1, data-postgres-2)가 생성됩니다.
PV 백업 전략
# PVC를 사용하는 파드 찾기
kubectl get pods -n storage-lab -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .spec.volumes[*]}{.persistentVolumeClaim.claimName}{"\n"}{end}{end}'
# Velero (백업 도구)로 PVC 포함 전체 네임스페이스 백업
velero backup create daily-backup \
--include-namespaces storage-lab \
--storage-location default
reclaimPolicy Retain 사용 시 PV 재사용
스토리지 리소스 삭제
안전한 실행 조건: 백업과 reclaimPolicy를 확인했고 더 이상 데이터가 필요 없을 때만 실행하세요.
실행 전 반드시 확인
- 현재 컨텍스트와 Namespace가 의도한 대상인지 확인했는가
- 운영 트래픽이나 상태 저장 데이터에 미치는 영향을 확인했는가
- 되돌릴 매니페스트, 백업, 또는 복구 절차가 준비되어 있는가
kubectl delete pvc my-pvc -n storage-lab위 항목을 모두 확인한 후 복사할 수 있습니다
# PVC 삭제 후 Retain 정책의 PV 상태
kubectl delete pvc my-pvc -n storage-lab
kubectl get pv my-pv-001
# STATUS: Released ← PVC는 없어졌지만 데이터는 보존됨
# Released PV를 새 PVC에 연결하려면 claimRef 제거 필요
kubectl patch pv my-pv-001 -p '{"spec":{"claimRef": null}}'
# 이후 STATUS: Available → 새 PVC와 바인딩 가능
실무 체크리스트
- 프로덕션 DB PV는
reclaimPolicy: Retain필수 volumeBindingMode: WaitForFirstConsumer로 파드 스케줄링 노드와 같은 가용 영역에 볼륨 생성- PVC 용량은 나중에 늘릴 수 있지만(StorageClass allowVolumeExpansion: true), 줄일 수는 없음
- 다중 복제본 StatefulSet은 각 파드마다 별도 PVC 사용 (RWX 공유 지양)
정리
스토리지 리소스 삭제
안전한 실행 조건: 백업과 reclaimPolicy를 확인했고 더 이상 데이터가 필요 없을 때만 실행하세요.
실행 전 반드시 확인
- 현재 컨텍스트와 Namespace가 의도한 대상인지 확인했는가
- 운영 트래픽이나 상태 저장 데이터에 미치는 영향을 확인했는가
- 되돌릴 매니페스트, 백업, 또는 복구 절차가 준비되어 있는가
kubectl delete pvc <pvc-name> -n <namespace>위 항목을 모두 확인한 후 복사할 수 있습니다
# PV/PVC 관련 주요 명령어
# PV/PVC 목록 확인
kubectl get pv
kubectl get pvc -n <namespace>
# PVC 상세 정보 (바인딩 상태, 용량, 이벤트)
kubectl describe pvc <pvc-name> -n <namespace>
# StorageClass 목록
kubectl get storageclass
# 파드의 볼륨 마운트 확인
kubectl describe pod <pod-name> -n <namespace> | grep -A10 "Volumes:"
# PVC 삭제 (reclaimPolicy에 따라 PV도 삭제될 수 있음)
kubectl delete pvc <pvc-name> -n <namespace>
# PV 삭제
kubectl delete pv <pv-name>
# PVC 용량 확장 (StorageClass가 allowVolumeExpansion: true인 경우)
kubectl patch pvc <pvc-name> -n <namespace> \
-p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'
다음 챕터에서는 Namespace로 개발/스테이징/운영 환경을 분리하고 ResourceQuota로 자원을 제한하는 방법을 학습합니다.