KubeVirtを使って自宅VM基盤を構築する
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: false
とExpandDisks
です。両方とも後述します。
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基盤としてはオーバースペックですが、楽しいので今後も運用していきたいと思います。