스토리지
- 20.1 Docker 스토리지
- 20.2 볼륨 드라이버 플러그인
- 20.3 Container Storage Interface
- 20.4 Kubernetes 볼륨
- 20.5 Persistent Volume
- 20.6 Persistent Volume Claim
- 20.7 Storage Class
Kubernetes 스토리지를 제대로 이해하려면 Docker의 레이어 구조부터 시작해서, 볼륨 드라이버, CSI 표준, 그리고 Kubernetes의 PV/PVC/StorageClass까지 쭉 흐름을 따라가야 해. 컨테이너는 본질적으로 일시적이니까, 데이터를 어떻게 보존하고 관리할지가 핵심이야.
Docker 스토리지를 이해하려면 레이어 구조부터 알아야 해. Docker 이미지는 계층(layer)으로 쌓이는 구조인데, Dockerfile의 각 명령어가 하나의 레이어를 만들어. 예를 들어 Ubuntu 베이스 이미지가 첫 번째 레이어(약 120MB), apt 패키지 설치가 두 번째(약 300MB), pip 패키지가 세 번째, 소스 코드 복사가 네 번째, 엔트리포인트가 다섯 번째 레이어가 되는 거지.
이 레이어 구조의 핵심 장점은 재사용이야. 두 개의 다른 앱이 같은 Ubuntu 베이스와 같은 패키지를 쓴다면, Docker는 처음 세 레이어를 캐시에서 그대로 가져다 쓰고 나머지 두 레이어만 새로 만들어. 디스크 공간도 아끼고 빌드 속도도 빨라지는 거지. 소스 코드만 수정했을 때도 마찬가지로, 이전 레이어는 전부 캐시에서 재사용하니까 재빌드가 엄청 빠르거든.
docker build로 만들어진 이 이미지 레이어들은 전부 읽기 전용이야. 빌드가 끝나면 수정할 수 없어. 그런데 docker run으로 컨테이너를 실행하면, Docker가 이 읽기 전용 레이어들 위에 쓰기 가능한 레이어를 하나 더 올려줘. 로그 파일이나 임시 파일 같은 건 다 이 쓰기 가능한 레이어에 저장돼. 같은 이미지로 만든 컨테이너들은 전부 동일한 이미지 레이어를 공유하고, 각자 자기만의 쓰기 레이어를 따로 가지는 구조야.
만약 이미지 레이어에 있는 파일(예: app.py 소스 코드)을 컨테이너 안에서 수정하려고 하면, Docker가 Copy-on-Write 메커니즘을 써. 원본은 그대로 두고, 쓰기 가능한 레이어에 복사본을 만들어서 거기서 수정하는 거야. 같은 이미지로 만든 다른 컨테이너들은 영향을 안 받지. 이미지 레이어가 읽기 전용이라는 건 이미지 자체가 절대 변경되지 않는다는 뜻이야. docker build로 다시 빌드하기 전까지는.
문제는 컨테이너가 삭제되면 쓰기 가능한 레이어도 같이 사라진다는 거야. app.py에 적용한 변경사항이든, 새로 만든 임시 파일이든 전부 날아가. 데이터를 보존하고 싶으면 볼륨을 써야 해.
docker volume create data_volume
docker run -v data_volume:/var/lib/mysql mysql
이렇게 하면 data_volume이 컨테이너 내부의 /var/lib/mysql에 마운트돼서, 데이터베이스가 쓰는 데이터가 실제로는 호스트의 /var/lib/docker/volumes/data_volume/ 아래에 저장돼. 컨테이너가 날아가도 데이터는 남아있는 거지. 참고로 docker volume create를 먼저 안 해도 docker run -v에서 볼륨 이름을 지정하면 Docker가 자동으로 만들어줘.
마운트 방식은 두 가지야. 볼륨 마운트는 /var/lib/docker/volumes/ 아래에 있는 Docker 관리 볼륨을 마운트하는 거고, 바인드 마운트는 호스트의 아무 경로나 직접 지정해서 마운트하는 거야. 예를 들어 호스트의 /data/mysql에 데이터를 저장하고 싶으면:
docker run -v /data/mysql:/var/lib/mysql mysql
-v 옵션은 사실 오래된 방식이고, 요즘은 --mount를 쓰는 게 권장돼. 각 파라미터를 key=value 형식으로 명시적으로 지정하니까 더 읽기 좋거든.
docker run --mount type=bind,source=/data/mysql,target=/var/lib/mysql mysql
이 모든 레이어 관리, Copy-on-Write, 레이어 간 파일 이동 같은 걸 담당하는 게 스토리지 드라이버야. AUFS, btrfs, Device Mapper, Overlay, Overlay2 같은 것들이 있고, OS에 따라 적합한 드라이버가 달라. Ubuntu는 기본적으로 AUFS를 쓰고, Fedora나 CentOS에서는 Device Mapper를 쓰는 식이지. Docker가 OS에 맞는 최적의 드라이버를 자동으로 선택해주니까 보통은 신경 안 써도 되는데, 성능이나 안정성 요구사항에 따라 직접 고를 수도 있어.
Docker가 데이터를 저장하는 기본 경로는 /var/lib/docker인데, 그 아래에 aufs, containers, image, volumes 같은 폴더가 있어서 각각의 역할에 맞게 데이터를 분류해서 저장하고 있어. 참고로 스토리지 드라이버는 이미지/컨테이너 레이어를 관리하는 거고, 볼륨은 별도의 볼륨 드라이버 플러그인이 처리해.
그럼 이 볼륨 드라이버 플러그인이 뭔지 좀 더 자세히 알아보자. 앞에서 스토리지 드라이버는 이미지와 컨테이너의 레이어를 관리한다고 했잖아. 그런데 볼륨은 스토리지 드라이버가 처리하는 게 아니야. 볼륨은 볼륨 드라이버 플러그인이 담당해. 이 두 가지를 헷갈리면 안 돼. 스토리지 드라이버는 이미지/컨테이너 레이어 관리, 볼륨 드라이버는 영속적 데이터 저장을 위한 볼륨 관리야.
기본 볼륨 드라이버는 local이야. 이건 Docker 호스트에 볼륨을 만들고 /var/lib/docker/volumes 디렉토리에 데이터를 저장하는 역할을 해. 근데 로컬 호스트에만 저장하면 한계가 있잖아. 그래서 서드파티 볼륨 드라이버 플러그인이 엄청 많아. Azure File Storage, Convoy, DigitalOcean Block Storage, Flocker, Google Compute Persistent Disk, GlusterFS, NetApp, REX-Ray, Portworx, VMware vSphere Storage 같은 것들이 있거든. 이건 일부에 불과하고 실제로는 훨씬 더 많아.
특히 REX-Ray 같은 드라이버는 여러 스토리지 백엔드를 지원해서, AWS EBS, S3, EMC의 Isilon이나 ScaleIO, Google Persistent Disk, OpenStack Cinder 같은 다양한 스토리지에 볼륨을 프로비저닝할 수 있어.
실제로 쓸 때는 Docker 컨테이너를 실행하면서 특정 볼륨 드라이버를 지정하면 돼. 예를 들어 REX-Ray EBS 드라이버를 쓰면 Amazon EBS에 볼륨이 자동으로 만들어지고 컨테이너에 연결돼. 컨테이너가 종료되더라도 데이터는 AWS 클라우드에 안전하게 남아있는 거지.
이제 Docker 레벨을 넘어서 컨테이너 오케스트레이션 세계로 가보자. CSI(Container Storage Interface)가 뭔지 이해하려면 Kubernetes가 왜 이런 인터페이스 표준들을 만들었는지부터 알아야 해.
원래 Kubernetes는 Docker(containerd)만 컨테이너 런타임으로 쓰다가, rkt나 CRI-O 같은 다른 런타임도 지원해야 했거든. 근데 그때는 각 런타임 관련 코드가 Kubernetes 소스 코드에 직접 박혀있었어. 새로운 런타임을 추가하려면 Kubernetes 코드 자체를 수정해야 했던 거지. 이건 확장성이 너무 안 좋으니까 **CRI(Container Runtime Interface)**를 만들었어. CRI는 Kubernetes 같은 오케스트레이션 도구가 컨테이너 런타임과 통신하는 방법을 정의하는 표준이야. 새로운 컨테이너 런타임이 나와도 CRI 표준만 따르면 Kubernetes 소스 코드를 건드릴 필요 없이 바로 연동할 수 있게 된 거지.
네트워킹도 마찬가지야. **CNI(Container Networking Interface)**가 있어서, 네트워킹 벤더들이 CNI 표준에 맞춰 플러그인을 개발하면 Kubernetes와 바로 연동돼.
스토리지도 똑같은 맥락에서 CSI가 나왔어. 예전에는 스토리지 솔루션을 추가하려면 Kubernetes 소스 코드를 직접 수정해야 했는데, CSI가 생기면서 스토리지 벤더가 자체 CSI 드라이버만 만들면 되는 거야. Portworx, Amazon EBS, Azure Disk, Dell EMC(Isilon, PowerMax, Unity, XtremIO), NetApp, Nutanix, HPE, Hitachi, Pure Storage 등등 거의 모든 주요 스토리지 벤더가 자기만의 CSI 드라이버를 가지고 있어.
CSI는 Kubernetes 전용이 아니라 범용 표준이라는 점도 중요해. Kubernetes뿐 아니라 Cloud Foundry, Mesos에서도 쓰고 있어. 한 번 구현하면 어떤 컨테이너 오케스트레이터에서든 동작하는 거지.
CSI가 구체적으로 하는 건 RPC(Remote Procedure Call)를 정의하는 거야. 컨테이너 오케스트레이터가 호출하면 스토리지 드라이버가 구현해서 응답하는 구조지. 예를 들어 Pod가 생성되면서 볼륨이 필요하면, Kubernetes가 CreateVolume RPC를 호출하고 볼륨 이름 같은 세부 정보를 넘겨. 그러면 CSI 드라이버가 실제 스토리지 어레이에 볼륨을 프로비저닝하고 결과를 돌려줘. 볼륨을 삭제할 때는 DeleteVolume RPC를 호출하고, 드라이버가 스토리지에서 볼륨을 해제하는 코드를 실행해. 파라미터, 응답 형식, 에러 코드까지 스펙에 다 정의돼 있어서, 어떤 벤더든 이 스펙만 맞추면 Kubernetes와 깔끔하게 연동되는 구조야.
그러면 이제 Kubernetes에서 실제로 볼륨을 어떻게 쓰는지 보자. Kubernetes에서 Pod는 일시적이야. Pod가 삭제되면 그 안의 데이터도 같이 날아가거든. Docker에서 컨테이너에 볼륨을 붙여서 데이터를 보존하는 것처럼, Kubernetes에서도 Pod에 볼륨을 붙여서 데이터를 유지할 수 있어.
간단한 예를 들어볼게. 1에서 100 사이 랜덤 숫자를 생성해서 /opt/number.out 파일에 쓰고 삭제되는 Pod가 있다고 해봐. 이 숫자를 보존하려면 볼륨을 만들어야 하는데, 가장 단순한 방법은 호스트의 디렉토리를 쓰는 거야.
apiVersion: v1
kind: Pod
metadata:
name: random-number-generator
spec:
containers:
- name: alpine
image: alpine
command: ["/bin/sh", "-c"]
args: ["shuf -i 0-100 -n 1 >> /opt/number.out;"]
volumeMounts:
- mountPath: /opt
name: data-volume
volumes:
- name: data-volume
hostPath:
path: /data
type: Directory
volumes 섹션에서 볼륨을 정의하고, 각 컨테이너의 volumeMounts에서 컨테이너 내부 경로에 마운트하는 구조야. 여기서는 hostPath로 호스트의 /data 디렉토리를 지정했으니까, 컨테이너 안에서 /opt에 쓰는 데이터가 실제로는 호스트의 /data에 저장되는 거지. Pod가 삭제돼도 파일은 호스트에 남아있어.
근데 hostPath는 싱글 노드에서만 잘 동작해. 멀티 노드 클러스터에서는 Pod가 어떤 노드에 뜨느냐에 따라 다른 /data 디렉토리를 보게 되니까 데이터가 일치하지 않는 문제가 생겨. 각 노드의 /data는 서로 다른 서버에 있는 별개의 공간이잖아. 외부에 복제된 클러스터 스토리지 솔루션을 따로 구성하지 않는 한 프로덕션에서는 쓰면 안 돼.
Kubernetes는 다양한 스토리지 백엔드를 지원하거든. NFS, GlusterFS, Flocker, Fibre Channel, CephFS, ScaleIO 같은 온프레미스 솔루션이나 AWS EBS, Azure Disk/File, Google Persistent Disk 같은 클라우드 솔루션을 쓸 수 있어. 예를 들어 AWS EBS를 쓰려면:
volumes:
- name: data-volume
awsElasticBlockStore:
volumeID: <volume-id>
fsType: ext4
이렇게 hostPath 부분을 awsElasticBlockStore로 바꾸면 돼. 볼륨 스토리지가 AWS EBS에 저장되는 거지.
하지만 이렇게 Pod 정의 파일마다 스토리지 설정을 넣는 건 관리가 힘들어. 사용자가 많고 Pod가 많은 대규모 환경에서는 매번 스토리지를 설정하는 것도 번거롭고, 변경사항이 생기면 모든 Pod를 다 수정해야 하거든. 그래서 스토리지를 중앙에서 관리할 수 있는 Persistent Volume이라는 개념이 나왔어.
앞에서 볼륨을 Pod 정의 파일 안에 직접 설정했잖아. 근데 사용자가 많고 Pod가 많은 대규모 환경에서는 이게 문제야. 매번 Pod마다 스토리지 설정을 넣어야 하고, 스토리지 솔루션을 바꾸려면 모든 Pod 정의를 다 수정해야 하거든. 스토리지를 중앙에서 관리하고 싶은 거잖아. 관리자가 큰 스토리지 풀을 미리 만들어놓고, 사용자가 필요한 만큼 가져다 쓰게 하는 거지. 이게 바로 **Persistent Volume(PV)**이야.
PV는 클러스터 전체에서 공유되는 스토리지 볼륨 풀이야. 관리자가 PV를 미리 만들어두면, 사용자는 Persistent Volume Claim(PVC)으로 필요한 스토리지를 선택해서 가져다 쓰는 구조야.
PV를 만드는 건 이렇게 해:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-vol1
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 1Gi
hostPath:
path: /tmp/data
accessModes는 볼륨을 어떻게 마운트할 수 있는지 정의하는 거야. ReadWriteOnce는 하나의 노드에서 읽기/쓰기가 가능하다는 뜻이고, ReadOnlyMany는 여러 노드에서 읽기 전용, ReadWriteMany는 여러 노드에서 읽기/쓰기가 가능하다는 뜻이야. capacity에는 이 PV에 얼마만큼의 스토리지를 할당할지 지정해. 위 예시에서는 1Gi로 설정했어.
여기서 hostPath는 노드의 로컬 디렉토리를 사용하는 건데, 프로덕션에서는 쓰면 안 돼. 프로덕션에서는 AWS EBS 같은 실제 스토리지 솔루션을 써야 해:
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 1Gi
awsElasticBlockStore:
volumeID: <volume-id>
fsType: ext4
kubectl create -f pv-definition.yaml
kubectl get persistentvolume
이렇게 PV를 만들어두면, 다음 단계는 PVC를 만들어서 이 PV의 스토리지를 실제로 사용하는 거야.
PV와 PVC는 Kubernetes에서 완전히 별개의 오브젝트야. 관리자가 PV를 만들고, 사용자가 PVC를 만들어서 스토리지를 요청하는 구조거든.
PVC가 생성되면 Kubernetes가 자동으로 적절한 PV를 찾아서 바인딩해줘. 이때 용량, 액세스 모드, 볼륨 모드, 스토리지 클래스 같은 속성을 기준으로 매칭해. 만약 조건에 맞는 PV가 여러 개면 레이블과 셀렉터를 써서 특정 PV를 지정할 수도 있어.
한 가지 중요한 건, PVC와 PV는 1:1 관계라는 거야. 하나의 PVC가 하나의 PV에 바인딩되면, 그 PV에 남은 용량이 있어도 다른 PVC가 쓸 수 없어. 그래서 500Mi만 요청했는데 1Gi짜리 PV밖에 없으면, 어쩔 수 없이 1Gi PV에 바인딩되고 나머지 용량은 낭비되는 거지. 사용 가능한 PV가 아예 없으면 PVC는 Pending 상태로 대기하다가, 새 PV가 생기면 자동으로 바인딩돼.
PVC를 만들어보자:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: myclaim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 500Mi
kubectl create -f pvc-definition.yaml
kubectl get persistentvolumeclaim
처음에는 Pending 상태로 나올 수 있어. Kubernetes가 기존 PV들을 확인해서 조건에 맞는 걸 찾는 거지. 예를 들어 이전에 만든 PV가 ReadWriteOnce에 1Gi 용량이면, 액세스 모드가 일치하고 용량도 충분하니까(500Mi 요청 < 1Gi 제공) 바인딩이 돼. 다른 PV가 없으니까 이 PV에 바인딩되는 거야. kubectl get pvc를 다시 실행하면 Bound 상태로 바뀌어 있을 거야.
PVC를 삭제하면 바인딩됐던 PV는 어떻게 될까? 이건 PV의 persistentVolumeReclaimPolicy에 따라 달라져.
Retain(기본값)이면 PV가 그대로 남아있지만, 다른 PVC에서 재사용할 수는 없어. 관리자가 수동으로 정리해야 해. Delete로 설정하면 PVC가 삭제될 때 PV도 같이 삭제돼. Recycle이라는 옵션도 있었는데, 이건 리사이클러 Pod를 띄워서 볼륨을 마운트하고 rm -rf로 파일을 지우는 방식이었어. 단순한 파일 수준 삭제를 통해 재사용 가능하게 만드는 거였지. 근데 이것만으로는 충분하지 않았어. 안전한 삭제를 보장하지 않았고, inode 메타데이터가 남을 수 있었고, 스냅샷 처리나 프로바이더 메타데이터 관리도 안 됐거든. 게다가 EBS, Google Cloud, Azure Disk, NFS 같은 특정 볼륨 플러그인에서만 동작했어. 제대로 된 정리를 하려면 마운트 해제, 디태치, 파일시스템 재포맷, 스냅샷 처리, 암호화 키 로테이션, 프로바이더 수준의 삭제 호출 같은 걸 다 해야 하는데, 단순 rm -rf로는 이런 걸 커버할 수가 없잖아. 그래서 Recycle은 이제 deprecated됐어.
요즘 권장하는 방식은 스토리지 클래스와 CSI 드라이버를 이용한 동적 프로비저닝이야. 지금까지 PV를 먼저 만들고, PVC로 클레임하고, Pod에서 PVC를 쓰는 흐름이었잖아. 근데 여기서 한 가지 불편한 점이 있어. PV를 만들기 전에 실제 스토리지(예: Google Cloud 디스크)를 먼저 수동으로 만들어야 한다는 거야. 매번 애플리케이션에 스토리지가 필요할 때마다 클라우드에서 디스크 만들고, 그 이름으로 PV 정의 파일 작성하고... 이걸 정적 프로비저닝이라고 해. 번거롭잖아.
스토리지 클래스가 이 문제를 해결해줘. 스토리지 클래스에 프로비저너(provisioner)를 정의해두면, PVC가 생성될 때 자동으로 스토리지를 프로비저닝하고 PV까지 만들어줘. 이게 동적 프로비저닝이야.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: google-storage
provisioner: kubernetes.io/gce-pd
이렇게 스토리지 클래스를 만들어두면, PVC에서 이 스토리지 클래스를 지정하기만 하면 돼:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: myclaim
spec:
accessModes:
- ReadWriteOnce
storageClassName: google-storage
resources:
requests:
storage: 500Mi
PVC가 생성되는 순간, 스토리지 클래스가 프로비저너(gce-pd)를 호출해서 GCP에서 필요한 크기의 디스크를 자동으로 만들고, PV를 생성하고, PVC에 바인딩까지 해줘. PV 정의 파일을 직접 만들 필요가 없어진 거야. 물론 PV 자체는 여전히 만들어지는데, 스토리지 클래스가 자동으로 해주는 거지. 그러니까 더 이상 수동으로 PV 정의 파일을 작성할 필요가 없다는 뜻이야.
프로비저너는 GCE 말고도 엄청 많아. AWS EBS, Azure File, Azure Disk, CephFS, Portworx, ScaleIO 등등. 각 프로비저너마다 추가 파라미터를 넘길 수 있는데, 예를 들어 Google Persistent Disk의 경우 디스크 타입을 Standard(pd-standard)로 할지 SSD(pd-ssd)로 할지 지정할 수 있고, 복제 모드를 none으로 할지 regional-pd로 할지도 정할 수 있어.
이걸 활용하면 용도별로 스토리지 클래스를 여러 개 만들 수 있어. 예를 들어 Silver는 Standard 디스크, Gold는 SSD 디스크, Platinum은 SSD + 복제 이런 식으로. PVC를 만들 때 storageClassName에 원하는 등급만 넣으면 그에 맞는 스토리지가 자동으로 프로비저닝되는 거야. 그래서 이름이 "스토리지 클래스"인 거지 -- 스토리지의 등급(class)을 분류하는 개념이니까.
정리
20장 읽고 기억할 거 세 가지:
- Docker의 스토리지 드라이버(이미지/컨테이너 레이어 관리)와 볼륨 드라이버(영속 데이터 관리)는 완전히 다른 역할이고, Kubernetes는 CSI 표준을 통해 어떤 스토리지 벤더든 플러그인만 만들면 연동할 수 있게 했다.
- PV는 관리자가 만들고, PVC는 사용자가 만들어서 1:1로 바인딩되는 구조인데, Reclaim Policy(Retain/Delete)에 따라 PVC 삭제 후 PV의 운명이 달라진다.
- StorageClass를 쓰면 PVC 생성 시 자동으로 스토리지 프로비저닝 + PV 생성까지 해주는 동적 프로비저닝이 가능해서, 수동으로 디스크 만들고 PV 정의 파일 작성하는 번거로움이 사라진다.