Files
dsh_ai1net_server/docs/集群与实例/跨节点迁移与节点自举_完整流程方案_20260916.md
admin ce8e6ceed9 chore(工作区): 纳入版本控制基线(回收 411 MB 过程产物)
回收 411 MB(470 M → 58.8 M),全部经回收站,可恢复:
- 待清理/(146.2 M,含 relay 分片 128 M 与 42 项过程目录)
- tmp/(32.4 M,按接续棒命名的过程临时区)
- .workbuddy/tmp/(39.5 M)
- 4 份 workbuddy.db 冗余副本(101 M,09-23 事故的坏副本 / 抢救产物)
- tmp/im16/gw/centrifugo 二进制(63.9 M,可重下)+ 缓存残留

入库范围:常驻规则(CODEBUDDY.md / README.md / state.py)、在途接续入口与
接续包、docs/、交付物/、交接单/、归档/、scripts/、.codebuddy/、
.workbuddy/memory/;共 398 件,其中 >60 KB 的 26 件全为文档。

排除(.gitignore):tmp/、待清理/、运行态日志与缓存、*.db 与 DB 备份整目录、
打包二进制(*.tar.gz / *.tgz)、记忆修复前备份。
2026-09-24 07:51:03 +08:00

194 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 跨节点迁移 & 节点自举 —— 完整流程方案
> 立稿 **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 一并排期。