Files
dsh_shenxian/dsh-server-docs/04-调整方案/120-跨节点迁移与节点自举-完整流程方案.md
T
admin 04776af4b1 docs: 工作区根项目文档批量入仓(04-调整方案 113–128 / 交接单 T09–T21 / ops / archive)
起因:用户 2026-09-17 明确「所有文档都要同步,都放在开发仓库 docs 对应文件夹下」。
判据:工作区根 *.md 中在仓库(git ls-files --quotepath=false)搜不到的那些。

入仓 33 份(一律复制,工作区根原件保留不动,避免引用断链):
- 04-调整方案/113–128(16 份 · 原子占号后落盘):覆盖网络 传输方案取舍 / 应用场景与待完善清单 /
  插件化vs改内核 / 问题逐条推演 / 参数表与观测口径;会合中继拆分取证与改造方案;
  集群化改造方案 Manager-Worker;跨节点迁移与节点自举;项目代码分层范式与迭代风险评估;
  搬运与共享重建方案 guest w47→w106;方案规划方法提炼;文档无效信息审计报告;
  会话接续机制复盘与修复;会话接续规范;dsh 客户端化部署方案;dsh 桌面客户端开发方案
- 交接单/archive/交接单-已完成/T09–T21(13 份 · 覆盖网络线已完成单归档)
- ops/(2 份运行态指针:接续入口 / 接续包 · 覆盖网络线)
- archive/(2 份临时与内部简报)

已排除(无需重复入仓):覆盖网络线 10 份方案正文已入档案 103–112(文件名不同)。

登记:INDEX.md §二 新增 04-113–128 共 16 行 + §四 追加 T09–T21 说明 + 机器摘要行刷新
(⛔ 未跑 docs-index-stats.py --write:该脚本会按 \r\n 归一化全文件行尾,故改为字节级单行替换);
README.md 追加 1 条入仓指针;docs-manifest.json 复跑 scripts/docs-manifest.py 刷新。

验收:工作区根 47 份 .md —— 同名已入仓 8 / 本次内容一致 29 / 已知改名映射 10 / 未入仓 0;
git status 待提交清单只含本次新增与登记 3 件(未涉 src/ 与 relay 代码面)。
2026-09-17 18:24:19 +08:00

15 KiB
Raw Blame History

跨节点迁移 & 节点自举 —— 完整流程方案

立稿 2026-09-16 00:3x | 起因:09-15 的 guest 迁移走的是手工临时方案(AI 人肉做了 6 件事)。 用户要求:「该谁处理就让对应功能处理,不要做这种临时方案;迁移应该谁发起、迁移完成应该如何处理,整个完整的流程」。 本文回答三件事:谁处理(责任矩阵)→ 完整流程(目标态)→ 要开发什么(缺口清单)。


0. 一句话

把「人手搬 6 件事」收敛成 两个平台功能: ① 节点自举(新节点加入时执行一次,含旧角色清理)② 迁移编排(admin 发起 → 平台搬数据 → 收尾归档)。


1. 这次实际做了什么(对照:本该谁做)

# 我手工做的(临时方案) 本该谁做 平台现状
1 useradd -u 100002 … 建 OS 账号 worker 自己(dsh-provision.path 触发) ⚠️ 单元只在 47;且 uid 取自 SQLite ⇒ 集群下失效
2 npm i -g @deepseek-ai/[email protected] 节点自举(一次) ❌ 无此功能
3 传 /usr/local/dsh-runtime(python/jq/rg) 节点自举(一次) ❌ 无此功能
4 chmod 711 /var/lib/dshs/users 节点自举(一次) ❌ 无此功能(且漏了就必崩)
5 搬 203 MB 用户数据 + 依赖 平台迁移编排 ❌ 迁移路由注释原文「数据不搬家」
6 源目录打包归档 → 删源 平台迁移收尾 ❌ 无此功能
7 ffmpeg 我从 47 搬(错) 节点自举:直接 dnf install ✅ 106 内网源 43 MB/s,几十秒装完(本轮已改)

2. 责任矩阵(谁处理什么)

环节 责任方 触发时机 幂等要求
节点自举:dsh 主程序 / dsh-runtime / 目录权限 / provisioner 单元 / egress 平台(运维脚本) 节点加入集群时一次 ✅ 必须可重跑
节点角色切换:清理旧角色服务(如 106 上的旧控制面) 平台(运维脚本) 角色变更时 ✅
用户目录出现 ⇒ 建 OS 账号 + chown worker 自己(systemd path 单元) 目录出现时 ✅ 已有(待铺到各 worker)
迁移发起 admin(人) 人为决策 —
迁移执行(drain → 数据同步 → 目标机拉起 → 归属) 平台 发起后 ✅
迁移收尾(源归档 / 清理 / 审计 / 通知) 平台 迁移成功后 ✅

原则:凡是"节点级别、一次性的"归自举;凡是"跟着用户走、每次迁移都发生"的归迁移编排。 ⛔ 两者都不该由 AI 或人手临时执行。


3. 完整流程(目标态)

A. 节点加入(自举,一次)

1. 运维执行 bootstrap-worker(幂等)
   ├─ 装 dsh 主程序(同版本,npm 或离线包)
   ├─ 装 dsh-runtime(python3.12 / jq / rg / ffmpeg —— 优先 dnf/内网源,而非从别处搬)
   ├─ 目录与权限(/var/lib/dshs 711、users 711、secret.key)
   ├─ 铺 provisioner 单元(dsh-provision.path + .service,脚本参数化到本机路径)
   └─ 清理旧角色残留(如该机曾跑控制面 ⇒ 停用并禁用)
2. 自检 doctor:四件套 + provisioner + 端口 + 数据根,全绿才允许被调度
3. 注册进 dsh_hosts(capacityMb / endpoint)

B. 用户迁移(admin 发起 → 平台执行 → 收尾)

1. 【发起】admin 在「用户管理」选用户 → 选目标节点 → 点"迁移"
   (入口已有 API:POST /api/admin/users/:id/dsh/migrate;门户缺 UI 入口)
2. 【前置校验】平台检查目标节点:status=up、capacity 足够、doctor 全绿
   不就绪 ⇒ 拒绝并列出缺项(不让人手工补)
3. 【执行】
   a. drain:停源机实例(优雅停机,会话落盘)
   b. 同步数据:源 → 目标(平台要负责;见 §4 D3)
   c. 目标机承租约拉起(epoch+1)
   d. 归属原子更新
4. 【收尾】源数据按策略归档(默认留 N 天)→ 清理源目录 → audit 记录 → 通知用户/管理员
5. 【回滚】归档可解压恢复;迁移可反向再执行一次

⚠️ 顺序不可换:先停源、再在目标机拉起(否则两机同 home ⇒ 双写)。这一点现有 API 已正确实现。


4. 缺口清单(要开发的功能)

# 功能 解决的问题 现状 优先级
D1 bootstrap-worker(节点自举脚本,幂等) 新节点上线全靠手工(本次 6 件事) ✅ 已交付:scripts/bootstrap-worker.sh(47 / 106 自检全绿) P0
D2 建号自动化(uid 由 Manager 投递,agent 在 spawn 前确保账号) 新用户落 worker 无人建号;uid 依赖 SQLite 在集群下失效 ✅ 已交付:src/worker/agent.ts 的 ensureOsAccount(端到端已验证) P0
D3 迁移含数据同步 路由注释明说"数据不搬家" ⇒ 现在必须人工搬 ❌ 无 P0
D4 迁移收尾自动化(归档 + 清理 + 通知) 现在靠人手打包删源 ❌ 无 P1
D5 门户「迁移」入口 + 节点状态面板 admin 现在只能调 API ⚠️ API 有 P1
D6 节点角色切换清理 106 的旧控制面已手工执行并验证(见 §5)⇒ 该流程本身仍未自动化 ❌ 无 P1

D2 的技术债已确认:uid-for-user 是 user?.uid ?? hashUid(id, baseUid), 而 hashUid = baseUid + hash % 100000 ⇒ 实测 106 上算 guest 得 184656,与真实 uid 100002 不符 ⇒ uid 只能从 PG 读(47 之所以能跑是因为它读 SQLite)。

⚠️ 实测:106 需要能读 PG —— 但 47 的 PG 只监听 127.0.0.1:15432,106 无法直连。 ⇒ D2 的正解不是"给脚本换个 --db 参数",而是 Manager 在创建用户时把 uid 下发给 worker(走已有 agent 通道),或让 worker 通过隧道读 PG。这是设计问题,应随 D2 一起定。

D1 的补充判据(实测 dshs doctor 的覆盖缺口):106 上跑 doctor 只报了 node/cgroup/bwrap/setpriv/systemd-run/nft/dsh/降权/dataRoot/DB —— 不覆盖本次真正踩到的四项:/var/lib/dshs/users 权限、dsh-runtime 缺失、provisioner 单元缺失、旧角色残留。 ⇒ D1 的自检清单必须把这四项补进去(否则"doctor 全绿"仍会起不来)。


5. ✅ 已执行:106 上的旧控制面已停用、禁用并删除(用户 2026-09-16 00:31 定)

决策过程:先定为「不处理、隔离即可」→ 人工复核隔离面后,用户改判为「停用并禁用 + 删除数据」。

已执行(2026-09-16 00:3x):

  1. systemctl stop + disable dsh-users-platform(同时移除 multi-user.target.wants 链接)
  2. 删除数据与程序:/var/lib/dsh-users-platform/(176 K,含 2 个用户目录 + state)· /root/dsh-users-platform/(144 M 平台程序)· /etc/dsh-users-platform.env · /etc/systemd/system/dsh-users-platform.service
  3. systemctl daemon-reload

删除前备份(留退路):/opt/dsh/backups/legacy-106-platform-20260916-002931.tar.gz(6.5 K:数据根 + env + 单元文件)。

验证(全部通过):单元文件 / 服务查询 / 残留进程 / 数据根 / 程序目录 / env 文件 全部不存在;3080 端口已释放; 新架构未受影响 —— dsh-100002-43771a07.scope(guest)与 dsh-100008-3099bb14.scope 仍 running,dshs-worker active。

删除前核销的隔离面(留档):数据根 / 用户目录 / OS 账号 / 端口 / systemd 单元五面均不重叠; 唯一残留耦合是"两者共用 BASE_UID=100000 的同一 uid 空间"(理论上可能撞 uid)—— 现已随删除彻底消除。


6. 立即能做的(不依赖 D1–D6 开发)

  1. ffmpeg/ffprobe/jq/rg:已改为 106 内网 dnf install(本轮已完成,几十秒,不再是遗留项)。
  2. provisioner 铺到 106:⚠️ 不是"给脚本换个 --db 参数"就行 —— uid 必须由 Manager 下发(见 §4 D2 说明:哈希兜底会算错,PG 又连不上)。
  3. 其余(D1 / D3 / D4 / D5 / D6)建议按 §4 优先级排期开发;D6 的手工执行已完成(106 旧控制面已删),只是那条流程仍未自动化。

7. 实施记录(2026-09-16 00:3x–01:0x)

7.1 D2 建号自动化 —— ✅ 已上线

改动:src/worker/agent.ts

  • 新增 ensureOsAccount(config, userId, uid):幂等,规格与 provision-new-users.sh 一致(dsh-<短id> / -M / nologin / 同 uid),并对齐用户目录属主;
  • 在 POST /launch 的 spawn 之前调用 —— OS 账号缺席时 bwrap 里的 setpriv --reuid 会直接失败,且不报权限错,只表现为"实例起不来";
  • 护栏:参数校验先行(UUID 形状 / uid ≥ baseUid)、uid 被他人占用则 fail-loud、非 Linux 静默返回;不接受调用方传命令或路径 ⇒ 不违反 agent 的「最小接口」纪律;
  • 测试:test/worker-provision.test.mjs(5 例)已并入 npm run verify 链,npm run verify 全绿;
  • 部署:47 送源码 + 服务器 npm run build;106 送编译产物;两边 restart dshs-worker(实例是独立 scope,不受影响);
  • 端到端验证:以测试用户(uid 199999)调 /launch ⇒ 账号被自动创建 dsh-11111111222233334444 ✓(实例本身 crashed 属预期:测试用户无工作区);验证后账号/scope 已清理干净。

7.2 D1 节点自举 —— ✅ 已上线

交付:scripts/bootstrap-worker.sh(幂等;--check 只读自检;--prune-legacy 清旧角色)。 覆盖链路:前置 → dsh 主程序(版本校验 + PATH 契约)→ runtime(jq/rg/ffmpeg/ffprobe 走本机包管理,不从别处搬)→ python 路径契约 → 数据根权限 711(漏了必崩)→ 旧角色残留 → 自检汇总(含 dshs doctor 漏掉的四项)。 实测:47 与 106 均 全绿、退出码 0。

7.3 🔴 事故与教训(我造成的,已修复)

事故:删除 106 的旧控制面目录 /root/dsh-users-platform 后,106 的 worker 起不来了(Cannot find package 'fastify')。 根因:/opt/dshs-cluster/node_modules 与 /usr/local/dshs-cluster/node_modules 都是符号链接 → /root/dsh-users-platform/node_modules;删掉被指向的目录 ⇒ 依赖链断裂。 我的失误:删除前只检查了「有没有 systemd 单元引用它」,没检查文件系统符号链接引用。 修复:从 47 取 package.json + package-lock.json → 106 上 npm ci --omit=dev --ignore-scripts 重建(109 包)⇒ worker 恢复 active、19000 监听正常。 ⚠️ 注意:npm ci 会触发 prepare(npm run build),而该目录没有源码 ⇒ 必须加 --ignore-scripts。 教训(已入纪律):删任何目录前,除 systemd 单元外,必须查符号链接引用: find / -lname '<path>*' -not -path '/proc/*' 2>/dev/null

7.4 未完成 / 待办

  • D3(迁移含数据同步):未开始 —— 这是"迁移不再靠人肉搬数据"的关键一条,建议下一步就做。
  • 代码尚未 commit:src/worker/agent.ts、package.json、scripts/bootstrap-worker.sh、test/worker-provision.test.mjs 都在代码仓工作树里(按纪律未擅自提交)。

8. 2026-09-16 06:5x 排查:旧控制面彻底清除 + admin 502 根因

8.1 旧控制面彻底清除(用户要求「还是删除」)

⚠️ 关键差别:这次先把依赖搬离再删 —— 昨晚就是栽在这一步。

  • 事实:/opt/dshs-cluster/node_modules 与 /usr/local/dshs-cluster/node_modules 原本都是符号链接 → /root/dsh-users-platform/node_modules;
  • 做法:mv 实体 → /opt/dshs-cluster/node_modules(109 包)→ /usr/local 侧改指新位置 → 重启并验证 worker active → 按 §7.3 的新纪律 find /opt /usr /var /etc /root -lname "<path>*" 确认无引用 → 删除 /root/dsh-users-platform;
  • 结果:旧控制面残留 0、dsh-users* 单元 0、3080 无监听、worker active、19000 正常。

8.2 admin 访问 502 的根因 = 冷启动时序(非崩溃、非超时)

证据链(全部实测):

  1. nginx 在 06:53:40 记 upstream prematurely closed connection ... request: "GET /" ... host: "admin.alotbuy.com"(即用户看到的那次 502);
  2. Manager(dshs)日志里该请求只有 incoming request、没有 request completed ⇒ 连接被上游主动断开,不是超时(该站点 proxy_read_timeout = 3600s);
  3. 06:53:28 出现 GET /wake.html?next=https://admin.alotbuy.com/ ⇒ 触发了唤醒流程,说明当时 admin 实例处于 stopped(被空闲回收);
  4. 06:56 我调 agent /launch 返回 already-running ⇒ 实例在那之前已被拉起;
  5. 06:56:34 对同一 URL 的请求 ⇒ 401(2 ms) ⇒ 链路本身健康。

⇒ 结论:实例被空闲回收后,用户访问触发唤醒;唤醒页跳回 / 时代理转发到实例,而实例端口尚未就绪 ⇒ 转发失败、连接被断 ⇒ nginx 502。 (这类"回收后再访问"的窗口期对低频访问但非新用户尤其明显 —— admin 管理台正是这种。)

修复方向(按优先级):

  1. 代理转发失败时做短重试(覆盖"实例已起但端口未 ready"的数秒窗口)—— 根因修复,改 src/supervisor/proxy.ts 的转发错误路径;
  2. 或把 /api/dsh/enter 的就绪判据从"拿到 launch token"加严到"端口可连"再返回;
  3. 运维缓解(不治本):admin 属高频管理入口,可对其不做空闲回收。

8.3 ⚠️ 顺带发现一个必须修的架构缺口(迁移引入的真实影响)

现象:POST /api/me/locale 对 guest(已迁到 106) 返回 500: ENOENT: no such file or directory, open '/var/lib/dshs/users/4092b965-…/home/settings.yaml'(06:54 出现两次)。

根因:src/web/home-files.ts 的 writeHomeFile 直接操作本地文件系统,没有走集群文件面 ⇒ Manager 上任何"直接写用户 home"的路由,对不在本机的用户都会 500(不只是 locale)。

影响面:凡迁到 106 的用户,在 47 的门户上做"改语言/改设置"类操作会 500 —— 这是集群化的通用缺口,不是 locale 一处的问题。

修复方向:这些路由改走 app.userFs(集群模式下即 RemoteUserFs,会代理到该用户所在 worker), 而不是直接 fs.writeFile。⇒ 建议与 D3 一并排期。