From bee363c4a34bb1f197f38513db17d560fa78bb1f Mon Sep 17 00:00:00 2001 From: "tao.gan" Date: Wed, 27 May 2026 10:13:57 +0800 Subject: [PATCH] [docs]: 关联工单:[ZSPHER-244](http://jira.zstack.io/browse/ZSPHER-244) — 更改数据存储支持数据盘指定不同目标 +> 关联前置:[ZSV-12280](http://jira.zstack.io/browse/ZSV-12280) — SharedBlock 多数据盘热迁移 +> 状态:设计阶段 +> 决策建议:方案 B(新增独立 API) + +--- + +## 1. 背景 + +`APIPrimaryStorageMigrateVolumeMsg` 当前承担的语义已经叠了两层: + +| 演进阶段 | 字段 | 适用场景 | +|---------|------|---------| +| 最早 | `volumeUuid`(path) + `dstPrimaryStorageUuid` | 单盘冷迁移(VM Stopped 或未挂载) | +| ZSV-12280(已落) | `volumeUuids: List` + `dstPrimaryStorageUuid` | 多盘热迁移(VM Running/Paused,**统一目标 PS**) | +| ZSPHER-244(待做) | 每盘独立目标:`List<{volumeUuid, dstPsUuid}>` | 多盘**冷迁移**(VM Stopped,**每盘不同目标**) | + +三种语义共用一个 API 已经出现"互斥字段"问题(`volumeUuid` 与 `volumeUuids` 必须二选一/包含关系)。ZSPHER-244 是否继续叠加,需要明确决策。 + +--- + +## 2. 方案 A:继续给 `APIPrimaryStorageMigrateVolumeMsg` 加参数 + +新增字段 `volumeMigrationSpecs: List`。 + +### 优点 +- 前端入口统一(一个 API 处理 volume 迁移所有场景) +- SDK 兼容:旧字段保留 +- LongJob 调度复用现有 `PrimaryStorageMigrateVolumeJob` + +### 缺点(多数已经显现) +1. **互斥字段爆炸**:`volumeUuid` / `volumeUuids` / `volumeMigrationSpecs` / `dstPrimaryStorageUuid` 之间需要 5+ 条互斥规则,Interceptor 校验逻辑将剧增。 +2. **语义模糊**:同一 API 既是热迁移又是冷迁移,既支持单目标又支持多目标——文档和 SDK 注释难写。 +3. **路径占位符 `volumeUuid` 越来越尴尬**:当请求是「按盘指定目标」时,path 上的 volumeUuid 是哪一块?需要继续打补丁说"必须是 specs 中的某一块"。 +4. **审计/事件 inventory 形状不一致**:单目标返回 `VolumeInventory`,多目标返回 `List`。老调用方期望单个,新调用方期望列表,已经在用 `inventories.get(0)` 凑活。 +5. **MN 升级风险**:当前 `PrimaryStorageMigrateVolumeJob.start` 用 `JSONObjectUtil.toObject` 反序列化 jobData,加字段虽兼容但分支判断越来越复杂。 + +--- + +## 3. 方案 B:新增独立 API `APIPrimaryStorageMigrateDataVolumesMsg` + +专门承载 ZSPHER-244 语义: + +```java +@Action(category = VolumeConstant.ACTIONS) +public class APIPrimaryStorageMigrateDataVolumesMsg extends APIMessage { + @APIParam(resourceType = VmInstanceVO.class) + private String vmInstanceUuid; + + @APIParam(nonempty = true) + private List volumeMigrationSpecs; + // VolumeMigrationSpec: volumeUuid + dstPrimaryStorageUuid (+ optional withSnapshots) +} +``` + +### 优点 +1. **语义干净**:一个 API 一种语义,文档/SDK/审计天然清晰。 +2. **Interceptor 简单**:只校验自己的字段,与老 API 完全解耦。 +3. **入口分流**:冷迁移多目标走新 Msg → 新 Job(`PrimaryStorageMigrateDataVolumesJob`);老 API 不动,回归"经典单盘迁移"角色。 +4. **Inventory 形状自然**:新事件直接返回 `List`,不用兼容老形状。 +5. **演进空间**:未来 ZSPHER-244 的衍生需求(按盘选 withSnapshots、按盘选 strategy 等)只在新 Msg 上扩展,不污染老路径。 +6. **可独立灰度**:新 API 出问题不影响存量。 + +### 缺点 +1. 前端要多一个 API 调用入口(但 UI 本来就是新的高级选项面板,天然走新接口)。 +2. SDK 需要重新生成(项目里本来就是必经流程)。 +3. 多了一个 LongJob 类(但本来就需要新逻辑,写在哪里都一样)。 + +--- + +## 4. 同类先例参考 + +ZStack 自身的 API 演进经验: + +- `APIMigrateVmMsg`(冷)/ `APILiveMigrateVmMsg`(热)—— **是分开的两个 API**,没有共用 `migrateVm` 加 `live: boolean`。 +- `APIChangeVmHaPolicyMsg` / `APISetVmInstanceDefaultCdRomMsg` 这类语义独立的操作,从不强行复用旧 API。 +- 当年 `volumeUuids` 加到 `APIPrimaryStorageMigrateVolumeMsg` 已经是历史包袱(即 ZSV-12280 当前进行中的工作),现在如果再叠一层 multi-target,会让这个 API 彻底变成"什么都能干的 god API"。 + +--- + +## 5. 推荐:方案 B(新增独立 API) + +### 关键理由 +1. ZSPHER-244 是 **冷迁移 + 每盘独立目标**,与 ZSV-12280 的 **热迁移 + 统一目标** 实际是两条完全独立的执行链路: + - 冷迁移:`MigrateVolumeOnPrimaryStorageMsg` fan-out(每盘可走不同目标 PS) + - 热迁移:KVM live storage migration workflow(per-volume LUN switch) + + 共用 API 只是表面省事,后端早晚要按字段分流。 + +2. **Interceptor 复杂度上限**:现在 `validateLiveMigrateMultiDataVolumes` 已经接近 80 行,再叠一种语义会到 150+ 行,可维护性崩盘。 + +3. **老 API 已经背了一个互斥规则补丁**(path `volumeUuid` 必须 ∈ body `volumeUuids`),再加规则会让前端联调和文档维护痛苦。 + +### 建议落地形态 + +- 新增 `APIPrimaryStorageMigrateDataVolumesMsg` + `APIPrimaryStorageMigrateDataVolumesEvent` +- 新增 `PrimaryStorageMigrateDataVolumesJob`(LongJob),内部串行/并行调用现有 `MigrateVolumeOnPrimaryStorageMsg`(每盘一个目标 PS) +- Interceptor 单独写 `validate(APIPrimaryStorageMigrateDataVolumesMsg)`,与 multi-volume 热迁移校验完全分离 +- 老 `APIPrimaryStorageMigrateVolumeMsg.volumeUuids` 路径继续仅服务 ZSV-12280 热迁移场景,不改动 + +### 边界情况 +- 如果 PD 要求"按盘选目标"也能用于**热迁移**,再单独评估是否要在新 API 上加 `live: boolean`,或者再开一个 `APILiveMigrateDataVolumesToDifferentTargets`。 +- 但 ZSPHER-244 原文明确写「SAN ↔ SAN **冷迁移**」,目前不必预留。 + +--- + +## 6. 决策待办 + +- [ ] 与 PD 确认 ZSPHER-244 是否仅冷迁移场景 +- [ ] 与前端确认是否接受新 API(UI 本身就是新面板,预计无阻力) +- [ ] 与架构组评审字段命名 `volumeMigrationSpecs` vs `volumeTargets` +- [ ] 确认审计 inventory 形状(List 返回是否需要追加 `failedVolumes` 字段) diff --git a/docs/modules/core/pages/primary-storage-migrate-volume.md b/docs/modules/core/pages/primary-storage-migrate-volume.md new file mode 100644 index 00000000000..bcfe545282a --- /dev/null +++ b/docs/modules/core/pages/primary-storage-migrate-volume.md @@ -0,0 +1,165 @@ +# APIPrimaryStorageMigrateVolumeMsg 冷迁移数据盘逻辑分析 + +## 方案速览(TL 确认) + +- 目标:开放 Ceph/NFS 不卸盘冷迁 +- 触发条件:VM 必须 Stopped +- 校验:dstPS 需共享 VM 网络集群 +- 候选 PS:挂载态按 VM nic L2 过滤 +- 老语义:未挂载/Root 行为不变 +- SB 已支持 不变(沿用旧路径) +- 风险拆分:startVm 并发另起 MR +- 工单:code-ZSV-12280(已实现) + +--- + +本文整理 `APIPrimaryStorageMigrateVolumeMsg` 在各存储类型下的限制与执行流程,重点说明 Ceph / NFS / SharedBlock 对数据盘冷迁移的差异化约束及其背后的设计动机。 + +## 一、API 概览 + +- **类位置**: `premium/mevoco/src/main/java/org/zstack/storage/migration/primary/APIPrimaryStorageMigrateVolumeMsg.java` +- **REST**: `PUT /primary-storage/volumes/{volumeUuid}/actions` +- **默认超时**: 72 小时 +- **入参**: `volumeUuid`、`dstPrimaryStorageUuid` +- **APINoSee(拦截器自动填充)**: `srcPrimaryStorageUuid`、`type`、`vmInstanceUuid` + +## 二、API 拦截校验(`StorageMigrationApiInterceptor`) + +### 通用约束 + +1. 源/目标 PS 不能为 `Disabled` 或 `Maintenance` +2. 卷 `Status == Ready`、`State != Disabled` +3. `srcPS == dstPS` 时仅 **Ceph** 允许(支持同 PS 换 pool) + +### 数据盘专属约束 + +```java +if (srcVolume.getVmInstanceUuid() != null) { + if (!srcPS.isSharedBlock()) { + throw "please detach it before migration"; // Ceph / NFS + } else if (state != Stopped) { + throw "vm is not stopped"; // SharedBlock + } +} +``` + +- **Ceph / NFS**: 数据盘只要有挂载关系就拒绝(不看 VM 状态) +- **SharedBlock**: 允许挂载,但 VM 必须 Stopped +- **Shareable 共享卷**: 一律拒绝 + +### 存储类型矩阵 + +定义于 `conf/springConfigXml/storageMigration.xml` 的 `primaryStoragePrimaryStorageMetrics`: + +| 源 → 目标 | 是否允许 | +|---|---| +| Ceph → Ceph | ✅ | +| NFS → NFS | ✅ | +| SharedBlock → SharedBlock | ✅(要求共享 cluster) | +| Local → Local | ❌ 单卷 API 不支持 | +| 跨类型(如 NFS→Ceph) | ❌ | + +## 三、执行流程 + +``` +APIPrimaryStorageMigrateVolumeMsg + ↓ StorageMigrationBase.handle +PrimaryStorageMigrateVolumeMsg (本地消息) + ↓ handle(PrimaryStorageMigrateVolumeMsg) + ↓ 数据盘分支 +MigrateDataVolumeOverlayMsg (以 volumeUuid 为 sync 锁) + ↓ +MigrateVolumeOnPrimaryStorageMsg + ↓ 按 type 装配 FlowChain +执行迁移 +``` + +> **注**:数据盘路径**不修改 VM 状态**。Root 盘冷迁移才有 `volumeMigrating → volumeMigrated` 状态机,并在迁移结束后通过 `setClusterAndLastHost` 重新计算 VM 可调度集群。 + +## 四、各存储 FlowChain 编排 + +| 类型 | Flow 序列 | +|---|---| +| Ceph → Ceph | `ReserveCapacityFromDstPSFlow` → `CephToCephMigrateVolumeFlow`(rbd export-diff / import-diff,按 snapshot 分 segment) → `UpdateVolumeVOFlow` → `DiscardVolumeReferenceFlow` → `CephDeleteVolumeFromSrcPSFlow` | +| NFS → NFS | `ReserveCapacityFromDstPSFlow` → `NfsToNfsMigrateVolumeFlow` → `UpdateVolumeVOFlow` → `DiscardVolumeReferenceFlow` → `NfsDeleteVolumeFromSrcPSFlow` | +| SharedBlock → SharedBlock | 同模式,由 `sharedblock` 子模块工厂提供 | + +`UpdateVolumeVOFlow` 只改 `primaryStorageUuid` 和 `installPath`,**不动 `vmInstanceUuid`、设备地址、bus、cache** 等元数据。 + +## 五、候选 PS 计算(`getPrimaryStorageCandidatesForVolumeMigration`) + +冷迁移路径: + +1. 过滤 PS 状态正常(非 Disabled/Maintenance/Disconnected) +2. 按类型矩阵过滤(Ceph→Ceph / NFS→NFS / SB→SB) +3. **SharedBlock 额外约束**:`primaryStorageNotSupportCrossClusterMigrationMetrics` 要求源/目标 PS 共享 cluster +4. 是否包含源 PS 自身:仅当 `VmStorageMigrationMetric.isSupportSameStorage() == true`(Ceph 满足) + +## 六、各存储限制对比 + +| 维度 | Ceph→Ceph | NFS→NFS | SharedBlock→SharedBlock | Local→Local | +|---|---|---|---|---| +| 单卷 API 允许 | ✅ | ✅ | ✅ | ❌ | +| 同 PS 迁移 | ✅(换 pool) | ❌ | ❌ | — | +| 跨 cluster 迁移 | ✅ | ✅ | ❌(必须共享 cluster) | — | +| 数据盘可挂载迁移 | ❌ 须 detach | ❌ 须 detach | ✅ VM 必须 Stopped | — | +| Shareable 卷 | ❌ | ❌ | ❌ | — | +| 实际主流场景 | 同 PS 不同 pool | 跨 NFS PS | 同 SB 集群内 LV 路径变更 | — | + +## 七、关键问题:Ceph / NFS 为什么强制 detach? + +经过逐层排查,否定了若干常见的"技术原因": + +| 误解 | 否定理由 | +|---|---| +| QEMU 句柄冲突 | VM 关机时没有 QEMU 进程,不存在 fd / librbd watcher 冲突 | +| 设备号 / bus / cache 元数据需要重建 | 这些字段与 PS 类型无关,迁移流程根本不动它们 | +| 目标 PS 不在 VM 当前 cluster 导致起不来 | Ceph / NFS 是共享存储,集群所有 host 都能访问;`PrimaryStorageClusterRefVO` 仅用于调度暴露,不构成访问壁垒 | + +### 真实原因:产品语义切分 + +ZStack 把"卷迁移"这件事在 API 层切成两条路径: + +- **单卷迁移 API**(`APIPrimaryStorageMigrateVolumeMsg`)= 处理**游离卷** +- **VM 迁移 API**(`APIPrimaryStorageMigrateVmMsg with withDataVolumes`)= 处理**挂着的数据盘** + +代码作者在 2017 年画了这条边界,把"挂着迁数据盘"的责任推给 VM 迁移 API,单卷 API 不去处理这种场景的复杂度(即使 Ceph / NFS 共享存储完全做得到)。 + +### SharedBlock 为何例外 + +SharedBlock 用 LVM LV 做 volume。一次 detach 涉及: + +- 在 host 上 `lvchange -an` 停用 LV +- 释放 lvmlockd 锁 +- 清理 multipath / device-mapper 映射 + +attach 时全部反向重做一遍。代价远高于 NFS 改文件路径或 Ceph 改 rbd path。如果对 SharedBlock 也强制 detach,体验会非常糟糕。 + +折中方案:**允许挂着迁,但要求 VM Stopped**——避开 LV 在线变更与 QEMU 的并发风险。 + +> **重要**:技术上 Ceph / NFS 完全可以做"挂着迁数据盘",只是没人写这段代码。这是产品边界,不是技术限制。 + +## 八、关键文件索引 + +| 文件 | 作用 | +|---|---| +| `primary/APIPrimaryStorageMigrateVolumeMsg.java` | API 消息定义 | +| `StorageMigrationApiInterceptor.java:245-372` | 入参校验 | +| `StorageMigrationBase.java:213` | API → 本地消息 | +| `StorageMigrationBase.java:893` | 数据盘 vs Root 盘分支 | +| `StorageMigrationBase.java:1402` | `migrateVolume` 装配 FlowChain | +| `StorageMigrationBase.java:1624` | 候选 PS 计算 | +| `conf/serviceConfig/storageMigration.xml` | 消息路由 | +| `conf/springConfigXml/storageMigration.xml` | 类型矩阵 + ChainFactory + Metric Bean | +| `primary/ceph/CephToCephMigrateVolumeFlow.java` | Ceph 迁移核心(segment + import-diff) | +| `primary/nfs/NfsToNfsMigrateVolumeChainFactory.java` | NFS 迁移工厂 | +| `primary/local/LocalToLocalMigrateVolumeChainFactory.java` | Local 迁移工厂(仅服务 VM 整机迁移) | +| `sharedblock/SharedBlockToSharedBlockVmStorageMigrationMetric.java` | SB 能力声明 | + +## 九、一句话结论 + +> `APIPrimaryStorageMigrateVolumeMsg` 是**游离数据盘的冷迁移 API**,仅支持同类型存储间迁移 +> (Ceph→Ceph / NFS→NFS / SharedBlock→SharedBlock)。 +> Ceph 唯一允许同 PS 内换 pool(这是它真实的主流场景); +> SharedBlock 唯一允许卷处于挂载态迁移(用 VM Stopped 替代 detach,避免 LV 在线变更代价)。 +> **对挂载态数据盘强制 detach 是产品边界,不是技术限制**——挂着的数据盘请走 VM 迁移 API。 diff --git a/docs/modules/core/pages/ps-migrate-volume-relax-attached-data-volume.md b/docs/modules/core/pages/ps-migrate-volume-relax-attached-data-volume.md new file mode 100644 index 00000000000..e92c06543a5 --- /dev/null +++ b/docs/modules/core/pages/ps-migrate-volume-relax-attached-data-volume.md @@ -0,0 +1,351 @@ +# 放宽 Ceph/NFS 数据盘冷迁移:挂载态可迁移(VM Stopped)可行性分析与设计 + +## 一、背景 + +`APIPrimaryStorageMigrateVolumeMsg` 当前对挂载态数据盘的约束: + +| 存储 | 现行约束 | +|---|---| +| Ceph→Ceph | 数据盘有挂载关系(`vmInstanceUuid != null`)即拒绝,强制用户先 detach | +| NFS→NFS | 同上 | +| SharedBlock→SharedBlock | 允许挂载,但 VM 必须 Stopped | +| Local→Local | 不支持单卷 API | + +经分析(见 [primary-storage-migrate-volume.md](primary-storage-migrate-volume.md)),Ceph/NFS 的 detach 要求是**产品边界**而非技术约束。在挂载态卷迁移已检测到目标 PS 与 VM 的 cluster/L2 兼容性的前提下,Ceph/NFS 同样能做到"挂着迁"。 + +## 二、目标 + +让 Ceph→Ceph、NFS→NFS 的数据盘冷迁移对齐 SharedBlock 语义: + +1. 卷可保持 `vmInstanceUuid != null`(挂载关系不变) +2. VM 必须处于 `Stopped` 状态 +3. 迁移完成后用户**无需 re-attach**,下次启动 VM 自动使用新 PS + +不在本期范围: + +- Local→Local(单卷 API 不支持,本期不扩展) +- Running/Paused VM 的在线迁移(属于热迁移路径,由 `KvmBlockLiveMigrationWorkFlow` 处理) + +## 三、可行性分析 + +### 3.1 当前流程是否依赖"卷已 detach"? + +逐项检查冷迁移 Flow 对 `vmInstanceUuid` 的依赖: + +| Flow | 行为 | 依赖挂载状态? | +|---|---|---| +| `ReserveCapacityFromDstPSFlow` | 在目标 PS 上预留容量 | ❌ 不关心 | +| `CephToCephMigrateVolumeFlow` | rbd export-diff / import-diff,按 segment 复制 | ❌ 不关心 | +| `NfsToNfsMigrateVolumeFlow` | 复制 qcow2 / raw 文件到目标 NFS export | ❌ 不关心 | +| `UpdateVolumeVOFlow` | 只更新 `primaryStorageUuid` 和 `installPath` | ❌ 不动 `vmInstanceUuid` | +| `DiscardVolumeReferenceFlow` | 处理快照引用 | ❌ 不关心 | +| `CephDeleteVolumeFromSrcPSFlow` / `NfsDeleteVolumeFromSrcPSFlow` | 删除源 PS 上的卷 | ❌ 不关心 | + +**结论**:Flow 链本身不依赖"卷已 detach",移除拦截器中的 detach 强制要求**不会破坏现有 Flow 行为**。 + +### 3.2 VM 启动是否能正确使用新 PS? + +VM 启动时 `VmInstanceBase.startVm` 通过 `VolumeInventory.valueOf(VolumeVO)` 实时构造 domain XML: + +- `installPath` 来自 VolumeVO(已被 `UpdateVolumeVOFlow` 改写) +- `primaryStorageUuid` 同上 +- 调度器(`DesignatedAllocateHostMsg`)会校验目标 host 所在 cluster 是否挂载新 PS + +**前提条件**:目标 PS 必须挂到 VM 可调度 cluster。这正是 Root 盘冷迁移在拦截器里做的校验(`StorageMigrationApiInterceptor.java:304-320`)。**数据盘场景需要补一段类似校验**。 + +### 3.3 Ceph/NFS 的共享存储语义 + +| 存储 | cluster 可见性 | +|---|---| +| Ceph | 单一 Ceph 集群,所有 host 通过 librbd 访问。`PrimaryStorageClusterRefVO` 决定能否被调度到该 cluster | +| NFS | 通过 mount 在 host 上可见。`PrimaryStorageClusterRefVO` 决定哪些 cluster 会 mount | + +只要目标 PS attach 到了 VM 可调度的 cluster(且 L2 网络可达),迁移完 VM 启动毫无问题。 + +### 3.4 候选 PS 计算(`getPrimaryStorageCandidatesForVolumeMigration`) + +当前实现对 Data 盘**不做 cluster 兼容性校验**(只对 Root 盘做 vmNic L2 匹配)。挂载态数据盘需要: + +- 复用 Root 盘的 L2 cluster 匹配逻辑(数据盘自身没有 nic,但**所属 VM 有**) +- 或在 API 拦截阶段补一次 dstPS cluster 校验 + +### 3.5 并发与一致性(重要:startVm 不阻断 Migrating 卷) + +VM Stopped 状态下: + +- QEMU 进程不存在,无 fd / librbd watcher 持有源卷 +- libvirt domain XML 不会被运行时引用 +- SharedBlock 担心的"LV 在线变更"在 Ceph/NFS 不存在等价问题 + +#### 3.5.1 同步链分析:数据盘迁移**不**锁 VM 操作 + +ZStack 的 `thdf.chainSubmit` 按 `(serviceId + resourceUuid)` 串行化。各迁移路径的实际归宿: + +| 路径 | 进哪个 service 同步链 | sync signature | 来源 | +|---|---|---|---| +| **数据盘迁移**(含 SB 数据盘) | `VolumeConstant.SERVICE_ID` + volumeUuid → VolumeBase | `syncThreadId`(volumeUuid 级) | `StorageMigrationBase.java:1040-1044` 注释 "queue on source data volume" | +| **Root 盘迁移** | `VmInstanceConstant.SERVICE_ID` + vmUuid → VmInstanceBase | VM 的 `syncThreadName`(vmUuid 级) | `StorageMigrationBase.java:967-971` `MigrateRootVolumeOverlayMsg` | +| **VM 启动/停止/重启** | `VmInstanceConstant.SERVICE_ID` + vmUuid → VmInstanceBase | VM 的 `syncThreadName`(vmUuid 级) | `VmInstanceBase.java:8031` | + +**关键事实**:数据盘迁移与 VM 操作在 thdf 中是**完全独立的两个 SyncThread 队列**,互不阻塞。Root 盘迁移天然不会与并发 startVm 撞车,是因为它共用 VM 同步链——**这一保护对数据盘不成立**。 + +#### 3.5.2 startVm 也不做卷状态校验 + +| 检查点 | 结论 | +|---|---| +| `VmInstanceApiInterceptor.validate(APIStartVmInstanceMsg)` (line 559) | 只设 cluster/host,**不检查关联卷状态** | +| `VmInstanceBase.startVm` (line 7563) | 只检查 VM state,不检查 `VolumeStatus` | +| `AbstractVolume.forbiddenOperations[Migrating]` | 仅禁止 Template/Backup/Snapshot/Delete,**不含 VM 启动消息** | + +#### 3.5.3 灾难场景 + +``` +T0: VM Stopped, 数据盘 Status=Ready, installPath=ceph-A +T1: 用户发起 Migrate → 进 volume sync 链;setStatus(Migrating) +T2: 用户并发发 startVm → 进 vm sync 链(不同队列!没人拦) + VM 用 installPath=ceph-A 起 qemu +T3: CephToCephMigrateVolumeFlow 完成 export-diff/import-diff +T4: UpdateVolumeVOFlow 改 installPath → ceph-B +T5: CephDeleteVolumeFromSrcPSFlow 删 ceph-A 上的卷 +T6: VM qemu 句柄崩溃,数据写入黑洞 +``` + +#### 3.5.4 SB 既有缺陷(独立问题,本期顺修) + +**该并发漏洞在现有 SharedBlock 数据盘迁移路径上同样存在**——SB 数据盘走的也是 `MigrateDataVolumeOverlayMsg` → VolumeBase 这条同一代码路径,没有任何 VM 级同步保护。 + +生产没爆雷的原因推测: + +1. SB 客户体量较小 +2. LV path 变更秒级完成,撞车窗口短 +3. UI 在 Stopped + 卷迁移中可能 disable 启动按钮 +4. HA 自动拉起若命中此窗口,故障易被归因为"存储抖动" + +**结论**:建议为此独立立 Jira 单(如 `ZSTAC-XXXXX: SB StartVm during data volume migration race`)追溯归属,但**实际修复合并到本期**,统一在 §4.4 完成。 + +#### 3.5.5 同 VM 多卷并发迁移 + +不同 volumeUuid 的 sync signature 不同,因此**同一 VM 上的多块数据盘可以并行进入迁移**。Stopped VM 下无害(不读写卷),但 §4.4 的 startVm 检查使用 "any attached volume status == Migrating" 的批量校验,自然覆盖此场景。 + +### 3.6 失败回滚 + +现有 Flow 已带 rollback(`CephToCephMigrateVolumeFlow.rollback` 删除目标 PS 上半成品的卷)。`UpdateVolumeVOFlow` 失败时 VolumeVO 状态机会回到 Migrating 之前。 + +新增挂载态支持后,由于不修改 `vmInstanceUuid`,回滚无新增风险。**唯一新增的回滚需求**:如果迁移失败,新 installPath 写回失败,需保证 VolumeVO 仍指向源 PS 的旧路径——这是现有 `UpdateVolumeVOFlow` 已有的行为。 + +### 3.7 风险评估 + +| 风险 | 描述 | 缓解 | +|---|---|---| +| dstPS 未挂到 VM cluster | 迁移成功但 VM 永远起不来 | 拦截器补 cluster 兼容性校验 | +| 迁移过程用户 attach/detach | 并发改 vmInstanceUuid | 现有 `MigrateDataVolumeOverlayMsg` 已在 volume sync 锁内串行化;attach/detach 也走同一锁 | +| **迁移过程用户启动 VM** | VolumeVO=Migrating,但 startVm 未阻断(见 3.5);数据盘 sync 链与 VM sync 链是不同队列,互不串行 | **必须**在 `APIStartVmInstanceMsg` 拦截层补卷状态校验 | +| Shareable 卷 | 共享卷可能挂多个 VM | 现行已禁止 Shareable 卷迁移,保留此约束 | +| 系统盘 image cache | Data 盘无关 | 不涉及 | + +### 3.8 兼容性 + +- 现有 detach → migrate → attach 流程**不受影响**(依然有效) +- 新增的"挂载态 + Stopped"路径是**新增能力**,无 API 行为破坏 +- SDK / 前端不需要改动签名 + +**可行性结论**:✅ **技术上可行**。工作量集中在拦截器、候选 PS 计算与 startVm 并发防护。 + +## 四、设计方案 + +### 4.1 修改点清单 + +| # | 文件 | 改动 | +|---|---|---| +| 1 | `StorageMigrationApiInterceptor.validate(APIPrimaryStorageMigrateVolumeMsg)` | 数据盘分支统一改为「VM 必须 Stopped」,去除 SharedBlock 与非 SharedBlock 的差异 | +| 2 | `StorageMigrationApiInterceptor`(同方法内) | 挂载态数据盘新增 dstPS 与 VM cluster 兼容性校验(参考 Root 盘的 vmNic L2 匹配逻辑) | +| 3 | `StorageMigrationBase.getPrimaryStorageCandidatesForVolumeMigration` | Data 盘候选 PS 计算补一段:若卷挂载在 VM 上,复用 VM vmNic L2 匹配过滤 | +| 4 | `VmInstanceApiInterceptor.validate(APIStartVmInstanceMsg)` | **新增**:拒绝启动有 `VolumeStatus=Migrating` 数据盘的 VM(修补现有 SB 路径同样存在的并发漏洞) | +| 5 | 文档 | 更新 [primary-storage-migrate-volume.md](primary-storage-migrate-volume.md) 对比表与"为什么强制 detach"章节 | + +### 4.2 拦截器改造(核心) + +`StorageMigrationApiInterceptor.java:321-342` 数据盘分支改造方案: + +```java +} else if (srcVolume.getType() == VolumeType.Data) { + if (srcVolume.getVmInstanceUuid() != null) { + VmInstanceVO vm = dbf.findByUuid(srcVolume.getVmInstanceUuid(), VmInstanceVO.class); + // 1. 统一要求 VM Stopped(原仅 SharedBlock 路径有此校验) + if (vm.getState() != VmInstanceState.Stopped) { + throw new ApiMessageInterceptionException(Platform.argerr( + "cannot migrate data volume[uuid:%s] when vm[uuid:%s] is not stopped.", + msg.getVolumeUuid(), srcVolume.getVmInstanceUuid() + )); + } + + // 2. 校验 dstPS 与 VM 的 cluster/L2 兼容性 + // (SharedBlock 在候选阶段已通过 NotSupportCrossClusterMigrationMetrics 保证, + // Ceph/NFS 跨 cluster 无需共享,但仍需保证 dstPS 挂到了某个能跑 VM 的 cluster) + checkDstPsClusterCompatibility(vm, dstPS); + } + + if (Q.New(ShareableVolumeVmInstanceRefVO.class) + .eq(ShareableVolumeVmInstanceRefVO_.volumeUuid, srcVolume.getUuid()).isExists()) { + throw new ApiMessageInterceptionException(Platform.argerr( + "do not support storage migration while shared volume[uuid:%s] attached", + srcVolume.getUuid() + )); + } +} +``` + +`checkDstPsClusterCompatibility` 复用 Root 盘的 vmNic L2 匹配逻辑(提取为辅助方法): + +```java +private void checkDstPsClusterCompatibility(VmInstanceVO vm, PrimaryStorageVO dstPS) { + for (VmNicVO vmNic : vm.getVmNics()) { + boolean match = false; + L3NetworkVO l3 = dbf.findByUuid(vmNic.getL3NetworkUuid(), L3NetworkVO.class); + L2NetworkVO l2 = dbf.findByUuid(l3.getL2NetworkUuid(), L2NetworkVO.class); + for (PrimaryStorageClusterRefVO pcRef : dstPS.getAttachedClusterRefs()) { + match = Q.New(L2NetworkClusterRefVO.class) + .eq(L2NetworkClusterRefVO_.l2NetworkUuid, l2.getUuid()) + .eq(L2NetworkClusterRefVO_.clusterUuid, pcRef.getClusterUuid()) + .isExists(); + if (match) break; + } + if (!match) { + throw new ApiMessageInterceptionException(Platform.argerr( + "destination primary storage is not attached to any cluster matching the VM's L2 networks" + )); + } + } +} +``` + +### 4.3 候选 PS 计算改造 + +`StorageMigrationBase.getPrimaryStorageCandidatesForVolumeMigration` 当前对 Data 盘 `if (srcVolume.getType() == VolumeType.Data) return candidates;` 直接返回。修改为: + +```java +if (srcVolume.getType() == VolumeType.Data) { + // 新增:挂载态数据盘需匹配 VM 的 cluster/L2 + if (srcVolume.getVmInstanceUuid() != null) { + VmInstanceVO srcVm = dbf.findByUuid(srcVolume.getVmInstanceUuid(), VmInstanceVO.class); + if (srcVm != null && srcVm.getVmNics() != null && !srcVm.getVmNics().isEmpty()) { + // 沿用下方 Root 盘的 vmNic 匹配逻辑 + candidates = filterCandidatesByVmNics(candidates, srcVm); + } + } + return PrimaryStorageInventory.valueOf(candidates); +} +``` + +将原 Root 盘段落中的 vmNic 过滤逻辑提取为 `filterCandidatesByVmNics(candidates, vm)` 共用。 + +### 4.4 startVm 并发防护(必修) + +`VmInstanceApiInterceptor.validate(APIStartVmInstanceMsg)` 当前只做 cluster/host 校验,需新增: + +```java +private void validate(APIStartVmInstanceMsg msg) { + // 既有逻辑:host uuid overrides cluster uuid + if (msg.getHostUuid() != null) { + msg.setClusterUuid(null); + } + + // 新增:禁止启动 VM 若任一挂载卷处于迁移中 + List migratingVols = Q.New(VolumeVO.class) + .select(VolumeVO_.uuid) + .eq(VolumeVO_.vmInstanceUuid, msg.getVmInstanceUuid()) + .eq(VolumeVO_.status, VolumeStatus.Migrating) + .listValues(); + if (!migratingVols.isEmpty()) { + throw new ApiMessageInterceptionException(operr( + "cannot start VM[uuid:%s], %d attached volume(s) are migrating: %s", + msg.getVmInstanceUuid(), migratingVols.size(), migratingVols)); + } +} +``` + +同时需补 `StartVmInstanceMsg`、`HaStartVmInstanceMsg` 内部消息分支(在 `VmInstanceBase.startVm` 早期 refreshVO 之后做相同检查),覆盖 API 之外的入口。 + +### 4.5 FlowChain 不动 + +`CephToCephMigrateVolumeFlow`、`NfsToNfsMigrateVolumeFlow`、`UpdateVolumeVOFlow` 等**不需要修改**。Flow 已不依赖卷的挂载状态。 + +### 4.6 测试用例(Groovy 集成测试位置参考) + +`premium/test-premium/src/test/groovy/org/zstack/test/integration/premium/storage/migration/` + +新增 case: + +1. `CephToCephAttachedDataVolumeMigrationCase` — VM Stopped + 数据盘挂载,迁移成功,启动 VM 后能读旧数据 +2. `NfsToNfsAttachedDataVolumeMigrationCase` — 同上 +3. `RunningVmRejectedCase` — VM Running 时拒绝迁移 +4. `DstPsClusterMismatchRejectedCase` — 目标 PS 未挂到 VM cluster 时 API 拦截 +5. `StartVmDuringMigrationRejectedCase` — 迁移中并发 startVm 必被拒绝 + +参考现有 `NfsToNfsMigrateDataVolumeCase.groovy` 的脚手架。 + +## 五、回滚方案 + +| 场景 | 回滚 | +|---|---| +| 拦截器允许后迁移失败 | 复用现有 `UpdateVolumeVOFlow` rollback + `CephToCephMigrateVolumeFlow.rollback` 删除目标半成品 | +| 上线后发现问题 | GlobalConfig 增加开关 `mevoco.storageMigration.relaxAttachedDataVolume`(默认 false),允许灰度 | +| 老用户期望"挂着即拒绝" | 保留 detach → migrate → attach 路径不变,新功能为额外能力,不破坏旧行为 | + +### 5.1 GlobalConfig(建议) + +```xml + + relaxAttachedDataVolume + mevoco.storageMigration + Allow data volume migration while attached to a stopped VM + false + boolean + +``` + +拦截器读取该 config,默认关闭新行为,老用户无感知。运维侧打开后启用新逻辑。 + +`startVm` 的并发防护(4.4)**不挂在此开关下**,无条件生效(因为它修补的是 SB 路径既有缺陷)。 + +## 六、工作量估算 + +| 项 | 估算 | +|---|---| +| 拦截器改造(含辅助方法提取) | 0.5 天 | +| 候选 PS 计算改造 | 0.5 天 | +| GlobalConfig + 灰度开关 | 0.3 天 | +| startVm 并发防护 + 单测 | 0.5 天 | +| Groovy 集成测试 5 个 | 2 天 | +| 文档更新 | 0.2 天 | +| **合计** | **~4 天** | + +## 七、验收标准 + +1. ✅ Ceph→Ceph 数据盘在挂载 + VM Stopped 状态下能成功迁移,启动 VM 后磁盘内容一致 +2. ✅ NFS→NFS 同上 +3. ✅ Running/Paused VM 上的数据盘迁移被拒绝 +4. ✅ dstPS 未挂到 VM cluster 时被 API 层拒绝 +5. ✅ 迁移过程中 `APIStartVmInstanceMsg` 被拒绝 +6. ✅ Shareable 卷继续拒绝 +7. ✅ SharedBlock 现有行为不变(含新增的 startVm 并发防护) +8. ✅ 已 detach 卷(`vmInstanceUuid == null`)迁移路径行为不变 +9. ✅ GlobalConfig 关闭时退化为旧行为(startVm 防护除外) + +## 八、开放问题 + +1. **GlobalConfig 默认值** — 是否首版默认 true?建议首版 false,下个 LTS 转 true +2. **是否同步扩展 `APIPrimaryStorageMigrateVmMsg withDataVolumes`**? 当前 VM 整机迁移已支持数据盘,本期不动 +3. **startVm 防护是否需要适配 4.4 之外的入口**(如 HA 自动拉起 `HaStartVmInstanceMsg`、scheduler `StartVmInstanceJob`)— 建议覆盖到 `VmInstanceBase.startVm` 公共入口 +4. **SB 既有并发漏洞的 Jira 单归属** — 建议单独立单(用于追溯与回归用例归类),但代码修复合并到本期 MR + +## 九、参考 + +- 现有逻辑分析:[primary-storage-migrate-volume.md](primary-storage-migrate-volume.md) +- `StorageMigrationApiInterceptor.java:245-372` +- `StorageMigrationBase.java:893,1624` +- `CephToCephMigrateVolumeFlow.java` +- `VmInstanceApiInterceptor.java:559` (startVm 校验缺失点) +- `VmInstanceBase.java:7563` (startVm 主体) +- `AbstractVolume.java:49,72` (Migrating 状态机) +- `premium/conf/springConfigXml/storageMigration.xml`