
# KubeVirt local-path 卷迁移实测：进程、I/O 与网络连续性

KubeVirt 普通热迁移要求源、目标节点同时访问 PVC，所以绑定 local-path RWO PVC 的 VM 不能直接执行 `virtctl migrate`。本文先用 CDI 创建 DataVolume，再让 VM 引用 DataVolume；迁移时创建另一个 DataVolume，由 KubeVirt 复制磁盘并切换卷引用。

[上一篇实验](/posts/kubevirt-kubeovn-kind-live-migration/) 测了共享存储和 Kube-OVN 网络。这次只测 local-path，并且不只看 VMIM 是否成功：guest 内持续写盘，外部同时请求 HTTP 服务并保持 TCP 长连接。

VM 没有重启，进程 PID 不变，6,689 次 `fdatasync` 没有丢序号，最大写入间隔为 `114.776ms`。网络侧没有这么平滑：TCP 探针超时后重新连接，外部相邻成功响应的最大间隔约为 `2.06s`。

<!--more-->

## 1. 普通热迁移与 Volume Migration

普通 Live Migration 假设持久盘已经是共享存储。KubeVirt 根据 PVC 的 Access Mode 判断 VMI 是否可迁移，普通迁移遇到 RWO 本地盘会直接拒绝。[KubeVirt Live Migration 文档](https://kubevirt.io/user-guide/compute/live_migration/)中说明：`LiveMigration` 只复制实例内存；`BlockMigration` 表示还有磁盘需要从源端复制到目标端。

Volume Migration 最终仍在两个 PVC 之间复制数据。本文不直接创建 PVC，而是先创建源、目标 [DataVolume](https://github.com/kubevirt/containerized-data-importer/blob/main/doc/datavolumes.md)，由 CDI 生成同名 PVC。VM 需要设置：

```yaml
spec:
  updateVolumesStrategy: Migration
```

VM 通过 `volumes[].dataVolume.name` 引用源 DataVolume。把这个字段改成目标 DataVolume 后，KubeVirt 创建 `VirtualMachineInstanceMigration`，复制卷并迁移 VM。目标卷可以使用不同的存储类型，但容量不能小于源卷。其他限制见 [Volume Migration 文档](https://kubevirt.io/user-guide/storage/volume_migration/)。

local-path PVC 仍然只属于一个节点。迁移发生在两个 PVC 之间：

```mermaid
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/>恢复执行"]
```

## 2. 实验环境

实验在一台 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 |

### 2.1 kind.yaml

三个 kind 节点都挂载宿主机的 `/dev/kvm`：

```yaml
# 创建一个 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
```

创建集群：

```bash
kind create cluster --config kind.yaml
```

### 2.2 kubevirt-config.yaml

安装 KubeVirt v1.9.0 和 CDI v1.66.0：

```bash
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 的配置文件：

```yaml
# 这个文件通过 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
```

```bash
kubectl -n kubevirt patch kubevirt kubevirt \
  --type=merge --patch-file kubevirt-config.yaml
kubectl -n kubevirt wait kubevirt kubevirt \
  --for=condition=Available --timeout=5m
```

### 2.3 业务资源

每个文件只放一个资源。把下面的代码块按标题保存到当前目录即可。CDI 根据 DataVolume 创建同名 PVC；两个临时 Pod 只用于让 `standard` StorageClass 分别在两个 worker 上创建本地 PV。

DataVolume 既可以作为系统盘，也可以作为数据盘。本文保留 `containerDisk` 作为启动盘，只迁移承载写入探针的数据盘，避免镜像导入过程影响连续性数据。VM 关联 DataVolume 的字段和迁移方式相同，都是 `volumes[].dataVolume.name`。

#### namespace.yaml

```yaml
# 实验资源统一放在独立命名空间，便于清理。
apiVersion: v1
kind: Namespace
metadata:
  name: vm-local-migration-test
```

#### source-dv.yaml

```yaml
# 源 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
```

#### target-dv.yaml

```yaml
# 目标 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
```

#### prepare-source.yaml

```yaml
# 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
```

#### prepare-target.yaml

```yaml
# 用临时 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
```

#### vm.yaml

```yaml
# 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
```

#### service.yaml

```yaml
# 通过 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：

```bash
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：

```bash
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 的节点关系为：

```text
local-source  -> kv-local-continuity-worker
local-target  -> kv-local-continuity-worker2
```

CDI 为 `blank` DataVolume 创建的空白盘包含大量未分配块，所以本文不比较磁盘吞吐量。

## 3. 探针设计

只比较文件内容无法排除 VM 重启，因此还要记录 boot ID、进程 PID 和外部连接。

### 3.1 确认 guest 没有重启

系统启动时会生成 `/proc/sys/kernel/random/boot_id`。迁移前后值相同，说明 guest 没有重启。

VM 内同时运行三个长期进程：

- 每 10ms 写盘并调用 `fdatasync` 的探针。
- TCP echo server。
- 每次请求返回 `probe-ok` 的 HTTP server。

迁移前记录 PID，迁移后检查对应的 `/proc/<pid>`。

### 3.2 观察 fdatasync 间隔

写盘探针使用静态链接的 C 程序。每次写入包括递增序号和 `CLOCK_REALTIME` 纳秒时间戳，写完立即调用 `fdatasync`：

```c
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。

### 3.3 观察服务访问

VM 通过 NodePort Service 暴露两个端口：

- HTTP 探针每 10ms 建立一个新连接并发起请求，单次超时 500ms。
- TCP 探针保持一条 echo 长连接，每 10ms 发送一次序号；发生错误后才重新连接。

探针经过完整的服务访问路径：

```text
m6 -> kind NodePort -> Kubernetes Service -> virt-launcher -> masquerade -> guest
```

这组数据用于区分 guest 停顿和入口网络切换。

## 4. 触发迁移

先尝试普通迁移：

```bash
virtctl -n vm-local-migration-test migrate local-continuity-vm
```

准入检查按预期拒绝：

```text
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：

```bash
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。最终状态如下：

```text
phase=Succeeded
pre_node=kv-local-continuity-worker
post_node=kv-local-continuity-worker2
method=BlockMigration completed=true
```

libvirt 使用 `PreCopy`，没有启用自动收敛、post-copy 或额外的工作负载中断：

```text
mode=PreCopy
allowAutoConverge=false
allowPostCopy=false
allowWorkloadDisruption=false
maxDowntimeMs=900
```

Pre-copy 先创建目标 VM，guest 继续在源端运行；状态复制完成后，guest 才切到目标端。[KubeVirt Live Migration 策略说明](https://kubevirt.io/user-guide/compute/live_migration/#understanding-different-migration-strategies)建议大多数场景使用这种方式。

## 5. 实验结果

### 5.1 boot ID 和 PID

迁移前后读取结果一致：

| 信号 | 迁移前 | 迁移后 |
| --- | --- | --- |
| boot ID | `ef3b014e-1546-4de0-862c-fb37168c0c0d` | 相同 |
| fdatasync 探针 PID | `665` | `665`，存活 |
| TCP server PID | `666` | `666`，存活 |
| HTTP server PID | `667` | `667`，存活 |

### 5.2 fdatasync 序列

| 指标 | 结果 |
| --- | ---: |
| 总落盘记录 | 6,689 |
| 首尾序号 | `1` 到 `6689` |
| 丢失序号 | 0 |
| 最大相邻落盘间隔 | `114.776ms` |

最大间隔出现在迁移开始后的约 `13.94s`，接近最终切换阶段：

```text
5501,1788687461340871853
5502,1788687461455647847
```

迁移后还能继续创建并同步新文件：

```text
post-migration-write-ok
```

这组顺序追加写没有丢数据，但它不能替代数据库事务和故障恢复测试，也不覆盖宿主机掉电。

### 5.3 HTTP 与 TCP

| 探针 | 总次数 | 成功 | 超时 | 相邻成功响应最大间隔 | 连接数 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 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 的交接重合；迁移完成后观察到：

```text
source virt-launcher: Completed, endpoint ready=false
target virt-launcher: Running,   endpoint ready=true
```

外部路径在切换阶段抖动约 2 秒，明显长于 guest 内约 115ms 的最大写入间隔。问题可能出在 NodePort、Service endpoint、virt-launcher 或 masquerade 的交接；由于没有抓包，本文不再细分。

```mermaid
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
```

## 6. 使用边界

本轮只说明：在 KubeVirt v1.9.0、Pre-copy 和低写入压力下，local-path Volume Migration 能保留 guest 与进程状态，持续同步写盘也没有丢序号。网络连续性要单独评估，不能用 VMIM `Succeeded` 代替业务可用性测试。

测试盘是稀疏文件，写入量也很小。高内存 dirty rate 或高磁盘写入速率可能让 Pre-copy 无法收敛。当前 `allowWorkloadDisruption=false`，超时后迁移应失败或取消；启用 post-copy 或允许暂停后，需要重新评估故障风险和停顿时间。

上线前至少测试：

1. 接近真实容量和已分配数据量的磁盘。
2. 数据库或实际业务的峰值写入速率。
3. 业务自己的 P99/P999 延迟和超时设置。
4. 生产 CNI、Service、LB 和客户端重试策略。
5. 迁移失败、取消以及 `ManualRecoveryRequired` 的恢复流程。

## 7. 清理

实验完成后删除临时 kind 集群：

```bash
kind delete cluster --name kv-local-continuity
```

## 参考资料

- [KubeVirt：Update volume strategy and volume migration](https://kubevirt.io/user-guide/storage/volume_migration/)
- [KubeVirt：Live Migration](https://kubevirt.io/user-guide/compute/live_migration/)
- [KubeVirt：Service objects](https://kubevirt.io/user-guide/network/service_objects/)
- [KubeVirt v1.9.0 Release Notes](https://kubevirt.io/user-guide/release_notes/#v190)

