Volume
Pod 스펙 안의 마운트. Pod과 수명을 같이한다.
컨테이너가 죽어도 남는 데이터
Kubernetes는 이걸 세 층으로 푼다.
Volume
Pod 스펙 안의 마운트. Pod과 수명을 같이한다.
PersistentVolume (PV)
Pod과 독립된 저장 공간. 클러스터 스코프 리소스다.
PersistentVolumeClaim (PVC)
“이만큼의 저장소를 달라”는 요청. 네임스페이스에 속한다.
세 층이 어떻게 이어지는가 —
flowchart LR
POD["Pod<br/>spec.volumes"] --> V["Volume<br/>emptyDir · hostPath<br/>configMap · secret"]
POD --> PVC["PersistentVolumeClaim<br/>10Gi · RWO 주세요"]
PVC -->|"바인딩 1:1"| PV["PersistentVolume<br/>실제 저장 공간"]
SC["StorageClass<br/>프로비저너"] -.->|"동적 생성"| PV
PV --> BACK[("NFS · EBS · CSI<br/>hostPath …")]
classDef ns fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef cluster fill:#fef3c7,stroke:#d97706,color:#78350f
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class POD,V,PVC ns
class PV,SC cluster
class BACK mute
파란 것은 네임스페이스 안(Pod·PVC), 노란 것은 클러스터 스코프(PV·StorageClass)다. 이 경계가 곧 뒤에 나올 개발자와 관리자의 경계이기도 하다.
spec: containers: - name: writer image: busybox:1.36 volumeMounts: - name: shared mountPath: /data - name: reader image: busybox:1.36 volumeMounts: - name: shared mountPath: /input volumes: - name: shared emptyDir: {} - name: cache emptyDir: medium: Memory # tmpfs — 메모리에 만든다 sizeLimit: 128Mimedium: Memory는 빠르지만 Pod의 메모리 limit에 포함된다수명의 경계가 어디인지가 전부다.
flowchart LR
C1["컨테이너 재시작"] --> K1["emptyDir 유지 ✅"]
C2["Pod 삭제 · 재스케줄"] --> K2["emptyDir 소멸 ❌"]
C3["노드 재부팅"] --> K3["emptyDir 소멸 ❌"]
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class K1 ok
class K2,K3 bad
class C1,C2,C3 mute
volumes: - name: docker-sock hostPath: path: /var/run/containerd/containerd.sock type: Socket # DirectoryOrCreate | Directory | FileOrCreate | File | Socket정당한 쓰임
/var/log를 읽을 때flowchart LR
subgraph A["클러스터 관리자 — 무엇이 있는가"]
SC["StorageClass<br/>프로비저너 · 파라미터"]
PV["PersistentVolume<br/>capacity · accessModes<br/>reclaimPolicy"]
end
subgraph B["앱 개발자 — 무엇이 필요한가"]
PVC["PersistentVolumeClaim<br/>10Gi · RWO"]
POD["Pod<br/>claimName: data"]
end
POD --> PVC
PVC -->|"바인딩"| PV
PVC -.->|"클래스 요청"| SC
SC -.->|"동적 생성"| PV
classDef admin fill:#fef3c7,stroke:#d97706,color:#78350f
classDef dev fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
class SC,PV admin
class PVC,POD dev
apiVersion: v1kind: PersistentVolumemetadata: name: pv-dataspec: capacity: storage: 10Gi accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain # Retain | Delete storageClassName: manual volumeMode: Filesystem # Filesystem(기본) | Block hostPath: # 실습용. 실제로는 nfs, csi 등 path: /mnt/datastorageClassName이 PVC와 일치해야 바인딩된다kubectl get pv# NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS# pv-data 10Gi RWO Retain Bound default/data manualapiVersion: v1kind: PersistentVolumeClaimmetadata: name: data namespace: defaultspec: accessModes: ["ReadWriteOnce"] storageClassName: manual resources: requests: storage: 5Gi # selector: # 특정 PV를 라벨로 고를 수도 있다 # matchLabels: { tier: fast }# Pod에서 쓰기spec: containers: - name: app volumeMounts: - name: data mountPath: /var/lib/data volumes: - name: data persistentVolumeClaim: claimName: dataPVC가 만들어지면 컨트롤러가 조건에 맞는 PV를 찾는다. 네 관문을 전부 통과해야 묶인다.
flowchart LR
PVC["새 PVC"] --> Q1{"storageClassName<br/>일치?"}
Q1 -->|아니오| X["Pending<br/>바인딩 안 됨"]
Q1 -->|예| Q2{"accessModes<br/>만족?"}
Q2 -->|아니오| X
Q2 -->|예| Q3{"용량이<br/>요청 이상?"}
Q3 -->|아니오| X
Q3 -->|예| Q4{"셀렉터가 있으면<br/>라벨 일치?"}
Q4 -->|아니오| X
Q4 -->|예| B["Bound ✅"]
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
class B ok
class X bad
class PVC key
1:1 관계다. 바인딩된 PV는 다른 PVC가 쓸 수 없다. 그리고 5Gi를 요청했는데 10Gi PV에 묶이면 10Gi 전부를 쓴다 — 남는 5Gi는 낭비된다.
| 모드 | 약칭 | 의미 |
|---|---|---|
ReadWriteOnce |
RWO | 한 노드에서 읽기·쓰기 |
ReadOnlyMany |
ROX | 여러 노드에서 읽기만 |
ReadWriteMany |
RWX | 여러 노드에서 읽기·쓰기 |
ReadWriteOncePod |
RWOP | Pod 하나만 읽기·쓰기 |
RWO의 경계는 노드다. 그림으로 보면 헷갈릴 일이 없다.
flowchart TB
subgraph NA["노드 A"]
P1["Pod 1"]
P2["Pod 2"]
end
subgraph NB["노드 B"]
P3["Pod 3"]
end
VOL[("RWO PV 하나")]
P1 -->|"마운트 ✅"| VOL
P2 -->|"같은 노드라 가능 ✅"| VOL
P3 -.->|"Multi-Attach error ❌"| VOL
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class P1,P2 ok
class P3 bad
class VOL mute
RWX는 아무 스토리지나 되는 게 아니다. 블록 스토리지(EBS, GCE PD)는 RWO만 가능하다. RWX는 NFS·CephFS·EFS 같은 파일 스토리지가 필요하다.
accessMode는 스토리지의 실제 능력을 강제하지 않는다. 바인딩 조건으로만 쓰인다.
stateDiagram-v2
[*] --> Available: PV 생성 또는 동적 프로비저닝
Available --> Bound: 조건에 맞는 PVC 등장
Bound --> Released: PVC 삭제 · reclaimPolicy Retain
Bound --> [*]: PVC 삭제 · reclaimPolicy Delete
Released --> Available: claimRef 를 지운다 · 수동
Released --> Failed: 자동 회수 실패
Failed --> [*]: 관리자가 정리
note right of Released
자동으로 재사용되지 않는다.
옛 PVC 정보가 spec.claimRef 에 남아 있기 때문.
end note
| 상태 | 의미 |
|---|---|
Available |
비어 있고 바인딩 가능 |
Bound |
PVC에 묶여 있다 |
Released |
PVC가 삭제됐지만 아직 회수되지 않았다 |
Failed |
자동 회수에 실패했다 |
| 정책 | PVC 삭제 시 |
|---|---|
Retain |
PV는 남고 상태가 Released 가 된다. 데이터 보존. 재사용하려면 수동 조치 |
Delete |
PV와 실제 스토리지까지 삭제한다. 동적 프로비저닝의 기본값 |
Recycle |
삭제되었다 (deprecated) |
kubectl patch pv pv-data -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'kubectl get pvkubectl get pvckubectl describe pvc data # ★ Events에 왜 Pending인지 나온다apiVersion: storage.k8s.io/v1kind: StorageClassmetadata: name: fast annotations: storageclass.kubernetes.io/is-default-class: "true"provisioner: kubernetes.io/no-provisioner # 또는 ebs.csi.aws.com 등parameters: type: gp3 fsType: ext4reclaimPolicy: DeleteallowVolumeExpansion: truevolumeBindingMode: WaitForFirstConsumerprovisioner: kubernetes.io/no-provisioner 는 동적 생성을 하지 않는다 (로컬 볼륨용)kubectl get sc# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE# standard (default) rancher.io/local-path Delete WaitForFirstConsumerPVC가 생기는 즉시 PV를 만들고 묶는다. 스케줄러는 그다음에 움직인다.
flowchart LR
PVC["PVC 생성"] --> BIND["즉시 바인딩<br/>zone A 에 볼륨 생성"]
BIND --> SCH["나중에 Pod 스케줄"]
SCH --> Z["zone B 노드에 배정"]
Z --> F["마운트 실패 ❌<br/>볼륨이 다른 zone 에 있다"]
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class F bad
class PVC,BIND,SCH,Z mute문제 — 볼륨이 zone A에 생겼는데 Pod은 zone B에 스케줄될 수 있다. 그러면 마운트가 실패한다.
Pod이 스케줄될 때까지 기다렸다가 그 노드에 맞는 볼륨을 만든다.
flowchart LR
PVC["PVC 생성"] --> W["Pending<br/>waiting for first consumer"]
W --> POD["Pod 생성"]
POD --> SCH["스케줄러가 노드 결정<br/>zone B"]
SCH --> BIND["그 zone 에 볼륨을 만들고 바인딩"]
BIND --> OK["마운트 성공 ✅"]
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class OK ok
class W warn
class PVC,POD,SCH,BIND mute토폴로지 제약이 있는 환경(멀티 AZ, 로컬 디스크)에서 사실상 필수다.
# StorageClass에 allowVolumeExpansion: true 가 있어야 한다kubectl patch pvc data -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'kubectl get pvc data -wFileSystemResizePending 이 뜬다spec: volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: fast resources: requests: storage: 10GiPod마다 PVC가 하나씩 생긴다. 이름이 고정이라 Pod이 죽었다 살아나도 같은 볼륨으로 돌아온다.
flowchart LR
STS["StatefulSet db<br/>volumeClaimTemplates: data"]
STS --> P0["db-0"] --> C0["PVC data-db-0"] --> V0[("PV")]
STS --> P1["db-1"] --> C1["PVC data-db-1"] --> V1[("PV")]
STS --> P2["db-2"] --> C2["PVC data-db-2"] --> V2[("PV")]
DEL["StatefulSet 삭제"] -.->|"PVC 는 남는다"| C0
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class STS key
class C0,C1,C2 warn
class P0,P1,P2,V0,V1,V2,DEL mute
data-db-0, data-db-1, …kubectl get pvc -l app=dbkubectl delete pvc data-db-0 # 정말 지우려면 명시적으로flowchart TB
API["kube-apiserver"]
KL["kubelet<br/>노드마다"]
subgraph CTRL["Controller 플러그인 — 클러스터에 하나"]
PROV["provisioner<br/>PV 생성·삭제"]
ATT["attacher<br/>노드에 attach"]
RES["resizer<br/>확장"]
SNAP["snapshotter<br/>스냅샷"]
DRV1["CSI 드라이버"]
end
subgraph NODEP["Node 플러그인 — DaemonSet, 노드마다 하나"]
REG["node-driver-registrar"]
DRV2["CSI 드라이버<br/>실제 마운트"]
end
API --> PROV
API --> ATT
API --> RES
API --> SNAP
PROV --> DRV1
ATT --> DRV1
KL --> DRV2
REG --> KL
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef side fill:#f1f5f9,stroke:#94a3b8,color:#334155
classDef drv fill:#fef3c7,stroke:#d97706,color:#78350f
class API,KL key
class PROV,ATT,RES,SNAP,REG side
class DRV1,DRV2 drv
kubectl get csidriverskubectl get csinodeskubectl get pods -n kube-system | grep csi| 컴포넌트 | 역할 |
|---|---|
| Controller 플러그인 | 볼륨 생성·삭제·attach (Deployment/StatefulSet) |
| Node 플러그인 | 노드에서 마운트 (DaemonSet) |
| sidecar 컨테이너들 | provisioner, attacher, resizer, snapshotter |
CSI는 커리큘럼의 “확장 인터페이스(CNI, CSI, CRI) 이해” 항목이다. 17장에서 함께 정리한다.
volumes: - name: config configMap: { name: app-config }
- name: creds secret: secretName: db-secret defaultMode: 0400
- name: info downwardAPI: # Pod 자신의 정보를 파일로 items: - { path: labels, fieldRef: { fieldPath: metadata.labels } }
- name: all-in-one projected: # 여러 소스를 한 디렉터리에 합친다 sources: - configMap: { name: app-config } - secret: { name: db-secret } - serviceAccountToken: path: token expirationSeconds: 3600 audience: vaultprojected의 serviceAccountToken이 현재 SA 토큰의 표준 주입 방식이다 (14장).
증상은 둘 중 하나다. 어느 쪽인지부터 가른다.
flowchart TD
S{"어디서 멈췄나"}
S -->|"PVC 가 Pending"| A["kubectl describe pvc<br/>★ Events 를 읽는다"]
S -->|"Pod 이 ContainerCreating"| B["kubectl describe pod<br/>★ Events 를 읽는다"]
A --> A1{"메시지"}
A1 -->|"waiting for first consumer"| OK["정상 · Pod 을 만들면 바인딩된다"]
A1 -->|"no persistent volumes available"| A2{"조건에 맞는 PV 가 있는가"}
A1 -->|"storageclass not found"| A3["StorageClass 이름 오타<br/>kubectl get sc"]
A2 -->|"없다"| A4["PV 를 만들거나 SC 로 동적 생성"]
A2 -->|"Available 인데 안 묶인다"| A5["조건 불일치<br/>storageClassName · accessModes · 용량"]
B --> B1{"메시지"}
B1 -->|"Multi-Attach error"| B2["RWO 를 두 노드에서<br/>→ Recreate 또는 StatefulSet"]
B1 -->|"FailedMount timeout expired"| B3["스토리지 백엔드 연결 문제"]
B1 -->|"MountVolume.SetUp failed not found"| B4["ConfigMap/Secret 이 없다"]
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
class OK ok
class A3,A4,A5,B2,B3,B4 bad
class A,B key
# PVC가 Pendingkubectl describe pvc data # ★ Eventskubectl get pv # 조건에 맞는 PV가 있는가kubectl get sc # StorageClass 이름이 맞는가
# Pod이 ContainerCreating에서 멈춤kubectl describe pod web # ★ Events에 마운트 에러kubectl get events --field-selector involvedObject.name=web
# 노드에서 확인kubectl get volumeattachmentssudo journalctl -u kubelet | grep -i mount| Events 메시지 | 원인 |
|---|---|
no persistent volumes available for this claim |
PV 없음 / 조건 불일치 |
waiting for first consumer |
정상. Pod을 만들면 된다 |
Multi-Attach error for volume |
RWO 볼륨을 두 노드에서 쓰려 한다 |
FailedMount: timeout expired waiting |
스토리지 백엔드 연결 문제 |
MountVolume.SetUp failed ... not found |
ConfigMap/Secret이 없다 |
Deployment + RWO PVC + RollingUpdate 조합은 거의 항상 막힌다.
왜 교착이 되는지는 시간순으로 보면 명확하다.
sequenceDiagram
participant D as Deployment · RollingUpdate
participant O as 노드 A · 옛 Pod
participant N as 노드 B · 새 Pod
participant V as RWO PV
O->>V: 이미 마운트 중
D->>N: 새 Pod 생성
N->>V: 마운트 시도
V--xN: Multi-Attach error
Note over N: ContainerCreating 에서 멈춘다
Note over D: 새 Pod 이 Ready 여야 옛 Pod 을 죽인다
Note over O,N: 서로를 기다린다 — 교착
Multi-Attach error → ContainerCreating에서 멈춘다해결 — 넷 중 하나
strategy: Recreate
옛 Pod을 먼저 죽이고 새 Pod을 만든다. 가장 간단하다. 짧은 다운타임을 받아들이는 셈.
maxSurge: 0
replicas: 1을 유지하고 새 Pod을 덧붙이지 않게 한다.
StatefulSet
Pod마다 PVC가 따로 생기므로 애초에 충돌하지 않는다.
RWX 스토리지
NFS·CephFS·EFS로 바꾸면 여러 노드에서 동시 마운트가 된다.
PV 생성 — 관리자의 몫
cat <<'EOF' | kubectl apply -f -apiVersion: v1kind: PersistentVolumemetadata: name: pv-manualspec: capacity: { storage: 1Gi } accessModes: ["ReadWriteOnce"] persistentVolumeReclaimPolicy: Retain storageClassName: manual hostPath: { path: /mnt/data }EOFPVC 생성 — 개발자의 몫. Bound가 되는지 확인한다
kubectl create -f pvc.yamlkubectl get pvc # Bound 확인Pod에서 사용
kubectl get pod web -o jsonpath='{.spec.volumes}'정리 후 확인 — Retain이면 PV가 Released로 남는다
kubectl delete pvc datakubectl get pv # Retain이면 Released 로 남는다ReadWriteOncePodRetain이면 PVC 삭제 후 Released — claimRef를 지워야 재사용된다WaitForFirstConsumer에서의 Pending은 정상이다allowVolumeExpansion: true 필요, 늘리기만 가능volumeClaimTemplates는 수정 불가Multi-Attach error = RWO를 두 노드에서 → Recreate 또는 StatefulSet