볼륨, PV·PVC, StorageClass, CSI
저번 포스팅에서는, 쿠버네티스의 네트워킹을 공부하였습니다. 이번에는 스토리지입니다. 컨테이너는 죽으면 안에 쓴 것이 사라지는데 데이터베이스처럼 데이터를 남겨야 하는 것들은 어떻게 해야 하는지를 다룹니다. 네트워킹과 마찬가지로 쿠버네티스는 저장소를 직접 구현하지 않습니다. 대신 인터페이스를 정해두고 구현을 위임합니다. 같은 설계 방식이 반복되는 것을 확인하는 편이기도 합니다.
1. 컨테이너의 파일 시스템
먼저 왜 별도의 장치가 필요한지부터 짚겠습니다.
컨테이너 이미지는 읽기 전용 레이어들이 쌓인 것이고 컨테이너를 띄울 때 그 위에 쓰기 가능한 레이어가 하나 얹힙니다. 컨테이너 안에서 파일을 만들면 이 레이어에 기록됩니다. 문제는 이 레이어의 수명이 컨테이너와 같다는 점입니다. 컨테이너가 재시작되면 새 레이어가 만들어지고 그 전에 쓴 것은 사라집니다. Pod 가 죽는 것도 아니고 컨테이너가 한 번 재시작되기만 해도 없어집니다.
또 하나, 이 레이어는 오버레이 파일 시스템을 거치기 때문에 쓰기 성능이 좋지 않습니다. 데이터베이스처럼 쓰기가 많은 것을 여기에 두면 안 되는 이유이기도 합니다. 그래서 쿠버네티스는 볼륨(Volume) 이라는 개념을 둡니다. 컨테이너 바깥에 있는 저장 공간을 컨테이너의 특정 경로에 붙여주는 것입니다.
2. 볼륨
볼륨은 Pod 에 정의하고 각 컨테이너가 원하는 경로에 마운트합니다. Pod 단위이기 때문에 같은 Pod 안의 여러 컨테이너가 같은 볼륨을 공유할 수 있습니다. 3편에서 본 사이드카 패턴이 여기에 기댑니다.
2.1. emptyDir
Pod 가 생길 때 빈 디렉토리로 만들어지고, Pod 가 사라질 때 함께 사라지는 볼륨입니다.
- 컨테이너가 재시작되어도 남아 있습니다. 컨테이너 수명이 아니라 Pod 수명을 따르기 때문입니다.
- Pod 가 다른 노드로 옮겨가면 사라집니다. 노드의 디스크를 쓰기 때문입니다.
쓰임새는 임시 작업 공간, 캐시, 그리고 사이드카와의 파일 공유입니다. 애플리케이션이 로그를 emptyDir 에 쓰고 사이드카가 그것을 읽어 보내는 구성이 대표적입니다. medium: Memory 로 두면 디스크 대신 메모리(tmpfs) 를 씁니다. 빠르지만 그 용량이 Pod 의 메모리 사용량으로 계산되므로, 많이 쓰면 메모리 제한에 걸려 Pod 가 죽습니다.
2.2. hostPath
노드의 특정 경로를 그대로 컨테이너에 붙입니다.
노드의 로그 디렉토리를 읽어야 하는 로그 수집기나, 노드의 상태를 봐야 하는 모니터링 에이전트처럼 노드 자체를 다뤄야 하는 경우에 씁니다. 3편에서 본 DaemonSet 과 짝을 이루는 경우가 많습니다. 일반 애플리케이션에는 쓰면 안 됩니다. 이유가 두 가지입니다.
- Pod 가 다른 노드로 가면 그 경로에 다른 내용이 있거나 아예 없습니다.
- 보안상 위험합니다. 호스트 경로를 마운트할 수 있으면 노드의 민감한 파일에 접근하거나, 컨테이너 런타임 소켓으로 노드를 장악하는 경로가 열릴 수 있습니다. 7편에서 볼 Pod Security 정책에서 가장 먼저 제한하는 항목 중 하나입니다.
2.3. 설정을 담는 볼륨
ConfigMap 과 Secret 도 볼륨으로 마운트할 수 있습니다. 이 경우 키가 파일 이름, 값이 파일 내용이 됩니다. 7편에서 자세히 다루겠습니다.
3. PV 와 PVC
emptyDir 과 hostPath 로는 노드를 옮겨다녀도 따라오는 저장 공간을 만들 수 없습니다. 그래서 쿠버네티스는 저장소를 오브젝트로 추상화했습니다.
3.1. 왜 둘로 나누었는가
- PersistentVolume(PV) : 실제 저장 공간입니다. "이 NFS 서버의 이 경로" 또는 "이 클라우드 디스크" 같은 구체적인 것을 가리킵니다.
- PersistentVolumeClaim(PVC) : 저장 공간에 대한 요청입니다. "10기가, 읽기쓰기 하나" 처럼 필요한 조건만 적습니다.
왜 굳이 나누었을까요. 역할을 가르기 위해서입니다. 애플리케이션 개발자는 저장소가 NFS 인지 EBS 인지 알 필요가 없습니다. "10기가 필요하다" 만 말하면 됩니다. 인프라를 담당하는 쪽이 실제 저장소를 준비합니다. PVC 는 요청서이고 PV 는 실물이며 쿠버네티스가 둘을 이어준다(binding).
이 분리 덕분에 같은 YAML 이 개발 환경에서는 로컬 디스크로, 운영에서는 클라우드 디스크로 동작할 수 있습니다.
3.2. 접근 모드
PVC 가 요청하는 조건 중 자주 헷갈리는 항목입니다.
| 모드 | 뜻 |
|---|---|
| ReadWriteOnce (RWO) | 하나의 노드에서 읽기쓰기로 마운트 |
| ReadOnlyMany (ROX) | 여러 노드에서 읽기 전용으로 마운트 |
| ReadWriteMany (RWX) | 여러 노드에서 읽기쓰기로 마운트 |
| ReadWriteOncePod (RWOP) | 하나의 Pod 에서만 읽기쓰기로 마운트 |
가장 흔한 오해가 RWO 를 "Pod 하나" 로 이해하는 것입니다. RWO 는 노드 기준입니다. 같은 노드에 있는 여러 Pod 는 같은 RWO 볼륨을 함께 마운트할 수 있습니다. 정말 Pod 하나만 붙게 하려면 RWOP 를 써야 합니다. 두 Pod 가 같은 디스크에 동시에 쓰면 데이터가 깨지는 경우(대부분의 데이터베이스)에 필요합니다.
그리고 RWX 는 아무 저장소나 되는 것이 아닙니다. 블록 스토리지(EBS, GCE PD 등)는 원리상 한 노드에만 붙으므로 RWX 를 지원하지 않습니다. NFS 나 CephFS 같은 파일 스토리지여야 합니다.
3.3. 반환 정책
PVC 를 지웠을 때 PV 를 어떻게 할지 정합니다.
- Delete : PV 와 실제 저장소를 함께 삭제합니다. 동적 프로비저닝의 기본값입니다.
- Retain : PV 를 남깁니다. 데이터는 보존되지만 다른 PVC 가 자동으로 쓰지는 못하고 관리자가 수동으로 정리해야 합니다.
운영 데이터베이스라면 Retain 을 고려해야 합니다. PVC 를 실수로 지우는 순간 데이터가 사라지는 것을 막기 위해서입니다.
4. StorageClass 와 동적 프로비저닝
4.1. 정적 프로비저닝의 문제
PV 를 관리자가 미리 만들어두는 방식을 정적 프로비저닝이라고 합니다. 이 방식에는 문제가 있습니다. 개발자가 언제 얼마나 필요할지 미리 알 수 없어서 남는 PV 를 많이 만들어두거나 매번 요청을 받아 만들어야 합니다.
4.2. StorageClass
StorageClass 는 "이런 종류의 저장소를 이렇게 만들어라" 는 템플릿입니다. PVC 에 StorageClass 이름을 적으면 PVC 를 만드는 순간 PV 가 자동으로 생성됩니다. 이것이 동적 프로비저닝입니다.
StorageClass 에는 성능 등급이나 디스크 타입 같은 것을 담습니다. 클러스터에 fast-ssd 와 standard 를 두고 개발자가 골라 쓰게 하는 식입니다. 기본 StorageClass 를 지정해두면 PVC 에 아무것도 적지 않아도 그것이 쓰입니다.
4.3. volumeBindingMode
StorageClass 에서 가장 자주 문제가 되는 설정입니다.
- Immediate (기본) : PVC 를 만드는 즉시 볼륨을 만들고 바인딩합니다.
- WaitForFirstConsumer : 그 PVC 를 쓰는 Pod 가 스케줄될 때까지 기다렸다가 만듭니다.
왜 이것이 중요한가 하면, 클라우드 디스크는 대개 특정 가용 영역(AZ)에 묶이기 때문입니다. Immediate 로 두면 Pod 가 어디에 갈지 모르는 상태에서 디스크가 A 영역에 만들어집니다. 그런데 스케줄러가 Pod 를 B 영역 노드에 배치하면 그 디스크를 붙일 수 없어 Pod 가 영원히 Pending 상태로 남습니다.
WaitForFirstConsumer 는 스케줄링이 끝난 뒤에 그 노드가 있는 영역에 디스크를 만듭니다. 여러 영역에 걸친 클러스터에서는 사실상 필수입니다.
4.4. 볼륨 확장
allowVolumeExpansion: true 인 StorageClass 라면 PVC 의 용량을 키워 볼륨을 확장할 수 있습니다. 줄이는 것은 불가능합니다. 확장은 두 단계로 일어납니다. 먼저 저장소 쪽 용량이 늘고 그다음 파일 시스템이 확장됩니다. 드라이버에 따라 Pod 를 재시작해야 파일 시스템 확장이 반영되는 경우가 있습니다.
5. CSI
5.1. 왜 나왔는가
초기 쿠버네티스는 저장소 연동 코드를 본체 안에 갖고 있었습니다. AWS EBS, GCE PD, NFS 같은 것들의 드라이버가 쿠버네티스 소스에 들어 있었던 것이다(in-tree 플러그인). 문제가 뚜렷했습니다. 저장소 벤더가 기능을 추가하려면 쿠버네티스 릴리스 주기를 기다려야 했고 드라이버 버그가 쿠버네티스 본체의 문제가 되었습니다.
5.2. Container Storage Interface
CSI 는 이를 표준 인터페이스로 분리한 것입니다. 4편의 CNI 와 같은 발상입니다. 벤더는 CSI 규격에 맞춰 드라이버를 만들어 배포하고 쿠버네티스는 그것을 호출하기만 합니다. 지금은 in-tree 플러그인이 대부분 제거되었고 기존 것들은 CSI 드라이버로 넘기는 마이그레이션이 진행되어 정리되었습니다.
5.3. 드라이버의 구성
CSI 드라이버는 보통 두 부분으로 나뉩니다.
- 컨트롤러 플러그인 : 볼륨을 만들고 지우고, 노드에 붙이고 떼는 일(attach/detach)을 합니다. 클러스터에 몇 개만 뜹니다.
- 노드 플러그인 : 노드에 붙은 볼륨을 실제로 마운트합니다. 모든 노드에 있어야 하므로 DaemonSet 으로 뜹니다.
여기서 attach 와 mount 가 다른 단계라는 점을 알아두면 문제 진단에 도움이 됩니다. attach 는 "디스크를 이 노드에 연결" 이고 mount 는 "그 디스크를 이 경로에 붙임" 입니다. Pod 가 뜨지 않을 때 어느 단계에서 막혔는지가 이벤트에 드러납니다.
5.4. 스냅샷과 복제
CSI 로 오면서 표준으로 들어온 기능들입니다.
- VolumeSnapshot : 볼륨의 특정 시점 스냅샷을 만듭니다. 그 스냅샷으로부터 새 PVC 를 만들 수도 있습니다.
- 볼륨 복제 : 기존 PVC 를 원본으로 새 PVC 를 만듭니다.
다만 드라이버가 지원해야 동작하고 스냅샷이 곧 백업은 아닙니다. 대개 같은 저장 시스템 안에 있기 때문에, 그 시스템 자체가 망가지면 함께 잃습니다.
6. StatefulSet 과 스토리지
3편에서 StatefulSet 이 Pod 마다 자기 볼륨을 갖는다고 했는데 그 부분을 이어서 보겠습니다.
6.1. volumeClaimTemplates
StatefulSet 에 volumeClaimTemplates 를 적으면 Pod 마다 PVC 가 하나씩 자동으로 만들어집니다.
data-web-0, data-web-1, data-web-2
이름이 순번을 따라가기 때문에, web-1 이 죽었다 다시 떠도 항상 data-web-1 을 다시 붙잡습니다. 이것이 StatefulSet 이 "고정된 스토리지" 를 보장한다는 말의 실체입니다.
6.2. 알아둘 점
- PVC 는 자동으로 지워지지 않습니다. StatefulSet 을 지워도, 스케일 다운을 해도 남습니다. 다시 늘리면 남아 있던 PVC 를 그대로 다시 씁니다. 실수로 데이터를 날리지 않기 위한 기본값입니다.
- 템플릿은 나중에 바꿀 수 없습니다. 용량을 키우려면 각 PVC 를 개별적으로 수정해야 합니다.
- RWO 볼륨이면 Pod 가 그 노드에 묶입니다. 볼륨이 특정 영역에 있으면 Pod 도 그 영역을 벗어나지 못합니다.
7. 자주 겪는 문제들
7.1. Pod 가 Pending 에서 멈춘다
PVC 가 바인딩되지 않은 경우가 흔합니다. 확인할 것들입니다.
- StorageClass 이름을 잘못 적었는가, 기본 StorageClass 가 있는가
- 요청한 접근 모드를 그 저장소가 지원하는가 (RWX 를 블록 스토리지에 요청하지 않았는가)
Immediate바인딩 때문에 볼륨과 Pod 의 영역이 어긋나지 않았는가
7.2. Multi-Attach 오류
노드가 갑자기 죽었을 때 자주 나옵니다. RWO 볼륨이 그 노드에 붙어 있는데 쿠버네티스는 죽은 노드가 정말 죽었는지 확신할 수 없습니다. 네트워크만 끊겼을 수도 있고 그 경우 두 노드가 같은 디스크에 쓰면 데이터가 깨집니다.
그래서 안전하게 일정 시간 기다렸다가 강제로 떼어냅니다. 그동안 새 Pod 는 볼륨을 붙이지 못해 Multi-Attach 오류가 납니다. 가용성보다 데이터 무결성을 택한 설계입니다.
7.3. 디스크가 가득 차 Pod 가 쫓겨난다
볼륨이 아니라 노드의 디스크가 문제인 경우입니다. 컨테이너 로그와 이미지가 노드 디스크를 쓰기 때문에, 로그를 많이 남기면 노드가 DiskPressure 상태가 되고 kubelet 이 Pod 를 쫓아냅니다. 6편에서 다루겠습니다.
8. 조금 더 들어가보기 : 마운트와 fsync
볼륨이 "붙는다" 는 말을 여러 번 썼는데 커널에서 실제로 무슨 일이 벌어지는지까지 내려가 보겠습니다. Pod 가 뜨지 않을 때 어느 단계에서 막혔는지 를 읽으려면 이 구분이 필요합니다.
8.1. 마운트는 경로를 갈아끼우는 일이다
리눅스에서 마운트는 어떤 경로에 다른 파일 시스템을 연결하는 것입니다. 그리고 앞서 본 대로 컨테이너는 자기만의 마운트 네임스페이스를 갖습니다. 그래서 볼륨을 붙이는 일은 이렇게 진행됩니다.
- kubelet 이 노드의 어떤 경로에 볼륨을 마운트합니다.
- 컨테이너를 띄울 때 그 경로를 컨테이너 안의 경로로 다시 연결합니다. 이것을 바인드 마운트라고 합니다.
컨테이너가 보는 /data 는 실제로는 노드의 어딘가를 가리키고 있는 셈입니다. 노드에 들어가 그 경로를 보면 같은 내용이 보입니다.
8.2. 붙이는 것과 마운트하는 것은 다르다
CSI 에서 컨트롤러와 노드 플러그인이 나뉜 이유가 여기 있습니다. 단계가 실제로 다릅니다.
- Attach : 클라우드의 디스크를 그 노드의 장치로 연결합니다. 노드에서 보면 새 블록 장치가 생깁니다. 물리적으로 케이블을 꽂는 것에 가깝습니다.
- Mount : 그 장치를 파일 시스템으로 인식해 경로에 붙입니다. 필요하면 포맷도 여기서 합니다.
Pod 가 뜨지 않을 때 이벤트를 보면 어느 단계에서 막혔는지가 드러납니다. 붙이는 단계에서 막혔다면 클라우드 쪽 문제이거나 노드당 붙일 수 있는 디스크 개수 한도에 걸린 것이고 마운트 단계라면 파일 시스템이나 권한 문제일 가능성이 큽니다.
8.3. 블록과 파일의 차이가 접근 모드를 정한다
앞서 RWX 가 아무 저장소에서나 되지 않는다고 했는데 그 이유가 이 구분입니다.
- 블록 스토리지 : 디스크 한 덩어리를 통째로 빌려주는 것입니다. 파일 시스템은 빌린 쪽이 올립니다. 그런데 두 노드가 같은 블록 장치에 각자 파일 시스템을 올려 동시에 쓰면 서로의 메타데이터를 덮어써 데이터가 깨집니다. 그래서 원리적으로 한 노드에만 붙습니다.
- 파일 스토리지 : 파일 시스템이 서버 쪽에 있고 여러 클라이언트가 프로토콜로 접근합니다. 동시 접근을 서버가 조율하므로 여러 노드가 함께 쓸 수 있습니다.
RWX 가 필요하면 저장소 종류부터 골라야 한다는 결론이 여기서 나옵니다. 설정으로 바꿀 수 있는 문제가 아닙니다.
8.4. 쓰기는 곧바로 디스크에 가지 않는다
애플리케이션이 파일에 쓰면 커널의 페이지 캐시에 먼저 들어가고 나중에 디스크로 내려갑니다. 그래서 쓰기가 빠릅니다. 문제는 그 사이에 노드가 죽으면 사라진다는 것입니다. 데이터베이스가 fsync 를 호출해 "지금 디스크에 확정해달라" 고 요구하는 이유가 이것입니다.
여기서 몇 가지가 따라옵니다.
- 컨테이너를 정상 종료시키는 것이 중요합니다. SIGKILL 로 갑자기 죽으면 캐시에만 있던 것이 사라질 수 있습니다.
- 네트워크 스토리지에서는 fsync 가 아주 비쌉니다. 매번 네트워크를 왕복해야 하므로, 데이터베이스를 그 위에 올리면 성능이 크게 떨어집니다.
- 앞서 본 대로 페이지 캐시가 컨테이너의 메모리 사용량으로 계산되어 파일을 많이 쓰는 컨테이너의 메모리가 높아 보입니다.
8.5. 죽은 노드의 디스크를 언제 떼어낼 것인가
앞서 다중 연결 오류를 봤는데 그 배경을 조금 더 보겠습니다. 노드가 응답하지 않을 때 쿠버네티스가 아는 것은 "응답이 없다" 뿐입니다. 정말 죽었는지, 네트워크만 끊겼는지 알 수 없습니다. 네트워크만 끊긴 것이라면 그 노드의 Pod 는 여전히 디스크에 쓰고 있습니다. 여기서 다른 노드가 같은 디스크를 붙여 쓰기 시작하면 파일 시스템이 깨집니다.
그래서 일정 시간을 기다린 뒤에야 강제로 떼어냅니다. 가용성보다 무결성을 택한 것이고 그 대가가 장애 조치에 걸리는 몇 분입니다. 상태를 가진 애플리케이션이 쿠버네티스에서 장애 조치가 느린 이유가 여기에 있습니다. 더 빠르게 하려면 저장소 쪽에서 확실히 차단하는 수단이 필요합니다.
8.6. 대규모에서 부딪히는 것들
- 노드당 디스크 연결 한도 : 클라우드 인스턴스 타입마다 붙일 수 있는 볼륨 수가 정해져 있습니다. 상태를 가진 Pod 를 한 노드에 많이 몰면 여기에 먼저 걸립니다.
- 영역 제약 : 볼륨이 특정 영역에 묶이므로 그 Pod 도 그 영역을 벗어나지 못합니다. 노드가 부족하면 다른 영역에 자리가 있어도 Pending 입니다.
- PVC 가 쌓인다 : StatefulSet 을 지워도 남으므로, 실험용으로 만들었다 지운 것들이 계속 비용을 씁니다.
- 동시에 붙일 때의 속도 제한 : 노드 수십 대가 동시에 볼륨을 요구하면 클라우드 API 에 속도 제한이 걸려 순차적으로 처리됩니다.
결론
스토리지를 정리하면서 가장 인상 깊었던 것은 PV 와 PVC 로 나눈 이유였습니다. 처음에는 오브젝트를 두 개나 만들어야 하는 것이 번거롭게만 보였는데 이것이 "필요한 것을 말하는 쪽" 과 "실물을 준비하는 쪽" 을 가르는 장치라는 것을 알고 나니 납득이 되었습니다. 덕분에 애플리케이션 YAML 이 저장소 종류를 몰라도 되고 환경이 바뀌어도 같은 파일이 그대로 동작합니다.
두 번째는 volumeBindingMode 였습니다. 설정 하나로 Pod 가 영원히 Pending 에 빠질 수 있다는 것이, 스토리지와 스케줄링이 얼마나 얽혀 있는지를 보여주는 예였습니다. 볼륨을 먼저 만들면 Pod 의 자리가 제약되고, Pod 를 먼저 배치하면 볼륨의 자리가 정해집니다. 순서를 뒤집은 것만으로 문제가 풀리는 것이 재미있었습니다.
세 번째는 Multi-Attach 오류를 대하는 태도였습니다. 노드가 죽었는지 확신할 수 없으니 기다린다는 것인데 이는 하둡 시리즈에서 공부한 스플릿 브레인 문제와 정확히 같은 이야기였습니다. 분산 시스템은 "죽었다" 와 "응답이 없다" 를 구별할 수 없고 그 앞에서 가용성을 포기하고 무결성을 지키는 쪽을 고른 것입니다.
그리고 CNI 와 CSI 가 같은 모양이라는 점도 다시 확인했습니다. 쿠버네티스는 직접 구현하는 대신 규격을 정하고 위임하는 방식을 일관되게 쓰고 있었습니다.
공부 참고 자료
📑 1. Kubernetes 공식 문서 - Volumes
📑 2. Kubernetes 공식 문서 - Persistent Volumes
📑 3. Kubernetes 공식 문서 - Storage Classes
📑 4. Kubernetes 공식 문서 - Volume Snapshots
📑 5. Kubernetes 공식 문서 - Storage Capacity
📑 6. Container Storage Interface 명세