为什么造工具:382 台机器要搬两次家
本次迁移涉及 382 台 VM、414 块磁盘:先把整批机器从 HDD 存储迁到 CEPH,底层工作完成后再迁回。面板自带迁移能力,操作流程为选机器、点开磁盘、挑目标存储、确认执行。机器数量在个位数时,这套流程可用;数量为 382 时,逐台手动操作不再可行。
414 块磁盘分三类:运行中的业务盘需要热迁移;已关机的实例走冷迁移;带快照的盘需额外确认,目标存储默认不允许丢弃快照。全部依赖人工处理,速度慢,且在半夜低峰窗口容易点错、漏点、重复点。一次误操作可能把业务机器迁到错误的目标存储,或让带快照的盘反复卡在确认环节。
这是一次双向迁移:先把整批机器从 HDD 迁到 CEPH,底层工作完成后再迁回。工具不能只服务单一方向。目标存储与迁移方向写死后,工具只覆盖半程,后半程仍需手动操作。选盘、目标存储、热迁或冷迁从第一版起就由运行时决定,不写成代码常量。
基于以上约束,开发一个批量热迁移控制台,目标为:批量选择、按 VM 分组提交、并发受控、进度可视、断点续传,任务完成后自动从列表退场,避免大列表刷屏。工具不追求通用,只服务这一次「搬过去、再搬回来」的迁移。它就是这 382 台机器的搬家指挥中心。
先摸清 API:ZJMF v1 契约与形状坑
写代码前,先逆向面板的 HTTP 接口。ZJMF v1 的迁移链路不复杂,主要障碍在几个反直觉的返回形状。核心契约如下:
登录:
POST /v1/login返回的是裸 JSON 字符串 token,不是常规的 {"token":"..."} 对象;拿到后先 strip 掉引号,之后每个请求带 access-token 头。这个坑隐蔽:按对象解析登录响应,token 会带一对引号,原样放进请求头后接口持续拒绝,面板页面正常。排查时容易先怀疑网络或证书,实际问题是解析逻辑。
触发迁移:
PUT /v1/clouds/{id}/disks/stores
{
"id": <cloud>,
"disk": [{"id": <disk>, "store": <target>}],
"hot_migrate": 0|1,
"allow_discard_snap": 0
}返回 HTTP 202,body 是 {"taskid":"<字符串>"}。
轮询进度:
GET /v1/tasks/{id}?noQprogress=true返回 {status:0-5, progress:0-100(int), msg, data:[] 或 {active:{name,target,dir}}}。
只看上面的契约,客户端一个上午可以写完。实际耗时主要在一批「形状坑」:
| 接口 / 字段 | 直觉以为 | 实际形状 |
|---|---|---|
taskid | int | JSON 字符串 |
任务 data | 总是对象 | 空时是 [],运行中是对象 |
disk.size / real_size | 字符串 | 数字 |
| 详情字段 | 与列表同名 | 详情用 kvmid / node_id,列表用 kvm / node |
/v1/nodes、/v1/stores | 裸数组 | {data:[...]} |
/v1/clouds 分页 | per_page=200 单次拉取 | 只拉 200 台(实际 382),要按 meta.total 翻页拉全量 |
这些坑单独看都很小,组合起来会让一个「看起来写完了」的客户端在真机上拉不全列表、解析不了任务、拿不到进度。/v1/clouds 的分页最典型:写死 per_page=200 只拉到 200 台,现场有 382 台,其余 182 台不会出现在列表里。必须按 meta.total 计算页数,翻页拉齐全量。
处理方式:类型不稳定的字段统一用 json.RawMessage 接住,再用辅助方法按实际形状取值:
// 松类型字段统一 RawMessage,再用辅助方法按实际形状取值
type Task struct {
Status int `json:"status"`
Progress int `json:"progress"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
func (t Task) Active() (name, target, dir string) {
if len(t.Data) == 0 || string(t.Data) == "[]" {
return "", "", ""
}
var v struct {
Active struct {
Name string `json:"name"`
Target string `json:"target"`
Dir string `json:"dir"`
} `json:"active"`
}
_ = json.Unmarshal(t.Data, &v)
return v.Active.Name, v.Active.Target, v.Active.Dir
}后端:单二进制里的队列引擎
后端用 Go 1.25.6(windows/amd64)实现。embed.FS 将前端 dist 内嵌进二进制,net/http 启动一个只监听 127.0.0.1:8090 的本地服务,交付物为单个 exe,双击即用。
队列引擎是整个工具的调度核心,关键设计如下:
- pending 按 VM 分组:一块盘一次请求会产生过多任务;改为「一 VM 一次 PUT 全盘」,一次请求提交一台机器的所有磁盘,与面板迁移任务粒度对齐。
- 并发上限 2:迁移是重 IO 操作,不能无限并发。队列只放行 2 台 VM 同时运行,其余排队。
- activeTasks 门控:提交成功后进入 active 集合,任务完成或失败才释放名额,防止轮询与提交互相抢跑。
- 3 秒轮询:对 active 任务每 3 秒轮询一次进度,展示 status 0-5、progress 0-100 与
data.active的 name/target/dir。 - 速度估算:用
real_size与两次轮询的 progress 差值估算迁移速度,在界面展示。 - 持久化断点续传:任务状态落盘到
migrator_jobs.json,工具重启后无需重新选机器。 - 重启恢复 running 任务:启动时把上次仍在 running 的任务重新挂回 active 并继续轮询,不打断在途迁移。
「按 VM 分组」是这套引擎中最关键的设计。414 块盘若逐盘提交,需要 414 次提交并跟踪 414 个任务;按 VM 分组后,请求量降为 382 次,列表对应「这台机器搬完了没有」的心智模型,不需要逐盘查看进度。
工具被误关后重新打开,任务列表原样恢复,运行中的任务继续显示进度,382 台机器不需要重新开始。
前端 UI 演进:从登录卡到指挥中心
前端用 React 19 + Vite 6 + TypeScript 搭建,样式采用 Tailwind v4 + shadcn/ui(radix 基础、nova 预设、浅色主题)。功能逐轮叠加:
- 登录卡:填写地址和账号密码,登录后拿到 strip 过的 token,之后所有请求自动带
access-token。 - 节点 / 目标存储联动过滤:选择节点后,按
store.node.id过滤出本节点的目标存储,同时排除名称含 HDD 的存储,避免把盘迁回 HDD。 - 6 张统计卡:总量、已选、运行中、排队中、已完成、失败等状态一屏查看。
- 卡片 / 列表视图切换:ToggleGroup 切换,卡片视图看状态,列表视图看字段,满足不同操作习惯。
- 整卡可点选:点击卡片任意位置即选中,无需点击角落的 checkbox。
- 快照盘 ⚠ 徽章:带快照的盘打标,确认时走 AlertDialog,确认后才把
allow_discard_snap置为 1。 - sonner toast:操作结果即时反馈。
- 3 秒轮询:前端与后端同频刷新,进度条平滑推进。
- 完成条目自动淡出:任务完成后条目在视图停留 2.6 秒,再以 scale/opacity 动画淡出并移除。实现为
doneAt/hidden两个状态组成的小状态机:done 时记录doneAt,2.6 秒后置hidden;重新迁移同一台机器时自动取消隐藏,条目回到视图。
几百台机器同时迁移时,完成项持续留在列表会把「运行中」「排队中」淹没。完成条目自动消失后,列表只保留在途和待操作项。2.6 秒窗口用于确认任务已完成,随后条目退场。
真机验证:热迁、回迁、冷迁、并发
工具在真机上验证,分四步:
- 双盘热迁:VM 1728(kvm1727)两块盘从 U.2 热迁到 CEPH,task 26825、26827 done;再从 CEPH 回迁 U.2,task 26826、26828 done。往返均完成,验证了 API 契约与分组提交逻辑。
- 关机实例冷迁:cloud 1727(kvm1726)处于关机状态,HDD 迁 CEPH,
hot=false,task 26830 done,数据完好。 - 两 VM 并发观测:kvm1778、kvm1755 同时处于 running,验证并发上限 2 的队列门控在真机生效。
- 持久化验证:任务状态保存在
migrator_jobs.json,重启后 running 任务可恢复并继续轮询。
四步验证各有分工:热迁往返验证契约与分组,冷迁验证 hot=false 分支,并发观测验证队列门控,持久化验证重启恢复。全部通过后,工具从本地 demo 进入可交付状态,可在凌晨低峰窗口执行。
Windows 本地开发坑
开发环境为 Windows,过程中记录到以下平台相关的问题:
- PowerShell 不支持 heredoc,传递长命令和多行文本不便,调试 API 时构造迁移 body 较繁琐。
curl内联 JSON 的引号会被 shell 处理丢失,改用--data-binary @file.json从文件读取 body,避免引号被 shell 消费。pkill会匹配到自身的 bash,用'think cr[o]n'括号正则避开自匹配,防止杀进程时终止自身。go run残留子进程占用端口,用Get-NetTCPConnection -LocalPort 8090 | Stop-Process OwningProcess清理,否则 8090 端口一直被上一代进程占用。- pnpm 11 需要在
pnpm-workspace.yaml中配置allowBuilds: { esbuild: true },否则 esbuild 安装失败,前端构建停在依赖安装阶段。
复盘要点
从逆向 API 到真机验证,这个工具留下几条可复用的经验:
- 先摸清 API 再写代码:形状坑多于预期,逆向阶段省下的时间在真机阶段会成倍消耗。
- 类型宽松字段统一用 RawMessage + 辅助方法:把「时有时无、时数组时对象」的字段挡在解析边界内。
- 队列引擎按 VM 分组而非按盘:请求粒度与迁移任务粒度对齐,任务更少、恢复更简单。
- UI 的「完成自动消失」影响使用效率:完成项不占列表,注意力集中在运行中和排队中的任务。