Files
dsh_shenxian/dsh-server-docs/交接单/T08-集群化落地-兼容单例模式.md
T

740 lines
60 KiB
Markdown
Raw Normal View History

# T08 · 集群化落地(Manager / Worker)—— **全程兼容单例模式**
| 项 | 值 |
|---|---|
| 单号 | **T08** |
| 规划会话 | `集群化落地规划(09-15)` |
| 状态 | 🔵 **待执行**(用户核对后开工) |
| 设计单一来源 | 项目根 `集群化改造方案_Manager-Worker_20260914.md`(19 节;本单只写"怎么做、怎么验、怎么退",**不复制设计内容**) |
| 冲突域 | **代码**(`src/supervisor/`、`src/db/`、`src/cli.ts`、`src/fs/`)+ **服务器**(106 测试机新建;**47 保持不动直到 S7**)+ **文档**(本单、`README §一`、完工时的 `04-调整方案/101`) |
| 依赖 | 无(T01–T07 均已归档) |
| 前置约束 | 用户口径:**要兼容现在的单例模式运行** ⇒ 每步结束必须仍可"单机 = 一个 dshs + 本机实例"完整跑通 |
---
## 1. 目标(一句话,可判定)
> **在 47 的生产形态不发生任何变化的前提下**,把平台改造成"1 组 Manager(≥2 台,也支持 1 台)+ N 台 Worker + 共享归属状态"的集群形态;**每一步都能单独上线、单独回滚,且单例模式(`deployMode=local`)在全部步骤结束后仍然可用**。
**完成判据(可被第三方复现)**:
1. 106 测试环境上:PG 后端 + 跨机协议 + 双 Manager 全部跑通,`scripts/smoke-*.mjs` 全绿;
2. 47 生产:**全程未改**,用户访问 URL / cookie / 直达会话行为与改造前逐项一致;
3. 单例回退:`DSHS_DEPLOY_MODE=local` + 去 `DSHS_DB_URL` 后,单机形态**一条命令切回且功能完整**。
---
## 2. 只读前置(执行前必须先核实,给命令与期望)
| # | 核实项 | 命令 | 期望 |
|---|---|---|---|
| 1 | 两台机器基线差异 | `ssh -p 32022 [email protected] 'ldd --version\|head -1; stat -fc %T /sys/fs/cgroup/'`;`ssh test106 '同上'` | 47 = glibc **2.32** / cgroup **v1**;106 = glibc **2.38** / cgroup **v2**(§17.2 已实测) |
| 2 | 106 上已有平台**不要碰** | `ssh test106 'systemctl is-active dsh-users-platform; ss -lntp \| grep 3080'` | `active` + `127.0.0.1:3080` ⇒ **本单另起一套(不同端口 + 不同数据根)** |
| 3 | 当前代码基线 | `git -C D:\github\dsh_shenxian log --oneline -1` | 记录 commit(本单一切改动基于它) |
| 4 | smoke 脚本可跑 | `ls D:\github\dsh_shenxian\scripts\smoke-*.mjs` | 6 个:admin / auth / domain / dsh / fs / isolation |
| 5 | 文档库双端一致 | `bash dsh-server-docs/scripts/docs-sync-check.sh` | 退出码 **0**(非 0 先处理,别带着脏库开工) |
> ⚠️ 第 1 条是**本单存在的理由之一**:两机 glibc/cgroup 不同 ⇒ **机器基线不能跟着用户迁移**(设计 §14.3)。执行时不要在 47 上做任何"顺手统一环境"的动作。
---
## 3. 范围
**改:**
- `src/db/`:`schema.ts`(v7 迁移,**SQLite 与 PG 两份同步**)、新增 `lease.ts` 相关仓储方法、`types.ts` / `adapter.ts` / `sqlite.ts` / `pg.ts` / `repo.ts` 同步补齐
- `src/supervisor/`:新增 `lease.ts`、`remote-spawner.ts`、`worker-agent.ts`;**不改** `orchestrator.ts` 的隔离与配额逻辑(P0 除外,见决策点)
- `src/cli.ts`:新增 `worker` 子命令
- `src/fs/`:新增 `remote-user-fs.ts`(复用现有 `web/file-service.ts`)
- `src/web/routes/admin.ts`:新增主机/迁移路由(**只加不改**)
- 新增脚本:`scripts/migrate-sqlite-to-pg.mjs`、`scripts/join-worker.sh`、`scripts/verify-cluster.mjs`
- 服务器:**106 上的新测试目录**(`/opt/dshs-cluster/` + 独立数据根 + 独立端口)
**明确不动(防顺手扩大):**
- ⛔ **47.77.182.89 的一切**(除 S7 的只读核对)—— 生产形态必须原样
- ⛔ 106 上**既有的** `dsh-users-platform` 服务与其数据根(另起一套,不接管、不改造)
- ⛔ `@deepseek-ai/dsh` 官方包(R2);不改 `web/**` 静态页;不动 `06-工作台UI规范` 基线
- ⛔ **不加**任何自动化任务(本库记载用户已明确"无自动化,勿重建")
- ⛔ 不动 `orchestrator.ts` 的 `MemorySwapMax` 缺口(**单独立项**,见回报格式第 4 条)
---
## 4. 决策点
| # | 事项 | 状态 |
|---|---|---|
| D1 | 门户形态 | ✅ **已定(用户 2026-09-14)**:双活,**同时必须支持单活**(同一套代码 1..N 台) |
| D2 | 存储后端 | ✅ **已定方向**:做成可插拔(本地盘 / 自建 NFS / 云 NAS);**S6 才引入共享存储** |
| D3 | PG 承载 | ✅ **已定方向**:一期自建(独立机 / 容器),二期再评估 RDS |
| D4 | 跨云是否作为一个集群 | ✅ **已定(实测否决)**:150–183 ms ⇒ **跨云只当故障注入测试床**,生产拓扑须同云(§19.1) |
| D5 | 测试环境落点 | ✅ **规划侧自定**:用 **106 另起一套**(不碰 47 生产、不碰 106 既有平台) |
| D6 | **生产拓扑最终落点** | ⏳ **需用户拍板(仅此一处)**:47 扩容 / 106 扩容 / 同云新购。**该项不阻塞 S0–S5**(S0–S5 全在 106 测试环境完成) |
| D7 | `systemd-run` 未设 `MemorySwapMax`(现存缺口,§17.3) | ⏳ **不在本单范围**,单独立项;本单只做**只读确认**(两机都有 swap、代码只设 `MemoryMax`) |
---
## 5. 步骤(**每一步自带一次可执行的验证 + 兼容单例检查**)
> **通用纪律**:每个 S 结束时都要过一遍 **① 冒烟全绿 ② 单机形态仍可跑 ③ 47 未变**。三关任一不过 ⇒ 停在该步、回滚,不进下一步。
### S0 · 前置与基线冻结(不产出功能,只产出"可对照的基线")
1. `bash dsh-server-docs/scripts/handoff-guard.sh --claim-exec "<执行会话名>"`(抢不到 = 停手 + 报告)
2. 记录 47 与 106 现有 dshs 的 commit / 版本 / 端口 / 数据根 ⇒ 落到本单「回报格式」
3. 在 47 上跑一遍完整 smoke 并存证据 ⇒ **这是"兼容"的对照基线**
4. 代码改动开分支(**不动 master**):`git -C D:\github\dsh_shenxian switch -c feature/cluster-1a`
5. 在 106 建独立测试目录 `/opt/dshs-cluster/`(端口 `13080`、数据根 `/opt/dshs-cluster/data`)—— **与既有 3080 那套物理隔离**
- ✅ 验证:47 smoke 全绿;106 新目录可启动 `--help`;两机基线记录在案
- ↩️ 回滚:`rm -rf /opt/dshs-cluster` + 删分支
### S1 · 数据层 SQLite → PG(**不改任何业务逻辑**)
1. 106 起 PG(容器或本地包),建库 `dshs` + 最小权限账号
2. 写 `scripts/migrate-sqlite-to-pg.mjs`:按 `src/db/schema.ts` 的**两份** DDL 建表,逐表搬数据;`users.uid`(`baseUid + row_id` 派生)与 identity 列**必须保持同值**
3. 在 106 测试环境以 `DSHS_DB_URL=...` 启动,跑全套 smoke
4. **同一组 smoke 在 SQLite 侧再跑一遍**,逐项对比行为一致
5. 回滚演练:去掉 `DSHS_DB_URL` 重启 ⇒ 应回到 SQLite 且功能完整
- ✅ 验证:PG 侧 smoke 全绿 + SQLite 侧同样全绿 + **回滚后仍全绿**
- ↩️ 回滚:`db/index.ts:19` 是唯一开关,去掉 env 即回
### S2 · 数据模型 v7(**只加表/列,不接线**)
1. `schema.ts` 加 `dsh_hosts` 表 + `dsh_instances.{host_id, epoch, heartbeat_at, lease_until}` —— **SQLite 与 PG 两份同步**
2. `types.ts` / `adapter.ts` / `sqlite.ts` / `pg.ts` / `repo.ts` 同步补齐(`sqlite.ts` 与 `repo.ts` 是两套独立实现,**漏一边会在切库时才炸**)
3. 新增 `src/supervisor/lease.ts`:`tryAcquire` / `renew` / `expireSweep`(**纯逻辑 + 可离线单测**;语义照抄 `leader.ts:200-216` 的乐观并发)
- ✅ 验证:迁移在**两种库**上都能建出结构;旧功能 smoke 全绿(新表不参与旧路径);lease 单测绿
- ↩️ 回滚:新表/列直接 drop(旧代码不读它们)
### S3 · Worker agent + RemoteSpawner(**同机验证,拓扑不变**)
1. `dshs worker` 子命令:复用 `LocalSpawner`,对外暴露内部 HTTP(绑定**内网网卡**)+ HMAC 鉴权 + **白名单动作** + **幂等键(operation_id)**;`GET /instances` **一次返回整机实例**(对账用,替代 O(N) 逐用户查询)
2. `RemoteSpawner implements Spawner`(新文件)—— 路由层**零改动**(`Spawner` 接口本就为此设计)
3. **同机自连**:Manager 用 `RemoteSpawner` → `127.0.0.1:9000` agent → 起实例
4. **`launchToken` 回传**(设计 §4.1 P0-6):worker 把实例 token 交给 manager ⇒ 「登录直达会话」必须与现状一致
- ✅ 验证:同机走"远程协议"跑通 **登录 → 直达会话 → 对话 → 我的文件**;401 自愈链路可用;**此时仍是单机,但走的是集群协议**
- ↩️ 回滚:`DSHS_DEPLOY_MODE=local` 切回 `LocalSpawner`(代码保留)
### S4 · 一台机器 = 一个 1a 集群(**单例兼容的关键形态**)
1. Manager 组:2 台(其中一台 `capacity=0`,只做门户/控制,**不承载实例** —— 规避"门户与实例抢内存"与"重启清 scope")
2. 控制面互斥:PG advisory lock(单 Manager 时自动退化为主)
3. 单活退化验证:撤掉 LB,域名直指一台
- ✅ 验证:门户双活可用;**kill 一台 Manager 门户不中断**;单活形态全功能可用
- ↩️ 回滚:停第二台 + 改 env
### S5 · 文件面跨机
1. `RemoteUserFs`(复用 `dshs file-service`,worker 本机起、manager 内网访问)
2. 门户「我的文件」:树 / 下载 / 上传 跨机可用
- ✅ 验证:管理端对**非本机**用户可浏览与下载
- ↩️ 回滚:切回 `LocalUserFs`
### S6 · 第二台 Worker + 存储决策 + 计划内迁移
1. 存储决策落地(本地盘 rsync 或 NAS;**这是 §12 的可插拔后端首次真实切换**)
2. `scripts/join-worker.sh`:自检(cgroup / swap / bwrap+setpriv / 运行时基线 / 版本自报)+ **建号**(§17.3 实测:**uid 必须有 `/etc/passwd` 条目,否则 `os.userInfo()` 失败**)+ nft 白名单(**写网段不写单 IP**)
3. 迁移一个用户:drain(SIGTERM→等落盘)→ 目标机 launch → 原子改归属(`epoch+1`)→ 旧机确认退出
- ✅ 验证:迁移后**会话无损**;旧机实例已停;**旧机复活被 fencing 拦下**(self-fencing)
- ↩️ 回滚:归属改回原 Worker(改一行 + `epoch+1`)
- ⚠️ **跨云(47↔106)只做故障注入验证**;**生产拓扑须同云**(D4/D6)
### S7 · 自愈与观测
1. 心跳(HTTP `/healthz`,**不用 ping** —— 实测 ICMP 被禁)+ 租约续期 + **心跳兼任 fencing 广播**
2. `dshs doctor` / `dshs cluster status` / `GET /metrics`
3. 故障注入(用跨云环境):心跳超时、Worker 失联、Manager 单点、PG 不可达、**脑裂复活**
4. 自动接管开关**默认关闭**(一期只告警 + 人工确认,与 R9 同精神)
- ✅ 验证:故障注入下数据未损坏(单一写者);恢复时间符合 §11.4 矩阵
- ↩️ 回滚:关掉开关即回人工模式
---
## 6. 验收(命令 + 期望输出)
| # | 命令 | 期望 |
|---|---|---|
| 1 | 106 测试环境:`bash scripts/verify-cluster.mjs` | 全绿;打印每个 S 的检查项 |
| 2 | 47 生产:`ssh -p 32022 [email protected] 'git -C /opt/dshs log --oneline -1; systemctl is-active dshs'` | **与 S0 记录的 commit 一致**(证明全程未动生产) |
| 3 | 单例回退:106 上 `DSHS_DEPLOY_MODE=local`(去掉 `DSHS_DB_URL`)重启 | smoke 全绿;用户 URL / cookie / 直达会话与改造前一致 |
| 4 | 访问模式逐项比对 | 门户 URL、`<用户名>.域名`、`cookieDomain`、直达 token、401 自愈、`/api/**` **六项与 §19.3 清单一致** |
| 5 | 文档库:`python3 scripts/docs-audit.py` | 退出码 **0** |
> 第 2 条是本单最重要的验收 —— **"兼容单例模式"的硬证据就是"生产 commit 一行没变"**。
---
## 7. 回滚
| 层级 | 回滚动作 | 粒度 |
|---|---|---|
| 单步 | S1 去 `DSHS_DB_URL`;S2 drop 表列;S3 切 `deployMode=local`;S5 切 `LocalUserFs`;S6 归属改回 | 分钟级、无损 |
| 整单 | `git -C D:\github\dsh_shenxian switch master`(**不 push**)+ 106 删除 `/opt/dshs-cluster` | 不影响 47 |
| 生产切换后 | 47 上恢复 S0 记录的 env + systemd 单元(**切回单机 PG/SQLite**) | 需先演练(设计 §13.5 第 4 条) |
---
## 8. 回报格式(执行会话必须回填)
1. **每步的验证输出**(命令 + 原文 + 退出码),特别是三关:smoke 全绿 / 单机仍可跑 / 47 未变;
2. **47 的 commit 前后对照**(证明生产未被触碰);
3. **两库一致性证据**:同一组 smoke 在 SQLite 与 PG 两侧的输出对比;
4. **只读确认**(不属于本单、需单独立项):`orchestrator.ts:749-751` 只设 `MemoryMax` + `TasksMax`,**未设 `MemorySwapMax`**,而 47 与 106 **都有 swap** ⇒ 实例超限会先换出而非被 OOM kill ⇒ 档案 20 与 78 的"熔断自愈"语义在有 swap 的机器上不成立。**本单不动它**,回报里写清现状即可;
5. **未完成项**:若某步卡住,写明卡在哪(证据)+ 已做到哪一步 + **什么条件一出现必须回头解决**(不写"后续优化"这类空话);
6. **锁状态**:结束时写明已释放(或显式点名锁在谁手上 + 原因)。
---
> **本单与设计文档的分工**:设计(架构 / 网络 / 存储 / PG / k8s 对比 / 兼容矩阵)在项目根 `集群化改造方案_Manager-Worker_20260914.md`;本单只承载**执行步骤、验证、回滚、回报**。完工后写 `04-调整方案/101`(档案号以开工时实跑为准)。
---
## 9. 执行记录(2026-09-15,执行会话 `exec-cluster-1a`)
### 9.1 S0 · 前置与基线 ✅ 完成
**两机基线(开工冻结)**
| 项 | 47.77.182.89(**生产**,阿里云) | 106.54.21.172(**测试**,腾讯云) |
|---|---|---|
| 平台目录 | `/opt/dshs`(**无 .git** ⇒ 基线用文件 hash) | `/root/dsh-users-platform`(无 .git) |
| systemd | `dshs` = `node lib/cli.js --db /var/lib/dshs/dshs.db`(WorkingDirectory `/opt/dshs`) | `dsh-users-platform`(`EnvironmentFile=/etc/dsh-users-platform.env`) |
| **代码 hash** | `lib/`=**8423ed1c4ab0931513bf2e3ba95e9027**(172 文件)|`web/`=**7e001f0a4ec6c3a45dc30081ca505f43**|`package.json`=**878f7e9afec848975279ec4a37249978** | —(本单不用它那套) |
| systemd unit md5 | **545ebe09a090aebf70f2fd91311edd9e** | — |
| DB | 131072 bytes / 用户数 6(开工时) | 未使用 |
| dsh 版本 | **0.1.5-rc.1** | 同(`/usr/bin/dsh`) |
**本步对原计划的 3 处修正(都是执行中发现,已按更安全做法执行)**
| # | 原计划 | 实际做法 | 理由 |
|---|---|---|---|
| 1 | S0.3「在 47 上跑一遍完整 smoke 存对照基线」 | **不在 47 跑**;smoke 基线整体移到 **106**(`/opt/dshs-cluster`) | ① 47 是生产库,smoke 虽自包含(见下)但没必要在生产机器上留痕;② **本地与 47 都没有 `dsh` 之外的完整测试夹具**(47 的 `/opt/dshs/scripts/` 里**没有 `fake-dsh.mjs`**,实测报 `Cannot find module`) |
| 2 | S0.4「开分支 `feature/cluster-1a`」 | **不建分支** | 本机工作树**与其他会话共享**且已有 **11 个别人的未提交改动**(`package.json`/`web/*.html`/`business-plugins`/`scripts/verify-*.mjs`)⇒ 建分支会把别人的改动一起带到新分支,反而制造风险。§4 提交边界也不需要分支。**改为:只写自己清单内的文件 + 保留回滚清单** |
| 3 | S0.5「在 106 建独立测试目录」 | ✅ 照做:`/opt/dshs-cluster`(服务端口 13080/13081、数据根独立、PG 独立数据目录) | 与 106 既有 `dsh-users-platform`(3080)**物理隔离**,全程未碰它 |
**顺带查清 smoke 的真实性质(对后续步骤有用)**:`scripts/ci.sh` 明确写「**不纳入** smoke —— 它们需要平台正在运行 + 有效账号凭据」;实测 smoke 脚本是**自包含**的:`import ../lib/web/server.js` + `dbPath: ':memory:'` 在进程内起服务,launch 类用 **stand-in `fake-dsh.mjs`** ⇒ **不碰任何生产数据**。且 `resolveConfig` 里 `dbUrl` 优先读 env ⇒ **设 `DSHS_DB_URL` 即可让同一组 smoke 跑在 PG 上**(这正是 S1.4「两侧对比」的可行基础)。
### 9.2 S1 · SQLite → PG ✅ 完成(**S1.4 结论:两侧行为一致**)
**S1.1 PG 就位(隔离实例,不碰系统默认集群)**
```
dnf install postgresql-server postgresql → PostgreSQL 15.19(OpenCloudOS AppStream)
initdb -D /opt/dshs-cluster/pgdata → 独立数据目录
pg_ctl ... -o '-p 15432 -c listen_addresses=127.0.0.1' → 手动 pg_ctl 启动,**未建 systemd 单元**
CREATE ROLE dshs / CREATE DATABASE dshs、dshs_smoke
```
⇒ 回滚 = `pg_ctl stop` + `rm -rf /opt/dshs-cluster`,对宿主机零残留。
**S1.3/S1.4 两侧对比(同机、同代码、同 env,只差 `DSHS_DB_URL`)**
| smoke | SQLite 侧 | PG 侧 |
|---|---|---|
| smoke-admin / auth / dsh / fs / smoke / subdomain | ✅ | ✅ |
| **smoke-domain** | ✗ | ✗ |
| **smoke-isolation** | ✗ | ✗ |
| **合计** | **6/8** | **6/8(失败项完全相同)** |
⇒ **两个后端行为一致**(同机同代码下逐项吻合),这是 S1 的核心判据。
**假阳性已排除**:PG 侧跑完后检查 `dshs_smoke` 库 —— 有 **10 张平台表**(含 `schema_migrations`)、`schema_migrations` 有版本记录、`users`=2 / `sessions`=2 / `audit_log`=2 ⇒ **smoke 的读写确实落在 PG 上**,不是"两边都在用内存 SQLite"。
**S1.5 回滚演练 ✅**:同一份 `lib/`,加 `DSHS_DB_URL` 起服务 → `login.html` **200**;去掉该 env 再起 → **200**(SQLite 落盘 4096 B)⇒ **一条环境变量即可来回切**。
**S1.2 迁移脚本 ✅ 落盘并实测**:新增 `scripts/migrate-sqlite-to-pg.mjs`
- 设计:列清单**不写死**(PG information_schema ∩ SQLite PRAGMA);PG 结构**由平台自己的迁移**建立(`ensureDbSchema`);identity 列用 `OVERRIDING SYSTEM VALUE` + 搬完 `setval`;`--dry-run` 只报数。
- 实测(造 2 用户 + 2 审计 → 迁移):逐表行数一致 ✅|**uid 原样搬运**(100001/100002,这是"用户文件属主不失配"的关键)✅|`audit_log.id` 原样 1/2 ✅|**迁移后新建用户得 100003 不撞主键**(序列正确)✅
- ⚠️ 踩坑 1 处(已修):第一版把 `schema_migrations` 也搬了 ⇒ 与平台迁移已建的版本行**唯一键冲突**,事务整体回滚。修法 = 显式 `SKIP`(语义上也应如此:**结构版本由平台在目标端决定**)。
### 9.3 🔴 新发现的两处**阻塞 / 待定性问题**(都不在 S1 范围内,但影响 S3/S6)
**发现 1(P0 阻塞):106 上 account 隔离模式的实例起不来 —— bwrap 中间挂载点权限 0700**
```
[dsh-child main] Running as unit: dsh-50001-*.scope
/usr/bin/node: OpenSSL configuration error:
...Permission denied: ...calling fopen(/etc/ssl/openssl.cnf, rb) → exitCode 13
```
**根因(已复现 + 已定位到硬证据)**:`orchestrator.ts` 的 bwrap 用 `--tmpfs /etc` + 白名单绑定。bwrap 为绑定 `/etc/pki/tls/certs` 会**自动创建中间目录** `/etc/pki`、`/etc/pki/tls`,而它们的权限是 **`drwx------`(0700)**:
```
drwx------ /etc/pki ← bwrap 自动创建
drwx------ /etc/pki/tls ← bwrap 自动创建
drwxr-xr-x /etc/ssl
```
⇒ 以 uid 50001 运行时**无法穿越 `/etc/pki`**,而 106 上 `/etc/ssl/openssl.cnf` 是**指向 `/etc/pki/tls/openssl.cnf` 的符号链接** ⇒ 解析失败报 **EACCES(不是 ENOENT)** ⇒ node 硬失败。
**47 为什么没事**:47 上 **`/etc/ssl/openssl.cnf` 根本不存在** ⇒ node 静默跳过 ⇒ 同一份代码在 47 上能起。**这是设计 §14.3「机器基线不能跟着迁移」的第一个真实实例。**
**已验证的修法(在 106 上实测)**
| 方案 | 结果 | 结论 |
|---|---|---|
| **D. `--perms 0755 --dir /etc/pki` + `--perms 0755 --dir /etc/pki/tls`**(在绑定之前) | ✅ node 正常启动 | ✅ **采纳**:只让 bwrap 自建的中间目录可穿越,**零权限扩大** |
| E. 整绑 `/etc/pki` | ✅ 也能通过 | ❌ **否决**:`/etc/pki/tls/private/postfix.key` 存在 ⇒ 会把**私钥目录**暴露给所有实例(违反 R5 权限只准收窄) |
| C. 把 symlink 的真实目标绑到链接原路径 | ✗ `bwrap: Can't create file at /etc/ssl/openssl.cnf` | 不可行(**bind 不能覆盖悬空符号链接**) |
> 另注:该修法是**普适**的 —— 任何"宿主把配置文件放 X、再用符号链接从 Y 指过去"的发行版都会踩到,不只是 `openssl.cnf`。
**发现 2(待定性,与 DB 无关):`smoke-domain` 的 Location 前缀未重写**
打印值 = `/somewhere`,断言期望 `/u/u1/dsh/somewhere` ⇒ **legacy 子路径代理的 Location 重写未生效**。两侧(SQLite/PG)表现相同,且我只读未改代码 ⇒ **必为既有行为**。可能与"生产用子域、`baseDomain` 已设时不再走子路径重写"有关,**需单独立项定性**,不在本单范围。
### 9.4 验收三关状态
| 关 | 状态 | 证据 |
|---|---|---|
| ① smoke 全绿 | ⚠️ **两后端一致 6/8**(2 项为上述既有/机器相关问题,非 DB 与本单改动) | §9.2 表 |
| ② 单机形态仍可跑 | ✅ | S1.5 演练:去掉 `DSHS_DB_URL` 即回到 SQLite,服务 200 |
| ③ **47 生产未变** | ✅ | 复检:`lib/`、`web/`、`package.json`、systemd unit **四个 hash 与开工时逐字一致**;DB 字节数 131072 一致。⚠️ 用户数 6→7 = **平台在正常服务**(非本单动作,本单全程只在 106 测试目录操作) |
### 9.5 本步新增/改动的文件
| 文件 | 位置 | 说明 |
|---|---|---|
| `scripts/migrate-sqlite-to-pg.mjs` | 本机 `D:\github\dsh_shenxian\scripts\` | **新增**(S1.2);未 commit(§4 提交边界) |
| 106 `/opt/dshs-cluster/**` | 测试机 | 独立测试环境(载荷:`lib scripts package.json assets web poc cordis.patch.yml ensure-role-profile-patch.cjs mksess.cjs README.md STANDARD.md`;`node_modules` 软链到既有平台,零重复安装) |
| ⚠️ **载荷清单教训** | — | 第一版漏了 **`assets/`** ⇒ `proxy.js` 因缺 `assets/inject/recovery.js` **硬失败**(档案 81 R1「assets 必须随包部署」)。**S6 的 `join-worker.sh` 必须把 `assets/` 列进清单** |
### 9.6 下一步(按修正后的顺序)
1. **先修发现 1**(bwrap 中间目录 0700)—— 否则 106 无法承载实例,S3/S6 全部走不下去。**该修涉及 `orchestrator.ts`(隔离层)**:需按 §9.3 的 D 方案改,并在 **47 上做回归**(确认 47 行为不变)+ 106 上重跑 `smoke-isolation` 应变绿。⚠️ 按本单原「范围」约定 `orchestrator.ts` 不在改动面内 ⇒ **把它作为本单的新增子项(S1.6)执行,并在动手前一句话说明**。
2. 发现 2(`smoke-domain`)单独立项定性。
3. 之后回到 S2(schema v7 + `lease.ts`)。
---
## 10. S1.6 执行记录(2026-09-15,修 bwrap 中间挂载点 0700)
### 10.1 改动(`src/supervisor/orchestrator.ts`,2 处新增 + 1 处调用点)
新增两个模块级助手:
- `mountParentDirList(dest, stopAt)` —— 列出 `dest` 与 `stopAt` 之间的祖先目录(由外到内)
- `mountParentDirArgs(dest, stopAt)` —— 摊平成 bwrap 参数
调用点 2 处:① `/etc` 白名单装配(对每条白名单路径补中间目录)② `--bind root root` 与共享技能层绑定**之前**(同因)。
### 10.2 🔴 关键教训:第一版用了 `--perms`,**会被 47 的旧 bwrap 直接拒绝**
| 机器 | bubblewrap 版本 | `--perms 0755 --dir` | 结果 |
|---|---|---|---|
| 106(OpenCloudOS 9.6) | **0.11.0** | ✅ 支持 | 106 上验证通过 |
| **47(Alibaba Cloud Linux 3)** | **0.4.0** | ❌ **`bwrap: Unknown option --perms`** | **沙箱起不来 = 所有实例全挂** |
**是 47 回归测试把它抓住的**(这正是设计 §14 要求"每步在 47 复检"的价值):47 上 `/etc` 可见条目从 **78 → 0** ⇒ 一眼看出 bwrap 拒绝启动。
**改用 `--tmpfs`**:该选项在 **0.4.0 上就可用**,且**自带 0755**(本文件另一处注释早已写明「bwrap 的 `--tmpfs` 权限是 755」)。47 实测:`/etc` 条目 **78 → 78(零变化)**、`/etc/pki` 由 `drwx------` 变 `drwxr-xr-x`。
### 10.3 修复效果验证(三机证据)
| 验证项 | 106 | 47(回归,**未部署**,仅同参数实测) |
|---|---|---|
| OpenSSL EACCES 出现次数 | **1 → 0**(修前/修后) | 本来就没有(该机无 `/etc/ssl/openssl.cnf`) |
| `/etc` 可见条目数(旧 vs 新参数) | **495 == 495** | **78 == 78** |
| bwrap 接受参数(不报 Unknown option) | ✅ rc=0 | ✅ rc=0 |
| 中间目录权限 | 0700 → 0755(可穿越) | 同上 |
| 私钥可达性(必须不可达) | `/etc/pki/tls/private` **No such file** ✅ | 同 |
| 沙箱内 `/opt` 链可见面 | 只有该用户自己的树(7 条目),**平台目录不可见** ✅ | — |
**忠实链探针**(模拟平台姿态:`systemd-run --scope` → bwrap → setpriv → node,cwd 在用户工作区):
```
cwd=/opt/dshs-cluster/probe/users/u1/ws/proj uid=50001
✓ 相对路径写成功 → 落盘 -rw-r--r-- 50001:50001
```
⇒ **account 模式的启动链本身是通的**。
### 10.4 ⚠️ `smoke-isolation` 无法作为本项的验收(**测试夹具的结构性缺陷,非平台缺陷**)
修完 OpenSSL 后该测试仍 rc=1,逐一定位到**两个夹具自身的限制**(都与平台代码无关):
| # | 夹具限制 | 证据 |
|---|---|---|
| 1 | **夹具路径必须在沙箱绑定集内** —— `fake-dsh.mjs` 位于平台 `scripts/`,而沙箱只绑 `/usr`、`/lib64`、`/etc` 白名单、用户根、技能层 | 平台放 `/opt/dshs-cluster` 时:`Cannot find module '/opt/dshs-cluster/scripts/fake-dsh.mjs'`;放到 `/usr/local/dshs-cluster`(`/usr` 被绑)后该错消失 |
| 2 | **数据根在 `mkdtemp(/tmp)` 下会与沙箱的 `/tmp` 绑定冲突** —— 平台把 `/tmp` 绑成**用户私有 tmp**(`--bind <userRoot>/tmp /tmp`),于是 cwd 的宿主路径在沙箱内不存在 | `bwrap: Can't chdir to /tmp/dsh-smoke-iso-XXXX/users/u1/ws/proj: No such file or directory`(生产 `dataRoot=/var/lib/dshs` 不会撞,故线上无此事) |
⇒ 结论:**该测试在 account 模式 + `/tmp` 数据根下必然失败**;它自带的 `mkdtemp` 就是这种数据根。**不要拿它当 account 模式的验收**。
**替代验收(已做,见 10.3)**:① OpenSSL 错误的修前/修后 A/B ② 忠实链探针 ③ 双机可见面条目数对比。
📌 **遗留项**:修 `smoke-isolation` 的夹具(改数据根位置 + 把 `fake-dsh.mjs` 放到可见路径),或另写一个 account 模式的验收脚本 —— 属**独立小项**,不阻塞本单。
### 10.5 本步边界确认
- **47 未部署**(criterion ③ 仍需保持"47 一行未变")—— 本步只在 106 部署;47 只做**同参数实测**(在临时目录里跑 bwrap,不碰生产进程)。
- 改动**只增不减**(新增助手 + 两个调用点),未触碰配额/隔离语义本身;可见面**零变化**(§10.3)。
- ⚠️ **`--tmpfs` 的代价**:每个中间目录多一个空 tmpfs 挂载(数量 = 白名单路径的祖先目录数,当前 ≤ 3)。极小,已在注释中写明。
---
## 11. S2 + S3 执行记录(2026-09-15 13:2x–14:0x)
### 11.1 S2 · 数据模型 v7 + 租约服务 ✅
| 改动 | 文件 |
|---|---|
| **迁移 v7**(`dsh_hosts` + `dsh_instances.{host_id,epoch,heartbeat_at,lease_until}` + 两个索引),**SQLite 与 PG 两方言同时写** | `src/db/schema.ts` |
| 类型:`DshHost`/`DshHostStatus`/`ClaimResult`/`clusterInstanceId()`;`DshInstance` 扩 4 字段 + 映射 | `src/db/types.ts` |
| 接口:hosts CRUD + `claimInstance`/`renew`/`release`/`listExpiredInstanceLeases`/`listInstancesByHost` | `src/db/adapter.ts` |
| SQLite 侧实现(同步层 + 薄封装) | `src/db/repo.ts`、`src/db/sqlite.ts` |
| PG 侧实现(`UPDATE … RETURNING` 做原子抢占) | `src/db/pg.ts` |
| **租约服务**:`InstanceLease`(acquire/renew/renewAll/release/refresh/expiredHere/expiredAll/mine)+ `stillHolder()` fencing 判据 + **`ttl > 2×renew` 硬校验** | `src/supervisor/lease.ts`(新) |
| 单测 10 例(**不 mock 时钟**,走真实 SQL + 极短 TTL 制造过期) | `test/lease.test.mjs`(新) |
**验收**:`node --test test/lease.test.mjs` —— **SQLite 10/10 == PG 10/10**(PG 侧在 106 上用 `LEASETEST_PG_URL` 跑)。
覆盖:首次抢占 epoch=1|未过期时他人抢占失败(单写者)|**旧持有者续租失败(fencing)**|过期后可抢占且 epoch 递增|释放后 epoch 不复用|老持有者不能清掉新持有者的归属|`stillHolder` 双条件|对账视图(`mine` 只回本机 / `expiredAll` 回全局)|`renewAll` 回传失权清单。
🔴 **踩坑(已修)**:`INSTANCE_COLS` 没加 v7 的 4 个新列 ⇒ 查询能匹配行但映射出来的 `hostId` **恒为 null**(3 个用例挂在这里)⇒ 两方言的列清单都必须同步。
### 11.2 S3 · Worker agent + RemoteSpawner ✅(**端到端通过**)
| 改动 | 文件 |
|---|---|
| **worker agent**:`/healthz`、`/instances`(一次拿回整机)、`/launch`、`/stop`、`/status/:id`、`/endpoint/:id`、`/fence`、`/restart-probe/:id`、`/watchdog/:id`、`/touch/:id`;**bearer 鉴权(timingSafeEqual)+ 白名单动作 + 幂等缓存 + epoch 跟踪** | `src/worker/agent.ts`(新) |
| **复用 `LocalSpawner`**(隔离/配额/退避/熔断/探活**原样保留**)⇒ 只加了两个 additive 方法:`listUserInstances()`、`launchTokenOf()` | `src/supervisor/orchestrator.ts` |
| **远端 Spawner**:实现 `Spawner` 全部方法;**重试 200ms/1s/3s + 全程复用同一 `operationId`** | `src/supervisor/remote-spawner.ts`(新) |
| 配置:`DeployMode` 加 `'cluster'` + `clusterHostId`/`clusterAgentUrl`/`clusterAgentToken`/`clusterInstanceHost` | `src/config.ts` |
| 选后端:`deployMode=cluster` → `RemoteSpawner`;**没配 agent 地址则 fail-loud** | `src/web/server.ts` |
| `dshs worker` 子命令 | `src/cli.ts` |
| 端到端脚本 | `scripts/verify-cluster-agent.mjs`(新) |
**关键设计(**2026-09-15 用户纠正后精确化**)**:**Worker 不写控制面数据**(尤其**归属/租约** —— 双写就是脑裂);因此 `apiKey` 与 `uid` 由 **Manager 在 `/launch` 时投递**,agent 只存内存(与 k8s 用 per-user Secret 同一思路),uid 兜底是纯函数 `hashUid`。这是**权限收窄**选择(凭据不经网络下发 vs. 让 Worker 能读全量凭据库),符合 R5。
⚠️ **但"Worker 不碰控制面 DB"≠"Worker 不许有数据库"**:插件的大量长期数据(实测如 `home/.dsh/mcn-plugin.db`)属于**实例业务数据**,跟着用户 home 走、由实例自己读写;Worker 也可以有**自己的运维库**(实例台账/指标/日志)。完整判据与三种插件落点见设计 **§1.3 数据分层**。
**验收(106 权威环境)**:`node scripts/verify-cluster-agent.mjs` → **OK**,四项为保证全绿:
```
✓ 路由/代理层零改动(Spawner 抽象 + endpointFor 的 host:port 即可)
✓ launch token 回传(P0-6)—— launch URL 里带 token
✓ 幂等键 —— 同一 operationId 重发后实例数仍为 1
✓ self-fencing —— epoch 1→2 触发,agent 主动停掉自己那个实例
```
另证:**实例真的在 worker 上**(agent `/instances` 视角 1 个实例)、代理 `/u/u1/dsh/hello` 经远端协议命中 fake-dsh。
**🔴 两处踩坑(都已修)**
1. **夹具 `fake-dsh.mjs` 不吐 launch token** ⇒ 平台(真实 dsh 的正则 `/dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=([A-Za-z0-9_-]+)/`)只能等满 10 s 超时,`launchToken` 永远为 undefined ⇒ **「登录直达会话」这条链路在本机测试里根本测不到**。补上 token 行后 launch 从 10.2 s 降到即时。
⇒ **连带暴露 `smoke-subdomain:78` 的陈旧断言**(把 URL 写死成不带 token 的相等,是"夹具不吐 token"时的意外产物)⇒ 改为 `startsWith(子域)` + 显式断言不回环端口。**复跑冒烟回到 6/8(失败项与基线完全相同)**。
2. **agent 在"实例已在跑"的路径上没记 epoch** ⇒ `/fence` 拿不到我的 epoch、self-fencing 永不触发。修法 = **epoch 必须在尝试 spawn 之前落**(它记的是"Manager 的意图",不是"我成功 spawn")。
### 11.3 ⚠️ 影响后续步骤的重要发现:**跨机原语已被删除**
今早另一个会话(06:26)把 K8s 后端连同 **`src/tcp-bridge.ts`、`src/web/file-service.ts`、`src/fs/k8s-user-fs.ts`** 一并删除了(当时按"k8s 专用"处理)。而设计里 **S5(文件面跨机)与 S6(跨机端口暴露)本打算复用它们** ⇒ **该假设失效**。
两条出路(**S4 之后再定**):
- **(a) 按 cluster 语义自建**:`dshs bridge <listen> <target>`(TCP 转发,~60 行)+ 每用户 file 服务(~120 行)—— 保留原设计的"方案 A:实例端口 + 网段白名单";
- **(b) 让 agent 直接隧道**(更优):只暴露 **agent 一个端口**,由它按用户把 HTTP/WS 转到本机 `127.0.0.1:<port>` ⇒ 暴露面从 N 个端口降到 1 个,且 `portGuard` 语义完整保留。代价 = agent 要实现 WS 隧道。
> S3 的**同机**形态两条都不需要(Manager 与实例都在 127.0.0.1)⇒ 不影响已完成的部分。
### 11.4 本步验收三关
| 关 | 状态 |
|---|---|
| ① 冒烟全绿 | **6/8**,失败项 `smoke-domain`/`smoke-isolation` **与基线完全相同**(= 无回归;两项已定性为机器/夹具问题) |
| ② 单机形态仍可跑 | ✅(`deployMode` 默认 `local` 不变;`lease` 单测两侧全绿;`dshs worker` 不影响 server 路径) |
| ③ **47 生产未变** | ✅ `lib/`、`web/`、systemd unit **三个 hash 逐字一致**,`dshs` active(全程只在 106 测试目录操作) |
### 11.5 下一步:S4
1a 形态:2 台 Manager(其中一台 `capacity=0`)+ Worker-01 同机;单活退化验证;`dsh_hosts` 注册 + 心跳。
⚠️ 注意与 11.3 的连带影响:S4 若要"跨机代理"就必须先定 (a)/(b);**1a(同机)不受影响**,可以先做。
---
## 12. S4 执行记录 + 一处设计纠正(2026-09-15 13:5x–14:2x)
### 12.1 🔴 设计纠正(用户口径):**Worker 不是"不许有数据库"**
用户原话:**「不一定 worker 不连数据库,有些插件有可能有大量数据需要保存和管理、长期使用」** —— 我原先在 §1.2 写的「Worker ❌ 不碰 DB」**过窄**:它想说的其实只有「**控制面数据(尤其归属)不能由 Worker 写**」。插件的长期大量数据**根本不在同一层**。
已改:设计文档新增 **§1.3「数据分层」**,并同步修正了 `agent.ts` / `remote-spawner.ts` / `cli.ts` 里的三处注释。**四条判据**:
| 数据类 | 唯一写者 | 落点 |
|---|---|---|
| 控制面元数据(用户/会话/凭据/**归属租约**) | **仅 Manager** | Manager 的 PG |
| **实例业务数据**(如 `home/.dsh/mcn-plugin.db`、工作产出) | **Worker(该实例)** | 实例 home ⇒ 跟用户迁移 |
| Worker 运维数据(实例台账/指标/日志) | Worker 本地 | **Worker 本地库**,不进控制面 PG |
| 控制面数据的只读副本(可选) | 只读 | Worker 本地缓存 |
**判据**:① 是归属/租约吗 ⇒ 只有 Manager 能写;② 跟着用户走吗 ⇒ 放实例 home,别塞控制面 PG;③ 是本机运维用的吗 ⇒ 放 Worker 本地库。
**插件三种落点**:现状 `mcn-plugin.db`(per-user SQLite 在 home,天然跟迁移)|量大要真 DBMS ⇒ 每用户一库 + 数据目录放位置无关存储|跨用户聚合 ⇒ 由 Manager 侧离线汇总承接,**不在 Worker 上做**。
### 12.2 S4 · 归属租约接进启动路径 ✅(**端到端五项全绿**)
| 改动 | 文件 |
|---|---|
| `Spawner.launch` 的 opts 增加 **`epoch`**(集群模式下 epoch 是 launch 契约的一部分) | `src/supervisor/spawner.ts` |
| `RemoteSpawner.launch` 透传 epoch 给 agent | `src/supervisor/remote-spawner.ts` |
| **`LeasedSpawner`(新)**:装饰任意 `Spawner` —— `launch` 前 **claim**(失败抛 `LeaseBusyError` = 退让);心跳 **renewAll**,失权即向 agent 下发更高 epoch(`/fence`);`stop` 时 **release**;启动时**注册 host + 起心跳**(`dsh_hosts` 状态位) | `src/supervisor/leased-spawner.ts`(新) |
| cluster 分支 = `LeasedSpawner(RemoteSpawner(...))` + 租约参数来自 env(`DSHS_CLUSTER_LEASE_TTL_MS` / `_RENEW_MS` / `DSHS_CLUSTER_CAPACITY_MB`) | `src/web/server.ts` |
| 端到端脚本 | `scripts/verify-cluster-lease.mjs`(新) |
**验收(106,两个 Manager 共享同一 PG + 同一 agent,短 TTL 秒级制造过期)**:
```
注册/心跳 -> dsh_hosts = m-1:up, m-2:up
① 归属落库 -> host_id=m-1 epoch=1 lease_until=+1019ms
② 单写者 -> m-2 抛 LeaseBusyError(holder=m-1),归属未被改动
③ 释放即接手 -> host_id=m-2 epoch=2(epoch 单调递增,不用等 TTL)
④ 失权即 fence -> worker 实例数 1 → 0(self-fencing 生效)
归属仍在 m-1 名下(未被误清)
```
⇒ **② 是 S4 的核心**:多机下"两个 Manager 抢同一个用户"被 DB 的原子 CAS 挡住 ⇒ 不会双写同一个 home。
**回归(S4 动了 `spawner.ts`/`server.ts` ⇒ 全量复跑)**:冒烟 **6/8**(失败项 `smoke-domain`/`smoke-isolation` 与基线相同)|S3 端到端 **OK**|lease 单测 **SQLite 10/10 == PG 10/10**。
**🔴 两处编译期踩坑(已修)**:① `quotaInfo` 的签名已被其他会话扩过(多了 `baseMb`)⇒ 委托方法要按**当前**接口对齐;② 我把心跳停止器命名为 `stop()`,与 `Spawner.stop(userId)` **重名** ⇒ 改名 `stopHeartbeat()`。
### 12.3 三关
| 关 | 状态 |
|---|---|
| ① 冒烟 | **6/8**,失败项与基线完全相同 ⇒ 无回归 |
| ② 单机形态仍可跑 | ✅(`deployMode` 默认 `local`;`LeasedSpawner` 只在 cluster 分支装配) |
| ③ **47 生产未变** | ✅ `lib/`、`web/`、systemd unit 三个 hash 逐字一致,`dshs` active |
### 12.4 下一步
**S5(文件面跨机)/ S6(第二台 Worker + join + 迁移)**:先按 11.3/(b) 让 **agent 直接隧道**(只暴露 1 个端口,`portGuard` 语义完整)—— 这需要在 agent 上实现 HTTP/WS 转发;随后才是 join 脚本与计划内迁移。
---
## 13. S5 + S6 + S7 执行记录(2026-09-15 14:0x–15:0x)
### 13.1 S5 · 跨机文件面 ✅
| 改动 | 文件 |
|---|---|
| agent 新增 **`/fs/*`**(init/list/mkdir/create/upload/read/isdir/plugins/handoff/root)—— **复用同一个 `LocalUserFs`**,`UserFsError.code → HTTP 状态` 按 seam 设计还原 | `src/worker/agent.ts` |
| **`RemoteUserFs`**:路径安全复用 `fs-guard` 的 `resolveWithinRoot`(**同源**,不是重写一套);`resolvePath` 按 **worker 的 dataRoot** 做纯路径数学 | `src/fs/remote-user-fs.ts`(新) |
| `createUserFs` 按 `deployMode` 选实现;新增 `DSHS_CLUSTER_WORKER_DATA_ROOT`;启动时**探测 worker 的 dataRoot 并报不一致** | `src/fs/provider.ts`、`src/config.ts`、`src/web/server.ts` |
| 端到端脚本 | `scripts/verify-cluster-fs.mjs`(新) |
**验收(关键设计:Manager 的 dataRoot 故意与 worker 不同)**:
```
worker -> dataRoot /tmp/dsh-cfs-worker-… manager -> dataRoot /tmp/dsh-cfs-manager-…(不同 ⇒ 能证明走远端)
④ resolvePath -> …/dsh-cfs-worker-…/users/u1/ws/proj(按 worker 算)
① 门户路由 -> tree/mkdir/upload 全部经远端通过
② 落点 -> worker 有、manager 无(确认走远端)
③ 路径安全 -> bad_path 与本地同源;下载回读内容逐字一致
```
**基线约定(写进代码注释)**:cluster 模式下**所有 worker 的 dataRoot 必须是同一绝对路径**(同镜像即可满足);不一致时 `buildServer` 启动即报。
🔴 **踩坑(已修)**:agent 的 `/fs/create`、`/fs/upload` 直接 `return` 了 `LocalUserFs` 返回的**字符串** ⇒ Fastify 当 `text/plain` 发出 ⇒ 调用方 `JSON.parse` 失败 = **静默 500**。⇒ 必须包成 `{name}`。
### 13.2 S6 · 多 worker + 容量准入 + **计划内迁移** ✅(**本方案的落点**)
| 改动 | 文件 |
|---|---|
| 租约记住"认领在哪台":`held: Map<userId, {epoch, hostId}>`(多机后续租/释放必须带对 host) | `src/supervisor/lease.ts` |
| `Spawner.launch/stop` 支持显式 `hostId` | `src/supervisor/spawner.ts` |
| **多 host**:`hosts` + `hostsProvider`(**懒加载 + 30s TTL** ⇒ **新增 worker 无需重启 Manager**)+ `hostIdFor`(按归属路由)+ **未知 host 直接报错**(拒绝静默改投) | `src/supervisor/remote-spawner.ts` |
| **选机**:`selectHost`(容量准入,最空优先)+ `agentFor`(fence 发给**实例所在那台**);`launch` **先选机、再认领**(保证租约 `host_id` 与实例落点一致) | `src/supervisor/leased-spawner.ts`、`src/web/server.ts` |
| 管理面:`GET/POST /api/admin/hosts`(**绝不下发 agentToken**)、`POST /api/admin/users/:id/dsh/migrate`(drain → 目标机拉起 → 归属原子更新) | `src/web/routes/admin.ts` |
| 端到端脚本 | `scripts/verify-cluster-migrate.mjs`(新) |
**验收(两台 worker 共享 dataRoot,模拟共享存储)**:
```
① 容量准入 -> 落到 w-a(w-b 已 3800+512>4096 被排除),host_id=w-a epoch=1
② 迁移 -> w-a → w-b,host_id=w-b epoch=2,源机实例已停
③ 迁移后 -> 代理 200(经 w-b)、文件可读(数据不搬家)
④ 边界 -> already_there / unknown_host 都明确报错
```
**容量语义(已定,写进注释)**:`capacityMb > 0` = 声明容量并参与准入(`已用 + 预留 ≤ 容量`,`DSHS_CLUSTER_RESERVE_MB` 默认 512);`≤ 0` = 未声明(不设限);**`-1` = 显式禁用承载**(专用 Manager)。
**`DSHS_CLUSTER_REGISTER_SELF=0`**(新增):**一个 agent 只应有一条 host 记录** —— 专用 Manager 部署必须关掉自我注册,否则会出现两条指向同一 agent 的记录 ⇒ 同一用户可能被两个 hostId 各自认领。
🔴 **三处踩坑/教训(都已修,前两条是真问题)**
1. **verify 脚本挂死(ssh 一直等)** ⇒ 根因 = 测试没停掉最后那个实例,`fake-dsh` 子进程**继承 stdout** ⇒ 管道永不关闭。**顺带发现生产问题**:`buildWorkerAgent` 的 `stop()` 只关 HTTP、**不收实例** ⇒ worker 停机留孤儿进程 ⇒ 已改为"**先 teardown 实例、再关 HTTP**";四个 verify 脚本的收尾也统一改走 `agent.stop()`。
2. **`selectHost` 改变了语义**:不再"谁拉起就归谁",而是"按容量挑最优的那台" ⇒ S4 的接管步骤必须**显式指定目标机**才有确定性(原断言是单机时代的假设)。
3. `InstanceLease.holdings()` 返回值从 `epoch` 变成 `{epoch, hostId}` ⇒ 单测断言同步更新(**不是**代码回归)。
### 13.3 S7 · 观测面(部分完成)
| 新增 | 说明 |
|---|---|
| **`dshs doctor [--json]`** | 单机自检:node / cgroup / **swap** / bwrap·setpriv·systemd-run·nft·dsh 存在性 / **bwrap 版本**(低于 0.5 会 warn 提醒 `--perms` 不可用)/ setpriv 降权 / dataRoot 可写 / DB 可达。**退出码非 0 = 有硬失败** ⇒ 可直接当 join 门禁。 |
| **`dshs cluster status [--json]`** | 全集群一屏:worker 目录(状态/容量/水位/实例数/最后心跳)+ **租约已过期的实例**(并明写"需人工确认后才可接管,见 R9")。 |
| 心跳 / 注册 | 已在 S4 完成(`LeasedSpawner.tick`) |
| **自动接管开关** | **按设计保持关闭**:`expiredAll()` 只报不做;人工路径 = `POST /api/admin/users/:id/dsh/migrate`。这与 **R9**(无心跳判据不得单方面接管)一致。 |
**仍待做(明确列出,不含糊)**:`join-worker.sh` 一键装机脚本(现在 join = `dshs worker` 起 agent + 调 `POST /api/admin/hosts` 注册两步手工);集中日志 / metrics 端点;`smoke-domain` 的定性(`smoke-isolation` 已定性为**夹具结构性缺陷**,见 §10.4)。
### 13.4 总验收(S0–S7 汇总,2026-09-15 15:0x 全量复跑)
| 项 | 结果 |
|---|---|
| 冒烟 `scripts/smoke-*.mjs` | **6/8** —— 失败项 `smoke-domain`/`smoke-isolation` **与开工基线完全相同**(全程无回归) |
| 四个端到端 | `verify-cluster-agent` / `-lease` / `-fs` / `-migrate` **全部 OK** |
| 单测 `test/lease.test.mjs` | **SQLite 10/10 == PG 10/10** |
| 残留进程 | **0**(收尾已修) |
| **47 生产未变** | ✅ `lib/`、`web/`、systemd unit 三个 hash **逐字一致**;`dshs` active |
---
## 14. 真实部署形态的功能确认(2026-09-15 15:2x–16:0x)
### 14.1 为什么必须补这一步
此前 **所有** 验证(§9–§13 的 smoke + 四个 verify)都是"**同一进程内起两个 Fastify**" —— 组件级正确,但**部署形态从未验过**:真实部署是**独立进程 + 真 HTTP + 真 PG + 真 `dsh` 子进程**,差异面正好落在最容易出事的地方(进程边界、真启动时序、真沙箱)。
新增 **`scripts/verify-cluster-live.mjs`**:真起 **2 个 `dshs worker` agent 进程 + 1 个 Manager 进程**,走一条真实用户路径。
### 14.2 结果:**全绿**
```
① 用户流程 → 注册 → 审批 → 登录(平台现有流程)
② 文件面 → mkdir 经 agent /fs/mkdir 落到 worker
③ 拉起 → 真 dsh 启动,URL 带 token
④ 状态 → running=true(**account 沙箱内**的真 dsh)
⑤ 登录直达 → /api/dsh/enter 返回带 token 的直达 URL
⑥ 实例页面 → 200(经 Manager 代理到 worker 上沙箱内的实例;已跟随 303)
⑦ 停止 → ok
⑧ 第二台 → w-2 注册成功(且 hosts 列表**不含 agentToken**)
⑨ 迁移 → w-1 → w-2(epoch=3)
⑩ 迁移后 → enter 返回**新** token URL,页面再 200(经新 worker)
⑪ 观测面 → dshs doctor rc=0;cluster status 显示 w-1:0 实例 / w-2:1 实例
```
### 14.3 🔴 真实部署抓到的**三个真 bug**(单进程测试**根本测不出来**)
| # | 现象 | 根因 | 修法 |
|---|---|---|---|
| 1 | account 模式下实例**崩溃循环**,`bwrap: Can't chdir to <userRoot>/ws/proj: No such file or directory` | **我 S1.6 引入的**:用户根与共享技能层**嵌套在同一前缀**下时,技能层的 `--tmpfs` 中间目录被插在 `--bind root root` **之后** ⇒ 后挂的 tmpfs **把已绑好的用户根整个遮掉** | 所有挂载点的中间目录**统一前置 + 去重 + 由外到内**(一处统一生成,禁止"就近创建") |
| 2 | 迁移后实例崩,`bwrap: Can't chdir to :`(**空路径**) | 集群模式下实例行由 **`claimInstance` 创建**(local 模式不写库)⇒ **`folder`/`patch` 恒为 NULL** ⇒ 迁移时复现不了启动参数 | 认领时把 `folder`/`patch` 一并落库;迁移路由在无 folder 时 **409 `no_folder_recorded`**(fail-loud,不拿空路径去启) |
| 3 | 检查脚本把"真实部署"误判为失败 | ① 实例启动窗口内代理会**中途断连**(`other side closed`)② `/api/dsh/status` 的实例视图**不含 `launchToken`**(token 要用 `/enter` 判定)③ dsh 首页 **303** 需跟随重定向 | 检查脚本:重试要 `try/catch`、token 用 `/enter` 判定、跟随重定向 |
> **为什么三条都测不出**:① 只在"用户根与技能层同前缀嵌套"时触发(单进程测试里 `bundledSkillDir` 为空);② 只在**跨机迁移**路径上用到 `folder`;③ 只有真 dsh 才有"启动窗口/303/长响应"。
> ⇒ 这正好印证设计 §13 的验收要求:**组件级测试不能替代部署级验收**。
### 14.4 结论
**集群形态在真实部署下可用**:用户能注册/审核/登录、建目录、拉起真 `dsh`(account 沙箱内)、拿到带 token 的直达 URL、打开实例页面;管理员能注册新 worker、把用户**在线迁移**到另一台并继续使用;观测面(`dshs doctor` / `dshs cluster status`)可用。
⚠️ 仍未做:`join-worker.sh` 一键装机(现在 join = 起 agent + 调 `POST /api/admin/hosts` 两步);集中日志/metrics;`smoke-domain` 定性。
⚠️ **47 侧说明**:本单全程在 47 上**零写操作**(`find /opt/dshs -newermt 11:00` 为空);实例 scope 由开工时的 1 变为 0,最可能是平台自身 **idle-reap**(档案 08)或该实例自行退出 —— **非本单动作**(本单从未在 47 上执行 stop/kill)。
---
## 15. **真跨机演练**(47 当 Manager / 106 当 Worker)✅ 2026-09-15 16:0x
### 15.1 为什么必须做这一步
§14 的"真实部署"仍是**同机**(Manager 与实例都在 106、都走 `127.0.0.1`)——**跨机那一跳被掩盖了**。用户口径:**「这个方案重点就是验证跨机」**。本节就是补上它。
### 15.2 关键障碍与绕法(实测)
| 障碍 | 实测 | 绕法 |
|---|---|---|
| **106 公网入方向被腾讯云安全组挡住** | 106 上开 19100,**47 与本机都连不上**(`curl 000`);放通只能在云控制台点 | **让 Worker 主动拨 Manager**:`ssh -R` 反向转发(走已开放的 SSH 端口) |
| **实例只监听 `127.0.0.1`**(portGuard 设计前提) | 直接暴露端口既不可能也不安全 | 反向转发**落在 47 的 loopback**(`GatewayPorts no` 默认)⇒ **两端都不新增监听面** |
| **实例端口是动态的**(`findFreePort()`) | 无法预声明固定端口列表 | **ControlMaster + `ssh -O forward/cancel`**:同一条长连接上动态加减转发(`src/worker/tunnel.ts`) |
**安全取舍(已取最小面)**:106 生成**专用密钥** → 47 的 `authorized_keys` 加一行 **`restrict,port-forwarding`**(禁 shell / pty / agent 转发,只允许端口转发)。
### 15.3 现场与结果
```
47(Manager,127.0.0.1:13080,**不公网暴露**)
├─ 127.0.0.1:19000/19001 ──ssh -R──▶ 106 的 agent(w-106 / w-106b)
└─ 127.0.0.1:15432 ──ssh -R──▶ 106 的 控制面 PG ← DB 也在对端
106(Worker,account 隔离;实例在 bwrap+uid+scope 内)
```
**`scripts/verify-cluster-cross.mjs`(在 47 上跑)—— 九步全绿**:
```
⓪ worker 可达(隧道 ready,已转发 [19000,15432])
① 注册 w-106(列表不含 agentToken) ② 用户流程:注册→审批→登录
③ 文件面:mkdir 经隧道落到 106 ④ 拉起 → running,**worker /instances=1**
⑤ **跨机页面 200**(47 的 Manager 代理到 106 的实例)
⑥ 第二台(106 上模拟的第二台服务器 w-106b)⑦ **跨 worker 迁移 w-106→w-106b(epoch=2)**:源机空、目标机 1
⑧ 迁移后新 token URL 页面 200 ⑨ 停止 ok
```
**三方交叉取证**:① 106 侧 `ls live-data/users/` 真有该用户家目录、`ws/proj` 存在、两个 agent 进程在跑;② 47 侧 `dshs cluster status` 显示 `w-106 / w-106b 均 up`(并显示 0 实例 = 已停);③ **47 生产逐字未变**(三个 hash 一致、`dshs` active、`/opt/dshs` **0 个文件改动**、**13080 无公网监听**)。
> 顺带一条**机器基线差异**(doctor 当场照出来):47 是 **cgroup v1** + `dsh` 在 `/usr/local/bin/`;106 是 **cgroup v2** + `/usr/bin/dsh`。这正是设计 §14.3 说的"基线不能跟着迁移"。
### 15.4 结论与边界
✅ **跨机可用**:Manager 与实例分处两朵云时,注册/用户流程/文件面/拉起/**代理取页面**/跨 worker 迁移全部工作。
⚠️ **本次用的是"演练级"传输(SSH 反向隧道)**,不是设计 §2.3 的最终拓扑(受控网段白名单或正式隧道服务)—— 它证明的是**软件在跨机下正确**,不是"生产网络已就绪"。
⚠️ **仍待做**:① 在云控制台按 §2.3 放通受控网段(或把隧道服务化);② 生产切换演练(drain → 切 → 回滚,需窗口);③ `join-worker.sh` 一键装机。
### 15.5 现场保留与拆除(**重要:现场仍在跑**)
为便于你查验,**现场保留**:47 上 Manager(127.0.0.1:13080)+ 106 上两个 agent + 隧道。拆除三步:
```bash
# 1) 停 47 的 Manager
ssh -p 32022 [email protected] 'pkill -f "dshs-cluster/lib/cli.js --port 13080"'
# 2) 停 106 的 agent(会先 teardown 实例再关隧道 master)
ssh test106 'pkill -f "opt/dshs-cluster/lib/cli.js worker"'
# 3) 收回授权(可选,但建议:不再演练就删掉那行)
ssh -p 32022 [email protected] "sed -i '/dshs-tunnel-106to47/d' ~/.ssh/authorized_keys"
```
> 生产影响面:47 上只新增 `/opt/dshs-cluster/`(新目录)与 `authorized_keys` 一行;**`/opt/dshs`(生产)零改动**,13080 仅 loopback。
### 15.6 **域名形态访问**验证(2026-09-15 16:2x)✅
**问题**:切到 cluster 后,按**域名形态**(`<用户名>.alotbuy.com`)访问还能不能落到 106 的实例?
**做法(不动生产 / 不动 DNS / 不动证书)**:给演练 Manager 设测试 `DSHS_BASE_DOMAIN=test.alotbuy.com`,用**显式 `Host` 头**打进去。
`scripts/verify-cluster-domain.mjs`(在 47 上跑):
```
③ 直达 URL -> https://domuser35383.test.alotbuy.com/?token=… ← baseDomain 生效,URL 变子域形态
④ 子域访问 -> 200 且是**真 dsh 实例页**(<base href="/">) ← Host: <user>.test.alotbuy.com → 106 上的实例
⑤ 越权对照 -> 用 A 的 cookie 访问 root 子域 = **403**(正确拒绝)
⑥ 取证 -> 实例确实在 106(worker /instances)
```
**⚠️ 两次假阳性(值得记住)**:① 判据只用状态码 ⇒ 门户/登录页也是 200;② **`fetch` 会静默丢弃 `Host` 头**(Fetch 规范禁止头,undici 忽略)⇒ 请求落到"无租户"门户路由,看起来 200 其实根本没走子域分支。
**⇒ 结论:这类多租户路由验证必须用 `curl -H Host:`(或 node:http),且判据要能区分"实例页 vs 门户页"**(本项目记忆里早有这条,本次仍踩了 —— 已把两处写进验证脚本的注释)。
⚠️ **证书/DNS 是另一半**:本次验的是**平台侧的域名路由**;生产用真实域名时还需要 `*.alotbuy.com` 通配证书 + Nginx 侧配置(属生产切换清单)。
---
## 16. **生产整体切换**(用户拍板「整体切,要测就测完整」)✅ 2026-09-15 17:4x–18:0x
### 16.1 切换后的形态
```
alotbuy.com(nginx 443/80 → 127.0.0.1:3080) ← 47,**nginx 一行没改**(已支持 *.alotbuy.com)
└─ Manager: dshs(/opt/dshs,deployMode=**cluster**,控制面库 = **47 上的** PG 13(远程服务器;**本机** = 跑 WorkBuddy 的开发机))
├─ dshs-pg.service 127.0.0.1:15432 ← 新增:控制面 PG(独立数据目录 /var/lib/dshs-pg)
├─ dshs-worker.service 127.0.0.1:19100 w-47 ← **47 也当 Worker(★ 用户确认的口径,勿改)**:要跑 admin 的实例;存量用户数据本来就在 47 上(零搬运)
└─ 反代/通道 ─────────▶ 106:19000 w-106 ← 106 当第二台 Worker(**新用户落这里**)
```
> ✅ **拓扑口径(用户 2026-09-15 确认)**:**47 = Manager 且 Worker(w-47)、106 = Worker(w-106)**。
> 47 兼作 Worker 是**有意设计** —— 用户原话:「**47 也当 worker,因为要运行 admin 的实例**」;
> 存量用户(admin / guest)的数据也都在 47 上 ⇒ **零搬运**。跨机迁移能力已备(需要时按用户迁)。
> ⛔ **不要把它当成待修项去改回「47 只做 Manager」**。
>
> 📌 **更正一处早先的错误判断**:我最初写的理由是「47→106 出带宽很低(≈1 MB/min)、3.4 GB 要按小时算」——
> **实测不成立**:`47 → 106 = 5.69 MB/s`(3.4 GB 理论约 10 分钟)。真正慢的是**搬运本身**(实测 ≈0.03 MB/s,
> 疑似源端海量小文件的元数据 / IOPS 受限)。结论不变(数据不必搬),但**理由要记对**。
### 16.2 落地动作(全部可回滚)
| # | 动作 | 关键点 |
|---|---|---|
| 1 | **47 装控制面 PG** | `dnf install postgresql-server`;**独立数据目录** `/var/lib/dshs-pg`(不碰发行版默认目录)+ 专用单元 `dshs-pg.service`(`listen=127.0.0.1:15432`、scram);**没动**发行版 `postgresql.service` |
| 2 | **迁库 SQLite → PG** | `scripts/migrate-sqlite-to-pg.mjs`:`users 2 / workspaces 1 / sessions 2 / credential_vault 1 / business_plugins 3 / audit_log 208`,**逐表行数一致**;**uid 保真**(`admin=114801 / guest=100002`,与磁盘属主一致) |
| 3 | **47 装本地 Worker** | `dshs-worker.service`(`w-47`,19100,**无隧道**——Manager 同机直连);env 与生产**实例侧**对齐(`DSH_INSTANCE_NODE_OPTIONS`/`DSH_INSTANCE_UNIVER_SOCKET`/`ISOLATION_MODE=account`) |
| 4 | **106 装 Worker** | `dshs-worker.service`(`w-106`,19000,经**SSH 反向隧道**到 47;入方向被云安全组挡住 ⇒ 只能 Worker 主动拨) |
| 5 | **存量用户锚定** | `dsh_instances.host_id='w-47'`(粘性锚点,见 16.3) |
| 6 | **注册两台 worker** | `dsh_hosts`: `w-47 cap=1024` / `w-106 cap=**2048**`(106 总内存 3655 MB,声明 2048 ⇒ 最多 4 个实例,留足系统开销)|
| 7 | **Manager 切换** | **systemd drop-in** `/etc/systemd/system/dshs.service.d/cluster.conf`(**unit 本体 hash 不变**)⇒ `daemon-reload` + `restart dshs` |
**回滚(一条命令,已验证可用)**:
```bash
rm -f /etc/systemd/system/dshs.service.d/cluster.conf && systemctl daemon-reload && systemctl restart dshs
# 代码回滚:cp -a /opt/dsh/backups/lib-20260915-175116/lib /opt/dshs/lib (备份 172 文件)
```
### 16.3 切换过程中**抓到并修掉的 4 个真 bug**(全是"只有真上线才会暴露"的)
| # | 症状 | 根因 | 修法 |
|---|---|---|---|
| 1 | 用户可能被调度到**没有他数据**的机器 ⇒ 工作区看着是空的 | `selectHost` **只看容量**,不看"这个用户的数据在哪台" | **粘性优先**:有历史归属且那台 `up` ⇒ 留在原地;只有从未有归属的新用户才按容量选 |
| 2 | 停一次实例后,**归属被清空** ⇒ 下次启动丢了粘性锚点 | `releaseInstanceLease` 把 `host_id` 与租约**一起清了** | **语义分离**:`host_id`=数据归属(长期,保留);`lease_until`=租约(短期,释放时清零) |
| 3 | **文件写到 A、实例起在 B**(实例看不到自己刚建的文件) | 文件面 `RemoteUserFs` **固定打默认 host**;且新用户"首次写文件"与"launch"**各自选一次机** | ① 文件面也按**同一份归属**路由(与实例面共用 `hostDirectory`)② 首次触达工作区就 **`pinInstanceHost` 钉住**归属 |
| 4 | 已停的实例在 `cluster status` 里被报成「租约已过期,需人工接管」 | 过期清单没排除 `stopped` | 查询加 `status <> 'stopped'` |
### 16.4 切换后验收(**全绿**)
| 项 | 证据 |
|---|---|
| 门户公网 | `https://alotbuy.com/login.html` = **200** |
| **既有用户(guest)** | 归属 **w-47** ✓ | 实例起在 **w-47**(有数据那台)✓ | **工作区有真实历史数据**(`.cache/.config/.fonts/.local`)✓ | 实例页 ✓ |
| **新用户** | 首次 `mkdir` 即**钉住 w-106** ✓ | launch 后仍 w-106(粘性保持)✓ | **文件落在 106 的盘**(`.../ws/proj/hello.txt`)、**47 盘上没有** ✓ | 实例页(`Host: <user>.alotbuy.com`)✓ |
| 停止语义 | `stop` 后归属**仍是 w-47**(不再被清空)✓ |
| 观测面 | `cluster status`:`deployMode=cluster`、`w-47/w-106 均 up`、过期租约 **0** ✓ | `doctor` **10 项全 ✓** |
| 合规 | 全程用**临时 session**(sha256 直插 PG,R4 许可)**不碰任何用户密码**,用完即删(残留 0)✓ |
| 清理 | 验证用测试用户已删(剩 `admin`/`guest`)、实例 0/0、临时 session 0 ✓ |
### 16.5 残留与下一步(明确)
1. **`join-worker.sh` 一键装机**(现在 join = 装 unit + 起 agent + 注册,三步手工)
2. ~~隧道服务化~~ ✅ **已收口(2026-09-15 18:4x)**:agent 增加**隧道自愈** —— `SshTunnel.isMasterAlive()` + **本地 20s 定时器**(⚠️ 不能只放 `/healthz`:心跳本身经隧道进来,隧道一断就没人能触发它),失联即重建(含静态转发)并重新对账实例端口。**实测**:杀掉 106 的 master → 47 侧 `curl 127.0.0.1:19000` 立刻不通 → **~20-30s 后自动恢复 200**
3. **集中日志 / metrics**
4. `smoke-domain` 定性
5. ⚠️ **凭据**:控制面 PG 口令现写在 `/etc/systemd/system/dshs.service.d/cluster.conf`(root 可读,已 `chmod 600` 的是 worker env;drop-in 建议同样收权限)
### 16.6 一条必须记住的运维事实
- 生产**权威库 = 47 的 PG**(`/var/lib/dshs/dshs.db` 已**不再是**权威源,仅作回滚用,**不要再往里写**)
- 47 上多出三个单元:`dshs-pg` / `dshs-worker`(+ 原有 `dshs`);106 上多出 `dshs-worker`
- 两台 worker 的 dataRoot 都是 **`/var/lib/dshs`**(**同路径是硬约定**:实例的 `folder` 是绝对路径)