VM基盤を管理するツールとして、KubeVirtがあります。KubeVirtを使うとKubernetes上でコンテナと同じようにVMを管理できます。自宅で簡単にVMを立てられるようにするためにKubeVirtを試してみたので、方法と感想をお伝えします。

KubeVirtとは

VMをmanifestsとして記述すると、KubeVirtのControllerが良い感じにVMを作成してくれます。この時VMはコンテナと同じネットワーク上に存在するので、コンテナとの通信やアクセス制御などもKubernetesの仕組みに基づいて管理できます。

virtctl(kubectl virt)というCLIツールが提供されており、これを用いてVMをstart, stopしたり、ssh, console, vncなどでVMに接続したりできます。

またContainerized Data Importer(CDI)というサブプロジェクトがあります。DataVolumeというPersistentVolumeClaim(PVC)を抽象化したリソースによってVMイメージをダウンロードしたり、さらにDataVolumeをCloneしてVM起動時に使えるようにしたりできます。

アーキテクチャなどについてはこのスライドがとても分かりやすかったので詳しくはこちらにお任せします。

環境

ノードは2つあり、どちらとも仮想化機能が有効化されている状態です。 自宅のKubernetes上のmanifestは全てmy-k8s-clusterで管理しており、ArgoCDでデプロイしています。READMEを見ながらクラスタを作成すれば、(IPアドレスやドメインを除いて)ほぼ同じ環境を作れるはずです。この記事に記載しているコードへのリンクも記載しているので参考にご覧ください。

NFSサーバー

準備として、VMのデータを保存するためのStorageClassとPersistentVolume(PV)を用意します。 今回はノードの一つにNFSサーバーを用意し、NFS CSI driver for Kubernetesを用いてStorageClassとして使えるようにします。 ノード上で以下を実行します。

sudo apt install nfs-kernel-server
sudo mkdir -p /export/nfs
sudo chmod 777 /export/nfs
cat << EOF >> /etc/exports
/export/nfs 192.168.0.0/24(rw,no_root_squash,no_subtree_check)
EOF
sudo systemctl enable nfs-blkmap.service --now
sudo exportfs -a

192.168.0.0/24はノードのあるネットワークです。

そして以下のmanifestをapplyします。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/storage-class/mandoloncello-nfs.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: mandoloncello-nfs
provisioner: nfs.csi.k8s.io
parameters:
  server: mandoloncello.node.internal.onoe.dev # ノードのアドレス
  share: /export/nfs
  mountPermissions: "777"
reclaimPolicy: Retain
volumeBindingMode: Immediate
mountOptions:
  - nfsvers=4.2
allowVolumeExpansion: true

Dynamic Volume Provisioningが有効なので、PVは用意しなくても大丈夫です。Bind待ちのPVCがあるとDriverがPVを作成してBindしてくれます。

Multus

今回はVMをコンテナネットワークだけでなくホストネットワークにも接続したいため、Meta CNI PluginであるMultusを用意します。Multusを使えば、コンテナに複数のNICをattachすることが出来ます。KubeVirtはmultusをネイティブにサポートしており、VMにも同様に複数のNICをattachすることが出来ます。

まず全てのノードでbr0というブリッジを作成します(参考: KVMでホストとブリッジ接続したVMを作成する)。そして 公式の手順に基づいてMultusをapplyし、br0に対応したNetworkAttachmentDefinitionもapplyします。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/multus/bridge.yaml
apiVersion: "k8s.cni.cncf.io/v1"
kind: NetworkAttachmentDefinition
metadata:
  name: underlay-bridge
  namespace: kube-public
spec:
  config: |
    {
      "cniVersion": "0.3.1",
      "name": "underlay-bridge",
      "type": "bridge",
      "bridge": "br0",
      "ipam": {
          "type": "host-local",
          "subnet": "192.168.0.0/24"
      }
    }    

ここで例として適当なnginx Podに以下のannotationを追加してapplyします。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/example/nginx.yaml#L16-L25
      annotations:
        k8s.v1.cni.cncf.io/networks: |
          [
            {
              "name": "underlay-bridge",
              "namespace": "kube-public",
              "interface": "eth1",
              "ips": [ "192.168.0.171" ]
            }
          ]          

nginx Podに入ってIPアドレスを見てみると以下のようになっています。

$ kubectl exec -it nginx-55bb7d4dbd-n4blx -- /bin/bash
root@nginx-55bb7d4dbd-n4blx:/# apt update && apt install iproute2
...
root@nginx-55bb7d4dbd-n4blx:/# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0@if34: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UP group default qlen 1000
    link/ether fe:ee:b0:c3:79:f5 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.10.141.152/32 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::fcee:b0ff:fec3:79f5/64 scope link
       valid_lft forever preferred_lft forever
3: eth1@if35: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether aa:3a:8d:5c:bf:c1 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 192.168.0.171/24 brd 192.168.0.255 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 2400:2650:8022:3c00:a83a:8dff:fe5c:bfc1/64 scope global dynamic mngtmpaddr
       valid_lft 293sec preferred_lft 293sec
    inet6 fe80::a83a:8dff:fe5c:bfc1/64 scope link
       valid_lft forever preferred_lft forever

eth0@if34(10.10.141.152)が通常のコンテナネットワークのものです。それに加えてeth1@if35(192.168.0.171)というNICがあります。これがホストネットワークのものになります。 VMでも同様のことが出来ます。

KubeVirtとCDIのインストール

KubeVirtを公式の手順に基づいてapplyします。 設定manifest(kubevirt.io/v1.KubeVirt)は一部以下のように変更しています。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/kubevirt/kubevirt-cr.yaml
apiVersion: kubevirt.io/v1
kind: KubeVirt
metadata:
  name: kubevirt
  namespace: kubevirt
spec:
  configuration:
    network:
      permitBridgeInterfaceOnPodNetwork: false
    developerConfiguration:
      featureGates:
      - ExpandDisks
  imagePullPolicy: IfNotPresent

デフォルトからの変更点はpermitBridgeInterfaceOnPodNetwork: falseExpandDisksです。両方とも後述します。

CDIも公式の手順に基づいてapplyします。 設定manifest(cdi.kubevirt.io/v1beta1.CDI)は以下のとおりです。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/kubevirt/cdi-cr.yaml
apiVersion: cdi.kubevirt.io/v1beta1
kind: CDI
metadata:
  name: cdi
spec:
  config:
    podResourceRequirements:
      limits:
        cpu: '1'
        memory: 5Gi
  imagePullPolicy: IfNotPresent
  infra:
    nodeSelector:
      kubernetes.io/os: linux
    tolerations:
    - key: CriticalAddonsOnly
      operator: Exists
  workload:
    nodeSelector:
      kubernetes.io/os: linux

デフォルトからの変更点はconfig.podResourceRequirementsです。デフォルトのlimitだと値が小さすぎてVMイメージのダウンロード時にOOMKillされてしまったので変更しています。

VMイメージのダウンロード

VMを作成する前にVMイメージをダウンロードするDataVolumeをapplyします。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-image.yaml
apiVersion: cdi.kubevirt.io/v1beta1
kind: DataVolume
metadata:
  name: ubuntu-image-2404
spec:
  storage:
    accessModes:
      - ReadWriteOnce
    resources:
      requests:
        storage: 5Gi
    storageClassName: mandoloncello-nfs
  source:
    http:
      url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img

今回はUbuntu24.04(noble)を使います。最初はisoファイルを使ってみたのですがbootがうまくいかないのでcloud image(imgファイル)を使います。

ちなみにですがKubeVirtがimageファイルをContainer Diskとしても提供しています。 今回は使いませんが以下のようにContainer DiskからVMを作成することができます。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/example/vm.yaml#L44
      volumes:
        - name: containerdisk
          containerDisk:
            image: quay.io/containerdisks/ubuntu:22.04

VMの作成

ここからVMを定義するmanifestをapplyします。全体像はこちらですが長いので順番に解説していきます。

DataVolume

先ほど作成したVMイメージのためのDataVolumeをCloneしてVM作成に使います。もう一つDataVolumeを定義しても良いのですが、DataVolumeはVMのmanifestにtemplateとして記述できます。以下のように記述すればCloneできます。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L9-L23
  dataVolumeTemplates:
    - metadata:
        name: vm-pg-1
      spec:
        storage:
          accessModes:
            - ReadWriteOnce
          resources:
            requests:
              storage: 64Gi
          storageClassName: mandoloncello-nfs
        source:
          pvc:
            name: ubuntu-image-2404
            namespace: playground

ここで先ほどの設定ExpandDisksが生きてきます。VMイメージのためのDataVolumeは5GiBに設定されている一方で、このDataVolumeは64GiBに設定されています。この時PVCとしては64GiB要求されますが、後に実行されたVMからは5GiBしか見えません。ExpandDisksを設定することによってVMからも64GiB見えるようになります。

Resource

VMが使うCPU, Memoryを設定します。ここで指定したCPU, MemoryがVMから見えます。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L30-L34
      domain:
        cpu:
          cores: 8
        memory:
          guest: 8Gi

また別の箇所で以下のように通常のPodと同じようにrequest, limitも設定できます。これはVMのスケジューリングに使われるものであり、実際にVMから見えるリソースではありません。domain.cpu, domain.memoryの値はrequestsとlimitsの中間である必要があります。ちなみにdomain.cpu, domain.memoryが設定されていないと、resources.requestsの値が代わりにVMから見えます。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L55-L61
        resources:
          requests:
            cpu: 500m
            memory: 512Mi
          limits:
            cpu: '8'
            memory: 8Gi

Volume

以下のようにDiskを設定します。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L36-L45
          disks:
          - disk:
              bus: virtio
            name: disk0
            bootOrder: 1
          - cdrom:
              bus: sata
              readonly: true
            name: cloudinitdisk
            bootOrder: 2

実体は以下のように設定します。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L68-L72
      volumes:
      - name: disk0
        persistentVolumeClaim:
          claimName: vm-pg-1
      - cloudInitNoCloud:

1つ目のdisk0は先ほどのPVCです。2つ目のcloudinitdiskはCloudInitのために使用します。ここでは以下のように設定しています。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L74-L86
          userData: |
            #cloud-config
            hostname: vm-pg-1
            users:
            - name: onoe
              ssh_import_id: gh:hiroyaonoe
              lock_passwd: false
              passwd: $6$salt$IxDD3jeSOb5eB1CX5LBsqZFVkJdido3OUILO5Ifz5iwMuTS4XMS130MTSuDDl3aCI6WouIL9AjRbLCelDCy.g.
              shell: /bin/bash
              sudo: ALL=(ALL) NOPASSWD:ALL
              uid: 1000
            ssh_pwauth: true
            disable_root: false            

Network

先ほど説明した通り、通常のコンテナネットワークに加え、Multusを用いてホストネットワークにも接続します。manifestの記述に加えてCloudInitを用いた設定もします。

# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L46-L52
          interfaces:
          - name: default
            masquerade: {}
            bootOrder: 3
          - name: underlay
            bridge: {}
            bootOrder: 4
# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L62-L67
      networks:
      - name: default
        pod: {}
      - name: underlay
        multus:
          networkName: kube-public/underlay-bridge
# https://github.com/hiroyaonoe/my-k8s-cluster/blob/30daa0da2767d6f4b490b781a1b3f119dd1ac427/argocd/manifests/playground/vm-pg-1.yaml#L87-L95
          networkData: |
            version: 2
            ethernets:
              enp1s0:
                dhcp4: true
              enp2s0:
                dhcp4: false
                addresses: [192.168.0.162/24]
                gateway4: 192.168.0.1            

1つ目のdefault(enp1s0)がコンテナネットワークです。VM実行時に作成されるvirt-launcherというPodを通じてコンテナネットワークと繋がります。masquaradeモードの場合、VMはvirt-launcherと同じネットワーク(コンテナネットワークとは別でありデフォルトは10.0.2.0/24)にいます。VMはDHCPでvirt-launcherからIPアドレスを受け取ります。VMとコンテナネットワーク間の通信はvirt-launcherがNATすることで実現します。ここでmasquaradeモードにしない場合、CNI Pluginによってはうまく通信できない可能性があるらしく、masquaradeモードを強制する設定がpermitBridgeInterfaceOnPodNetwork: falseです。

2つ目のunderlay(enp2s0)がホストネットワークです。先ほど作成したNetworkAttachmentDefinition(namespaceがkube-publicでnameがunderlay-bridge)に関連づけます。CloudInitを用いて静的にアドレスを決定しています。

起動

DataVolumeのダウンロードやCloneが完了した状態でrunning: trueになっていればVirtualMachineInstanceが作成されます。またvirt-launcherというPodが作成されます。このPodがlibvirtdやqemuを使って実際のVMを作成します。virt-launcherは先ほど説明した通りVMのネットワークも管理します。

実際にVMに入ってみます。console, vnc, sshなどいくつか方法がありますが、ここではsshを使います。sshするにもいくつか方法があります。1つ目はvirtctlを用いる方法でvirtctl ssh vm-pg-1で接続できます。2つ目はホストネットワークを通じてsshする方法です。3つ目は22番ポートをNodePort Serviceとして公開し、コンテナネットワークを通じてsshする方法です。基本的にはvirtctlが楽ですが、細かいsshのオプションを付けたいなら2つ目か3つ目が良いでしょう。

実際にVMに入って色々確認します。

onoe@vm-pg-1:~$ lscpu
Architecture:             x86_64
  CPU op-mode(s):         32-bit, 64-bit
  Address sizes:          39 bits physical, 48 bits virtual
  Byte Order:             Little Endian
CPU(s):                   8
  On-line CPU(s) list:    0-7
...

onoe@vm-pg-1:~$ free -h
               total        used        free      shared  buff/cache   available
Mem:           7.8Gi       520Mi       7.2Gi       1.1Mi       300Mi       7.2Gi
Swap:             0B          0B          0B

onoe@vm-pg-1:~$ df -h
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           794M  1.1M  793M   1% /run
/dev/vda1        61G  1.5G   60G   3% /
tmpfs           3.9G     0  3.9G   0% /dev/shm
tmpfs           5.0M     0  5.0M   0% /run/lock
/dev/vda16      881M   61M  758M   8% /boot
/dev/vda15      105M  6.1M   99M   6% /boot/efi
tmpfs           794M   12K  794M   1% /run/user/1000

onoe@vm-pg-1:~$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host noprefixroute
       valid_lft forever preferred_lft forever
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc pfifo_fast state UP group default qlen 1000
    link/ether b6:29:60:82:0b:eb brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.2/24 metric 100 brd 10.0.2.255 scope global dynamic enp1s0
       valid_lft 86301118sec preferred_lft 86301118sec
    inet6 fe80::b429:60ff:fe82:beb/64 scope link
       valid_lft forever preferred_lft forever
3: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 62:d0:72:71:e8:f5 brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.162/24 brd 192.168.0.255 scope global enp2s0
       valid_lft forever preferred_lft forever
    inet6 2400:2650:8022:3c00:60d0:72ff:fe71:e8f5/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 297sec preferred_lft 297sec
    inet6 fe80::60d0:72ff:fe71:e8f5/64 scope link
       valid_lft forever preferred_lft forever

ちゃんと設定できていることがわかりました。

KubeVirtを使ってみた感想

コンテナネットワーク上にVMを立てることができるのはKubeVirtのメリットの一つとして挙げられます。ただし今運用されているVM基盤をKubernetesに移行したいとなった時に、VMのままKubeVirt on Kubernetesに移行するくらいなら、VMをコンテナに置き換えて素直にKubernetesで動かした方が移行コスト・将来の管理コスト含めて楽なのではないかと思いました。どうしてもVMでしか動かせないというユースケースならKubeVirtを使う価値はあるかもしれませんが、そのVMの数が少ないなら個別にネットワークを構築しても良さそうです。

またもう一つのメリットはVMをIaCとして管理できることです。これはTerraformなどでもできることですが、慣れ親しんだKubernetesの仕組みに則って管理できるのは便利です。 ただKubeVirtに限らずPV、StatefulSetなどにも言えることですが、宣言的な構成管理をするKubernetesでStatefulなリソースを管理するのは結構辛いのではないかと思います。KubernetesはReconcileを通じてあるべき姿に収束することを目指しますが、実際にそのリソースが確実に存在することは保証されません。ここら辺は適切に管理できれば問題ないのかもしれませんが、自分としては辛そうに感じました。

ちょっと否定的な感想になってしまいましたが、自分はOpenStackなども触ったことがないですし、プロダクト環境で大規模なVM基盤を運用している方ならもしかしたら違う感想になるかもしれません。

まとめ

KubeVirtやNFS、Multusなどを触れてとても勉強になりました。自宅VM基盤としてはオーバースペックですが、楽しいので今後も運用していきたいと思います。