Files
dsh_ai1net_server/dsh-server-docs/交接单/archive/交接单-已完成/T08-集群化落地-兼容单例模式.md
T

741 lines
60 KiB
Markdown
Raw 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.
# 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` 是绝对路径)