TopoLVM vs Local Path Provisioner
Kubernetes에서 노드 로컬 디스크를 동적으로 내주는 두 가지 방법
노드 로컬 디스크를 PVC로 내주는 두 동적 프로비저너 이야기다. Local Path Provisioner는 노드 파일시스템에 디렉토리를 만들어 주고, TopoLVM은 LVM 풀에서 블록 디바이스를 잘라 준다. 갈림길은 볼륨 사이에 용량의 벽이 있는가다.
Intro
hostPath는 데이터가 노드에 묶여서 위험하니, NFS나 EBS 같은 네트워크 스토리지로 옮기고 싶어하는 경우가 꽤 있다.
그런데 반대로 노드 로컬 디스크를 쓰고 싶은 경우도 있다.
로컬 NVMe는 네트워크 스토리지보다 IOPS와 throughput이 높고 latency가 낮다.
네트워크를 한 번도 건너지 않고 PCIe로 직결되기 때문이다.
데이터베이스처럼 디스크 I/O가 병목인 워크로드에서는 이 차이가 그대로 성능이 된다.
문제는 방법이다. hostPath로 돌아가자니 스케줄러가 데이터 위치를 모른다는 문제가 그대로 남는다. Pod이 다른 노드로 옮겨가면 데이터와 분리된다.
이 문제를 푸는 Kubernetes의 원형이 Local Persistent Volume(이하 LPV)이다.
그런데 LPV는 준비를 전부 사람이 하는 정적 방식이라, 실전에서는 그 위에 동적 프로비저닝을 얹은 도구를 쓴다.
가장 가볍고 널리 퍼진 것이 Rancher의 Local Path Provisioner(이하 local-path)이고, 용량까지 손에 쥐는 쪽이 LVM 기반의 TopoLVM이다.
두 프로비저너가 내주는 것의 차이를 한 장으로 먼저 보면 이렇다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "At a glance: 디렉토리를 내주다 vs 블록을 잘라 주다",
"width": 960,
"height": 562,
"mobileWidth": 900,
"regions": [
{
"id": "lpp-side",
"label": "Local Path Provisioner · 디렉토리",
"x": 36,
"y": 58,
"width": 430,
"height": 446
},
{
"id": "topolvm-side",
"label": "TopoLVM · 블록 디바이스",
"x": 494,
"y": 58,
"width": 430,
"height": 446
}
],
"nodes": [
{
"id": "l-disk",
"kind": "memory",
"label": "물리 디스크",
"x": 251,
"y": 175,
"width": 240,
"height": 56
},
{
"id": "l-fs",
"kind": "host",
"label": "파일시스템",
"caption": "/opt/local-path-provisioner",
"x": 251,
"y": 301,
"width": 360,
"height": 60
},
{
"id": "l-d1",
"kind": "note",
"label": "10Gi",
"caption": "디렉토리",
"x": 131,
"y": 427,
"width": 110,
"height": 60
},
{
"id": "l-d2",
"kind": "note",
"label": "30Gi",
"caption": "디렉토리",
"x": 251,
"y": 427,
"width": 110,
"height": 60
},
{
"id": "l-d3",
"kind": "note",
"label": "60Gi",
"caption": "디렉토리",
"x": 371,
"y": 427,
"width": 110,
"height": 60
},
{
"id": "r-disk",
"kind": "memory",
"label": "물리 디스크",
"x": 709,
"y": 175,
"width": 240,
"height": 56
},
{
"id": "r-vg",
"kind": "host",
"label": "VG",
"caption": "경계 없는 하나의 풀",
"x": 709,
"y": 301,
"width": 360,
"height": 60
},
{
"id": "r-lv1",
"kind": "gpu",
"label": "10Gi",
"caption": "LV",
"x": 569,
"y": 427,
"width": 80,
"height": 60
},
{
"id": "r-lv2",
"kind": "gpu",
"label": "30Gi",
"caption": "LV",
"x": 674,
"y": 427,
"width": 110,
"height": 60
},
{
"id": "r-lv3",
"kind": "gpu",
"label": "60Gi",
"caption": "LV",
"x": 814,
"y": 427,
"width": 150,
"height": 60
}
],
"links": [
{
"id": "l1",
"points": [[251, 203], [251, 271]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "l2",
"points": [[191, 331], [131, 397]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "l3",
"points": [[251, 331], [251, 397]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "l4",
"points": [[311, 331], [371, 397]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "r1",
"points": [[709, 203], [709, 271]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "r2",
"points": [[649, 331], [569, 397]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "r3",
"points": [[709, 331], [674, 397]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "r4",
"points": [[769, 331], [814, 397]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
}
],
"labels": [
{
"text": "요청 크기는 기록만 되고 강제되지 않는다",
"x": 251,
"y": 481
},
{
"text": "요청 크기대로 잘리고 강제된다",
"x": 709,
"y": 481
}
]
}
Local Persistent Volume
LPV는 Kubernetes에 내장된 local 볼륨 타입이다. 디스크, 파티션, 디렉토리 같은 노드 로컬 스토리지를 PV로 등록한다.
hostPath와 겉모습은 비슷하다. 결정적인 차이는 스케줄러가 볼륨의 위치를 안다는 점이다.
hostPath 볼륨을 쓰는 Pod은 스케줄러가 아무 노드에나 배치할 수 있다. 데이터가 있는 노드로 보내 주는 장치가 없다.
LPV는 PV 객체에 nodeAffinity를 박아 넣는다. 이 PV가 어느 노드의 디스크인지 PV 스스로 선언하는 것이다.
스케줄러는 이 정보를 보고, 그 PV를 쓰는 Pod을 데이터가 있는 노드로 배치한다.
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-ssd1
spec:
capacity:
storage: 100Gi
accessModes:
- ReadWriteOnce
storageClassName: local-storage
local:
path: /mnt/disks/ssd1
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node-1
local.path가 노드의 실제 디스크 경로이고, nodeAffinity가 “이 디스크는 node-1에 있다”는 선언이다.
LPV에서
nodeAffinity는 옵션이 아니라 필수다.
또 하나 눈에 띄는 점은 이 PV를 사람이 직접 만든다는 것이다.
LPV는 정적 프로비저닝(static provisioning) 전용이다. PVC를 만들면 알아서 볼륨이 생기는 동적 프로비저닝을 지원하지 않는다.
관리자가 노드마다 디스크를 파티셔닝하고, 마운트하고, 그 하나하나를 PV로 등록해 둬야 한다.
이 등록 작업을 자동화해 주는 local static provisioner가 있다. 노드의 지정 디렉토리(discovery directory) 아래 마운트된 디스크를 발견해서 PV로 만들어 준다. 이름 그대로 static이라 디스크를 쪼개는 일 자체는 여전히 사람 몫이고, 이미 준비된 디스크를 PV 객체로 옮겨 적어 줄 뿐이다.
WaitForFirstConsumer
LPV의 StorageClass는 조금 이상하게 생겼다.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
provisioner가 no-provisioner다. 동적 프로비저닝을 안 하는데 StorageClass는 왜 필요할까.
volumeBindingMode: WaitForFirstConsumer 때문이다.
기본 동작인 Immediate 모드에서는 PVC가 만들어지는 즉시 PV와 바인딩된다. 이게 로컬 스토리지에서는 함정이 된다.
PVC가 node-1의 PV와 먼저 바인딩됐다고 하자. 그런데 정작 Pod은 GPU가 node-2에만 있어서 node-2로 가야 한다면? 볼륨은 node-1에, Pod은 node-2에 가야 하니 둘 중 하나는 포기해야 한다. Pod은 영영 스케줄되지 못한다.
WaitForFirstConsumer(이하 WFC)는 바인딩 시점을 뒤로 민다. PVC가 생겨도 바로 PV를 고르지 않고, 그 PVC를 쓰는 Pod이 스케줄될 때까지 기다린다. 스케줄러가 CPU, 메모리, node selector, affinity 같은 Pod의 다른 제약을 전부 계산해서 노드를 정하면, 그 노드의 PV와 바인딩한다.
볼륨이 Pod을 끌고 가는 게 아니라, Pod의 스케줄 결정에 볼륨이 따라가는 구조다. 위치가 고정된 로컬 스토리지에서는 이 순서가 필수라, LPV의 StorageClass는 사실상 WFC가 강제된다.
Where LPV Hurts
여기까지 보면 LPV는 깔끔한 답처럼 보인다. 실제로 볼륨 몇 개를 정적으로 쓰는 환경에서는 충분하다. 아픈 곳은 운영이 커질 때 드러난다.
디스크를 미리 쪼개야 한다. PV 크기는 파티션을 만드는 시점에 굳는다. 워크로드가 요구할 크기를 미리 알아야 한다는 뜻이다.
10Gi짜리 PVC가 들어왔는데 남은 PV가 100Gi짜리뿐이면, 그 PVC는 100Gi PV에 바인딩된다.
PVC와 PV 매칭은 “요청 이상 중에 고르기”라서, 90Gi는 그냥 낭비된다.
크기를 바꿀 수 없다. LPV는 volume expansion을 지원하지 않는다. 파티션이 곧 PV이니, 커지려면 디스크를 다시 쪼개고 PV를 다시 만들어야 한다.
노드마다 손이 간다. 노드를 늘릴 때마다 파티셔닝, 포맷, 마운트, PV 등록이 따라온다. static provisioner가 등록은 대신해 줘도 그 앞 단계는 남는다.
스케줄러가 남은 용량을 모른다. 스케줄러가 아는 것은 “PV 객체가 존재하는가”뿐이다. 어느 노드의 디스크에 실제 여유가 얼마나 남았는지는 스케줄링에 반영되지 않는다.
LPV는 위치 문제(스케줄러가 데이터 위치를 안다)는 풀었지만 수량 문제(누가 디스크를 쪼개고 관리하는가)는 사람에게 남겨 뒀다.
Local Path Provisioner
이 수량 문제를 가장 가볍게 받아친 것이 Rancher의 Local Path Provisioner다.
k3s를 설치하면 기본 StorageClass(local-path)로 함께 깔려 나오는, 아마 가장 널리 쓰이는 node-local 동적 프로비저너다.
동작은 소박하다. PVC가 들어오면 Pod이 스케줄된 노드의 지정 경로(기본 /opt/local-path-provisioner) 아래에 디렉토리를 하나 만들고, 그 디렉토리를 가리키는 hostPath PV를 만들어 준다.
디렉토리를 만드는 방식이 재밌다. 노드마다 데몬을 깔아 두는 대신, 그때그때 helper pod을 해당 노드에 띄워 mkdir을 실행한다.
삭제도 같은 방식으로 helper pod이 rm -rf를 실행한다. 이 setup, teardown 스크립트는 ConfigMap에 들어 있어 갈아 끼울 수 있다.
# ConfigMap의 setup / teardown 스크립트 (기본값)
mkdir -m 0777 -p "$VOL_DIR" # 볼륨 생성 = 디렉토리 생성
rm -rf "$VOL_DIR" # 볼륨 삭제 = 디렉토리 삭제
만들어진 PV에는 LPV처럼 kubernetes.io/hostname 기준 nodeAffinity가 박힌다. 스케줄러가 위치를 아는 성질은 그대로 가져가면서, 디스크 준비와 PV 등록에서 사람이 빠진다.
여기까지 보면 수량 문제가 풀린 것 같다. 그런데 내주는 것이 블록 디바이스가 아니라 디렉토리라는 사실에서 한계 세 개가 따라 나온다.
용량이 강제되지 않는다. 공식 README가 Cons 첫 줄에 적어 둔 내용이다. “볼륨 용량 제한은 지원하지 않으며, 용량 제한은 무시된다.” PVC에 10Gi라고 적어도 PV 객체에 그 숫자가 기록될 뿐이다. 디렉토리는 노드 파일시스템을 다른 볼륨들과 그대로 공유하므로, Pod 하나가 폭주하면 같은 노드의 다른 볼륨과 노드 자체가 같이 아프다.
스케줄러는 여전히 용량을 모른다. local-path는 CSI 드라이버가 아니라 external provisioner 라이브러리로 만들어져 있다. 노드별 디스크 여유를 스케줄러에 알려 주는 표준 경로(Storage Capacity Tracking)는 CSI 전용이라, local-path에는 그 통로 자체가 없다.
Filesystem 전용이다. 만들어지는 PV의 volumeMode는 항상 Filesystem으로 고정된다. 디렉토리를 내주는 구조이니 당연한 귀결이고, raw Block 디바이스가 필요한 워크로드에는 못 쓴다.
XFS project quota로 용량을 강제하는 예제(
examples/quota)가 repo에 있긴 하다. 전용 helper image를 빌드하고 privileged 옵션을 켜야 하는 우회이지 기본 동작은 아니다.
local-path는 동적 프로비저닝은 얻었지만, 볼륨의 실체는 여전히 공유 파일시스템 위의 디렉토리 하나다. PVC 사이에 용량의 벽이 없다.
LVM
이 벽을 세우려면 디렉토리가 아니라 블록 디바이스를 내줘야 한다. TopoLVM이 그 일을 어떻게 하는지 보기 전에, 이름에 들어 있는 LVM부터 정리하자.
LVM(Logical Volume Manager)은 리눅스에서 물리 디스크와 파일시스템 사이에 끼는 논리 계층이다. 디스크를 직접 파티셔닝하는 대신, 디스크들을 하나의 풀로 묶어 두고 필요할 때 원하는 크기로 잘라 쓴다.
개념은 세 단계다.
- PV(Physical Volume): LVM에 편입된 물리 디스크 또는 파티션.
/dev/nvme0n1같은 것 - VG(Volume Group): PV들을 묶은 풀. 여기서부터는 개별 디스크의 경계가 사라진다
- LV(Logical Volume): VG에서 잘라낸 논리 볼륨. 파일시스템은 이 위에 얹는다
케이크에 비유하면 PV는 재료, VG는 구워 놓은 케이크 한 판, LV는 주문이 올 때마다 잘라 주는 조각이다. 미리 조각 크기를 정해 두는 파티셔닝과 달리, 주문을 보고 자른다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "LVM: 디스크를 풀로 묶고 잘라 쓴다",
"width": 760,
"height": 530,
"mobileWidth": 720,
"regions": [
{
"id": "lvm-stack",
"label": "PV → VG → LV",
"x": 40,
"y": 50,
"width": 680,
"height": 430
}
],
"nodes": [
{
"id": "pv1",
"kind": "memory",
"label": "/dev/nvme0n1",
"caption": "PV · 물리 디스크",
"x": 220,
"y": 157,
"width": 280,
"height": 62
},
{
"id": "pv2",
"kind": "memory",
"label": "/dev/nvme1n1",
"caption": "PV · 물리 디스크",
"x": 540,
"y": 157,
"width": 280,
"height": 62
},
{
"id": "vg",
"kind": "host",
"label": "VG",
"caption": "디스크 경계가 사라진 풀",
"x": 380,
"y": 287,
"width": 600,
"height": 62
},
{
"id": "lv1",
"kind": "gpu",
"label": "LV 10Gi",
"caption": "ext4",
"x": 180,
"y": 417,
"width": 180,
"height": 62
},
{
"id": "lv2",
"kind": "gpu",
"label": "LV 20Gi",
"caption": "xfs",
"x": 380,
"y": 417,
"width": 180,
"height": 62
},
{
"id": "lv3",
"kind": "gpu",
"label": "LV 50Gi",
"caption": "raw block",
"x": 580,
"y": 417,
"width": 180,
"height": 62
}
],
"links": [
{
"id": "pv1-vg",
"points": [[220, 188], [220, 256]],
"tone": "primary",
"flow": { "speed": "slow", "emphasis": "soft" }
},
{
"id": "pv2-vg",
"points": [[540, 188], [540, 256]],
"tone": "primary",
"flow": { "speed": "slow", "emphasis": "soft" }
},
{
"id": "vg-lv1",
"points": [[180, 318], [180, 386]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "vg-lv2",
"points": [[380, 318], [380, 386]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "vg-lv3",
"points": [[580, 318], [580, 386]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
}
]
}
pvcreate /dev/nvme0n1 /dev/nvme1n1 # 디스크를 LVM에 편입
vgcreate my-vg /dev/nvme0n1 /dev/nvme1n1 # 풀로 묶기
lvcreate -n my-lv -L 10G my-vg # 10Gi 조각 잘라내기
lvextend -L 20G my-vg/my-lv # 나중에 20Gi로 확장
잘라낸 LV는 나중에 lvextend로 키울 수 있다. 파티션에는 없던 유연함이다.
명칭 주의. LVM의 PV(Physical Volume)와 Kubernetes의 PV(PersistentVolume)는 전혀 다른 물건인데 약어가 같다. 이 글에서 PV는 특별한 언급이 없으면 Kubernetes의 PersistentVolume이다.
CSI
LVM이 “무엇을 잘라 줄 것인가”의 답이라면, 그걸 Kubernetes에 꽂는 규격이 CSI(Container Storage Interface)다. Storage on Kubernetes에서 이름만 지나갔던 개념이라, TopoLVM에 들어가기 전에 여기서 짚고 가자.
CSI 이전의 Kubernetes는 스토리지 드라이버를 자기 코드 안에 품고 있었다. EBS, GCE PD 같은 벤더 드라이버가 Kubernetes 저장소 안에서(in-tree) 같이 컴파일됐고, 드라이버 버그 하나를 고치려 해도 Kubernetes 릴리스를 기다려야 했다. 새 스토리지를 지원하려면 코어에 코드를 넣어야 하니 벤더에게도 부담이었다.
CSI는 이 결합을 끊는다. “볼륨을 만들어라”, “이 노드에 마운트해라” 같은 요청을 표준 gRPC 인터페이스로 정의해 두고, 스토리지 쪽 구현은 전부 드라이버라는 별도 프로그램으로 밀어냈다. Kubernetes는 드라이버가 안에서 무슨 일을 하는지 모른다. 규격만 지키면 어떤 스토리지든 꽂힌다.
동작 흐름은 이렇다. PVC가 만들어지면 드라이버 옆에 붙은 external-provisioner라는 sidecar가 그걸 감지하고, 드라이버에 CreateVolume을 호출한다. 그 요청을 받아 무엇을 할지는 드라이버 마음이다. AWS EBS 드라이버는 AWS API를 불러 EBS 볼륨을 만들고, Ceph 드라이버는 RBD 이미지를 만들고, TopoLVM은 lvcreate를 친다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "CSI: 요청은 표준, 구현은 드라이버 마음",
"width": 960,
"height": 540,
"mobileWidth": 900,
"regions": [
{
"id": "k8s",
"label": "Kubernetes",
"x": 36,
"y": 50,
"width": 888,
"height": 190
},
{
"id": "driver",
"label": "CSI Driver",
"x": 36,
"y": 300,
"width": 888,
"height": 190
}
],
"nodes": [
{
"id": "pvc",
"kind": "note",
"label": "PVC",
"caption": "10Gi 볼륨 요청",
"x": 190,
"y": 170,
"width": 250,
"height": 64
},
{
"id": "ep",
"kind": "switch",
"label": "external-provisioner",
"caption": "PVC 감시 sidecar",
"x": 480,
"y": 170,
"width": 250,
"height": 64
},
{
"id": "cv",
"kind": "gpu",
"label": "CreateVolume",
"caption": "표준 gRPC 인터페이스",
"x": 770,
"y": 170,
"width": 250,
"height": 64
},
{
"id": "ebs",
"kind": "host",
"label": "AWS EBS 드라이버",
"caption": "EBS API 호출",
"x": 190,
"y": 417,
"width": 250,
"height": 68
},
{
"id": "ceph",
"kind": "host",
"label": "Ceph 드라이버",
"caption": "RBD 이미지 생성",
"x": 480,
"y": 417,
"width": 250,
"height": 68
},
{
"id": "topolvm",
"kind": "gpu",
"label": "TopoLVM",
"caption": "lvcreate 실행",
"x": 770,
"y": 417,
"width": 250,
"height": 68
}
],
"links": [
{
"id": "pvc-ep",
"points": [[315, 170], [355, 170]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "ep-cv",
"points": [[605, 170], [645, 170]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "cv-ebs",
"points": [[770, 202], [770, 270], [190, 270], [190, 383]],
"tone": "muted",
"dashed": true,
"flow": { "speed": "slow", "emphasis": "muted" }
},
{
"id": "cv-ceph",
"points": [[770, 202], [770, 270], [480, 270], [480, 383]],
"tone": "muted",
"dashed": true,
"flow": { "speed": "slow", "emphasis": "muted" }
},
{
"id": "cv-topolvm",
"points": [[770, 202], [770, 383]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
}
],
"labels": []
}
드라이버가 이 규격을 충족하면 Kubernetes의 표준 스토리지 기능 궤도에 올라탄다.
앞서 local-path가 얻지 못한다고 했던 Storage Capacity Tracking이 대표적이고, volume expansion과 snapshot도 있다.
이름이 헷갈리기 쉬운 지점 하나. local-path가 쓰는 external provisioner 라이브러리는 CSI 이전부터 있던 프로비저닝 확장 방식이고, 방금 나온 external-provisioner sidecar는 CSI 드라이버 옆에 붙는 컴포넌트다. 이름은 닮았지만 앞의 것은 “CSI 바깥의 옛 방식”, 뒤의 것은 “CSI의 부품”이다.
TopoLVM
TopoLVM은 이 LVM을 백엔드로 쓰는 CSI 드라이버다. 노드 로컬 디스크를 VG로 묶어 두면, PVC가 만들어질 때마다 그 VG에서 LV를 잘라 PV로 내준다.
TopoLVM 문서는 스스로를 이렇게 규정한다. “CSI와 LVM을 사용한 local persistent volume의 구현”. 즉 LPV와 경쟁하는 다른 무언가라기보다, LPV가 사람에게 남겨 둔 일을 소프트웨어로 옮긴 물건이다.
컴포넌트는 역할별로 나뉜다.
- lvmd: 노드에서 실제 LVM 명령을 수행하는 데몬. VG의 여유 공간을 감시하고, LV를 만들고 지우고 키운다. gRPC를 unix domain socket으로 노출한다
- topolvm-node: 노드마다 도는 CSI node service. lvmd와 통신하며 자기 노드의 볼륨을 관리한다
- topolvm-controller: 클러스터에 한 벌 도는 CSI controller service. PVC 요청을 받아 어느 노드에 볼륨을 만들지 결정을 넘긴다
재밌는 부분은 controller와 node가 직접 통신하지 않는다는 점이다. 둘 사이에는 LogicalVolume이라는 커스텀 리소스가 낀다.
동적 프로비저닝의 흐름을 따라가면 이렇다.
- 사용자가 PVC를 만들고, Pod이 그 PVC를 참조한다
- WFC이므로 스케줄러가 먼저 Pod의 노드를 정한다
- topolvm-controller가 CSI
CreateVolume요청을 받아, 대상 노드를 적은LogicalVolume리소스를 만든다 - 그 노드의 topolvm-node가 자기 앞으로 온
LogicalVolume을 감지하고, lvmd에 LV 생성을 요청한다 - lvmd가 VG에서 LV를 잘라낸다
- PV가 만들어지고 PVC와 바인딩된다. PV에는 topology 제약이 붙어 Pod은 그 노드에서만 돈다
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "PVC에서 LV까지",
"width": 960,
"height": 584,
"mobileWidth": 900,
"regions": [
{
"id": "control-plane",
"label": "Control plane",
"x": 36,
"y": 50,
"width": 888,
"height": 210
},
{
"id": "chosen-node",
"label": "선택된 Node",
"x": 36,
"y": 296,
"width": 888,
"height": 238
}
],
"nodes": [
{
"id": "pvc",
"kind": "note",
"label": "PVC + Pod",
"caption": "WFC로 노드 먼저 결정",
"x": 172,
"y": 172,
"width": 210,
"height": 68
},
{
"id": "controller",
"kind": "switch",
"label": "topolvm-controller",
"caption": "CSI CreateVolume",
"x": 480,
"y": 172,
"width": 230,
"height": 68
},
{
"id": "lvcr",
"kind": "gpu",
"label": "LogicalVolume CR",
"caption": "대상 노드 기록",
"x": 788,
"y": 172,
"width": 210,
"height": 68
},
{
"id": "tnode",
"kind": "switch",
"label": "topolvm-node",
"caption": "자기 노드 CR 감지",
"x": 788,
"y": 437,
"width": 210,
"height": 68
},
{
"id": "lvmd",
"kind": "host",
"label": "lvmd",
"caption": "lvcreate 실행",
"x": 480,
"y": 437,
"width": 210,
"height": 68
},
{
"id": "vg",
"kind": "memory",
"label": "VG → LV",
"caption": "요청 크기로 잘림",
"x": 172,
"y": 437,
"width": 210,
"height": 68
}
],
"links": [
{
"id": "pvc-ctrl",
"points": [[277, 172], [365, 172]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "ctrl-cr",
"points": [[595, 172], [683, 172]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "cr-node",
"points": [[788, 206], [788, 403]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "node-lvmd",
"points": [[683, 437], [595, 437]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "lvmd-vg",
"points": [[375, 437], [287, 437]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
}
],
"badges": [
{ "text": "1", "x": 321, "y": 148, "tone": "primary" },
{ "text": "2", "x": 639, "y": 148, "tone": "primary" },
{ "text": "3", "x": 812, "y": 290, "tone": "primary" },
{ "text": "4", "x": 639, "y": 413, "tone": "primary" },
{ "text": "5", "x": 331, "y": 413, "tone": "primary" }
],
"labels": [
{
"text": "controller와 node는 CR을 사이에 두고 만난다",
"x": 480,
"y": 502
}
]
}
관리자가 할 일은 노드마다 VG 하나 만들어 두는 것으로 끝난다. 파티셔닝도, 크기 예측도, PV 수동 등록도 없다.
거기다 LV는 커널이 크기를 강제하는 진짜 블록 디바이스라서 local-path의 디렉토리와 달리, 10Gi로 잘라 줬으면 10Gi 이상은 물리적으로 못 쓴다.
크기 강제가 가능
PVC의 요청량을 수정하면 lvextend와 파일시스템 확장이 온라인으로 이어지는 volume expansion도 되고, volumeMode: Block으로 파일시스템 없이 raw 디바이스를 그대로 내줄 수도 있다.
Capacity-Aware Scheduling
한 가지 문제가 남는다. 스케줄러는 노드의 CPU와 메모리는 알지만, 디스크 여유 공간은 모른다.
동적 프로비저닝에서 이건 치명적이다. 스케줄러가 VG에 10Gi밖에 안 남은 노드에 100Gi PVC를 단 Pod을 배치하면, 볼륨 생성은 실패하고 Pod은 그 노드에 발이 묶인다.
TopoLVM이 “capacity-aware”를 내세우는 이유가 이것이다. 각 노드의 lvmd가 VG 여유 공간을 계속 보고하고, 이 정보가 스케줄링에 반영된다.
반영하는 경로는 Kubernetes 표준 기능인 Storage Capacity Tracking이다.
CSI 드라이버가 노드별 여유 용량을 CSIStorageCapacity 객체로 게시하면, 스케줄러가 WFC 바인딩 시점에 그걸 읽어서 용량이 부족한 노드를 후보에서 걸러낸다.
Kubernetes 1.24에서 GA가 됐고, TopoLVM은 이걸 기본으로 쓴다. 이 메커니즘은 CSI 드라이버 전용이라, external provisioner인 local-path는 애초에 참여할 수 없는 경로다.
다만 이 필터는 보장이 아니다. 스케줄러가 읽는 용량 정보는 한 박자 늦은 스냅샷이라, 여러 PVC가 동시에 몰리면 낡은 정보로 판단할 수 있다.
그래서 볼륨 생성이 실패하면 노드 선택을 리셋하고 다시 스케줄하는 재시도 루프가 함께 설계되어 있다. “첫 시도에 성공할 확률을 높이는 장치”지 트랜잭션이 아니다.
TopoLVM에는 scheduler extender 방식의 topolvm-scheduler도 있다. Storage Capacity Tracking이 부족한 노드를 걸러내기만 하는 반면, extender는 여유가 큰 노드에 가점을 주는 우선순위(prioritize)까지 한다. 대신 kube-scheduler 설정을 직접 고쳐야 해서 설치가 번거롭다. 지금은 Storage Capacity Tracking이 기본이고 extender는 opt-in이다.
Node Failure
여기까지 오면 TopoLVM이 일방적으로 좋아 보이지만, 두 프로비저너가 공유하는 본질적 한계가 있다. 데이터가 노드 안에 있다는 사실 자체는 무엇으로도 바뀌지 않는다.
네트워크 스토리지는 노드가 죽어도 데이터가 살아서, Pod이 다른 노드로 옮겨가 이어서 쓴다. 로컬 스토리지는 노드가 죽으면 데이터도 같이 간다. local-path든 TopoLVM이든 복제(replication)는 범위 밖이다.
노드가 사라졌을 때의 뒷처리는 결이 다르다.
local-path는 PV 객체가 클러스터에 그대로 남는다. 스케줄러는 이미 사라진 노드로 Pod을 보내려고 무한정 기다리고, 사람이 개입해서 PV와 PVC를 정리해야 한다. 정적 LPV도 같다.
TopoLVM은 노드가 클러스터에서 삭제되면 그 노드의 PVC를 자동으로 삭제한다. StatefulSet controller가 이를 보고 다른 노드에 새 PVC와 Pod을 만든다. 복구가 자동으로 굴러가는 대신, 새 볼륨은 빈 볼륨이다. 데이터를 되살려 주는 게 아니라 빈 자리를 빨리 다시 채워 주는 것이다.
그래서 두 방식 모두 전제가 같다. 데이터 내구성은 애플리케이션이 책임진다. 복제본을 여럿 두는 데이터베이스, 앱 레벨에서 백업과 복제를 처리하는 스토리지 시스템처럼, 노드 하나쯤 잃어도 스스로 복구하는 워크로드가 로컬 스토리지의 대상이다.
References
- Kubernetes — Volumes: local — https://kubernetes.io/docs/concepts/storage/volumes/#local
- Kubernetes — Storage Classes: Volume Binding Mode — https://kubernetes.io/docs/concepts/storage/storage-classes/#volume-binding-mode
- Kubernetes — Storage Capacity — https://kubernetes.io/docs/concepts/storage/storage-capacity/
- Kubernetes Blog — Kubernetes 1.14: Local Persistent Volumes GA — https://kubernetes.io/blog/2019/04/04/kubernetes-1.14-local-persistent-volumes-ga/
- sig-storage-local-static-provisioner — https://github.com/kubernetes-sigs/sig-storage-local-static-provisioner
- Local Path Provisioner — https://github.com/rancher/local-path-provisioner
- K3s — Volumes and Storage — https://docs.k3s.io/add-ons/storage
- Kubernetes CSI Developer Documentation — https://kubernetes-csi.github.io/docs/introduction.html
- Container Storage Interface (CSI) Specification — https://github.com/container-storage-interface/spec
- TopoLVM — https://github.com/topolvm/topolvm
- TopoLVM — Design Document — https://github.com/topolvm/topolvm/blob/main/docs/design.md
- TopoLVM — Advanced Setup: Scheduling — https://github.com/topolvm/topolvm/blob/main/docs/advanced-setup.md#scheduling
- TopoLVM — Limitations — https://github.com/topolvm/topolvm/blob/main/docs/limitations.md