Skip to content
ioob.dev
Go back

Kubernetes 입문 8편 — 스토리지: PV, PVC, StorageClass

· 9분 읽기
Kubernetes 시리즈 (8/12)
  1. Kubernetes 입문 1편 — Kubernetes란
  2. Kubernetes 입문 2편 — 클러스터 구조
  3. Kubernetes 입문 3편 — Pod
  4. Kubernetes 입문 4편 — 컨트롤러
  5. Kubernetes 입문 5편 — 서비스와 네트워킹
  6. Kubernetes 입문 6편 — Ingress와 Gateway API
  7. Kubernetes 입문 7편 — ConfigMap과 Secret
  8. Kubernetes 입문 8편 — 스토리지: PV, PVC, StorageClass
  9. Kubernetes 입문 9편 — 리소스 관리와 오토스케일링
  10. Kubernetes 입문 10편 — RBAC과 보안: 최소 권한의 원칙
  11. Kubernetes 입문 11편 — 관측성: 로그, 메트릭, 추적
  12. Kubernetes 입문 12편 — Helm과 패키지 관리
Table of contents

Table of contents

Pod는 사라진다

쿠버네티스를 배우기 시작하면 누구나 한 번은 놀란다. Pod 안에서 파일을 만들어놓고 Pod를 재시작하면, 그 파일이 말끔히 사라진다. 컨테이너의 파일시스템은 기본적으로 일회용이기 때문이다.

애플리케이션이 진짜로 stateless라면 문제가 없다. 그런데 현실은 그렇지 않다. 데이터베이스는 디스크에 데이터를 써야 하고, 업로드 서버는 파일을 보관해야 하고, 캐시 서버도 warm-up된 상태를 유지하고 싶다. Pod가 죽어도 데이터는 살아남아야 한다.

이 문제를 풀려고 쿠버네티스가 만든 추상화가 PersistentVolume(PV)PersistentVolumeClaim(PVC)이다. 이름이 길어서 처음 보면 부담스럽지만, 구조는 단순하다.

PV와 PVC를 왜 나눴나

스토리지를 Pod에 바로 연결하면 편할 것 같은데, 쿠버네티스는 굳이 두 단계로 나눠뒀다. 왜일까?

핵심은 관심사의 분리다. 스토리지를 “만드는 쪽”과 “쓰는 쪽”의 역할이 다르기 때문이다.

PVC를 만들면 쿠버네티스가 조건에 맞는 PV를 찾아서 연결(bind)해준다. Pod는 PVC만 바라보기 때문에, 뒤에 있는 스토리지가 EBS인지 NFS인지 신경 쓸 필요가 없다.

flowchart LR
    A[클러스터 관리자] -->|준비| B[PV<br/>50Gi EBS]
    C[개발자] -->|요청| D[PVC<br/>20Gi 요청]
    D -->|바인딩| B
    E[Pod] -->|마운트| D

이 분리 덕에 인프라가 바뀌어도 애플리케이션 매니페스트는 그대로다. 온프레미스에서 AWS로 이전해도 Pod 스펙의 PVC 참조는 변하지 않는다.

정적 프로비저닝 — 관리자가 미리 준비

가장 기본적인 방식이다. 관리자가 PV를 직접 만들어두면, 개발자가 PVC로 요청해서 받아 쓴다.

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-manual
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: manual
  hostPath:
    path: /mnt/data

로컬 디렉토리(/mnt/data)를 10Gi짜리 PV로 선언한 것이다. hostPath는 데모용이고, 실무에서는 NFS나 클라우드 볼륨을 쓴다.

그 다음 PVC로 이 PV를 요청한다.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-manual
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: manual

PVC를 배포하면 쿠버네티스가 조건(용량 ≥ 5Gi, 접근 모드 일치, StorageClass 일치)을 만족하는 PV를 찾는다. 방금 만든 pv-manual이 조건을 맞추니까 바인딩된다. kubectl get pvc를 쳐보면 상태가 Bound로 바뀐 걸 볼 수 있다.

마지막으로 Pod에 PVC를 마운트한다.

apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
    - name: app
      image: nginx:1.27
      volumeMounts:
        - name: data
          mountPath: /usr/share/nginx/html
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: pvc-manual

이제 /usr/share/nginx/html에 쓰는 데이터는 PV에 저장된다. Pod가 죽어도 데이터는 살아남는다.

동적 프로비저닝 — 필요할 때 자동 생성

정적 방식은 명료하지만 불편하다. 개발팀이 PVC를 만들 때마다 관리자가 PV를 손으로 준비해야 하는데, 이건 확장성이 떨어진다. 그래서 실무에서 거의 항상 쓰는 게 동적 프로비저닝이다.

핵심은 StorageClass라는 리소스다. “이런 종류의 스토리지가 필요하면 이 프로바이더에게 부탁해서 만들어라”고 정의해둔 템플릿이다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  iops: "3000"
  throughput: "125"
  encrypted: "true"
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true

AWS 환경에서 gp3 EBS 볼륨을 만드는 StorageClass다. provisioner는 실제로 볼륨을 만드는 CSI 드라이버 이름이고, parameters는 그 드라이버에 넘기는 설정이다.

이제 PVC에서 storageClassName: fast-ssd만 지정하면 된다.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-dynamic
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
  storageClassName: fast-ssd

PV를 미리 만들 필요가 없다. PVC가 생성되는 순간 StorageClass가 참조하는 프로바이더에게 “20Gi짜리 EBS 볼륨 하나 만들어”라고 요청하고, 돌려받은 볼륨을 PV로 등록한 뒤 PVC에 바인딩한다. 개발자 입장에서는 그냥 PVC만 만들면 디스크가 생긴다.

동적 프로비저닝은 대부분의 매니지드 쿠버네티스(EKS, GKE, AKS)에서 기본값으로 세팅되어 있다. 따로 storageClassName을 지정하지 않으면 클러스터의 기본 StorageClass가 쓰인다.

스토리지 바인딩 흐름

PV, PVC, Pod가 어떻게 맞물려 돌아가는지 한번 정리해보자.

sequenceDiagram
    participant D as 개발자
    participant K as kube-apiserver
    participant C as External Provisioner<br/>(CSI Driver)
    participant S as 스토리지 백엔드<br/>(EBS, NFS, ...)
    participant P as Pod

    D->>K: PVC 생성
    K->>C: "이 StorageClass로 볼륨 필요"
    C->>S: 실제 볼륨 생성
    S-->>C: 볼륨 ID
    C->>K: PV 등록
    K->>K: PV ↔ PVC 바인딩
    D->>K: Pod 생성 (PVC 참조)
    K->>P: Pod 스케줄
    P->>S: 볼륨 마운트

중요한 포인트가 하나 있다. StorageClass의 volumeBindingModeWaitForFirstConsumer면, PVC만 만들었을 때는 아직 실제 볼륨이 생기지 않는다. Pod가 스케줄되는 순간에야 “이 노드의 AZ에 볼륨을 만들어라”는 요청이 간다. 노드와 볼륨의 가용 영역을 맞추기 위한 설계다. 반대로 Immediate면 PVC 생성 즉시 볼륨이 만들어진다.

접근 모드 — ReadWriteOnce와 친구들

PV와 PVC를 선언할 때 반드시 지정하는 accessModes는 네 가지가 있다.

모드약어의미
ReadWriteOnceRWO한 노드에서 읽기-쓰기 가능
ReadOnlyManyROX여러 노드에서 읽기 전용
ReadWriteManyRWX여러 노드에서 읽기-쓰기 가능
ReadWriteOncePodRWOP한 Pod만 읽기-쓰기 (v1.29 정식)

많은 사람이 ReadWriteOnce를 “한 Pod만 쓸 수 있는 것”으로 오해한다. 사실은 한 노드에서만 쓸 수 있다는 뜻이다. 같은 노드에 스케줄된 여러 Pod는 동시에 마운트할 수 있다. 진짜로 한 Pod만 허용하려면 ReadWriteOncePod를 써야 한다.

접근 모드 선택은 스토리지 백엔드가 뭘 지원하느냐에 달렸다.

RWX가 필요한 상황은 생각보다 드물다. 대부분의 상용 DB(PostgreSQL, MySQL, Redis)는 RWO로 운영된다. 파일 공유가 진짜로 필요한 경우(WordPress 같은 레거시 업로드 서버, ML 데이터셋 공유)에만 RWX를 고민하면 된다.

Reclaim Policy — 삭제될 때의 처리

PVC를 지우면 연결된 PV와 실제 스토리지는 어떻게 될까? 이걸 결정하는 게 persistentVolumeReclaimPolicy다.

프로덕션 DB처럼 중요한 데이터를 쓰는 PVC는 Retain을 기본으로 하는 게 안전하다. 실수로 kubectl delete pvc를 쳤을 때 “아 다행히 PV는 남아있네”가 되기 때문이다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: retain-ssd
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
reclaimPolicy: Retain   # 삭제해도 볼륨은 남김

StatefulSet이 만드는 PVC 패턴

지금까지는 PVC를 직접 만들었다. 그런데 데이터베이스처럼 각 레플리카마다 고유한 볼륨이 필요한 경우에는 StatefulSet의 volumeClaimTemplates를 쓴다.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres-headless
  replicas: 3
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: fast-ssd
        resources:
          requests:
            storage: 100Gi

replicas: 3이면 data-postgres-0, data-postgres-1, data-postgres-2 PVC가 자동으로 만들어진다. 각 Pod는 자기 이름과 매칭되는 PVC만 마운트한다. Pod가 재시작되거나 다른 노드로 이동해도 자기 볼륨을 다시 찾아간다. 스테이트풀한 워크로드의 핵심 기능이다.

볼륨 확장

운영하다 보면 볼륨이 부족해지는 일이 생긴다. 쿠버네티스는 allowVolumeExpansion: true가 설정된 StorageClass에 한해 PVC 크기를 늘릴 수 있게 해준다.

# 기존 PVC를 편집해서 storage 요청량을 늘린다
kubectl edit pvc pvc-dynamic
# spec.resources.requests.storage: 20Gi → 50Gi

CSI 드라이버가 온라인 확장을 지원하면 Pod 재시작 없이도 늘어난다. 단, 축소는 지원하지 않는다. 한 번 키우면 되돌릴 수 없으니 신중하게 늘려야 한다.

CSI — 스토리지 플러그인의 공통 언어

예전에는 쿠버네티스 안에 스토리지 드라이버가 내장되어 있었다(in-tree 드라이버). AWS EBS, GCE PD, Ceph 같은 것들이 전부 쿠버네티스 코드에 박혀 있었다. 이 방식은 새 스토리지를 추가하려면 쿠버네티스 자체를 업데이트해야 하는 문제가 있었다.

그래서 등장한 게 CSI(Container Storage Interface)다. 스토리지 벤더가 CSI 스펙만 구현하면, 쿠버네티스 코어를 건드리지 않고도 새 스토리지를 추가할 수 있다. 지금은 거의 모든 스토리지가 CSI 드라이버 형태로 제공된다.

flowchart TB
    A[kube-apiserver] --> B[External Provisioner]
    A --> C[External Attacher]
    A --> D[External Resizer]
    B --> E[CSI Driver<br/>각 벤더 구현]
    C --> E
    D --> E
    E --> F[스토리지 백엔드]

사이드카 컨테이너들(Provisioner, Attacher, Resizer)이 쿠버네티스 이벤트를 구독하다가 CSI 드라이버에게 실제 작업을 위임하는 구조다. kubectl get pods -n kube-system | grep csi를 쳐보면 이런 컴포넌트들이 돌고 있는 걸 확인할 수 있다.

실습 — 동적 프로비저닝으로 Nginx 띄우기

클러스터에 기본 StorageClass가 있다는 가정 아래 실습해보자. minikube나 kind, EKS, GKE 모두 기본 SC가 세팅되어 있다.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: web-data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          volumeMounts:
            - name: data
              mountPath: /usr/share/nginx/html
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: web-data

배포하고 상태를 확인한다.

kubectl apply -f web.yaml
kubectl get pvc
# NAME       STATUS   VOLUME                                     CAPACITY
# web-data   Bound    pvc-abc123-...                             1Gi

kubectl exec -it deploy/web -- bash -c 'echo "<h1>Hello persistent</h1>" > /usr/share/nginx/html/index.html'
kubectl delete pod -l app=web   # Pod만 삭제

# 재생성된 Pod에서 데이터 확인
kubectl exec -it deploy/web -- cat /usr/share/nginx/html/index.html
# <h1>Hello persistent</h1>

Pod가 죽고 다시 태어나도 데이터가 살아있다. 스토리지가 Pod의 수명과 완전히 분리되었음을 확인할 수 있다.


다음 편에서는 “Pod에 얼만큼의 CPU와 메모리를 줄 것인가”라는 문제를 다룬다. requests와 limits, QoS 클래스가 스케줄링과 OOMKilled에 어떤 영향을 주는지, HPA로 자동 스케일링을 어떻게 구성하는지 살펴본다.

9편: 리소스 관리와 오토스케일링


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kubernetes 입문 7편 — ConfigMap과 Secret
Next Post
Kubernetes 입문 9편 — 리소스 관리와 오토스케일링