상태: 초안 (2026-10-16), 우리 코드 + 공식 문서·소스 + 장난감 실측. 골든 VM 실측은 아직 없음. 골든 디스크는 코드와 운영자 도구까지 들어갔지만, 글을 쓰는 날 클러스터에는 골든 PVC 도 그것을 읽을 권한도 아직 적용되지 않았습니다(읽기 전용 조회로 확인). 그래서 이 글의 골든 VM 동작은 KubeVirt v1.9.0·CDI v1.66.0 의 문서와 소스로 확인한 것이고, 숫자는 노트북에서 돌린 장난감과 컨테이너 디스크(같은 원리)에서 잰 것입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 문서에서 찾지 못한 것은 「미확인」으로 표시합니다.
학생 VM 이 뜨기까지 4: PVC 관점의 오버레이
목차
- 1. 하고 싶은 것
- 2. 원리: 바탕과 쓰기 층
- 3. KubeVirt 의 ephemeral 볼륨: 이것을 PVC 위에서
- 4. PVC 의 눈으로 보면
- 5. 왜 CSI 복제나 스냅샷이 아닌가
- 6. 세션이 끝나면: 쓰기 층만 사라진다
- 7. 대가와 한계
- 참고
3편은 두 길의 빈자리로 끝났습니다. 컨테이너 디스크는 20초에 뜨지만 루트가 2.4GiB 이고, DataVolume 은 루트가 크지만 VM 마다 이미지를 새로 가져와 첫 부팅이 195초까지 갑니다. 이 글은 그 사이를 메우는 세 번째 길, 골든 PVC + 쓰기 층을 봅니다.
1. 하고 싶은 것
- 학생마다 큰 루트(10Gi 또는 20Gi)를 준다. kubeadm 처럼 루트를 많이 쓰는 실습이 공간 부족으로 죽지 않게.
- 빨리 연다. VM 을 만들 때마다 수 GB 를 가져오거나 복사하지 않는다.
- 작게 쓴다. 학생 서른 명이면 서른 벌의 10Gi 를 만들지 않는다.
- 세션이 끝나면 흔적이 없다. 다음 학생은 깨끗한 디스크를 받는다.
네 가지를 한꺼번에 얻는 오래된 방법이 있습니다. 읽기 전용 바탕 하나 위에, 사람마다 바뀐 부분만 담는 얇은 층을 얹는 것입니다. 컨테이너 이미지의 레이어, 가상화의 링크드 클론, qcow2 의 백킹 파일이 모두 같은 생각입니다.
2. 원리: 바탕과 쓰기 층
qcow2 디스크 이미지는 백킹 파일을 가리킬 수 있습니다. qemu-img create -f qcow2 -b <바탕> -F raw <쓰기 층> 으로 만들면 새 이미지는 바탕과 달라진 부분만 기록합니다(qemu-img 문서). 이때 디스크는 일정한 크기의 칸(클러스터)으로 나뉘고, 규칙은 두 줄입니다.
- 읽기. 쓰기 층에 그 칸이 있으면 쓰기 층에서, 없으면 바탕에서 읽는다(qcow2 형식 명세: 할당되지 않은 클러스터는 백킹 파일에서 읽는다).
- 쓰기. 처음 건드리는 칸은 바탕에서 그 칸을 끌어와 쓰기 층에 새로 자리를 잡고 고친다. 바탕은 절대 바뀌지 않는다.
그래서 쓰기 층은 비어서 시작하고, 게스트가 쓴 칸만큼만 자랍니다. 바탕을 바꾸지 않으므로 여러 디스크가 같은 바탕을 함께 가리켜도 서로 섞이지 않습니다.
장난감으로 재 보기
노트북에 qemu-img 가 없어서(새로 받지 않았습니다) 위의 두 규칙만 파이썬으로 흉내 내어 같은 조건에서 쟀습니다. 바탕 512MiB(무작위 바이트), 게스트가 4KiB 쓰기를 무작위 자리에 1,000번 하는 일입니다. 「통째로 복사한 뒤 쓰기」 와 「빈 쓰기 층에 쓰기」 를 다섯 번씩 돌린 중앙값입니다(0번 바퀴는 예열로 뺐습니다). 두 방식의 디스크 전체 해시가 다섯 바퀴 모두 같아, 게스트가 보는 디스크는 똑같다는 것도 확인했습니다.
| 칸 64KiB | 칸 2MiB | |
|---|---|---|
| 통째로 복사(512MiB) | 0.217초 | 0.151초 |
| 빈 쓰기 층 만들기 | 0.0002초 | 0.0002초 |
| 쓰기 1,000번 뒤 쓰기 층 크기 | 59MiB | 504MiB(바탕의 98%) |
환경: Apple M1 Max 노트북, 내장 SSD, Python 3.12, 표준 라이브러리만. 코드와 원자료는 docs/experiments/2026-10-16-cow-overlay-toy/ 에 있습니다.
숫자에서 읽을 것은 세 가지입니다.
- 만들기는 사실상 공짜입니다. 빈 쓰기 층은 디스크 크기와 상관없이 0.2밀리초입니다. 복사는 디스크 크기에 비례합니다. 여기서 0.2초인 것은 같은 SSD 안의 복사이고 바탕이 파일 캐시에 있었기 때문이라, 네트워크 너머의 저장소에서 10Gi 를 복사하는 시간은 이 숫자로 말할 수 없습니다(미실측). 계산만 해 보면 1Gbps 회선에서 10GiB 는 이론상 약 86초입니다.
- 쓰기 층은 「쓴 양」 이 아니라 「건드린 칸」 만큼 자랍니다. 실제로 쓴 것은 3.9MiB 인데 64KiB 칸 945개를 건드려 59MiB 가 됐습니다(약 15배). 흩어진 작은 쓰기는 칸 크기만큼 부풉니다.
- 칸이 크고 쓰기가 흩어지면 층이 바탕만큼 커집니다. 2MiB 칸에서는 512MiB 디스크가 256칸뿐이라 쓰기 1,000번이 거의 모든 칸을 건드렸습니다. 쓰기 층이 얇게 유지되는 것은 게스트가 디스크의 작은 부분만 고친다는 가정 위에서입니다. OS 를 통째로 업그레이드하는 실습이라면 이 가정이 깨집니다.
실제 VM 에서는 어떨까요. 같은 구조인 컨테이너 디스크 VM 하나(운영자용, 9일째 실행 중)를 읽어 보니, 바탕(약 596MiB) 위의 쓰기 층이 약 1.10GiB 였습니다(실측, 2026-10-14). 9일 동안 패키지를 깔고 로그를 쌓은 VM 이라 한 시간짜리 실습 세션보다 훨씬 많이 쓴 경우입니다. 골든 VM 의 부팅 직후·실습 뒤 쓰기 층 크기는 미실측입니다.
3. KubeVirt 의 ephemeral 볼륨: 이것을 PVC 위에서
KubeVirt 에는 이 구조를 PVC 로 해 주는 볼륨 원본이 이미 있습니다. 사용자 안내서의 ephemeral 볼륨입니다. 요약하면 이렇습니다.
- 네트워크 볼륨(PVC)을 읽기 전용 바탕으로 쓰고, 바탕은 바꾸지 않는다.
- 모든 쓰기는 로컬의 쓰기 층 이미지로 간다.
- 쓰기 층은 VM 이 시작될 때 만들고, VM 이 멈추거나 끝나면 버린다.
- 바탕으로는 지금 PVC 만 된다.
TT Lab 백엔드가 만드는 골든 VM 의 루트 볼륨은 이것 한 줄입니다(backend/app/vms.py 의 vm_manifest).
volumes:
- name: root
ephemeral:
persistentVolumeClaim:
claimName: golden-ubuntu-24.04-10gi # OS 와 크기로 정해진 이름
readOnly: true # 빼지 않는다
KubeVirt v1.9.0 소스를 따라가면 이 한 줄이 다음 일을 합니다.
- virt-controller 가 virt-launcher 파드를 만들 때, 이 PVC 를 파드 볼륨으로 그대로(
readOnly포함) 옮겨 적고, 쓰기 층을 둘 빈 디렉터리(emptyDir)를 하나 더 붙입니다(pkg/virt-controller/services/rendervolumes.go). - 파드 안에서 PVC 는
/var/run/kubevirt-private/vmi-disks/root/에 붙고, 바탕은 그 안의disk.img(raw)입니다. - 시작할 때
qemu-img create -f qcow2 -b <바탕 disk.img> -F raw <쓰기 층 disk.qcow2>로 쓰기 층을 만듭니다(pkg/ephemeral-disk/ephemeral-disk.go). 쓰기 층의 가상 크기는 바탕과 같습니다. 그래서 바탕의 크기가 곧 게스트 루트의 크기입니다.
컨테이너 디스크도 같은 원리라는 점이 재미있습니다. 바탕이 레지스트리에서 받은 노드 캐시냐, 스토리지 클래스가 내준 PVC 냐만 다릅니다(3편 그림).
4. PVC 의 눈으로 보면
「누가 어떤 PVC 를 갖고, 무엇이 언제 생기고 사라지나」 로 정리하면 이렇습니다.
| 골든 PVC | 쓰기 층 | |
|---|---|---|
| 무엇 | OS·크기마다 하나(이름 golden-<OS>-<크기>) |
VM 마다 하나의 disk.qcow2 파일 |
| 어디에 | 공유 스토리지(CSI 드라이버), RWX, 파일시스템 모드 | virt-launcher 파드의 emptyDir — 노드 로컬, PVC 가 아니다 |
| 누가 만드나 | 운영자가 CDI DataVolume 으로 한 번(레지스트리 원본) | KubeVirt 가 VM 을 켤 때마다 |
| 누가 소유하나 | 아무도(VM 이 소유하지 않는다) | virt-launcher 파드 |
| 붙는 모양 | 모든 VM 이 readOnly: true 로 |
그 VM 의 QEMU 만 |
| 언제 사라지나 | 운영자가 지울 때만 | VM 이 끝나 파드가 지워질 때 |
몇 가지 결정에는 이유가 있습니다.
- RWX 로 만든다. 학생 VM 은 여러 노드에 흩어져 앉고, 같은 골든에 동시에 붙어야 합니다. RWO 는 노드 하나에만 붙으므로, 백엔드는 RWO 골든을 「쓸 수 없음」 으로 판정합니다. ROX 가 아니라 RWX 인 것은 CDI 가 가져오면서 써야 하기 때문입니다.
- 진짜 보호는
readOnly: true다. 3편에서 본 대로 접근 모드는 쓰기를 막지 않습니다. 바탕이 단 한 번이라도 바뀌면 그 위의 모든 쓰기 층이 엉뚱한 디스크를 보게 됩니다. 그래서 읽기 전용 마운트를 빼지 않고, 시험이 매니페스트에 읽기·쓰기로 붙는 골든이 없는지 봅니다. - 부팅해 본 적 없는 원본이어야 한다. 한 번 부팅한 디스크를 골든으로 쓰면 SSH 호스트 키·machine-id·cloud-init 기록이 모든 학생 VM 에 똑같이 복제됩니다. 그래서 골든은 컨테이너 디스크와 같은 이미지를 그대로 가져오고, PVC 에 원본 이미지 주소를 어노테이션으로 남겨 백엔드가 확인합니다.
- 바로 바인딩한다. 3편에서 본 「첫 사용자까지 미루기」 를 따르면 가져오기가 첫 학생 VM 이 올 때 시작됩니다. 골든은 그 전에 다 가져와 있어야 하므로 즉시 바인딩을 요청합니다.
- 백엔드는 읽기만 한다. 백엔드가 골든에 대해 할 수 있는 일은 이름을 알고 있는 PVC 를
get하는 것 하나입니다. 만들기·지우기 권한이 없으므로 회수 코드의 실수 하나로 모두의 바탕이 사라질 길이 없습니다(6편).
판정과 대체
백엔드는 VM 을 만들기 전에 골든을 읽어 지금 써도 되는지 판정합니다. 없는 PVC 를 가리키는 VM 은 KubeVirt 가 파드를 만들지 못해 영영 뜨지 않기 때문입니다(v1.9.0 rendervolumes.go 의 PVC 없음 오류).
| 상황 | 결과 |
|---|---|
| 골든 PVC 가 없다 · 읽을 권한이 없다 · API 오류 | 컨테이너 디스크로 연다 |
| 지우는 중이다 · 아직 바인딩되지 않았다 | 컨테이너 디스크로 연다 |
| 한 노드에만 붙는 볼륨이다 | 컨테이너 디스크로 연다 |
| CDI 가 아직 가져오는 중이다 | 컨테이너 디스크로 연다 |
| 다른 이미지에서 가져온 골든이다 | 컨테이너 디스크로 연다 |
| 레슨의 값이 오타다 | 오류(커리큘럼 시험이 먼저 잡는다) |
대체될 때 학생 터미널에는 「루트가 약 2.4GiB 로 평소보다 작다, df -h / 로 확인하라」 는 안내가 뜹니다. 안내는 대화형 셸에서만 찍습니다. 채점 에이전트도 로그인 셸로 명령을 돌리기 때문에, 아니면 안내가 채점 출력에 섞입니다. 왜 DataVolume 이 아니라 컨테이너 디스크로 돌아가느냐 하면, 백엔드에 PVC 를 만드는 권한을 다시 주지 않기 위해서이고, 195초짜리 첫 부팅이 골든 실습의 준비 시간 상한을 넘겨 오히려 실패로 끝나기 때문입니다. 그래서 골든은 최적화이지 전제가 아닙니다. 골든을 켠 실습도 컨테이너 디스크에서 통과해야 합니다.
5. 왜 CSI 복제나 스냅샷이 아닌가
쿠버네티스에는 볼륨을 불리는 공식 방법이 이미 둘 있습니다.
- CSI 볼륨 복제. 새 PVC 의
dataSource에 원본 PVC 를 적으면, 빈 볼륨 대신 원본의 복제본이 생깁니다. 복제본은 원본과 독립된 볼륨입니다. CSI 드라이버만 되고, 같은 네임스페이스여야 하며, 원본과 볼륨 모드가 같고 크기가 같거나 커야 합니다. 드라이버가 이 기능을 구현했는지는 드라이버마다 다릅니다(쿠버네티스 문서). - 볼륨 스냅샷. 어느 시점의 볼륨을 스냅샷으로 떠 두고 거기서 새 PVC 를 만듭니다. CSI 드라이버의 스냅샷 기능, 스냅샷 컨트롤러와 CRD, 그리고 VolumeSnapshotClass 가 있어야 합니다(쿠버네티스 문서).
CDI 는 DataVolume 으로 PVC 를 복제할 때 이 둘을 알아서 고릅니다. 스냅샷을 쓸 수 있으면 「스냅샷 → 복원 → 스냅샷 삭제」 로 하고(smart clone), CSI 복제를 쓸 수 있으면 그것을, 둘 다 안 되면 원본에서 대상으로 데이터를 흘려 보내는 호스트 복사로 떨어집니다(CDI 복제·StorageProfile 문서). 이 선택은 스토리지 클래스의 StorageProfile 에 cloneStrategy 로 남습니다.
글을 쓰는 날 TT Lab 의 공유 스토리지 클래스를 읽어 보니, 스냅샷 CRD 는 깔려 있지만 VolumeSnapshotClass 가 하나도 없고, StorageProfile 의 복제 전략은 copy(호스트 복사) 였습니다(2026-10-16 조회). 그러니 이 저장소에서 PVC 복제로 학생마다 10Gi 를 만들면, VM 하나마다 10Gi 를 네트워크로 통째로 복사하게 됩니다. 스냅샷 길은 아예 없습니다.
저장소가 바뀌어도 쓰기 층이 유리한 점은 남습니다.
| PVC 복제·스냅샷 복원 | 골든 + 쓰기 층 | |
|---|---|---|
| VM 마다 생기는 PVC | 하나 | 없음 |
| PVC 쿼터(VM 네임스페이스 12개)에 대해 | 동시 VM 수가 PVC 수에 묶인다 | 골든 수만 센다 |
| 백엔드에 필요한 권한 | PVC 만들기·지우기 | PVC 읽기 하나 |
| 만들 때 드는 시간 | 저장소의 복제 구현에 달렸다(여기선 전체 복사) | 빈 파일 하나 |
| 세션이 끝나면 | PVC 를 지워야 한다(지우기 실패 = 공간 누수) | 파드와 함께 사라진다 |
| 학생 VM 을 끄고 다시 켜면 | 디스크가 남는다 | 쓰기 층이 사라진다 |
마지막 줄이 이 방식이 치르는 값입니다. 실습 세션은 한 번 열고 끝나므로 받아들였습니다. 학생이 며칠에 걸쳐 같은 디스크를 이어 써야 하는 과제라면 이 방식은 맞지 않습니다.
6. 세션이 끝나면: 쓰기 층만 사라진다
골든 VM 매니페스트에는 dataVolumeTemplates 가 없습니다. VM 이 골든을 소유하지 않으므로 VM 을 지워도 골든은 남습니다. 회수기가 지우는 것은 VM 과 cloud-init Secret 뿐이고(5편), VM 이 지워지면 virt-launcher 파드가 끝나며 그 emptyDir, 곧 쓰기 층이 함께 사라집니다. 다음 학생에게 넘어가는 것은 아무것도 없습니다.
골든 자체를 바꿔야 할 때(이미지 갱신)는 지우고 같은 이름으로 다시 만듭니다. 쓰고 있는 VM 이 있으면 쿠버네티스의 「사용 중인 스토리지 객체 보호」 때문에 PVC 는 그 VM 들이 끝날 때까지 남고, 그동안 백엔드는 「지우는 중」 을 보고 새 VM 을 컨테이너 디스크로 엽니다. 이름에 버전을 넣어 새 골든을 옆에 만드는 방법(CDI 의 DataImportCron 이 하는 방식)도 있지만, 그러면 백엔드가 이름을 풀려고 권한이 하나 더 늘고 학생 환경이 시험해 보지 않은 이미지로 저절로 바뀝니다. OS 여섯 개 규모에서는 손으로 갈아 끼우는 편이 싸다고 판단했습니다.
7. 대가와 한계
- 쓰기 층에는 크기 상한이 없습니다. 쓰기 층이 사는 emptyDir 에 크기 제한이 걸려 있지 않아서, 상한은 바탕의 가상 크기뿐입니다. 학생이 루트를 가득 채우면 노드 로컬 디스크를 그만큼 씁니다. 노드의 디스크 압박이 오면 kubelet 은 많이 쓴 파드부터 내보내는데, 그것이 바로 그 학생의 VM 입니다. 그래서 크기를 10Gi·20Gi 메뉴로 묶었습니다. VM 의 임시 저장소 요청·한도를 스케줄러에 알리는 방법은 아직 확인하지 않았습니다.
- 안 쓴 칸을 읽을 때마다 공유 스토리지로 갑니다. 여러 VM 이 같은 바탕을 동시에 읽을 때의 속도는 저장소마다 다르고 미실측입니다.
- 골든 VM 이 실제로 뜨는지 아직 돌려 보지 않았습니다. 읽기 전용으로 붙은 파일시스템 PVC 를 바탕으로 쓰는 경로에서, KubeVirt 가 디스크 파일의 소유권을 맞추거나 크기를 고치려고 바탕에 쓰려 하면 실패할 수 있습니다. 측정의 첫 바퀴가 이것을 확인합니다.
- 게스트 루트 크기는 계산이 엇갈립니다. 3편에서 본 대로 CDI 문서대로라면 10Gi 골든의 게스트 디스크는 10Gi 이고, 설계 문서는 약 9.4GiB 를 예상했습니다. 미실측입니다.
잴 것과 재는 방법
측정 스크립트(scripts/measure_vm_disks.py)는 컨테이너 디스크·DataVolume·골든을 같은 노드에서 한 대씩 만들고 재고 지웁니다. 시작 전과 바퀴마다 노드의 상태와 메모리 여유를 보고, 0번 바퀴는 예열로 따로 적고, 1번 바퀴부터 중앙값·최소·최대를 냅니다. 재는 것은 준비 시간(요청부터 에이전트 준비까지), VMI Running 까지의 시간, 게스트 루트의 크기와 여유, 쓰기 층의 실제 크기(부팅 직후와 128MiB 를 쓴 뒤), 지우기 시간입니다. VM 을 만들고 지우는 일이라 클러스터 쓰기 권한이 있는 사람이 돌립니다.
python3 deploy/vms/golden.py manifest --os ubuntu-24.04 --storage-class <공유 스토리지 클래스> # 골든 하나(적용은 사람)
python3 scripts/measure_vm_disks.py --preflight --node <노드> # 아무것도 만들지 않고 본다
python3 scripts/measure_vm_disks.py --node <노드> --rounds 4 # 예열 1 + 3바퀴
골든을 실습에 켤지는 이 결과로 정합니다. 준비 시간 중앙값이 컨테이너 디스크의 1.5배 안이고, 루트가 9GiB 이상이며, 부팅 직후 쓰기 층이 수백 MB 안이면 kubeadm 류 실습부터 켭니다.
티티 한마디. 서른 명에게 10Gi 씩 나눠 주는데 실제로 만드는 건 10Gi 하나와 빈 파일 서른 개예요. 대신 「바탕은 절대 건드리지 않기」 가 생명이라
readOnly: true는 꼭 붙여 두고, 쓰기는 칸 단위로 부푼다는 것도 기억해 두세요!
참고
문서와 소스는 모두 2026-10-16 에 열람했습니다.
- KubeVirt 사용자 안내서, 디스크와 볼륨(ephemeral): https://kubevirt.io/user-guide/storage/disks_and_volumes/
- KubeVirt v1.9.0 소스:
pkg/ephemeral-disk/ephemeral-disk.go(바탕 경로vmi-disks/<볼륨>/disk.img,qemu-img create -f qcow2 -b … -F raw) https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/ephemeral-disk/ephemeral-disk.go,pkg/virt-controller/services/rendervolumes.gohttps://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-controller/services/rendervolumes.go - QEMU 문서, qemu-img(백킹 파일): https://www.qemu.org/docs/master/tools/qemu-img.html, qcow2 형식 명세(할당되지 않은 클러스터 읽기): https://www.qemu.org/docs/master/interop/qcow2.html
- 쿠버네티스 문서, CSI 볼륨 복제: https://kubernetes.io/docs/concepts/storage/volume-pvc-datasource/, 볼륨 스냅샷: https://kubernetes.io/docs/concepts/storage/volume-snapshots/, 퍼시스턴트 볼륨(사용 중 보호): https://kubernetes.io/docs/concepts/storage/persistent-volumes/
- CDI 문서, DataVolume 복제: https://github.com/kubevirt/containerized-data-importer/blob/main/doc/clone-datavolume.md, smart clone: https://github.com/kubevirt/containerized-data-importer/blob/main/doc/smart-clone.md, StorageProfile(
cloneStrategy): https://github.com/kubevirt/containerized-data-importer/blob/v1.66.0/doc/storageprofile.md - TT Lab 코드와 기록:
backend/app/vms.py(golden_unusable_reason·choose_root_disk·vm_manifest),deploy/vms/golden.py,deploy/vms/README.md(골든 디스크 절),scripts/measure_vm_disks.py,docs/plans/2026-10-14-vm-golden-overlay.md - 장난감 실측:
docs/experiments/2026-10-16-cow-overlay-toy/(cow_toy.py,output.jsonl)