「搬家的指挥中心」用 Go + React/shadcn 写一个批量热迁移控制台

为什么造工具: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}}}。

只看上面的契约,客户端一个上午可以写完。实际耗时主要在一批「形状坑」:

接口 / 字段直觉以为实际形状
taskidintJSON 字符串
任务 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 的「完成自动消失」影响使用效率:完成项不占列表,注意力集中在运行中和排队中的任务。
标签:Go, React, 迁移