60 KiB
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)在全部步骤结束后仍然可用。
完成判据(可被第三方复现):
- 106 测试环境上:PG 后端 + 跨机协议 + 双 Manager 全部跑通,
scripts/smoke-*.mjs全绿; - 47 生产:全程未改,用户访问 URL / cookie / 直达会话行为与改造前逐项一致;
- 单例回退:
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 · 前置与基线冻结(不产出功能,只产出"可对照的基线")
bash dsh-server-docs/scripts/handoff-guard.sh --claim-exec "<执行会话名>"(抢不到 = 停手 + 报告)- 记录 47 与 106 现有 dshs 的 commit / 版本 / 端口 / 数据根 ⇒ 落到本单「回报格式」
- 在 47 上跑一遍完整 smoke 并存证据 ⇒ 这是"兼容"的对照基线
- 代码改动开分支(不动 master):
git -C D:\github\dsh_shenxian switch -c feature/cluster-1a - 在 106 建独立测试目录
/opt/dshs-cluster/(端口13080、数据根/opt/dshs-cluster/data)—— 与既有 3080 那套物理隔离
- ✅ 验证:47 smoke 全绿;106 新目录可启动
--help;两机基线记录在案 - ↩️ 回滚:
rm -rf /opt/dshs-cluster+ 删分支
S1 · 数据层 SQLite → PG(不改任何业务逻辑)
- 106 起 PG(容器或本地包),建库
dshs+ 最小权限账号 - 写
scripts/migrate-sqlite-to-pg.mjs:按src/db/schema.ts的两份 DDL 建表,逐表搬数据;users.uid(baseUid + row_id派生)与 identity 列必须保持同值 - 在 106 测试环境以
DSHS_DB_URL=...启动,跑全套 smoke - 同一组 smoke 在 SQLite 侧再跑一遍,逐项对比行为一致
- 回滚演练:去掉
DSHS_DB_URL重启 ⇒ 应回到 SQLite 且功能完整
- ✅ 验证:PG 侧 smoke 全绿 + SQLite 侧同样全绿 + 回滚后仍全绿
- ↩️ 回滚:
db/index.ts:19是唯一开关,去掉 env 即回
S2 · 数据模型 v7(只加表/列,不接线)
schema.ts加dsh_hosts表 +dsh_instances.{host_id, epoch, heartbeat_at, lease_until}—— SQLite 与 PG 两份同步types.ts/adapter.ts/sqlite.ts/pg.ts/repo.ts同步补齐(sqlite.ts与repo.ts是两套独立实现,漏一边会在切库时才炸)- 新增
src/supervisor/lease.ts:tryAcquire/renew/expireSweep(纯逻辑 + 可离线单测;语义照抄leader.ts:200-216的乐观并发)
- ✅ 验证:迁移在两种库上都能建出结构;旧功能 smoke 全绿(新表不参与旧路径);lease 单测绿
- ↩️ 回滚:新表/列直接 drop(旧代码不读它们)
S3 · Worker agent + RemoteSpawner(同机验证,拓扑不变)
dshs worker子命令:复用LocalSpawner,对外暴露内部 HTTP(绑定内网网卡)+ HMAC 鉴权 + 白名单动作 + 幂等键(operation_id);GET /instances一次返回整机实例(对账用,替代 O(N) 逐用户查询)RemoteSpawner implements Spawner(新文件)—— 路由层零改动(Spawner接口本就为此设计)- 同机自连:Manager 用
RemoteSpawner→127.0.0.1:9000agent → 起实例 launchToken回传(设计 §4.1 P0-6):worker 把实例 token 交给 manager ⇒ 「登录直达会话」必须与现状一致
- ✅ 验证:同机走"远程协议"跑通 登录 → 直达会话 → 对话 → 我的文件;401 自愈链路可用;此时仍是单机,但走的是集群协议
- ↩️ 回滚:
DSHS_DEPLOY_MODE=local切回LocalSpawner(代码保留)
S4 · 一台机器 = 一个 1a 集群(单例兼容的关键形态)
- Manager 组:2 台(其中一台
capacity=0,只做门户/控制,不承载实例 —— 规避"门户与实例抢内存"与"重启清 scope") - 控制面互斥:PG advisory lock(单 Manager 时自动退化为主)
- 单活退化验证:撤掉 LB,域名直指一台
- ✅ 验证:门户双活可用;kill 一台 Manager 门户不中断;单活形态全功能可用
- ↩️ 回滚:停第二台 + 改 env
S5 · 文件面跨机
RemoteUserFs(复用dshs file-service,worker 本机起、manager 内网访问)- 门户「我的文件」:树 / 下载 / 上传 跨机可用
- ✅ 验证:管理端对非本机用户可浏览与下载
- ↩️ 回滚:切回
LocalUserFs
S6 · 第二台 Worker + 存储决策 + 计划内迁移
- 存储决策落地(本地盘 rsync 或 NAS;这是 §12 的可插拔后端首次真实切换)
scripts/join-worker.sh:自检(cgroup / swap / bwrap+setpriv / 运行时基线 / 版本自报)+ 建号(§17.3 实测:uid 必须有/etc/passwd条目,否则os.userInfo()失败)+ nft 白名单(写网段不写单 IP)- 迁移一个用户:drain(SIGTERM→等落盘)→ 目标机 launch → 原子改归属(
epoch+1)→ 旧机确认退出
- ✅ 验证:迁移后会话无损;旧机实例已停;旧机复活被 fencing 拦下(self-fencing)
- ↩️ 回滚:归属改回原 Worker(改一行 +
epoch+1) - ⚠️ 跨云(47↔106)只做故障注入验证;生产拓扑须同云(D4/D6)
S7 · 自愈与观测
- 心跳(HTTP
/healthz,不用 ping —— 实测 ICMP 被禁)+ 租约续期 + 心跳兼任 fencing 广播 dshs doctor/dshs cluster status/GET /metrics- 故障注入(用跨云环境):心跳超时、Worker 失联、Manager 单点、PG 不可达、脑裂复活
- 自动接管开关默认关闭(一期只告警 + 人工确认,与 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. 回报格式(执行会话必须回填)
- 每步的验证输出(命令 + 原文 + 退出码),特别是三关:smoke 全绿 / 单机仍可跑 / 47 未变;
- 47 的 commit 前后对照(证明生产未被触碰);
- 两库一致性证据:同一组 smoke 在 SQLite 与 PG 两侧的输出对比;
- 只读确认(不属于本单、需单独立项):
orchestrator.ts:749-751只设MemoryMax+TasksMax,未设MemorySwapMax,而 47 与 106 都有 swap ⇒ 实例超限会先换出而非被 OOM kill ⇒ 档案 20 与 78 的"熔断自愈"语义在有 swap 的机器上不成立。本单不动它,回报里写清现状即可; - 未完成项:若某步卡住,写明卡在哪(证据)+ 已做到哪一步 + 什么条件一出现必须回头解决(不写"后续优化"这类空话);
- 锁状态:结束时写明已释放(或显式点名锁在谁手上 + 原因)。
本单与设计文档的分工:设计(架构 / 网络 / 存储 / 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(bwrap 中间目录 0700)—— 否则 106 无法承载实例,S3/S6 全部走不下去。该修涉及
orchestrator.ts(隔离层):需按 §9.3 的 D 方案改,并在 47 上做回归(确认 47 行为不变)+ 106 上重跑smoke-isolation应变绿。⚠️ 按本单原「范围」约定orchestrator.ts不在改动面内 ⇒ 把它作为本单的新增子项(S1.6)执行,并在动手前一句话说明。 - 发现 2(
smoke-domain)单独立项定性。 - 之后回到 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。
🔴 两处踩坑(都已修)
- 夹具
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(失败项与基线完全相同)。 - 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 各自认领。
🔴 三处踩坑/教训(都已修,前两条是真问题)
- verify 脚本挂死(ssh 一直等) ⇒ 根因 = 测试没停掉最后那个实例,
fake-dsh子进程继承 stdout ⇒ 管道永不关闭。顺带发现生产问题:buildWorkerAgent的stop()只关 HTTP、不收实例 ⇒ worker 停机留孤儿进程 ⇒ 已改为"先 teardown 实例、再关 HTTP";四个 verify 脚本的收尾也统一改走agent.stop()。 selectHost改变了语义:不再"谁拉起就归谁",而是"按容量挑最优的那台" ⇒ S4 的接管步骤必须显式指定目标机才有确定性(原断言是单机时代的假设)。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 + 隧道。拆除三步:
# 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 |
回滚(一条命令,已验证可用):
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 残留与下一步(明确)
join-worker.sh一键装机(现在 join = 装 unit + 起 agent + 注册,三步手工)隧道服务化✅ 已收口(2026-09-15 18:4x):agent 增加隧道自愈 ——SshTunnel.isMasterAlive()+ 本地 20s 定时器(⚠️ 不能只放/healthz:心跳本身经隧道进来,隧道一断就没人能触发它),失联即重建(含静态转发)并重新对账实例端口。实测:杀掉 106 的 master → 47 侧curl 127.0.0.1:19000立刻不通 → ~20-30s 后自动恢复 200- 集中日志 / metrics
smoke-domain定性- ⚠️ 凭据:控制面 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是绝对路径)