이전 글에서 Pod를 생성하여 관리하는 방법에 대해 정리했는데, 컨테이너가 죽었을 때 데이터를 어떻게 보존할까 하는 궁금이 생겼습니다.
컨테이너 파일 시스템은 재시작 시 스토리지가 초기화되기 때문에, Pod 내에서 기록한 데이터는 전부 사라지게 됩니다.
이번 글에서는 영속 스토리지를 위한 Volume, PV, PVC, Storage Class, 그리고 백업을 위한 VolumeSnapshot에 대해 정리합니다.
Volume
kubernetes에서 Volume은 Pod 안에서 컨테이너들이 접근할 수 있는 디렉터리를 가리키는 추상적인 개념입니다.
이전 글에서 본 ConfigMap과 Secret도 사실은 Volume의 한 종류이기 때문에, configMap, secret, emptyDir 등의 데이터가 volume 하위의 내용으로 들어가게 됩니다.
| Volume 종류 | 수명 | 용도 |
| emptyDir | Pod 수명 | 컨테이너 간 임시 공유, 캐시 |
| configMap / secret | ConfigMap·Secret과 동일 | 설정 파일 주입 |
| hostPath | 노드 수명 | 노드 파일 시스템 접근 (위험) |
| persistentVolumeClaim | PVC 수명 (독립적) | 영속 데이터 (DB, 업로드 파일 등) |
이번 글에서 다루는 PV / PVC는 이중에서 영속 데이터 영역에 포함됩니다.
emptyDir — 컨테이너 간 임시 공유
같은 Pod 내 여러 컨테이너가 데이터를 공유할 때 사용할 수 있고, Pod가 삭제되면 데이터도 사라집니다.
volumes:
- name: shared
emptyDir: {} # Pod 삭제 시 데이터도 삭제
# 메모리 기반 (tmpfs)으로도 가능 — 빠르지만 Pod 메모리에서 차감
- name: cache
emptyDir:
medium: Memory
sizeLimit: 500Mi
영속성이 필요 없는 경우, 예를 들어 사이드카가 메인 컨테이너의 로그를 읽어 전송하는 경우에 적합합니다. 메인 컨테이너가 /var/log/app에 로그를 쓰고, 사이드카가 같은 경로를 읽어 외부로 보내는 식이죠.
medium: Memory로 설정하면 디스크가 아닌 tmpfs(메모리)에 마운트되어 I/O가 빠릅니다. 다만 Pod 메모리에서 차감되니 sizeLimit을 같이 잡아두는 게 안전합니다.
예를 들어, 사이드카가 메인 컨테이너의 로그를 읽어 전송하는 경우처럼 영속성이 필요 없는 경우에 사용할 수 있습니다.
메인 컨테이너가 /var/log/app에 로그를 작성하면 사이드카가 같은 경로를 읽어 외부로 보내는 형식으로 사용합니다.
Note : medium: Memory와 같이 설정하면 디스크가 아닌 tmpfs(메모리)에 마운트되어 I/O가 빠르지만, Pod의 메모리에 포함되어 sizeLimit를 함께 고려하는 것이 안전합니다.
⚠️ 주의: emptyDir은 Pod 재시작 시에는 유지되지만, Pod 삭제 시에는 사라집니다. Deployment의 롤링 업데이트는 새 Pod를 만들고 기존 Pod를 지우는 방식이므로, emptyDir 데이터는 보존되지 않습니다.
hostPath — 노드 파일 시스템에 직접 접근
hostPath는 노드의 실제 파일 시스템 경로를 Pod에 마운트합니다.
volumes:
- name: docker-sock
hostPath:
path: /var/run/docker.sock
type: Socket
hostPath는 노드 자체에 접근해야하는 시스템 컴포넌트에서 사용할 수 있습니다.
예를 들어, Fluent Bit DaemonSet과 같이 로그 수집 DeamonSet이 /var/log를 읽거나, 모니터링 에이전트가 /proc을 읽는 경우에 사용할 수 있습니다.
⚠️ 주의: 일반 애플리케이션에서는 hostPath를 쓰지 않는 게 원칙입니다.
노드 파일 시스템을 그대로 노출해서 컨테이너 격리가 깨지고, Pod가 어느 노드에 가느냐에 따라 데이터가 달라져서 사실상 영속 스토리지로도 쓰지 못하기 때문에 데이터가 필요하면 PV/PVC를 쓰는 것이 권장됩니다.
PV / PVC - 영속 스토리지 계층
영속 데이터는 PV, PVC의 추상화 계층을 거쳐 저장할 수 있습니다.
| 개념 | 범위 | 누가 만들까? |
| PersistentVolume (PV) | 클러스터 수준 | 관리자 또는 동적 프로비저너 |
| PersistentVolumeClaim (PVC) | Namespace 수준 | 개발자(앱) |
PV를 스토리지 제공자(관리자), PVC를 소비자(개발자) 형태로 분리하여 사용하는 형태로 스토리지의 관리 영역을 분리할 수 있습니다.
예를 들어, 관리자가 PV를 만들어주면, 개발자는 "10G 용량의 스토리지가 필요해"라고 PVC를 작성해서 사용할 수 있습니다.
개발자 입장에서는 어떤 디스크인지, 어떤 파일 시스템 형식인지는 몰라도 사용이 가능합니다.

PVC가 PV를 "주장(claim)" 한다는 표현을 사용하는데, PVC는 요청서, PV는 실제 디스크로 이해할 수 있습니다.
개발자가 필요한 스토리지 용량의 요청서를 제출하면, 클러스터가 조건에 맞는 PV를 찾아 바인딩(연결)해줍니다.
PV - 실제 스토리지의 표현
PV는 클러스터에 등록된 실제 스토리지 용량을 표현합니다.
일반 Pod가 NFS 서버 주소나 EBS 볼륨 ID를 직접 들고 있지 않아도 되도록, 스토리지 정보를 클러스터 객체로 추상화한 것입니다.
정적 프로비저닝 — PV를 수동으로 만들기
관리자가 PV를 미리 만들어두면, 개발자 요청한 PVC가 조건에 맞는 PV를 찾아 바인딩됩니다.
이걸 "정적 프로비저닝(static provisioning)"이라고 합니다.
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-pv-001
spec:
capacity:
storage: 50Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: nfs # 같은 storageClassName을 가진 PVC와 매칭
nfs:
server: nfs.example.com
path: /exports/data
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-data
spec:
storageClassName: nfs
accessModes:
- ReadWriteMany
resources:
requests:
storage: 50Gi
PVC가 PV와 바인딩되려면 아래의 내용이 일치해야 합니다.
- storageClassName 일치
- PVC 요청 크기 ≤ PV 용량
- PVC accessModes ⊆ PV accessModes
- PVC selector(있는 경우) ⊆ PV labels
정적 프로비저닝은 사내 NFS 서버나 기존 인프라의 디스크를 끌어다 쓸 때 유용합니다.
다만 매번 관리자가 PV를 만들어야 해서 번거롭고, 그래서 실무에서는 다음에 다룰 동적 프로비저닝을 더 많이 씁니다.
PV의 생명주기
PV는 자기만의 상태(phase)를 가집니다.
| phase | 의미 |
| Available | 만들어졌지만 아직 바인딩 안 됨 |
| Bound | 어떤 PVC와 연결된 상태 |
| Released | PVC가 삭제됐지만 PV는 아직 정리되지 않음 (Retain 정책일 때) |
| Failed | 자동 회수에 실패한 상태 |
Released가 좀 헷갈리는 설정인데, Retain 정책에서 PVC를 삭제하면 PV는 데이터를 그대로 유지한 채 Released 상태가 됩니다.
이 PV는 다른 PVC가 자동으로 가져갈 수 없고, 관리자가 데이터를 확인하고 수동으로 정리해야 다시 Available이 됩니다.
# PV 상태 확인
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM
nfs-pv-001 50Gi RWX Retain Bound default/shared-data
StorageClass — 스토리지의 "유형"을 정의
StorageClass는 "어떤 종류의 스토리지인가"를 정의하는 객체입니다.
빠른 SSD, 일반 디스크, 아카이브용 저렴한 스토리지 등 클러스터에 여러 등급을 만들어둘 수 있습니다.
PVC는 storageClassName으로 원하는 등급을 고르고, StorageClass가 그에 맞는 PV를 자동 생성합니다.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com # CSI 드라이버
parameters: # provisioner마다 다른 옵션
type: gp3
iops: "3000"
throughput: "125"
encrypted: "true"
reclaimPolicy: Delete # PVC 삭제 시 PV/디스크 처리
allowVolumeExpansion: true # 나중에 크기 확장 가능
volumeBindingMode: WaitForFirstConsumer
mountOptions:
- debug
핵심 필드를 하나씩 살펴보겠습니다.
provisioner — 어떤 드라이버에서 디스크 생성할까?
provisioner 필드는 어떤 CSI 드라이버가 실제 디스크 생성을 담당할지 지정합니다. 클라우드/온프레미스마다 다릅니다.
| Provisioner | 백엔드 |
| ebs.csi.aws.com | AWS EBS |
| efs.csi.aws.com | AWS EFS (RWX 지원) |
| pd.csi.storage.gke.io | GCP Persistent Disk |
| disk.csi.azure.com | Azure Disk |
| file.csi.azure.com | Azure Files (RWX 지원) |
| rook-ceph.rbd.csi.ceph.com | Ceph RBD (온프레미스) |
| nfs.csi.k8s.io | NFS (외부 NFS 서버 연결) |
매니지드 쿠버네티스(EKS, GKE, AKS)는 기본 provisioner가 미리 설치되어 있고,
직접 구축한 클러스터는 별도로 CSI 드라이버를 설치해야 합니다.
parameters
parameters는 provisioner가 해석하는 옵션입니다.
AWS EBS라면 type (gp3/io2/sc1 등), iops, throughput, encrypted 같은 값이 들어가고, GCP는 type이 pd-ssd/pd-standard 와 같이 사용할 수 있습니다.
각 플랫폼 별로 어떤 값을 사용할 수 있는지는 각 CSI 드라이버 문서를 확인하여 사용할 수 있습니다.
volumeBindingMode
volumeBindingMode 는 PV를 언제 만들지를 결정하는 필드입니다.
| 모드 | 동작 |
| Immediate | PVC가 만들어지자마자 즉시 PV(=클라우드 디스크) 생성 |
| WaitForFirstConsumer | Pod가 PVC를 마운트하려는 순간까지 기다렸다가 생성 |
두개의 모드를 지원하는 이유는 클라우드 환경의 AZ(가용 영역) 때문입니다.
예를 들어, AWS EBS 같은 블록 스토리지는 특정 AZ에 묶여 있어서, AZ-a에 만든 디스크는 AZ-b의 노드에 마운트할 수 없습니다.
- Immediate 모드로 디스크가 AZ-a에 먼저 만들어졌는데, 스케줄러는 노드 자원을 보고 Pod를 AZ-b에 배치하려고 하면 충돌이 발생합니다.
- WaitForFirstConsumer는 스케줄러가 Pod를 어느 AZ에 배치할지 먼저 정하고, 그 AZ에 디스크를 만듭니다.
운영 환경에서는 WaitForFirstConsumer를 쓰는 게 안전합니다.
Note : PVC를 만들고 한참 동안 Pod에 연결 안 하는 상황이라면 PVC가 계속 Pending 상태로 보이는데, 이건 비정상이 아니라 의도된 동작입니다.
reclaimPolicy — PVC 삭제 시 처리 방법
StorageClass 수준에서 기본값을 정합니다. PVC를 통해 동적으로 만들어진 PV는 이 정책을 상속받습니다.
| 정책 | 동작 |
| Delete (기본값) | PVC 삭제 시 PV와 실제 디스크 모두 삭제 |
| Retain | PVC 삭제 시 PV는 Released로 남고 데이터 유지 |
⚠️ 주의: 동적으로 생성된 PV에 Delete 정책이 적용된 채로 PVC를 삭제하면 실제 클라우드 디스크도 사라집니다.
프로덕션 DB용 StorageClass는 반드시 "reclaimPolicy: Retain"으로 별도로 만들어두는 게 안전합니다.
이미 만들어진 PV의 reclaimPolicy를 사후에 바꾸는 것도 가능합니다.
# 단일 PV의 정책 변경
kubectl patch pv <pv-name> -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
allowVolumeExpansion — 나중에 크기 확장 가능한지
"allowVolumeExpansion: true"이면 PVC의 spec.resources.requests.storage를 늘려서 디스크를 확장할 수 있습니다.
단방향이라 축소는 불가능합니다. 처음 StorageClass를 만들 때 활성화하여 사용할 수 있습니다.
StorageClass 티어링 패턴
운영 환경에서는 보통 StorageClass를 여러 개 만들어 용도별로 나눕니다.
# 빠른 SSD - 트랜잭션 DB, 실시간 워크로드
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
parameters: { type: gp3, iops: "3000" }
reclaimPolicy: Retain # 중요 데이터는 Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
---
# 일반 표준 - 일반 앱 데이터
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: standard
annotations:
storageclass.kubernetes.io/is-default-class: "true" # 기본 StorageClass
provisioner: ebs.csi.aws.com
parameters: { type: gp3 }
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
---
# 저렴한 콜드 스토리지 - 로그, 백업
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: cold
provisioner: ebs.csi.aws.com
parameters: { type: sc1 }
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
is-default-class 어노테이션을 단 StorageClass는 PVC에서 storageClassName을 생략했을 때 쓰이는 기본값이 됩니다.
단, 기본 StorageClass는 클러스터당 하나만 있어야 합니다.
# 기본 StorageClass 확인 (이름 옆에 (default) 표시)
$ kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
fast-ssd ebs.csi.aws.com Retain WaitForFirstConsumer
standard (default) ebs.csi.aws.com Delete WaitForFirstConsumer
cold ebs.csi.aws.com Delete WaitForFirstConsumer
PVC를 Pod에서 사용하는 방법
지금까지 만든 StorageClass + PVC를 Pod에서 마운트하는 방법입니다.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-data
spec:
storageClassName: fast-ssd
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
apiVersion: v1
kind: Pod
metadata:
name: db
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: db-data
PVC만 apply하면 EBS 볼륨이 자동으로 생성되고, PV로 등록되고, PVC와 바인딩되고, Pod에 마운트됩니다.
접근 모드 (Access Modes)
PVC를 만들 때 accessModes에 어떤 방식으로 마운트할지를 지정합니다.
| 약어 | 설명 | 사용 대상 |
| RWO (ReadWriteOnce) | 하나의 노드에서만 읽기/쓰기 | AWS EBS, GCP PD, Azure Disk |
| ROX (ReadOnlyMany) | 여러 노드에서 읽기 전용 | NFS, CephFS |
| RWX (ReadWriteMany) | 여러 노드에서 동시 읽기/쓰기 | NFS, EFS, CephFS, Azure Files |
| RWOP (ReadWriteOncePod) | 하나의 Pod에서만 읽기/쓰기 (v1.29 GA) | CSI 드라이버 일부 |
RWO vs RWX — 언제 무엇을 쓰나
AWS EBS, GCP Persistent Disk처럼 블록 스토리지는 한 노드에서만 마운트 가능하기 때문에, 가장 흔하게 사용되는 옵션은 RWO입니다.
데이터베이스, 단일 인스턴스 앱, StatefulSet의 각 Pod 등이 여기에 해당합니다.
RWX는 여러 Pod가 같은 파일 시스템을 동시에 봐야 할 때 씁니다. 대표적인 시나리오는 아래와 같습니다.
- 워드프레스 같은 CMS의 업로드 디렉터리 공유 (여러 웹 Pod가 같은 미디어 파일에 접근)
- ML 학습 데이터셋을 여러 워커가 함께 읽는 경우
- 로그를 여러 Pod에서 같은 디렉터리에 쓰는 경우
이런 경우엔 블록 스토리지(EBS 등)로는 RWX를 지원할 수 없기 때문에, NFS, EFS, CephFS 같은 파일 스토리지가 필요합니다.
⚠️ 주의: 접근 모드는 대상 스토리지에서 지원해야 합니다. AWS EBS는 RWO만, NFS/EFS는 RWX를 지원합니다.
지원하지 않는 모드를 요청하면 바인딩에 실패합니다.
RWOP — RWO보다 더 엄격한 격리
RWO가 "한 노드"라면 RWOP는 "한 Pod"입니다. 같은 노드 위에 있어도 다른 Pod는 이 PVC를 마운트할 수 없습니다.
보안에 민감한 워크로드(중요 인증서, 키 자료 등)에서 같은 노드의 다른 Pod와 격리하고 싶을 때 씁니다.
subPath — 한 볼륨의 일부만 마운트
볼륨 전체가 아니라 특정 경로만 마운트하고 싶을 때 subPath를 씁니다.
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql # PVC의 mysql/ 디렉터리만 마운트
- name: data
mountPath: /var/log/app
subPath: logs # PVC의 logs/ 디렉터리만 마운트
하나의 PVC를 여러 용도로 나눠 쓸 때 유용한데, 보안 측면에서 주의할 점이 있습니다.
⚠️ 주의: subPath에 변수($(VAR))나 사용자 입력을 그대로 넣으면 경로 탐색(path traversal) 취약점이 될 수 있습니다. Kubernetes는 절대 경로와 ..을 거부하지만, 동적으로 만들어지는 값이라면 검증 후 써야 합니다. 안전한 대안으로 subPathExpr이 있는데, 환경 변수 기반으로만 동작하고 더 엄격하게 검증됩니다.
⚠️ 주의: subPath로 마운트하는 경우 ConfigMap/Secret의 자동 갱신이 동작하지 않습니다. 자동 갱신이 필요하면 별도 디렉터리를 마운트하는 방식으로 우회해야 합니다.
VolumeSnapshot — 백업과 복원 (v1.20 GA)
VolumeSnapshot은 KubernetesAPI 로 통합관리할 수 있는 백업 방식입니다.
v1.12 alpha → v1.17 beta → v1.20 GA를 거쳐 안정화됐고, CSI 드라이버가 지원하는 스토리지 유형(EBS, GCP PD, CephFS 등)에서 동작합니다.
스냅샷 만들기
# 관리자가 만들어두는 VolumeSnapshotClass
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: ebs-snapshot
driver: ebs.csi.aws.com
deletionPolicy: Delete
---
# 개발자가 작성하는 VolumeSnapshot
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: db-data-backup-20260605
spec:
volumeSnapshotClassName: ebs-snapshot
source:
persistentVolumeClaimName: db-data # 백업할 PVC
apply하면 CSI 드라이버가 클라우드 스냅샷 API를 호출해서 실제 디스크 스냅샷을 만듭니다.
스냅샷에서 복원
복원은 PVC를 만들 때 dataSource에 스냅샷을 지정하면 됩니다.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-data-restored
spec:
storageClassName: fast-ssd
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
dataSource:
name: db-data-backup-20260605
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
새 PVC가 스냅샷 시점의 데이터로 채워진 상태로 만들어집니다.
장애 복구나 환경 복제(스테이징을 프로덕션 데이터로 채우는 등)에 유용합니다.
⚠️ 주의: VolumeSnapshot은 CSI 드라이버가 지원해야 동작합니다. in-tree 드라이버는 지원하지 않습니다. 그리고 스냅샷 자체에도 비용이 들고(특히 클라우드), 자동 삭제 정책(deletionPolicy)을 잘못 설정하면 의도치 않게 스냅샷이 사라질 수 있으니 주의합니다.
Trouble Shooting
PVC Pending
PVC가 Pending에서 안 넘어가는 원인은 아래의 세 가지 중 하나입니다.
# PVC 이벤트 확인
kubectl describe pvc db-data
- 조건에 맞는 PV가 없다 (크기·접근 모드·StorageClass 불일치) — 정적 프로비저닝
- StorageClass의 CSI 드라이버가 설치되지 않았다 — 동적 프로비저닝에서 흔함
- volumeBindingMode: WaitForFirstConsumer인데 아직 Pod가 스케줄되지 않았다 — 정상 동작
세 번째는 앞서 다룬 것처럼 AZ 매칭을 위해 의도된 것이기 때문에, Pod가 스케줄 되면 그때 PV가 생성됩니다.
⚠️ 주의: PVC는 축소할 수 없습니다. 확장은 allowVolumeExpansion: true인 StorageClass에서만 가능합니다.
# PV/PVC 상태 확인
kubectl get pv
kubectl get pvc -n prod
# StorageClass 목록
kubectl get storageclass
# PVC 크기 확장
kubectl edit pvc db-data # spec.resources.requests.storage 수정
StatefulSet과의 연계
StatefulSet을 정리할 때 volumeClaimTemplates 옵션을 사용했었는데, PVC를 자동으로 만들어주는 장치입니다.
StatefulSet이 Pod를 3개 띄우면 PVC도 3개(data-redis-0, data-redis-1, data-redis-2)가 자동 생성되고, 각 Pod가 자기 PVC에 바인딩됩니다.
그리고 StatefulSet을 삭제해도 PVC는 남는다는 점도 데이터 보호를 위한 의도적인 설계이기 때문에, 완전히 정리하려면 PVC를 명시적으로 삭제해야 합니다.