soobook
KUBERNETES

KubeVirt Snapshot, Restore, Export, Clone

VM 백업 및 이동에 쓰이는 4개의 초식. Snapshot, Restore, Export, Clone.

Intro

KubeVirt는 VM을 Pod 안에서 돌린다. virt-launcher라는 Pod이 뜨고 그 안에서 QEMU 프로세스가 게스트를 실행한다.

Pod은 죽으면 다시 만들면 그만이지만 VM은 그렇지 않다.

그렇다 보니 KubeVirt에는 상태를 따로 다루는 API가 있다.

What a VM’s State Is

VM의 상태는 정의, 디스크, 실행 중 메모리로 나눠서 보면 된다.

VirtualMachine 객체에는 VM의 정의가 들어 있다.

“이 VM은 CPU 4개, 메모리 8Gi, 디스크는 이 PVC”

VM을 켜면 이걸 바탕으로 VirtualMachineInstance가 만들어지고, 그에 맞춰 virt-launcher Pod이 스케줄된다.

게스트 OS와 사용자 데이터는 디스크에 있다. 보통 DataVolume이 PVC를 만들고 그 PVC가 PV에 묶인다.

실행 중 메모리는 QEMU 프로세스가 들고 있는 게스트 RAM이다.

API마다 이 셋 중 건드리는 범위가 다르다.

KubeVirt VM 상태의 세 층. 정의는 VirtualMachine과 VirtualMachineInstance, 디스크는 DataVolume에서 PVC를 거쳐 PV로, 실행 중 메모리는 virt-launcher pod 안의 QEMU 프로세스 RAM이다. Snapshot과 Restore와 Clone은 정의와 디스크에 닿고 Export는 디스크를 내려받으며 정의는 manifest로 내보내는데, 실행 중 메모리는 네 API 중 어느 것도 잡지 않는다

실행 중 메모리는 네 API 중 어느 것도 잡지 않는다.

Not Every Disk Is a Disk

디스크 층에는 함정이 하나 더 있다.

Volume source가 무엇이냐에 따라 로컬 디스크가 재부팅했을때 없어질수도, 아닐수도 있다.

“노드 재부팅을 견디나”보다 “VMI가 죽어도 남나”를 따져야 한다.

노드를 재부팅하면 그 위의 VMI는 어차피 죽는다

volume sourceVMI가 죽어도 남나?snapshot에 들어가나?빠졌을 때 signal
persistentVolumeClaim남는다들어간다PartialSnapshot
dataVolume남는다들어간다PartialSnapshot
memoryDump남는다들어간다PartialSnapshot
ephemeralVM이 멈추면 COW 이미지가 사라진다안 들어간다없음
containerDisk안 남는다안 들어간다없음
emptyDiskguest 재부팅까지만 남는다안 들어간다없음

COW(Copy-on-Write) 이미지는 원본 디스크를 직접 수정하지 않고, 변경된 block만 별도 overlay 파일에 기록하는 방식이다.

ephemeral이 특히 위험하다. 공유 read-only PVC를 backing store로 두고 쓰기는 전부 노드 로컬 COW 이미지에 쌓는 방식인데, 문서에 이렇게 쓰여있다.

KubeVirt dynamically generates the ephemeral images associated with a VM when the VM starts, and discards the ephemeral images when the VM stops.

VM이 멈추면 그동안 쓴 게 사라지고 snapshot으로 남길 수도 없다. 실제 구현이 아래와 같다.

func (ctrl *VMSnapshotController) isVolumeSnapshottable(volume *kubevirtv1.Volume) bool {
	return volume.VolumeSource.PersistentVolumeClaim != nil ||
		volume.VolumeSource.DataVolume != nil ||
		volume.VolumeSource.MemoryDump != nil
}

Snapshot

Snapshot을 뜰 때 KubeVirt가 디스크 block을 직접 복사하는 건 아니다. VM 정의는 복사해두고, 디스크 시점은 CSI의 VolumeSnapshot으로 남겨 storage에 맡긴다.

대신 storage 쪽 준비가 필요하다. VM disk PVC를 만든 StorageClass.provisionerVolumeSnapshotClass.driver가 같아야 한다.

Kubernetes Volume Snapshot API v1과 KubeVirt Snapshot feature gate도 필요하다. KubeVirt v1.9부터 beta feature gate는 기본으로 활성화되지만, 이전 버전 cluster나 Snapshot을 명시적으로 비활성화한 cluster에서는 설정을 확인해야 한다.

참고로 여기서 default StorageClass와 VolumeSnapshotClass는 다른 얘기다.

default StorageClass는 새 PVC를 어느 storage에 만들지 정할 뿐이고 snapshot을 어떤 driver로 만들지는 VolumeSnapshotClass가 정한다.

CephFS가 default여도 matching VolumeSnapshotClass가 없으면 snapshot을 만들 수 없다.

apiVersion: snapshot.kubevirt.io/v1beta1
kind: VirtualMachineSnapshot
metadata:
  name: snap-larry
spec:
  source:
    apiGroup: kubevirt.io
    kind: VirtualMachine
    name: larry

켜져 있는 VM도 snapshot을 뜰 수 있다. 그런데 켜져 있을 때 그대로 뜨면 게스트 입장에서는 전원이 갑자기 나간 것과 같다.

sequenceDiagram
    autonumber
    actor U as kubectl
    participant C as snapshot-controller
    participant V as VM / VMI
    participant H as virt-handler
    participant G as qemu-guest-agent
    participant S as CSI driver

    Note over U,V: Create snapshot
    U->>C: Create VirtualMachineSnapshot
    C->>C: Create VirtualMachineSnapshotContent
    C->>V: Check AgentConnected

    alt Guest agent ready
        Note over C,G: Quiesce guest
        C->>H: Freeze filesystems
        H->>G: fsfreeze
    else Guest agent unavailable
        Note over C,V: NoGuestAgent, crash-consistent
    end

    Note over C,S: Snapshot volumes
    loop Each snapshottable volume
        C->>S: Create VolumeSnapshot
        S-->>C: creationTime set
    end

    opt Guest was frozen
        C->>H: Thaw filesystems
        H->>G: thaw
    end

    Note over C,S: Wait for snapshot readiness
    S-->>C: readyToUse
    C->>C: Set Succeeded + indications

controller는 VMI status의 AgentConnected를 보고 qemu-guest-agent가 붙어 있는지 확인한다. 붙어 있으면 게스트 filesystem을 freeze해서 쓰기를 멈춘 다음 snapshot을 뜨고, 끝나면 풀어준다.

어떻게 떴는지는 status.indications에 남는다.

indication의미
OnlineVM이 켜진 상태에서 떴다
GuestAgentagent가 guest filesystem을 freeze한 filesystem-consistent snapshot이다. application consistency는 자동으로 보장되지 않는다
NoGuestAgentagent가 없거나 준비 안 됨. crash-consistent
QuiesceTimeoutfilesystem freeze에는 성공했지만 snapshot 생성이 끝나기 전에 snapshot creation window가 만료되어 KubeVirt가 자동으로 thaw했다. application consistency는 보장되지 않는다

NoGuestAgent가 붙었다는 건 전원 코드를 뽑은 시점의 디스크를 떴다는 뜻이다. 파일시스템 저널은 복구하겠지만 애플리케이션이 반쯤 쓰다 만 데이터는 그대로 남는다.

database/application consistency가 필요하면 agent만 설치해서는 부족하다. application별 flush 또는 pre/post snapshot hook으로 쓰기 중인 데이터를 안전한 지점까지 반영해야 한다.

phase는 InProgress, Succeeded, Failed, Deleting, Unknown 다섯 가지다. 기본 deadline은 5분이고, 5분 안에 못 끝내면 실패로 처리된다.

Where the Snapshot Lives

스냅샷이 사실상 Restore까지 영향을 주기때문에 더 자세히 보자.

여기서 VolumeSnapshot을 snapshot 데이터 그 자체로 보면 헷갈린다.

VolumeSnapshot은 Pod이 아니고 “이 PVC의 지금 시점을 찍어달라”는 요청과 상태를 담는 namespaced Kubernetes API object다.

source PVC와 VolumeSnapshotClass를 가리킬 뿐, 실제 block이나 file은 이 object 안에 없다.

VolumeSnapshot
  → external-snapshotter
  → CSI CreateSnapshot
  → storage backend snapshot

external-snapshotter가 VolumeSnapshot을 보고 CSI driver의 CreateSnapshot을 호출한다. backend가 snapshot을 만들면 VolumeSnapshotContent가 그 handle을 들고 VolumeSnapshot과 bind된다.

PVC와 PV에 빗대면 VolumeSnapshot은 PVC 쪽, VolumeSnapshotContent는 PV 쪽에 가깝다. 그래도 VolumeSnapshotContent 안에 데이터가 들어가는 건 아니다. 실제 snapshot 데이터는 storage backend에 남는다.

그럼 “snapshot을 지원한다”는 건 뭘까.

  1. cluster에 VolumeSnapshot CRD와 snapshot controller가 있고,
  2. CSI driver가 snapshot RPC를 구현하고,
  3. 같은 driver를 쓰는 VolumeSnapshotClass가 있고,
  4. storage backend가 native snapshot을 만들 수 있어야 한다.

NFS로 mount된다고 해서 이 조건들이 따라오는 건 아니다.

NFS protocol 자체에는 Kubernetes snapshot을 만드는 명령이 없다.

NFS server가 자체 snapshot 기능을 갖고 있어도 CSI driver가 backend API와 연결해주지 않으면 Kubernetes에서는 VolumeSnapshot으로 쓸 수 없다.

반대로 CSI driver가 이 연결을 제공하면 NFS backend도 snapshot 대상이 될 수 있다.

예를 들어 default StorageClass가 CephFS이고 VM disk PVC가 그 class로 만들어졌다고 해보자.

CephFS CSI driver가 같은 Ceph backend에 native snapshot을 만든다. cluster에 따로 mount해둔 NFS로 복사되는 게 아니다.

VM에 CephFS volume과 NFS volume이 함께 붙어 있으면 volume마다 따로 처리한다. CephFS volume은 snapshot에 들어가고 snapshot을 지원하지 않는 NFS volume은 빠질 수 있다. 이 경우 PartialSnapshot indication을 확인해야 한다.

backend의 native snapshot은 보통 기존 데이터를 바로 전부 복사하지 않는다. 원본과 데이터를 공유하다가 이후 쓰기가 들어오면 변경 전 block을 보존하는 식이다.

CephFS, RBD, EBS가 실제로 구현하는 방식은 서로 다르다.

삭제할 때도 Kubernetes object와 실제 데이터는 구분해야 한다. VolumeSnapshotClass.deletionPolicyDeleteVolumeSnapshot을 지울 때 backend snapshot도 같이 지운다.

Retain이면 VolumeSnapshotContent와 backend snapshot을 남기고 수명 관리를 사람이 맡는다.

What to Check

snapshot이 Succeeded로 끝났다고 안심하면 안 된다. 봐야 할 필드가 둘 더 있다.

VirtualMachineSnapshot.status.readyToUse는 이 snapshot으로 바로 복원할 수 있는지를 말한다. 내부 VolumeSnapshot 중 하나라도 없거나 아직 readyToUse가 아니라면 false가 된다.

includedVolumesexcludedVolumes를 보면 무엇이 실제로 들어갔는지 알 수 있다. KubeVirt는 snapshot을 못 뜨는 volume을 만나도 실패하지 않기 때문에 이 필드를 확인해야 한다.

소스 코드를 보면 이렇게 처리한다.

volumeSnapshotClass, err := ctrl.getVolumeSnapshotClassName(*sc)
if err != nil || volumeSnapshotClass == "" {
	log.Log.Warningf("Couldn't find VolumeSnapshotClass for %s", *sc)
	return nil, err
}

StorageClass에 맞는 VolumeSnapshotClass를 못 찾으면 경고 로그만 남기고 그 volume은 조용히 빠진다.

그래도 snapshot 자체는 Succeeded로 끝난다.

다만 제외된 volume 중에 원래는 뜰 수 있었던 것이 하나라도 있으면 PartialSnapshot indication이 붙는다.

excludedVolumes = append(excludedVolumes, volume.Name)
if !hasExcludedSnapshottableVolume && ctrl.isVolumeSnapshottable(&volume) {
	hasExcludedSnapshottableVolume = true
}
...
if hasExcludedSnapshottableVolume {
	ctrl.addPartialSnapshotIndication(snapshot)
}

그런데 ephemeralisVolumeSnapshottable이 false라서 이 플래그를 세우지 않는다.

PartialSnapshot조차 안 붙는다. 루트 디스크가 ephemeral인 VM을 snapshot하면 Succeeded가 뜨는데 정작 루트 디스크는 snapshot에 들어 있지 않다. 이건 kubevirt#17242에 2020년부터 TODO로 남아 있는 미해결 상태다.

Restore

Restore는 snapshot에 담긴 것을 되돌린다. VolumeSnapshot을 dataSource로 삼아 PVC를 새로 만들고, VM 정의를 snapshot 시점 것으로 되돌린다.

apiVersion: snapshot.kubevirt.io/v1beta1
kind: VirtualMachineRestore
metadata:
  name: restore-larry
spec:
  target:
    apiGroup: kubevirt.io
    kind: VirtualMachine
    name: larry
  virtualMachineSnapshotName: snap-larry

target VM이 완전히 멈춰 있어야 한다.

돌아가는 VM의 디스크를 밑에서 갈아치울 수는 없으니까.

sequenceDiagram
    autonumber
    actor U as kubectl
    participant C as restore-controller
    participant V as target VM
    participant K as Kubernetes API
    participant S as CSI driver

    Note over U,V: Prepare restore
    U->>C: Create VirtualMachineRestore
    C->>V: Check fully stopped

    alt Target already stopped
        V-->>C: Ready
    else Target is running
        C->>V: Apply targetReadinessPolicy
        alt Policy reaches stopped state
            V-->>C: Fully stopped
        else Policy fails
            C-->>U: Restore failed
        end
    end

    Note over C,S: Rebuild volumes
    loop Each volume
        C->>C: Apply volumeRestoreOverrides
        C->>K: Create PVC from VolumeSnapshot
        K->>S: Provision from snapshot
        S-->>K: Restored volume ready
    end

    Note over C,V: Restore VM definition
    C->>V: Patch spec from snapshot content
    C-->>U: Ready = True
    U->>V: Start target VM

멈춰 있지 않을 때 어떻게 할지를 targetReadinessPolicy로 고른다.

policy동작
WaitGracePeriod기본값. 5분 기다리고 그래도 안 멈췄으면 실패
StopTargettarget을 멈추고 바로 진행
FailImmediate안 기다리고 즉시 실패
WaitEventuallytarget이 준비될 때까지 restore 객체를 들고 기다림

자동화 파이프라인이라면 StopTarget이 편하고, 사람이 개입할거면 WaitEventually가 안전하다.

기본값인 WaitGracePeriod는 5분이라는 임의의 시간에 성패가 갈리니 의도적으로 고른 게 아니면 피하는 게 낫다.

Volume Naming

복원된 PVC 이름도 policy로 고를 수 있다!

policy이름
RandomizeNames기본값. restore job의 UID로 무작위 생성
InPlace원본을 덮어씀. 기존 PVC가 있으면 지우고 만듦
PrefixTargetName{targetVM}-{volume} 형태. 63자로 잘림

기본값인 RandomizeNames는 충돌이 없는 대신 이름을 예측할 수 없어서 선언적으로 관리하는 환경에서는 매번 다른 이름이 튀어나오니 곤란하다.

그럴 땐 PrefixTargetName이 맞다.

InPlace는 이름 그대로 원본 PVC를 지우고 만든다. 되돌리기의 의미로는 가장 직관적이지만 되돌릴 여지가 없어지니 신중해야 한다.

이름 말고 label이나 annotation을 손보고 싶으면 volumeRestoreOverrides로 볼륨별로 지정한다.

Export

Snapshot과 Restore가 cluster 안에서 몸비틀기 하는 방식이고, Export는 cluster 밖으로 꺼내서 쇼부보는 방식이다.

요청을 받은 controller는 전용 Pod을 하나 띄우고 거기에 PVC를 붙인다. 그 Pod이 HTTPS로 디스크 이미지를 서빙하고, 토큰으로 인증한다.

apiVersion: export.kubevirt.io/v1beta1
kind: VirtualMachineExport
metadata:
  name: example-export
spec:
  source:
    apiGroup: kubevirt.io
    kind: VirtualMachine
    name: example-vm
  ttlDuration: 1h

tokenSecretRef를 생략하면 export-controller가 token을 생성하고 관리한다. 기존 Secret을 참조하려면 해당 Secret을 먼저 만들어야 한다.

sequenceDiagram
    autonumber
    actor U as virtctl
    participant C as export-controller
    participant P as source PVCs
    participant E as export server
    participant R as Service / Ingress

    Note over U,C: Create export
    U->>C: Create token Secret + VirtualMachineExport
    C->>C: Validate source

    alt Running VM
        C-->>U: Pending, source in use
    else Snapshot source
        C->>P: Create PVCs from VolumeSnapshots
    else PVC or stopped VM
        C->>P: Use source PVCs
    end

    C->>E: Start pod and mount PVCs
    C->>R: Expose internal / external links
    C-->>U: Publish status.links

    Note over U,R: Download
    alt Ingress or Route
        U->>R: GET volume + token header
        R->>E: Forward request
        E-->>R: Stream disk image
        R-->>U: Stream disk image
    else Port forward
        U->>E: GET volume + token header
        E-->>U: Stream disk image
    end

    Note over C,E: TTL cleanup, default 2h
    C->>E: Stop export server
    C->>R: Remove links

주요 source는 PersistentVolumeClaim, VirtualMachineSnapshot, VirtualMachine이다. KubeVirt v1.9에서는 feature gate를 활성화하면 VirtualMachineTemplateVirtualMachineBackup도 source로 사용할 수 있다.

실행 중인 VirtualMachine을 source로 지정해도 export object는 만들 수 있다. 다만 VM이 실행 중인 동안 InUse condition과 함께 pending 상태에 머물며 export server는 시작되지 않는다. 활성 export 도중 VM이 시작되면 export는 종료된다. 실행 중인 VM은 먼저 snapshot을 만든 뒤 이를 source로 export하는 편이 낫다.

snapshot을 source로 주면 controller가 VolumeSnapshot들로부터 PVC를 먼저 만들고, 전부 준비되면 export server를 띄운다.

꺼낼 수 있는 볼륨이 하나도 없으면 Skipped로 끝나고 Pod은 아예 안 뜬다.

Not Just the Disk

Export는 디스크만 주는 게 아니라 “이 디스크를 이렇게 붙여라”는 정보까지 같이 준다.

manifest를 내려받으려면 Ingress/Route를 통해 접근 가능한 external export URL이 있거나 --service-url로 URL을 직접 지정해야 한다.

virtctl vmexport download example-export \
  --manifest --include-secret --output=import.yaml

import.yaml에는 export token Secret이 포함된다. 절대 commit하거나 CI artifact로 보관하면 안 된다. 적용을 마치면 로컬 파일을 삭제해야 하며, token lifetime은 export resource와 TTL을 따른다.

VM manifest, DataVolume, CA 인증서가 든 ConfigMap, 그리고 CDI가 알아먹는 형태의 토큰 Secret을 한 덩어리로 만들어줘서 그대로 적용하면 된다.

생성된 VM/DataVolume manifest는 source namespace를 유지하므로 target cluster에 해당 namespace가 미리 있어야 한다.

kubectl apply -f import.yaml --kubeconfig=kubeconfig-target

Clone

phase 순서만 봐도 내부 구현을 알 수 있다.

SnapshotInProgress → RestoreInProgress → CreatingTargetVM → Succeeded

그냥 Snapshot + Restore

sequenceDiagram
    autonumber
    actor U as kubectl
    participant C as clone-controller
    participant S as snapshot-controller
    participant R as restore-controller
    participant V as target VM

    Note over U,S: Snapshot source
    U->>C: Create VirtualMachineClone
    C->>S: Create source VirtualMachineSnapshot
    S-->>C: Succeeded

    Note over C,V: Build target identity
    C->>C: Strip MAC addresses
    C->>C: Regenerate SMBIOS serial
    C->>C: Apply filters and JSON patches

    Note over C,V: Restore as target
    C->>R: Create VirtualMachineRestore
    R->>V: Create PVCs and materialize VM
    R-->>C: Restore Succeeded
    C->>S: Delete intermediate snapshot
    C-->>U: Clone Succeeded

VM을 그대로 복사하면 같은 네트워크에 같은 MAC 주소를 가진 기계가 두 대 생긴다. 같은 SMBIOS serial을 가진 머신도 두 대가 된다.

Clone은 복사하면서 이런 것들을 바꾼다.

kind: VirtualMachineClone
apiVersion: clone.kubevirt.io/v1beta1
metadata:
  name: testclone
spec:
  source:
    apiGroup: kubevirt.io
    kind: VirtualMachine
    name: vm-cirros
  target:
    apiGroup: kubevirt.io
    kind: VirtualMachine
    name: vm-clone-target
  labelFilters:
    - "*"
    - "!someKey/*"
  template:
    annotationFilters:
      - "anotherKey/*"

MAC 주소는 기본적으로 전부 제거된다.

kube-mac-pool이 배포된 cluster라면 새 주소가 자동으로 할당됨

특정 인터페이스에 원하는 값을 박고 싶으면 newMacAddresses로 지정한다.

labelFiltersannotationFilters는 무엇을 따라 보낼지 고르는 glob이다. "*"로 전부 받고 "!someKey/*"로 특정 prefix를 빼는 식이다.

template.labelFilterstemplate.annotationFilters는 VMI template 레벨에 적용된다.

이게 따로 있는 이유가 나름 실용적인데, Kube-OVN이나 OVN-Kubernetes 같은 CNI가 VM의 annotation에 네트워크 정보를 주입하기 때문이다.

그대로 복사하면 clone이 원본과 같은 네트워크를 물게 된다.

When to Use

flowchart LR
    A{Leave the cluster?}
    A -->|Yes| B{VM running?}
    B -->|Yes| S[Snapshot]
    S --> E[Export]
    B -->|No| E
    E --> I[CDI import]

    A -->|No| G{Goal inside the cluster?}
    G -->|Roll back original| R[Restore<br/>target stopped]
    G -->|Create a copy| C[Clone<br/>new identity]
    G -->|Mark a point in time| P[Snapshot]

References