Files

193 lines
15 KiB
Markdown
Raw Permalink Normal View 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 一并排期。