KubeVirt 普通热迁移要求源、目标节点同时访问 PVC,所以绑定 local-path RWO PVC 的 VM 不能直接执行 virtctl migrate。本文先用 CDI 创建 DataVolume,再让 VM 引用 DataVolume;迁移时创建另一个 DataVolume,由 KubeVirt 复制磁盘并切换卷引用。
上一篇实验 测了共享存储和 Kube-OVN 网络。这次只测 local-path,并且不只看 VMIM 是否成功:guest 内持续写盘,外部同时请求 HTTP 服务并保持 TCP 长连接。
VM 没有重启,进程 PID 不变,6,689 次 fdatasync 没有丢序号,最大写入间隔为 114.776ms。网络侧没有这么平滑:TCP 探针超时后重新连接,外部相邻成功响应的最大间隔约为 2.06s。
普通 Live Migration 假设持久盘已经是共享存储。KubeVirt 根据 PVC 的 Access Mode 判断 VMI 是否可迁移,普通迁移遇到 RWO 本地盘会直接拒绝。KubeVirt Live Migration 文档中说明:LiveMigration 只复制实例内存;BlockMigration 表示还有磁盘需要从源端复制到目标端。
Volume Migration 最终仍在两个 PVC 之间复制数据。本文不直接创建 PVC,而是先创建源、目标 DataVolume,由 CDI 生成同名 PVC。VM 需要设置:
1
2
|
spec:
updateVolumesStrategy: Migration
|
VM 通过 volumes[].dataVolume.name 引用源 DataVolume。把这个字段改成目标 DataVolume 后,KubeVirt 创建 VirtualMachineInstanceMigration,复制卷并迁移 VM。目标卷可以使用不同的存储类型,但容量不能小于源卷。其他限制见 Volume Migration 文档。
local-path PVC 仍然只属于一个节点。迁移发生在两个 PVC 之间:
flowchart LR
A["worker<br/>local-source DV/PVC"] -->|"复制磁盘块"| B["worker2<br/>local-target DV/PVC"]
C["源 virt-launcher<br/>guest 继续运行"] -->|"迁移内存与设备状态"| D["目标 virt-launcher<br/>恢复执行"]
实验在一台 Linux 主机上创建临时三节点 kind 集群:
| 组件 |
版本或配置 |
| kind |
v0.32.0 |
| Kubernetes |
v1.35.0,1 个 control-plane、2 个 worker |
| KubeVirt |
v1.9.0 |
| CDI |
v1.66.0 |
| 宿主机内核 |
6.8.0-138-generic |
| 虚拟化 |
/dev/kvm |
| 数据盘 |
两个 DataVolume,各生成一个 ReadWriteOnce、Filesystem、standard PVC |
| VM 网络 |
kind 默认 CNI、KubeVirt masquerade、NodePort Service |
三个 kind 节点都挂载宿主机的 /dev/kvm:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# 创建一个 control-plane 和两个 worker,用于验证跨节点迁移。
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: kv-local-continuity
nodes:
- role: control-plane
image: kindest/node:v1.35.0
extraMounts:
# 把宿主机的 KVM 设备挂进 kind 节点。
- hostPath: /dev/kvm
containerPath: /dev/kvm
- role: worker
image: kindest/node:v1.35.0
extraMounts:
- hostPath: /dev/kvm
containerPath: /dev/kvm
- role: worker
image: kindest/node:v1.35.0
extraMounts:
- hostPath: /dev/kvm
containerPath: /dev/kvm
|
创建集群:
1
|
kind create cluster --config kind.yaml
|
安装 KubeVirt v1.9.0 和 CDI v1.66.0:
1
2
3
4
5
6
7
8
9
|
kubectl apply -f https://github.com/kubevirt/kubevirt/releases/download/v1.9.0/kubevirt-operator.yaml
kubectl apply -f https://github.com/kubevirt/kubevirt/releases/download/v1.9.0/kubevirt-cr.yaml
kubectl -n kubevirt wait kubevirt kubevirt \
--for=condition=Available --timeout=20m
kubectl apply -f https://github.com/kubevirt/containerized-data-importer/releases/download/v1.66.0/cdi-operator.yaml
kubectl apply -f https://github.com/kubevirt/containerized-data-importer/releases/download/v1.66.0/cdi-cr.yaml
kubectl -n cdi wait cdi cdi \
--for=condition=Available --timeout=15m
|
下面是本次实验合并到 KubeVirt CR 的配置文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# 这个文件通过 kubectl patch 合并到已安装的 KubeVirt CR。
apiVersion: kubevirt.io/v1
kind: KubeVirt
metadata:
name: kubevirt
namespace: kubevirt
spec:
configuration:
# 允许运行中的 VM 通过热更新应用配置。
vmRolloutStrategy: LiveUpdate
workloadUpdateStrategy:
# Volume Migration 依赖 LiveMigrate 更新方式。
workloadUpdateMethods:
- LiveMigrate
|
1
2
3
4
|
kubectl -n kubevirt patch kubevirt kubevirt \
--type=merge --patch-file kubevirt-config.yaml
kubectl -n kubevirt wait kubevirt kubevirt \
--for=condition=Available --timeout=5m
|
每个文件只放一个资源。把下面的代码块按标题保存到当前目录即可。CDI 根据 DataVolume 创建同名 PVC;两个临时 Pod 只用于让 standard StorageClass 分别在两个 worker 上创建本地 PV。
DataVolume 既可以作为系统盘,也可以作为数据盘。本文保留 containerDisk 作为启动盘,只迁移承载写入探针的数据盘,避免镜像导入过程影响连续性数据。VM 关联 DataVolume 的字段和迁移方式相同,都是 volumes[].dataVolume.name。
1
2
3
4
5
|
# 实验资源统一放在独立命名空间,便于清理。
apiVersion: v1
kind: Namespace
metadata:
name: vm-local-migration-test
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# 源 DataVolume 由 CDI 创建同名 PVC,并生成空白数据盘。
apiVersion: cdi.kubevirt.io/v1beta1
kind: DataVolume
metadata:
name: local-source
namespace: vm-local-migration-test
spec:
source:
blank: {}
pvc:
accessModes:
- ReadWriteOnce
volumeMode: Filesystem
# kind 默认的 standard StorageClass 使用本地存储。
storageClassName: standard
resources:
requests:
storage: 1Gi
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# 目标 DataVolume 使用相同容量,稍后固定到第二个 worker。
apiVersion: cdi.kubevirt.io/v1beta1
kind: DataVolume
metadata:
name: local-target
namespace: vm-local-migration-test
spec:
source:
blank: {}
pvc:
accessModes:
- ReadWriteOnce
volumeMode: Filesystem
storageClassName: standard
resources:
requests:
storage: 1Gi
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# kind 没有业务侧的调度逻辑,用临时 Pod 把源 DV 固定到第一个 worker。
apiVersion: v1
kind: Pod
metadata:
name: prepare-source
namespace: vm-local-migration-test
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: kv-local-continuity-worker
containers:
- name: prepare
image: quay.io/quay/busybox:latest
command:
- /bin/true
volumeMounts:
- name: disk
mountPath: /data
volumes:
- name: disk
persistentVolumeClaim:
claimName: local-source
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# 用临时 Pod 把目标 DV 固定到第二个 worker,确保迁移跨节点发生。
apiVersion: v1
kind: Pod
metadata:
name: prepare-target
namespace: vm-local-migration-test
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: kv-local-continuity-worker2
containers:
- name: prepare
image: quay.io/quay/busybox:latest
command:
- /bin/true
volumeMounts:
- name: disk
mountPath: /data
volumes:
- name: disk
persistentVolumeClaim:
claimName: local-target
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
# VM 的数据盘通过 DataVolume 名称关联,而不是直接引用 PVC。
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
name: local-continuity-vm
namespace: vm-local-migration-test
spec:
runStrategy: Always
# 修改 DataVolume 引用时,要求 KubeVirt 执行 Volume Migration。
updateVolumesStrategy: Migration
template:
metadata:
labels:
kubevirt.io/domain: local-continuity-vm
spec:
evictionStrategy: LiveMigrate
domain:
resources:
requests:
memory: 256Mi
devices:
disks:
- name: rootdisk
disk:
bus: virtio
- name: datadisk
disk:
bus: virtio
- name: cloudinitdisk
disk:
bus: virtio
interfaces:
- name: default
masquerade: {}
networks:
- name: default
pod: {}
volumes:
- name: rootdisk
containerDisk:
image: quay.io/kubevirt/cirros-container-disk-demo:latest
- name: datadisk
dataVolume:
name: local-source
- name: cloudinitdisk
cloudInitNoCloud:
# 仅供临时实验登录,不要在生产环境使用明文密码。
userData: |
#cloud-config
password: gocubsgo
chpasswd: { expire: false }
ssh_pwauth: true
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
# 通过 kind 节点的固定端口访问 VM 内的 SSH、HTTP 和 TCP 探针。
apiVersion: v1
kind: Service
metadata:
name: local-continuity-vm
namespace: vm-local-migration-test
spec:
type: NodePort
selector:
kubevirt.io/domain: local-continuity-vm
ports:
- name: ssh
port: 22
targetPort: 22
nodePort: 30022
- name: http
port: 8080
targetPort: 8080
nodePort: 30080
- name: tcp-echo
port: 9000
targetPort: 9000
nodePort: 30090
|
先创建源 DataVolume。等待它和临时 Pod 完成后,再创建引用源 DV 的 VM:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
kubectl apply -f namespace.yaml
kubectl apply -f source-dv.yaml
kubectl apply -f prepare-source.yaml
kubectl -n vm-local-migration-test wait dv/local-source \
--for=condition=Ready --timeout=5m
kubectl -n vm-local-migration-test wait pod/prepare-source \
--for=jsonpath='{.status.phase}'=Succeeded --timeout=5m
kubectl -n vm-local-migration-test delete pod prepare-source --wait=true
kubectl apply -f vm.yaml
kubectl apply -f service.yaml
kubectl -n vm-local-migration-test wait \
--for=create vmi/local-continuity-vm --timeout=2m
kubectl -n vm-local-migration-test wait vmi/local-continuity-vm \
--for=condition=Ready --timeout=5m
|
VM 运行后,再创建目标 DataVolume,并把它固定到第二个 worker:
1
2
3
4
5
6
7
8
9
|
kubectl apply -f target-dv.yaml
kubectl apply -f prepare-target.yaml
kubectl -n vm-local-migration-test wait dv/local-target \
--for=condition=Ready --timeout=5m
kubectl -n vm-local-migration-test wait pod/prepare-target \
--for=jsonpath='{.status.phase}'=Succeeded --timeout=5m
kubectl -n vm-local-migration-test delete pod prepare-target --wait=true
|
此时两个 DataVolume 都是 Succeeded,对应 PVC 的节点关系为:
1
2
|
local-source -> kv-local-continuity-worker
local-target -> kv-local-continuity-worker2
|
CDI 为 blank DataVolume 创建的空白盘包含大量未分配块,所以本文不比较磁盘吞吐量。
只比较文件内容无法排除 VM 重启,因此还要记录 boot ID、进程 PID 和外部连接。
系统启动时会生成 /proc/sys/kernel/random/boot_id。迁移前后值相同,说明 guest 没有重启。
VM 内同时运行三个长期进程:
- 每 10ms 写盘并调用
fdatasync 的探针。
- TCP echo server。
- 每次请求返回
probe-ok 的 HTTP server。
迁移前记录 PID,迁移后检查对应的 /proc/<pid>。
写盘探针使用静态链接的 C 程序。每次写入包括递增序号和 CLOCK_REALTIME 纳秒时间戳,写完立即调用 fdatasync:
1
2
3
4
5
6
7
8
|
while (running) {
clock_gettime(CLOCK_REALTIME, &now);
snprintf(line, sizeof(line), "%llu,%lld%09ld\n",
++sequence, now.tv_sec, now.tv_nsec);
write_all(fd, line, strlen(line));
fdatasync(fd);
nanosleep(&interval_10ms, NULL);
}
|
迁移后检查序号和相邻时间戳。间隔还包含 guest 调度和 fdatasync 延迟,只能视为 guest 可见停顿的上界,不能当作 QEMU 的精确 downtime。
VM 通过 NodePort Service 暴露两个端口:
- HTTP 探针每 10ms 建立一个新连接并发起请求,单次超时 500ms。
- TCP 探针保持一条 echo 长连接,每 10ms 发送一次序号;发生错误后才重新连接。
探针经过完整的服务访问路径:
1
|
m6 -> kind NodePort -> Kubernetes Service -> virt-launcher -> masquerade -> guest
|
这组数据用于区分 guest 停顿和入口网络切换。
先尝试普通迁移:
1
|
virtctl -n vm-local-migration-test migrate local-continuity-vm
|
准入检查按预期拒绝:
1
2
3
|
Cannot migrate VMI, Reason: DisksNotLiveMigratable,
Message: cannot migrate VMI: PVC local-source is not shared,
live migration requires that all PVCs must be shared
|
然后把 VM 数据卷改为目标 DataVolume:
1
2
3
|
kubectl -n vm-local-migration-test patch vm local-continuity-vm \
--type=json \
-p='[{"op":"replace","path":"/spec/template/spec/volumes/1/dataVolume/name","value":"local-target"}]'
|
提交 PATCH 后,KubeVirt 自动创建 VMIM。最终状态如下:
1
2
3
4
|
phase=Succeeded
pre_node=kv-local-continuity-worker
post_node=kv-local-continuity-worker2
method=BlockMigration completed=true
|
libvirt 使用 PreCopy,没有启用自动收敛、post-copy 或额外的工作负载中断:
1
2
3
4
5
|
mode=PreCopy
allowAutoConverge=false
allowPostCopy=false
allowWorkloadDisruption=false
maxDowntimeMs=900
|
Pre-copy 先创建目标 VM,guest 继续在源端运行;状态复制完成后,guest 才切到目标端。KubeVirt Live Migration 策略说明建议大多数场景使用这种方式。
迁移前后读取结果一致:
| 信号 |
迁移前 |
迁移后 |
| boot ID |
ef3b014e-1546-4de0-862c-fb37168c0c0d |
相同 |
| fdatasync 探针 PID |
665 |
665,存活 |
| TCP server PID |
666 |
666,存活 |
| HTTP server PID |
667 |
667,存活 |
| 指标 |
结果 |
| 总落盘记录 |
6,689 |
| 首尾序号 |
1 到 6689 |
| 丢失序号 |
0 |
| 最大相邻落盘间隔 |
114.776ms |
最大间隔出现在迁移开始后的约 13.94s,接近最终切换阶段:
1
2
|
5501,1788687461340871853
5502,1788687461455647847
|
迁移后还能继续创建并同步新文件:
1
|
post-migration-write-ok
|
这组顺序追加写没有丢数据,但它不能替代数据库事务和故障恢复测试,也不覆盖宿主机掉电。
| 探针 |
总次数 |
成功 |
超时 |
相邻成功响应最大间隔 |
连接数 |
| HTTP 新连接 |
5,502 |
5,494 |
8 |
2059.871ms |
每次新建 |
| TCP 长连接 |
5,890 |
5,886 |
4 |
2060.876ms |
2 |
切换附近,连接 1 上的一次请求在 500ms 内没有响应,探针随即关闭它。三次重连超时后,连接 2 建立成功。由于探针主动关闭了原 socket,本轮无法判断它是否会自行恢复,只能确认客户端没有无感通过切换。
HTTP 超时也集中在切换附近,但不是连续失败。失败时段与源 virt-launcher、目标 virt-launcher 和 EndpointSlice 的交接重合;迁移完成后观察到:
1
2
|
source virt-launcher: Completed, endpoint ready=false
target virt-launcher: Running, endpoint ready=true
|
外部路径在切换阶段抖动约 2 秒,明显长于 guest 内约 115ms 的最大写入间隔。问题可能出在 NodePort、Service endpoint、virt-launcher 或 masquerade 的交接;由于没有抓包,本文不再细分。
sequenceDiagram
participant Client as 外部探针
participant Source as 源 virt-launcher
participant VM as guest
participant Target as 目标 virt-launcher
Client->>Source: HTTP 和 TCP 流量
Source->>VM: guest 继续运行并 fdatasync
Source-->>Target: 复制磁盘、内存和设备状态
Note over VM: 最大写入间隔约 114.8ms
Source--xClient: 原连接未按时响应
Target->>VM: 在目标节点继续执行
Client->>Target: 重连后恢复
Note over Client,Target: 相邻成功响应最大间隔约 2.06s
本轮只说明:在 KubeVirt v1.9.0、Pre-copy 和低写入压力下,local-path Volume Migration 能保留 guest 与进程状态,持续同步写盘也没有丢序号。网络连续性要单独评估,不能用 VMIM Succeeded 代替业务可用性测试。
测试盘是稀疏文件,写入量也很小。高内存 dirty rate 或高磁盘写入速率可能让 Pre-copy 无法收敛。当前 allowWorkloadDisruption=false,超时后迁移应失败或取消;启用 post-copy 或允许暂停后,需要重新评估故障风险和停顿时间。
上线前至少测试:
- 接近真实容量和已分配数据量的磁盘。
- 数据库或实际业务的峰值写入速率。
- 业务自己的 P99/P999 延迟和超时设置。
- 生产 CNI、Service、LB 和客户端重试策略。
- 迁移失败、取消以及
ManualRecoveryRequired 的恢复流程。
实验完成后删除临时 kind 集群:
1
|
kind delete cluster --name kv-local-continuity
|