Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/119-集群化改造方案-Manager-Worker.md
T
admin 04776af4b1 docs: 工作区根项目文档批量入仓(04-调整方案 113–128 / 交接单 T09–T21 / ops / archive)
起因:用户 2026-09-17 明确「所有文档都要同步,都放在开发仓库 docs 对应文件夹下」。
判据:工作区根 *.md 中在仓库(git ls-files --quotepath=false)搜不到的那些。

入仓 33 份(一律复制,工作区根原件保留不动,避免引用断链):
- 04-调整方案/113–128(16 份 · 原子占号后落盘):覆盖网络 传输方案取舍 / 应用场景与待完善清单 /
  插件化vs改内核 / 问题逐条推演 / 参数表与观测口径;会合中继拆分取证与改造方案;
  集群化改造方案 Manager-Worker;跨节点迁移与节点自举;项目代码分层范式与迭代风险评估;
  搬运与共享重建方案 guest w47→w106;方案规划方法提炼;文档无效信息审计报告;
  会话接续机制复盘与修复;会话接续规范;dsh 客户端化部署方案;dsh 桌面客户端开发方案
- 交接单/archive/交接单-已完成/T09–T21(13 份 · 覆盖网络线已完成单归档)
- ops/(2 份运行态指针:接续入口 / 接续包 · 覆盖网络线)
- archive/(2 份临时与内部简报)

已排除(无需重复入仓):覆盖网络线 10 份方案正文已入档案 103–112(文件名不同)。

登记:INDEX.md §二 新增 04-113–128 共 16 行 + §四 追加 T09–T21 说明 + 机器摘要行刷新
(⛔ 未跑 docs-index-stats.py --write:该脚本会按 \r\n 归一化全文件行尾,故改为字节级单行替换);
README.md 追加 1 条入仓指针;docs-manifest.json 复跑 scripts/docs-manifest.py 刷新。

验收:工作区根 47 份 .md —— 同名已入仓 8 / 本次内容一致 29 / 已知改名映射 10 / 未入仓 0;
git status 待提交清单只含本次新增与登记 3 件(未涉 src/ 与 relay 代码面)。
2026-09-17 18:24:19 +08:00

1097 lines
81 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.
# DSH 平台集群化改造方案 —— Manager / Worker 模式
| 项 | 值 |
|---|---|
| 版本 | v0.1(草案,2026-09-14) |
| 目标 | 单机 `local` 模式 → **1 组 Manager(≥2 台,负载均衡 + 主备)+ N 台 Worker** 承载用户实例;支持实例跨 Worker 迁移 |
| 现状基线 | 生产 = 单机 `dshs`(systemd 127.0.0.1:3080)+ bwrap/uid/scope 隔离 + SQLite;实测宿主 1.8 G ⇒ **1–3 人** |
| 代码基线 | `D:\github\dsh_shenxian` @ `1d72e8f`(dsh `0.1.2-rc.1`,官方包零改动) |
| 性质 | **纯增量改造**,不触碰官方 dsh(R2);隔离机制与平台自研能力原样保留 |
| 归档 | ⚠️ 本文件是**草案**(放项目根)。定稿后再占号入 `dsh-server-docs/04-调整方案/`(档案只增不改,不宜冻结草案) |
---
## 0. 一句话方案
> **Manager 只管「门户 + 路由 + 归属」,Worker 只管「跑实例」。**
> 归属用 **PG 租约(原子 CAS)**、数据放 **用户专属共享存储**、跨机流量走 **内网 + 白名单**。
> 于是「迁移」= 改一行归属 + drain 一次进程,**不需要搬数据**。
**已有代码资产可直接复用(本次核对)**:
| 资产 | 位置 | 复用方式 |
|---|---|---|
| `Spawner` 抽象 | `src/supervisor/spawner.ts`(头注释:route 层只依赖此接口) | 新增第 3 个后端,**路由层不改** |
| 代理「TCP 目标 / Host 头分离」 | `src/supervisor/proxy.ts:299` vs `:108` | **跨机代理零改动**(Host 头恒为 `127.0.0.1:<port>`,通过 dsh 信任栅栏) |
| 栅栏判定「只看主机名不看端口」 | 官方 `packages/client/connection/src/api-request-trust.ts:103` + `proxy.ts:30 STRIP_HEADERS` | 转发端口 ≠ 实例端口也安全 |
| 租约语义 | `src/supervisor/leader.ts:200-216`(replace + resourceVersion 乐观并发) | **照抄**成 PG 版 |
| fencing | `src/supervisor/k8s-spawner.ts:112 stampFencing` | 照抄,加在每次实例操作上 |
| 跨机文件面 | `src/fs/k8s-user-fs.ts` + `src/web/file-service.ts`(每用户 sidecar) | **泛化**为 `RemoteUserFs` |
| 跨机端口暴露原语 | `src/tcp-bridge.ts`(`dshs tcp-bridge <listen> <target>`) | Worker 侧一条命令暴露实例 |
| PG 后端 | `src/db/index.ts:19`(`DSHS_DB_URL` 非空即切) | **Manager 组多节点的硬前提**(见 §6 P0-1) |
| 每用户 uid 一致性 | uid = `baseUid + row_id`,由 DB 决定 | **跨机天然一致**,不需要 uid 分配协议 |
---
## 1. 架构
### 1.1 角色划分
```
┌── 入口(保留:腾讯云 nginx + CF,443 / 通配证书) ──┐
│ *.domain → Manager VIP / SLB │
└──────────────────┬───────────────────────────────┘
│
┌──────────────────────────────┴──────────────────────────────┐
│ Manager 组(≥2 台) │
│ 【门户层 · 双活】 dshs 全功能 web:登录/审核/门户/密钥/技能 │
│ 【控制层 · 主备】 迁移 / 回收 / 归属租约续期(PG advisory) │
│ 【代理层】 Host → 归属 → 实例(本机直连 / 跨机直连) │
└───────┬──────────────────────────────────────┬──────────────┘
│ 5432(内网) │ 9000-9999(内网 + nft 白名单)
┌───────┴────────┐ ┌─────────┴─────────┐
│ PG(共享状态) │ │ Worker 组(N 台)│
│ 归属/租约/用户 │ │ 每台:worker agent│
│ 会话/凭据/插件 │ │ + bwrap 实例 │
└───────┬────────┘ └─────────┬─────────┘
│ │
┌───────┴──────────────────────────────────────┴──────────────┐
│ 共享存储(用户专属目录,位置无关):NAS RWX 或 对象存储 │
│ <user>/{home,ws} ← 任意 Worker 挂载即可接手 │
└──────────────────────────────────────────────────────────────┘
```
### 1.2 职责边界(**这条决定改造量**)
| | Manager | Worker |
|---|---|---|
| 门户 / 认证 | ✅ 独占 | ❌ |
| **控制面数据库**(用户/会话/凭据/候选池/**归属与租约**) | ✅ **唯一写入者** | ⚠️ **不是"不许有数据库"** —— 见 §1.3:控制面数据只有 Manager 能写;Worker 完全可以有自己的库 |
| 归属租约(谁托管谁) | ✅ 唯一写入者 | ❌ 只被通知 |
| 实例进程生命周期 | ❌ 不 spawn 本地进程 | ✅ 独占(bwrap/uid/scope 全在本机) |
| 代理转发 | ✅ 唯一出入口 | ❌ 不直连公网 |
| 熔断 / 内存配额 / 插件探活 | ❌ 读 Worker 上报 | ✅ 本地自管(**保持现有语义不变**) |
| 文件读写(门户「我的文件」) | 转发 | ✅ 在本机(worker 有用户卷) |
| **实例业务数据**(插件库 / 会话日志 / 工作产出) | ❌ | ✅ **实例独占**(放 home,跟用户走) |
### 1.3 数据分层(**2026-09-15 用户纠正:Worker 不是"不许有数据库"**)
> 用户原话口径:**「不一定 worker 不连数据库,有些插件有可能有大量数据需要保存和管理、长期使用」**。
> ⇒ 原表述「Worker ❌ 不碰 DB」**过窄**:它想说的其实只有一件事 —— **控制面数据(尤其归属)不能由 Worker 写**。
> 插件的大量长期数据与这件事**根本不在同一层**,不该被这条规则挡住。
| 数据类 | 例子(都是实测存在的) | 唯一写者 | 落点 |
|---|---|---|---|
| **控制面元数据** | 用户 / 会话 / 凭据 / 插件候选池 / **归属与租约** | **仅 Manager** | Manager 的 PG |
| **实例业务数据** | `home/.dsh/mcn-plugin.db`(插件自带 SQLite)、会话日志、`.univer`、工作产出 | **Worker(该实例)** | **实例 home**(位置无关存储)→ 跟着用户迁移 |
| **Worker 运维数据** | 实例台账、指标、日志、心跳历史 | Worker 本地 | **Worker 本地库/文件**(SQLite 足够)——**不进控制面 PG** |
| **控制面数据的只读副本**(可选) | 某些插件需要用户/插件元数据 | 只读 | Worker 本地缓存 + 明确失效策略;**绝不写归属** |
**三条判据(以后按这个判,别再用"碰不碰 DB")**:
1. **是"归属/租约"吗?** ⇒ 是:**只有 Manager 能写**(否则就是双写归属 = 脑裂)。
2. **跟着用户走吗?** ⇒ 是:放**实例 home / 位置无关存储**,**不要**塞控制面 PG。
3. **是本机运维用的吗?** ⇒ 是:放**Worker 本地库**,同样不进控制面 PG。
**为什么"大量数据 + 长期使用"不该塞控制面 PG**:会把控制面变成混合负载 ⇒ 备份窗口、锁竞争、扩容、故障半径全部耦合;而且业务数据量随用户数线性增长,会让控制面 PG 的容量规划被业务数据绑架。
**给插件的三种落点(按数据量与查询形态选)**:
| 场景 | 落点 | 迁移行为 |
|---|---|---|
| 单用户、量不大、无需跨用户查询(**现状**:`mcn-plugin.db`) | 实例 home 里的 per-user SQLite | 跟 home 走,**天然迁移** ✓ |
| 单用户、量大、需要真 DBMS | 每用户一个 database/schema(Worker 本地 PG/MySQL,**数据目录放在位置无关存储上**) | 迁移 = dump/restore 或共享存储 |
| 跨用户聚合分析(运营看板之类) | **不要**在 Worker 上做 ⇒ 由 Manager 侧的离线汇总表承接 | — |
**与 S3 实现的关系**:agent 目前**不需要**DB(`apiKey`/`uid` 由 Manager 随 launch 投递)—— 这是**权限收窄**选择(凭据不经网络下发 **vs.** 让 Worker 能读全量凭据库),符合 R5。
但**这不妨碍** Worker 承载自己的数据库:插件的 per-user SQLite 一直在实例 home 里、由实例自己读写;将来接 DBMS 按上表落点即可,**与本设计的 Manager/Worker 边界不冲突**。
**关键设计选择:把「实例本地概念」留在 Worker 自管**(熔断、配额推导、崩溃退避、插件探活回滚),Manager 只读上报 —— 这样 `档案 34/58/74/78/81` 那批能力**一行不用重写**,这是本方案相对 k8s 路线最大的成本优势。
---
## 2. 网络拓扑
### 2.1 流量路径(4 条)
| # | 路径 | 协议/端口 | 说明 |
|---|---|---|---|
| 1 | 用户浏览器 → 入口 nginx(腾讯云) | 443(CF 回源) | **不变**,通配证书 `*.domain` 仍在入口 |
| 2 | 入口 nginx → Manager 组 | 443 或 3080 | 经 VIP(keepalived)或云 SLB |
| 3 | Manager → Worker(**内网**) | 9000–9999 | 代理转发 + 实例控制 API(launch/stop/status) |
| 4 | Manager ⇄ PG | 5432(内网) | 归属租约 + 全部业务表 |
### 2.2 端口矩阵
| 节点 | 端口 | 用途 | 暴露面 |
|---|---|---|---|
| 入口 | 80 / 443 | 用户入口 | 公网 |
| Manager | 3080 | dshs(门户+代理) | 仅入口 nginx 与内网 |
| Manager | 5432 出 | PG 客户端 | 内网 |
| Worker | **9000–9999** | ① 实例 tcp-bridge ② agent 控制口 | **仅 Manager 组 IP**(nft 白名单) |
| Worker | 22 | 运维 | 跳板机 |
| Worker | 443 出 | 模型 API(DeepSeek 等) | 出网(可收敛 CIDR) |
### 2.3 防火墙(nft)三条铁律
1. **Worker 的 9000–9999 只允许 Manager 组 IP**;其余一律 drop(替代现在单机的 `portGuard` iptables owner-match 语义,见 §6 P0-2)。
2. Worker **不监听公网**;实例仍只绑 `127.0.0.1`(dsh 硬禁 `--host 0.0.0.0`,官方 `startup.ts:74-76` 原文 *"it would expose remote code execution to the network"*)。
3. Manager 之间只放 keepalived VRRP + PG + 代理端口,不留其它互通。
**✅ 现成的护栏可直接复用**:`src/supervisor/firewall.ts` 的 `createPortGuard()`(iptables OUTPUT owner-match)**在 Worker 上语义不变** —— 它防的是"本机其它 uid 直连实例端口",而实例仍以 `dsh-<uid>` 运行、桥仍由平台账号监听。⇒ Worker 侧本机护栏**零改动复用**,无需重新设计。
**控制口与代理口分离**(建议):
| 端口 | 用途 | 策略 |
|---|---|---|
| 9000 | **控制口**(agent API:launch/stop/status/list) | 短超时(10s)、强鉴权(HMAC)、nft 只放行 Manager 组 |
| 9001–9999 | **代理口**(每实例一个 tcp-bridge) | 长超时(SSE/WS 不限时)、Host 头透传 |
### 2.4 负载均衡与主备(按你的要求:≥2 台)
| 维度 | 严格主备(active-standby) | **推荐:门户双活 + 控制主备** |
|---|---|---|
| 门户层 | 备机闲置 | 两台都服务,容量 ×2 |
| 控制层(迁移/回收/续租) | 主机独占 | **PG advisory lock 抢主**,同一时刻单写者 |
| 代价 | 浪费一台 | 多一个 advisory lock 与"控制任务幂等"要求 |
| 实现 | keepalived + VIP 漂移 | 同上 + 控制任务租约 |
> **✅ 已定(2026-09-14 用户拍板):门户层「双活」,同时必须支持「单活」部署。**
> 实现要点:门户无状态 ⇒ **双活与单活是同一套代码,1..N 台都成立**,不需要为单活写分支:
>
> | 场景 | 行为 |
> |---|---|
> | N 台(双活) | LB/VIP 分发;控制任务靠 **PG advisory lock** 抢主,同一时刻单写者 |
> | **1 台(单活)** | 撤掉 LB,域名直指该机;advisory lock 自然由它持有 ⇒ **控制层零改动** |
>
> **单活只需注意两处(都不是新增代码)**:
> 1. **会话/门户状态必须落 PG**(已满足:会话查 DB、`cookieDomain` 跨子域)⇒ 否则切机即掉登录;
> 2. **长连接断开后前端要能自动重连**(WebSocket 由 Manager 代理持有;Manager 重启/VIP 漂移会断流)⇒ 需实测 dsh 前端重连行为,必要时由代理层注入重连脚本(现有 401 自愈注入机制「档案 50」可复用同一注入位)。
>
> 状态机建议:`dsh_hosts.status` 同族的 `manager` 角色标记 + 一条 PG advisory lock,**禁止**任何"必须 2 台"的硬假设(例如配置里写死 peer 地址)。
**⚠️ 备案坑(已有实测记录)**:阿里云公网 SLB 对未在阿里云备案的域名返回 `403 Non-compliance ICP Filing`(`D:\github\dsh_shenxian\docs\k8s-deploy.md §7.4`)。⇒ **入口继续留在腾讯云 nginx,Manager 组只在内网/专线可达**,不要在阿里云侧直接对公网提供域名入口。
---
## 3. 数据模型
### 3.1 新增/扩展(迁移 v7)
```sql
-- 主机(Worker)注册表:容量准入的依据
CREATE TABLE dsh_hosts (
id TEXT PRIMARY KEY, -- worker-01
endpoint TEXT NOT NULL, -- 10.0.1.11:9000
agent_token TEXT NOT NULL, -- 内部 HMAC 密钥(加密存储)
capacity_mb INTEGER NOT NULL, -- 该机可用内存预算
used_mb INTEGER NOT NULL DEFAULT 0,-- 由心跳上报
status TEXT NOT NULL DEFAULT 'up',-- up|draining|down
last_heartbeat BIGINT
);
-- 归属(租约):一行 = 一个用户的 main 实例归属
ALTER TABLE dsh_instances ADD COLUMN host_id TEXT;
ALTER TABLE dsh_instances ADD COLUMN epoch BIGINT NOT NULL DEFAULT 0;
ALTER TABLE dsh_instances ADD COLUMN heartbeat_at BIGINT;
ALTER TABLE dsh_instances ADD COLUMN lease_until BIGINT;
```
### 3.2 租约的原子抢占(**这是整套方案的承重墙**)
```sql
UPDATE dsh_instances
SET host_id = $me, epoch = epoch + 1, heartbeat_at = now(), lease_until = now() + ttl
WHERE user_id = $u AND role = 'main'
AND (host_id IS NULL OR lease_until < now()); -- 过期才可抢
-- 受影响行数 = 1 ⇒ 抢到;= 0 ⇒ 有人在管,退让
```
- 语义**照抄** `leader.ts:200-216`(replace + resourceVersion 乐观并发);
- 每次实例操作带 `expected_epoch`,不匹配即拒绝(fencing,参照 `k8s-spawner.ts:112`);
- 建议时序:**TTL 30 s / 续期 10 s / 重试 3 s**(不变量:`TTL > 2 × 续期`,否则自身抖动误判)。
---
## 4. 关键流程
### 4.1 冷启动(用户首次访问子域)
```
浏览器 → 入口 → Manager(代理层查不到归属)
→ 租约抢到(affected=1)
→ 选 Worker(容量准入:used_mb + 实例配额 ≤ capacity_mb)
→ POST worker:9000/instances/launch {userId, folder, patch, epoch}
→ Worker:ensure profile(本机)→ tcp-bridge 暴露 → bwrap+scope 起实例 → 回传 {port, launchToken}
→ Manager 写归属(host_id=worker-01, port)
→ 代理转发(Host 头仍 127.0.0.1:<port>)→ 用户看到页面
```
> ⚠️ **`launchToken` 必须回传**:本地模式是解析实例 stdout 拿到的(`Instance.launchToken` 注释),跨机后 Worker 要把它交给 Manager,否则「登录直达会话」(档案 06/13/15)与 401 自愈(档案 24/51)会失效。**这是 P0 功能缺口,别漏。**
### 4.2 迁移(计划内)
```
admin POST /api/admin/instances/:id/migrate {targetHost}
① 加迁移锁(PG advisory)→ 目标 Worker 预约容量
② drain:SIGTERM → 等落盘(≤10 s)→ 确认 sessions 已写盘(SIGKILL 仅兜底)
③ 目标 Worker launch(数据在共享存储,无需搬运)
④ 原子更新归属:host_id=目标, epoch+1 ← 旧 Worker 的 fencing 立即失效
⑤ 旧 Worker 确认实例已退、端口已释放
⑥ 观察 60 s(无崩溃)→ 迁移结束;失败则按 ④ 反向改回归属(回滚 = 改一行)
```
### 4.3 故障转移(计划外)
```
Worker 心跳超时(> TTL)
→ 该机 status=down,其上所有归属的 lease_until 到期
→ Manager(控制主)逐个在新 Worker launch(epoch+1)
→ 旧 Worker 若复活:fencing 拦下它的一切写入 ← 唯一防止「双写坏数据」的机制
```
> 判定"对方已死"本质上不可靠(同 `R9` 的教训:无心跳时不得单方面接管)。**因此必须靠 lease TTL 硬等 + fencing 兜底**,并接受"复活窗口内可能有短暂双活"。
> **自动转移的前置 = 共享存储**;未上共享存储前,只能做计划内迁移。
---
## 5. 实现细节(落地清单)
| # | 改动 | 文件 | 量级 |
|---|---|---|---|
| 1 | 迁移 v7(`dsh_hosts` + 归属列) | `src/db/schema.ts` | 小 |
| 2 | PG 租约模块(抢/续/过期扫描) | `src/supervisor/lease.ts`(新) | 中 |
| 3 | `RemoteSpawner implements Spawner` | `src/supervisor/remote-spawner.ts`(新) | 中 |
| 4 | Worker agent(复用 `LocalSpawner`,内部 HTTP) | `src/cli.ts` 加 `dshs worker`、`src/worker/agent.ts`(新) | 中大 |
| 5 | 选机(容量准入) | 并入 #2/#3 | 小 |
| 6 | 文件面泛化(复用 file-service) | `src/fs/k8s-user-fs.ts` → `remote-user-fs.ts` | 小 |
| 7 | **模型落地层改造**(现在直写主机 home) | `src/web/server.ts:132 writeHomeFile` / `:165 landModels` | **中(必须改)** |
| 8 | **角色补丁 / picker**(主机侧脚本) | `orchestrator.ts:248 ensurePickerProfile`、`ensure-role-profile-patch.cjs` | **中(必须改)** |
| 9 | Worker 上报(熔断/配额/状态) | agent 暴露 `breakerInfo`/`quotaInfo` | 小 |
| 10 | admin 迁移/主机管理 API | `src/web/routes/admin.ts`(同族 `/api/admin/users/:id/dsh/*`) | 小 |
| 11 | 部署基线(同镜像 + nft + bwrap + uid) | 新装机脚本 | 中 |
| 12 | 路由 | `proxy.ts` **不改** ✅ | 0 |
**Worker 数量 → 容量(按现有配额实测值:实例 384–672 MiB)**
| 8C16G Worker | 并发活跃实例(保守 672 MiB) | 并发活跃实例(典型 384 MiB) |
|---|---|---|
| 每台 | **12–14** | 20–24 |
| 目标 | 目标并发 | 所需 Worker | 说明 |
|---|---|---|---|
| 50 人试点 | 15 | **2–3 台** | 含 1 台冗余 |
| 300 人 | 60–90 | 6–8 台 | 20–30% 同时在线 |
| 1000 人 | 200–300 | **16–25 台** | 需配合分钟级空闲回收 |
---
## 6. 潜在问题点(P0 / P1 / P2)
### P0(不解决就不能上)
| # | 问题 | 后果 | 应对 |
|---|---|---|---|
| P0-1 | **Manager ≥2 台 ⇒ PG 是硬前提**。SQLite 放共享存储多写者不安全 | 组不了 Manager 组 | 切 PG 只有一个环境变量(`db/index.ts:19`),但**要真做数据迁移与回归**(`db/pg.ts` 508 行,本项目从未在生产启用 —— 档案 19 §C8) |
| P0-2 | **实例端口对内网暴露 = 权限扩大(触 R5)** | 同 VPC 任意主机可直连实例端口(只剩 dsh 自身 token/cookie 一层) | nft 只放行 Manager 组 IP + 桥只绑内网网卡 + `tcp-bridge` 加共享密钥校验;**先出权限影响评估** |
| P0-3 | **脑裂双写同一 home** | 会话日志 append 冲突 → **数据损坏** | 租约 + fencing(§3.2/§4.3);宁可放弃自动转移,也不裸奔 |
| P0-4 | **模型落地层直写主机 home**(`writeHomeFile` + chown) | 档案 87「模型设置」在集群下**整块失效** | 改为经 Worker agent 写(或写共享存储后由实例读) |
| P0-5 | **角色补丁 / picker 是主机侧脚本** | 非 admin 隐藏模型分区、目录选择器收敛失效 | 并入 Worker launch 流程(Worker 本机执行,逻辑不变) |
| P0-6 | **`launchToken` 回传缺失** | 直达会话、401 自愈失效(档案 06/13/24/51) | Worker 必须把 token 交 Manager(协议字段) |
| P0-7 | **文件面跨机**:`userFs` 直读本机 `dataRoot/users/<id>/ws` | 门户「我的文件」对非本机用户 404/空 | 复用 `file-service`(已有实现)或共享存储直读 |
### P1(影响可用性/运维)
| # | 问题 | 应对 |
|---|---|---|
| P1-1 | **熔断/退避/配额是进程内存态**(`LocalSpawner`) | 留在 Worker 自管(推荐,零重写);但 Worker 重启即失忆 ⇒ 需接受"熔断态随重启清零"或落本机轻量状态文件 |
| P1-2 | **实例随机端口** → 防火墙难写 | 收敛到固定段(9000–9999),`findFreePort()` 限定范围 |
| P1-3 | **容量准入缺失**:现在只有本机 `maxIdleInstances` | Manager 按 `dsh_hosts.capacity_mb` 选机,否则某台被打满 |
| P1-4 | **长连接集中在 Manager**(WebSocket 全由代理持有) | 按连接数扩容 Manager;监控单副本连接数上限 |
| P1-5 | **时钟漂移影响租约** | 全集群 NTP;TTL 留足余量(≥2×续期) |
| P1-6 | **日志/可观测分散到 N 台** | 集中日志(Loki/SLS)+ `/healthz` 聚合;现有单机 journald 不够 |
| P1-7 | **备份窗口随用户数膨胀**(见 §7) | 分层备份 |
| P1-8 | **Worker 升级需同基线**(bwrap / `/etc/passwd` 白名单 / 镜像) | 统一镜像 + 灰度;`/etc` 白名单是运行时实测最小集(档案 39),换发行版会失效 |
### P2(可延后)
| # | 问题 |
|---|---|
| P2-1 | uid 是否需要在每台 Worker 建 OS 账号 —— 代码走 `setpriv --reuid <数字>`,但 `/etc/passwd` 被绑入沙箱(`orchestrator.ts:640`,为 `os.userInfo()`)⇒ **需 5 分钟实测确认**(若不建号也可,uid 分发零成本) |
| P2-2 | 迁移期间用户正在对话的体验(drain 10 s 内断连) |
| P2-3 | 单用户跨机后 `portGuard`(iptables owner-match)语义变化,需重做等价护栏 |
---
## 7. 备份方案(回答「有没有比文件备份更高效的方式」)
### 7.1 现状(实测)
- `/opt/dsh/backup.sh`(入库 `scripts/backup-platform.sh`):**每周日 05:00 全量** tar.gz + 60 天保留;含 `var/lib/dshs` + env + systemd + artifacts + nginx vhost;SQLite 用 `.backup` 一致性快照;
- 实测首跑 **8.7 MB / 6919 条目**(用户数还少)—— 现在全量 tar 毫无压力,**问题在用户数上去之后**。
### 7.2 五种方式的对比(**结论:单靠文件备份不是最高效的**)
| 方式 | 机制 | CPU/IO 成本 | 优点 | 缺点 |
|---|---|---|---|---|
| ① 全量 tar(现状) | 每次重打包 | 高(随数据量线性增长) | 简单、可读 | 全量重传;**数千小文件(`node_modules`)是 tar 最慢的部分** |
| ② rsync 增量 | 按 mtime/size 比对 | 中 | 差量传 | 需目标端为文件系统;无去重、无加密、版本管理弱 |
| ③ **restic / borg 去重增量** | 内容分块去重 + 快照 | 中低 | 增量 + 去重 + 加密 + 保留策略,天然适合小文件与历史版本 | 需目标为 S3/OSS 或本地仓库;首跑要建立索引 |
| ④ **存储层快照(最省)** | NAS / ESSD 快照 | **近零**(不经过应用) | 秒级、卷级原子、增量块级 | 粒度是**卷**不是用户;恢复单用户要挂快照再拷 |
| ⑤ **只备「不可重建」子集** | 选择性打包 | **最低** | 体积可砍掉大头 | 需要明确"什么可重建" |
### 7.3 推荐:分层备份(组合 ④ + ③ + ⑤)
| 层 | 对象 | 方式 | 频率 | 恢复目标 |
|---|---|---|---|---|
| L1 | 共享存储卷(全部用户数据) | **NAS/ESSD 自动快照** | 每日(保留 7–30 天) | 整卷回滚(分钟级) |
| L2 | 关键子集 → 对象存储(异地) | **restic 增量 + 去重** | 每日 | 单用户/单文件恢复 |
| L3 | DB | **PG 物理备份 + WAL 归档(PITR)** | 连续 | 任意时间点 |
| L4 | 平台配置(env/systemd/nginx/artifacts) | 现有 tar(体积小,保持全量) | 每周 | 重建平台 |
**L2 的「关键子集」= 只备不可重建的部分(这是最大的效率来源)**:
| 备份 | 不备份 | 理由 |
|---|---|---|
| `sessions/`(对话历史)、`.credentials.yaml`、`skills/`、`ws/`(产出物) | **`profiles/web/node_modules`** | 数千小文件、体积最大,且**可由「`dsh.profile.bundles` 清单 + 镜像版本」完整重建** |
| 平台注入的 patch / picker / 模型落地文件的**清单** | 其**内容** | 由代码 + DB 重建(`renderPatch` / `landModels` 幂等) |
| — | 各类缓存(`.cache` / 临时产物) | 无价值 |
> 体积预估:排除 `node_modules` 后,单用户数据通常从数百 MB 降到 **数 MB–数十 MB** ⇒ L2 的窗口从"小时级"回到"分钟级"。
> **必须保留**:`credentials`(不可再生成)与 `sessions`(对话不可重建)**一律备份,不要省**。
**附加要求(现状已有先例)**:备份**必须配恢复演练**(对照 `02-运维手册 §C.8` 的演练记录)+ 迁移/备份期间避免"实例正在写 sessions 时取半写快照"⇒ 优先用**存储层快照的原子性**,其次 drain 后备份。
---
## 8. 落地成本
### 8.1 机器(量级参考,需以实际报价核)
| 角色 | 规格 | 数量 | 说明 |
|---|---|---|---|
| 入口 | 现有腾讯云机 | 1 | 复用,不新增 |
| Manager | 4C8G | 2 | 双活门户 + 控制主备;主要是代理长连接的带宽/连接数 |
| Worker | 8C16G | 见 §5 表(2–25 台,随目标并发) | 主要成本项 |
| PG | 4C8G / 复用现有 RDS | 1(HA 则 2) | 共享状态 |
| 共享存储 | NAS(或 ESSD+快照) | 按用户数据量 | 容量 + 快照存储费 |
### 8.2 工程改动面(**比人日更可靠的口径**)
| 类别 | 内容 | 判断 |
|---|---|---|
| 新组件 | worker agent、`RemoteSpawner`、租约模块 | 3 个 |
| 必须改的现有能力 | 模型落地层、角色补丁/picker(P0-4/5) | 2 处 |
| 数据层 | PG 切换(本项目**从未在生产用过**)+ 迁移 v7 | 高风险项 |
| 不需要改 | 代理层、路由层、隔离机制(bwrap/uid/scope)、熔断/配额/插件探活 | ✅ 本方案最大收益 |
| 运维新增 | 装机基线、nft、集中日志、租约监控、备份四层 | 中 |
> **参考量级**(仅表示规模,不作为承诺):一个可上线的第一版 ≈ 一次性投入数周(含 PG 真机回归);之后每加一个能力(自动转移、弹性调度)都是独立增量。
### 8.3 三阶段路线
> ⚠️ **分期必须互相兼容**(一期做完,二期只是加机器/加开关/加运维,**不重写代码**)⇒ 约束、兼容矩阵、反模式与一期 DoD 见 **§14**。
| 期 | 内容 | 出口验收 |
|---|---|---|
| **一期(可用)** | **1a**:PG 切换 + 归属/租约 + worker agent + `RemoteSpawner` + 2 台 Manager(其中 1 台兼任 Worker-01,**现有数据原地不动**)→ **1b**:加 Worker-02 + NAS,**只做计划内迁移**(渐进路径见 **§15.3**) | 50 人真实使用;kill 一台 Manager 门户不中断;管理员迁移一个用户成功且会话无损 |
| **二期(稳)** | 文件面跨机、容量准入、集中日志、四层备份、灰度升级 | 300 人;单 Worker 可下线维护;备份恢复演练通过 |
| **三期(弹性)** | 心跳故障转移、自动回收、容量自动扩缩、可观测告警 | 1000 人;Worker 故障 5 分钟内自动恢复 |
### 8.4 回滚
| 变更 | 回滚方式 |
|---|---|
| 归属/租约(v7) | 新增列/表,不影响旧代码路径;`deployMode=local` 单机模式**保留可用**(代码不删) |
| PG 切换 | 保留 SQLite 路径;PG 出问题可切回单机(需数据回灌,**这是最大风险点,二期前必须演练**) |
| Worker 扩容 | 缩容 = 迁走用户 + `status=draining` |
| 单次迁移失败 | 归属改回原 Worker(改一行 + epoch+1) |
---
## 9. 决策状态(2026-09-14 更新)
| # | 事项 | 状态 | 结论 |
|---|---|---|---|
| 1 | Manager 组形态 | ✅ **已定** | **门户双活 + 控制主备**,且**支持单活部署**(同一套代码,1..N 台)→ 见 §2.4 |
| 2 | 共享存储选型 | ✅ **已定方向** | **平台侧做成「存储后端可插拔」**:本地盘 / 自建 NFS / 云 NAS / 云块存储都能接 → 详解见 **§12** |
| 3 | PG 承载方式 | ✅ **已定方向** | 一期**自建**(独立机 + 四层备份),300 人后再评估迁 RDS → 详解见 **§13** |
> 定项以外的技术细节(端口段、TTL 数值、对账周期、备份频率)由实现侧自主决定,不再上抛。
## 10. 动工前必须先做的 5 分钟实测(把未知数清掉)
> ✅ **本清单已于 2026-09-14 21:17–21:25 在真实两台服务器上执行完毕** ⇒ **结果与结论见 §17**(第 1、2 项已有答案,第 3–5 项依赖共享存储,尚未做)。
| # | 验证项 | 判据 |
|---|---|---|
| 1 | 跨机代理:实例经 `tcp-bridge` 暴露后,用带 `Host: 127.0.0.1:<非实例端口>` 的请求打 `/api/*` | `403` = 栅栏拒(理论被推翻)/ `401` = 栅栏放行(理论成立) |
| 2 | uid 是否需 OS 账号 | 不建号直接 `setpriv --reuid <数字>` 能否起实例 + `os.userInfo()` 是否报错 |
| 3 | 共享存储上的 watcher | `skill-filesystem.watchUsePolling=true` 后 skill 目录事件正常;credentials 热重载是否失效(最坏只需重启) |
| 4 | 共享存储冷启动耗时 | 一个真实 home(含 node_modules)从 NAS 起的冷启动 vs 本地盘,差多少 |
| 5 | 每用户数据体积 | 实测 `sessions/skills/ws`(含 node_modules)各占多少 ⇒ 定 L2 备份子集与 NAS 容量 |
---
## 11. Manager ⇄ Worker 连接保障与故障恢复(可靠性核心)
### 11.1 先立一条设计原则:**连接不是关键路径**
> **权威归属在 PG、用户数据在共享存储** ⇒ 「连接断了」本身**不会损坏数据**,最坏只是"暂时访问不到"。
> 所有可靠性设计都服务于这一条:**宁可短暂不可用,不可双写。**
### 11.2 连接形态:单向、Manager 主动拨入
| 通道 | 方向 | 协议 | 用途 | 超时 |
|---|---|---|---|---|
| 控制通道 | Manager → Worker:9000 | HTTP + HMAC | launch / stop / status / **list** / drain | 10 s |
| 心跳通道 | Manager → Worker:9000 | HTTP `GET /healthz` | 存活 + 容量 + **fencing 广播**(见 §11.5) | 2 s |
| 数据通道 | Manager → Worker:9001-9999 | HTTP + WebSocket | 用户流量代理 | 常规 30 s;SSE/WS 不限 |
**Worker 不反向连接 Manager、不直连 DB** ⇒ Worker 实现简单、无需知道 Manager 地址、控制口是它唯一的"被拨入口"。
**为什么心跳用「Manager 拉」而不是「Worker 推」**:① Worker 无需 DB 依赖(保持 §1.2 边界);② 多台 Manager 各自拉 = 天然冗余探测;③ 避免 Worker 侧维护"往哪个 Manager 推"的状态。
### 11.3 连接"确保"的六条工程手段
| # | 手段 | 具体做法 | 依据 |
|---|---|---|---|
| 1 | **网络层** | 同 VPC 内网;跨可用区走内网固定 IP;跨地域必须 WireGuard/专线,**不得公网明文** | — |
| 2 | **半开连接防护**(**必须做**) | 控制通道 `keepAliveTimeout` + request timeout;代理通道复用现有做法:**连接出错 → `agent.destroy()` + `new Agent()` → 短连接重试一次** | `proxy.ts:307/450-455` 记的 2026-09-09 事故:keep-alive 池缓存已回收实例的 socket → **请求永久挂起**。跨机后 NAT/防火墙会静默丢空闲连接,**这个问题只会更严重** |
| 3 | **幂等键**(**必须做**) | 每个控制请求带 `operation_id`(= `epoch` + 序号);Worker 保留最近 N 个并**回放上次结果** | 解决"Manager 发出去了但响应丢了"——否则超时重试可能起两个实例。Worker 侧按 userId 幂等(已运行则返回现有实例信息),与 `LocalSpawner` 的 `AlreadyRunningError` 语义对齐 |
| 4 | **鉴权 + 最小接口** | `X-DSH-Agent-Token`(HMAC,密钥加密存 `dsh_hosts.agent_token`);agent **只接受白名单动作**,参数受限(folder 必须在该用户根下、patch 有长度上限) | **安全红线**:agent 若接受任意命令,Worker 沦陷 = 全集群沦陷 |
| 5 | **重试与退避** | 控制请求重试 3 次(200 ms / 1 s / 3 s),仍失败则标记该 Worker 异常并告警 | 区分"抖动"与"真故障" |
| 6 | **连接池容量** | `maxSockets` 按 `Worker 数 × 平均实例数` 调整(当前 32) | 现状值是按单机定的 |
### 11.4 故障恢复矩阵(**10 个场景**)
| # | 场景 | 检测方式 | 恢复动作 | 用户感知 |
|---|---|---|---|---|
| 1 | 控制请求超时(网络抖动) | 请求超时 | 幂等重试 | 无(冷启动略慢) |
| 2 | Worker 心跳连续失败,**但未过 TTL** | 3 次 `/healthz` 失败 | **只告警,不接管** | 无 |
| 3 | Worker 失联 **超过 TTL** | 租约过期 | 先尝试 stop(网络若恢复);确认不可达后 → 新 Worker 用 `epoch+1` 重启;旧实例若复活被 fencing 拦下 | **约 30–60 s 不可用**(TTL 30 s + 冷启动) |
| 4 | Worker 计划内维护 | 人工置 `status=draining` | **先迁后停**:逐个 drain + migrate,全部迁完才停机 | 每次迁移数秒 |
| 5 | Worker agent 崩(机器/OS 正常) | 心跳恢复但实例列表为空 | **对账**:归属在该机但实例不在 → 按会话活跃度决定重拉或删归属 | 冷启动延迟 |
| 6 | Manager 挂 1 台 | LB 健康检查 | 另一台接管(门户双活);控制主经 advisory lock 自动转移 | 无(长连接断开,前端自动重连) |
| 7 | **Manager 全挂** | LB 无后端 | Worker 上**实例继续运行**(无人代理 = 不可访问);Manager 恢复后从 DB 读归属 → 逐个 Worker 核对 → 重新暴露 | 门户中断期间全员不可用 ⇒ 所以 2 台 Manager 要**跨可用区** |
| 8 | 实例进程崩(Worker 本地) | Worker 本地检测 | **Worker 自愈**(现有退避 + 熔断 + 插件自愈),Manager 不参与 | 与现状一致 |
| 9 | **PG 不可用** | 连接失败 | 拒绝新冷启动;**已代理流量继续转发**(依赖归属缓存,建议 TTL 60 s) | 新用户进不来,在线用户不受影响 |
| 10 | 网络分区(脑裂) | fencing 校验失败 | 老 holder 的一切操作被拒;新 holder 接管;分区恢复后自动对账 | 分区侧请求失败,恢复后收敛 |
### 11.5 心跳的三个职责(**别只当存活检测**)
1. **存活**:连续失败计数 → 决定是否标记 `down`;
2. **容量上报**:`used_mb` / 实例数 → 供选机(§5 容量准入);
3. **fencing 广播**(关键):Manager 在心跳响应里下发该机的 `expected_epoch` 清单。Worker 发现自己持有的实例 **epoch 落后** ⇒ **主动停掉自己那个实例(self-fencing)**。
> ⇒ **它是"防双写"的最后一道防线**:即使 Manager 判定错误,复活的老 Worker 也会因为 epoch 落后而自杀,而不是继续写共享存储。
### 11.6 对账(reconcile)设计
- **只有控制主跑**(PG advisory lock),周期 **30 s**;
- **一次调用拿回整机实例列表**(`GET /instances`),而不是逐用户查询 —— 这是相对 k8s 版 `reconcile.ts` 逐个 `ensureUserResources`/`reap`(O(N) API 调用)的**直接改进**;
- 差异处理(全部带 epoch、全部幂等):
- DB 有 / Worker 无 → 有活跃会话则重拉,否则删归属
- Worker 有 / DB 无 → **孤儿**,stop
- `host_id` 指向 `status=down` 的机 → 迁走
- Worker 响应的 `epoch` 与 DB 不一致 ⇒ **立即 self-fence 该 Worker**。
### 11.7 恢复动作必须可审计 + 不可逆操作守纪律
- 每次迁移/接管/孤儿清理写 `audit_log`(表已存在)+ 平台状态目录留痕;
- **不可逆破坏性动作(删归属、删用户数据)一律先出清单**,与现有纪律一致(`CODEBUDDY.md §3 R8`);
- **自动接管默认关闭**,一期只做"告警 + 人工确认接管" ⇒ 与 `R9`(无心跳不臆断对方已死)的教训保持一致,等 TTL + fencing 在真机跑稳后再开自动。
---
## 12. 存储选型详解("服务存储"与"接入存储空间"到底差什么)
### 12.1 先澄清:这是**四种产品形态**,不是两种
"服务存储"vs"接入存储空间"这个说法容易把两件不同的事混在一起 —— 真正的分类轴是**「能不能被多台机器同时挂载」**:
| 类型 | 例子(云厂商产品) | 能不能多机同时读写 | 本质 |
|---|---|---|---|
| **① 本地盘** | 实例自带的本地 SSD/NVMe、物理机 RAID | ❌ 只能本机 | 硬件直连 |
| **② 块存储** | 阿里云 **ESSD**、腾讯云 **CBS** | ❌ 一块盘挂一台(多重挂载需集群文件系统,实践罕见) | 一块"虚拟硬盘" |
| **③ 文件存储** | 阿里云 **NAS**、腾讯云 **CFS / CFS Turbo** | ✅ **RWX 多机同时挂**(NFS/SMB 协议) | 网络文件系统 |
| **④ 对象存储** | 阿里云 **OSS**、腾讯云 **COS**、S3 | ✅ 但是 HTTP API,**不是文件系统** | Key-Value 桶 |
### 12.2 我原来那两个选项,本质就是 ③ 与 ② 之争
| 维度 | ③ 文件存储(NAS RWX) | ② 块存储(ESSD + 定期同步) |
|---|---|---|
| 多机共享 | ✅ 天然(这正是"位置无关"的实现方式) | ❌ 只有挂它的那台能读写 ⇒ 靠**定时同步**间接共享 |
| 小文件性能 | ⚠️ 一般(元数据操作走网络,`node_modules` 数千小文件是典型受害者) | ✅ 好(本地/近本地块设备) |
| 迁移代价 | **不搬数据,改归属即完成** | **必须搬数据**(或接受"迁移后数据不是最新") |
| 可用区约束 | 可跨可用区(多可用区版) | **必须与实例同可用区** ⇒ 会限制 Worker 调度 |
| 快照 | 支持(文件系统级) | 支持(块级,更常见更成熟) |
| 计费 | 按容量(+吞吐/性能等级) | 按容量 + 性能等级(PL0→PL3 陡增) |
| 我们方案的定位 | **跨机共享层(主选)** | Worker 本地高性能层(可选加速) |
> **性能更快的版本**:文件存储不是只有"通用型"——阿里云有 **NAS 极速型 / CPFS**、腾讯云有 **CFS Turbo**,小文件性能大幅改善,代价是单价高。**若实测通用型 NAS 起不来实例,第一选择是换极速型,而不是放弃共享存储。**
### 12.3 "也是云厂商的服务吗" —— 是,但**都能自建**(重要)
| 类型 | 云厂商托管 | 自建等价物 |
|---|---|---|
| 块存储 | ESSD / CBS | 本地盘、Ceph RBD |
| 文件存储 | NAS / CFS | **自己搭 `nfs-kernel-server`**(一台机器导出 NFS) |
| 对象存储 | OSS / COS / S3 | MinIO、Ceph RGW |
| 数据库 | RDS PostgreSQL | 自己装 PG(见 §13) |
⇒ 所以"支持服务存储,也支持接入存储空间"这句话在架构上应该落成:**平台不绑定任何一种,只依赖一个「存储后端」抽象**。
### 12.4 怎么做到"两种都支持"(**成本很低**)
现状代码里所有用户路径都从 `config.dataRoot` 派生(`dataRoot/users/<id>/{home,ws}`)⇒ **把 `dataRoot` 指向挂载点即可切换后端,业务代码零改动**。真正要新增的只有**能力探测 + 明确降级**:
| 探测项 | 为什么要测 | 失败时怎么办 |
|---|---|---|
| 原子 rename / `fsync` | 会话日志与 `.credentials.yaml` 的原子写依赖它 | 降级告警,禁用依赖该能力的路径 |
| **NFS/网络文件系统识别**(`statfs` 的 fs 类型) | 决定是否自动开 `skill-filesystem.watchUsePolling=true`(官方开关,`index.ts:610-619`) | 自动开轮询 |
| 小文件写延迟采样(启动时写 1000 个小文件计时) | 预判 `node_modules` 冷启动是否可接受 | 超阈值 → **告警并建议换极速型** |
| 可用容量 / inode 余量 | NAS 的 inode 耗尽比容量耗尽更常见 | 低于阈值拒绝新实例 |
> **判据必须"启动时自证"**:宁可启动就报"我这台存储后端是 NFS 通用型、小文件延迟 8 ms、轮询已开",也不要等用户跑起来才发现慢。做法照我们已有的「按序探测 + 可观测降级」模式(`src/web/dsh-install.ts`,档案 88)。
### 12.5 推荐:**按目录分层挂载**(比"整卷选一种"更优)
不同子目录的特性完全不同,混在一层是浪费:
| 子目录 | 特性 | 建议落点 |
|---|---|---|
| `home/profiles/**/node_modules` | 数千小文件、**可完整重建** | **不入共享层**:镜像提供 + 清单重建(同时是备份 L2 的排除项,§7.3) |
| `home/sessions`、`home/.credentials.yaml` | 小文件多、总量小、**不可重建** | 共享层(NAS)或随实例走 + 迁移时同步 |
| `home/skills` | 小、不可重建 | 共享层 |
| `ws/`(用户工作产出) | 可能有大文件、增长快 | 共享层(NAS)或块存储 |
### 12.6 选型时**必须问厂商/自查的 6 个参数**
1. 是否支持 **RWX**(多挂载点)与**跨可用区**;
2. 小文件(4 KB 级)随机写 **IOPS/延迟**(不要看顺序吞吐的宣传值);
3. **inode 上限**与扩容方式;
4. 快照能力与**恢复粒度**(整卷 / 单文件);
5. 单价口径(容量 / 吞吐 / IOPS 分别计费的部分);
6. **与 Worker 的可用区关系**(块存储必须同可用区 ⇒ 会锁死调度)。
**本期建议**:一期先把 `dataRoot` 放在**现有服务器本地盘**(单机阶段无跨机需求)→ 上多 Worker 时切**云 NAS 通用型**(省事、位置无关)→ 实测不行则升**极速型**,或对 `ws/` 单独用块存储加速。
---
## 13. PG 承载详解(自建 vs 云 RDS)
### 13.1 四种形态
| 形态 | 说明 | 适用 |
|---|---|---|
| A. 与 Manager 同机 | PG 和门户抢资源;Manager 挂 = DB 也挂 | ❌ 不建议 |
| B. **独立 ECS 自建** | 单独一台跑 PG(+ 可选流复制备机) | ✅ **一期推荐** |
| C. **云 RDS(RDS PostgreSQL / TencentDB)** | 厂商托管,主备自动切换 + 自动备份 | ✅ 二期可迁 |
| D. CNPG on K8s | 若将来走 k8s,`deploy/01-dsh-pg.yaml` 已写好 | 未来 |
### 13.2 对比
| 维度 | B. 自建(独立机) | C. 云 RDS |
|---|---|---|
| 月成本(4C8G 量级) | 只付机器 | **约 2–4×** |
| 延迟(同 VPC) | 0.2–1 ms | 0.2–1 ms(同 VPC 相当) |
| 高可用 | 自己做(流复制 + 手工/脚本切换) | **自带主备自动切换** |
| 备份 / PITR | 自己做(本方案 §7.3 的 L3) | **自带**(可视化 + 时间点恢复) |
| 参数/扩展 | **全自由**(任意扩展、任意参数) | 部分受限,白名单/权限有约束 |
| 运维负担 | 高(升级、监控、切换演练) | 低 |
| 风险 | ⚠️ **我们从未在生产跑过 PG**(档案 19 §C8 标注"未使用、未验证") | 厂商背书,但**同样要做 PG 回归** |
### 13.3 三个必须知道的坑
| # | 坑 | 说明 |
|---|---|---|
| 1 | **跨云/跨地域绝对不要** | 我们的入口在腾讯云;若集群侧在阿里云而 DB 在腾讯云 ⇒ 每条 SQL 走公网 10–30 ms + 抖动,**会被放大到每一个请求**。⇒ **PG 必须与 Manager 同 VPC**,跨云只用专线。 |
| 2 | **连接数** | dshs 每个 Manager 一个连接池;RDS 小规格常限 100 连接、且**每 Request 新建连接是灾难**(要复核是否全部走池)。2–3 台 Manager + 未来副本要算总数。 |
| 3 | **两套 DB 实现并行** | `db/sqlite.ts`(含 `repo.ts` 同步实现)与 `db/pg.ts` 是**两份独立实现** ⇒ PG 回归必须覆盖两者行为一致(同一组 smoke 跑两边对比),这比"切个 URL"重得多。 |
### 13.4 推荐与迁移路径
- **一期:B(独立 ECS 自建)+ §7.3 的四层备份**。理由:成本低、延迟可控,而且**无论选谁都要做 PG 回归**(这是最大风险项,与选型无关);
- **二期(300 人后)**:评估迁 C。**在线迁移用逻辑复制**(原生 logical replication 或 pglogical):源库订阅 → 追平 → 秒级切写 → 改 `DSHS_DB_URL` 重启 Manager,停机窗口可压到秒级;
- 若团队无人愿承担 DB 运维 ⇒ **一上来就用 C 也完全合理**,代价只是钱。
### 13.5 无论选哪个都必须先做的回归
1. **数据迁移脚本**(SQLite → PG):注意 `identity` 列与 `uid` 列(`db/pg.ts` 用 `BIGINT GENERATED ALWAYS AS IDENTITY`,`baseUid + row_id` 的 uid 派生依赖它);
2. **跑通现有 smoke**:`scripts/smoke-*.mjs` 全套(admin/auth/dsh/fs/domain/isolation)在 PG 下重跑一遍;
3. **会话与凭据回归**:登录态跨 Manager 生效、`credential_vault` 加解密读写一致;
4. **回滚演练**:PG 出问题时能否切回 SQLite(这是本方案**最大风险点**,必须先演练再上量)。
---
## 14. 阶段兼容性设计(**一/二期不返工**)
> **用户要求(2026-09-14):一期与二期方案必须互相兼容。**
> 落地成一条总原则:**分期只分「自动化程度与规模」,不分「机制、数据结构、协议」** —— 机制和结构一期就定死,二期只是加机器、加开关、加运维。
### 14.1 兼容矩阵(7 个维度)
| 维度 | 一期(可用) | 二期(稳) | **一期必须现在就做对的约束** |
|---|---|---|---|
| Manager 数量 | 2 台(也支持 1 台) | 更多 / 跨可用区 | 门户无状态 + advisory lock;**禁止"必须 2 台"的硬假设** |
| Worker 数量 | 2 台 | 6–25 台 | **一期就走 `RemoteSpawner` + agent 协议**(哪怕 Worker 就在本机)⇒ 加机器是纯运维动作,不重写代码 |
| 存储 | 本地盘 | 云 NAS / 极速型 / 块存储加速 | **一期就写"能力探测 + 按目录分层挂载"**(哪怕全是本地盘);`dataRoot` 指向挂载点(**派生点 80 处已全走 config ✅**) |
| DB | **自建 PG(硬要求)** | 云 RDS | 两阶段**都是 PG** ⇒ 只需 `DSHS_DB_URL` + 逻辑复制在线迁;⚠️ **一期不能先用 SQLite 顶**,因为租约依赖 PG 的原子 `UPDATE … WHERE` |
| 自动接管 | **关闭**(只告警 + 人工确认) | 开启 | **lease + fencing + self-fencing 一期全实现** —— 分期只分"自动化程度",**不分安全机制**(fencing 是防双写的,不能二期才加) |
| 备份 | 四层结构的低频版 | 高频 + 异地 + PITR | **一期就用同一套工具与格式**(restic + 快照 + WAL),只是频率低 ⇒ 二期只调参数,不换格式 |
| 代理/路由 | 跨机转发 | 不变 | **零改动**(已验证:TCP 目标与 Host 头分离 + 栅栏只看主机名) |
### 14.2 六个「现在不做、二期必返工」的点(**已核实,全部是真实存在**)
| # | 点 | 现状(实测) | 一期就要做 |
|---|---|---|---|
| 1 | **平台状态目录散落 8 处硬编码** | `/opt/dsh/state/{runtime-baseline,capabilities}.json`、`/opt/dsh/state`(模型托管清单)、`/opt/dsh/backups`、`/var/run/dsh-storage-report.json`、`/var/log/dsh-crash-breaker.log`、`/opt/dshs/scripts/ensure-biz-plugins.cjs`、`/usr/local/dsh-runtime`(×3) | 先给"平台状态 vs 用户数据 vs 机器基线"**三分类**(见 14.3),把它们收敛到 1–2 个**显式配置的根**;否则二期换存储会出现"一半在 NAS、一半在本地"的隐性分裂 |
| 2 | **模型托管清单是"每机本地文件"** | `server.ts:106` `DSH_PLATFORM_STATE_DIR/model-landing/<userId>.json` | 🔴 **必须挪到 PG 或共享目录** —— 否则 A 机拉起的实例写过的 ref,B 机不知道 ⇒ 违反档案 87「**只碰自己写过的**」约束 ⇒ **重复写 / 漏删用户凭据** |
| 3 | **`process.cwd()` 定位脚本** | `orchestrator.ts:251` 用 cwd 找 `poc/workspace-scoped-picker/ensure-workspace-picker.cjs` | 改成基于**安装根**的绝对路径(多机/多部署形态下 cwd 由 systemd `WorkingDirectory` 决定,不稳定)—— 复用 `src/web/dsh-install.ts` 的按序探测 + 可观测降级(档案 88) |
| 4 | **两套 DB 实现并行** | `db/sqlite.ts`(+`repo.ts`)与 `db/pg.ts` 是独立两份 | **所有 schema 变更(如 v7)两份同时改**;只用两者都支持的 SQL 子集;时间统一 BIGINT epoch ms;布尔沿用现有转换约定 |
| 5 | **`dsh_hosts` / 归属列** | 不存在 | **一期就建**(哪怕只注册 1 台)⇒ 二期加机器**零 schema 变更** |
| 6 | **uid 是否需要 OS 账号未定** | `setpriv --reuid <数字>`;但 `/etc/passwd` 是 bwrap 白名单必挂项(`orchestrator.ts:640`,为 `os.userInfo()`) | **一期测掉**(§10 第 2 项)—— 它决定二期加机器要不要同步建号 |
### 14.3 状态三分类(**先分类,再谈存储**)
| 类别 | 例子 | 多机后应放哪 | 迁移时是否跟着用户走 |
|---|---|---|---|
| **用户数据** | `dataRoot/users/<id>/{home,ws}` | 共享存储(NAS) | ✅ 跟走 |
| **平台状态** | 模型托管清单、capabilities、storage-report、crash-breaker 日志 | **PG 或共享目录**(唯一一份) | ❌ 平台级,不属于某个用户 |
| **机器基线** | `/usr/local/dsh-runtime`(glibc/共享工具)、镜像、bwrap 白名单 | **每台 Worker 各自一份,必须同基线** | ❌ **不能跟着走**(跟着走反而错) |
> **这张表是"存储可插拔"能否成立的前提**:分类做对,二期只改挂载表;分类不做,二期要翻代码。
### 14.4 反模式清单(**一期"先简单来"会锁死二期的五种偷懒**)
| ❌ 反模式 | 为什么锁死二期 |
|---|---|
| 一期先在本机直接 spawn,二期再做 RPC | Worker 抽象被绕过 ⇒ 二期重写实例生命周期全链路 |
| 一期先用 SQLite 跑("PG 以后再说") | 租约/归属必须 PG;二期要补数据迁移 + 双实现回归,风险集中爆发 |
| 一期把心跳/租约做成"能跑就行" | fencing 缺位 ⇒ 二期开自动接管 = 直接暴露在双写风险下 |
| 一期备份用 tar,二期换 restic | 两套格式 ⇒ 恢复路径分叉,演练覆盖不全 |
| 一期把平台状态继续写在 `/opt/dsh/**` | 二期换存储时状态分裂,且多 Manager 读不到同一份 |
### 14.5 一期 DoD(**验收时必须已包含这些"二期就绪项"**)
- [ ] 至少 1 台"远端 Worker"(即使物理上同机)走完整 `RemoteSpawner` + agent 协议,含幂等键与 fencing;
- [ ] `dsh_hosts` + 归属/租约列已建,advisory lock 生效,**单 Manager 时自动退化为主**;
- [ ] PG 跑通全套 `scripts/smoke-*.mjs`(SQLite 侧同组对比),**回滚演练过**;
- [ ] 存储能力探测已输出(启动日志能看出"这台是什么后端、哪些能力被降级");
- [ ] 平台状态三分类已收敛(14.2 的 #1/#2 完成);
- [ ] 四层备份已按同一套工具落地(低频即可),**恢复演练通过**;
- [ ] 自动接管开关存在且**默认关闭**,人工接管流程演练过;
- [ ] 单活部署实测通过(撤掉 LB 后域名直指一台,全功能可用)。
---
## 15. 部署与管理(**方便上线、方便日常运维**)
> **用户要求(2026-09-14 21:06):还要考虑如何方便部署和管理。**
> 判据一句话:**凡是"要登录某台机器手工做"的事,都必须能用一条命令替代**(系统级故障除外)。
### 15.1 先定部署单元:**Worker 必须整机,不能容器化**
| 角色 | 交付物 | 理由 |
|---|---|---|
| **Worker** | **整机镜像**(云自定义镜像 / kickstart·preseed 脚本) | 🔴 **硬约束**:实例隔离依赖 `systemd-run --scope` + `bwrap` + 每用户 `setpriv` 改 uid ⇒ 需要真实 systemd 与特权。容器里做不到(做得到也得放弃现有隔离语义 = 换一套实现)。**这是与 k8s 模式最本质的差别,别照搬容器路线。** |
| Manager | 整机镜像或容器均可 | 它不需要特权(纯 Node + Fastify),且无状态 ⇒ 容器化收益大、风险低 |
| 产物 | **同一版本产物分发到所有节点** | 避免"服务器源码 ≠ git"那类静默漂移(我们踩过) |
**为什么强烈建议整机镜像而不是"每台手工装"**:§14.3 已把"机器基线"列为**不能跟着用户迁移**的一类状态(`/usr/local/dsh-runtime`、bwrap 白名单、系统包)。手工装必然漂移 ⇒ 二期加机器时性能/行为不一致。整机镜像 = 基线一致 + 回滚容易(换镜像 ID)。
### 15.2 一条命令加机器(Worker join)
目标:**新机器从零到接单 ≤ 3 条命令**。
```bash
# ① 用平台镜像起机(或用 join 脚本在干净系统上跑)
curl -fsSL https://<入口>/join.sh | bash -s -- --manager https://mgr.internal --token <一次性令牌>
# ② 脚本自检 + 注册 + 起服务
# ③ 在门户「集群」页确认它变绿
```
`join.sh` 要做的**自检(全部硬失败或显式降级,不许静默)**:
| 检查项 | 依据 |
|---|---|
| 内核 cgroup v2 / swap 关闭 / nft 可用 | k8s 部署时踩过(`docs/k8s-deploy.md §1`) |
| `bwrap` 可运行、`setpriv` 可用、每用户 uid 可 `setuid` | 隔离前提;**同时验证 §14.2 #6(是否需建号)** |
| `/usr/local/dsh-runtime` 存在且版本匹配(glibc 运行时,档案 76) | 否则 univer 等原生插件装不上 |
| 存储后端探测(本地盘/NFS)+ 小文件延迟采样 | §12.4 |
| 出网 443 可达模型 API;内网可连 Manager | 网络矩阵 |
| **版本自报**(`GET /version` 上报给 Manager) | 防版本漂移 |
**注册是幂等的**:同一 `host_id` 重复执行 = 更新记录,不产生重复行。
### 15.3 首次上线的渐进路径(**关键:共享存储不是开通集群的前置**)
我此前把"共享存储"写进了必备项,**顺着"方便部署"重新推演后发现可以更好** —— 拆成两步,第一步几乎零风险:
| 步骤 | 部署形态 | 现有用户怎么办 | 拿到什么 | 需要共享存储吗 |
|---|---|---|---|---|
| **1a** | **Manager-01 + Worker-01 同机**(Manager 组里有一台兼任 Worker),**dataRoot 原地不动** | **零迁移**(老用户目录不动、路径不变) | 归属表 + 租约 + 双活门户 + **跨机协议已验证** | ❌ 不需要(本地盘即可) |
| **1b** | 加 Worker-02 + 挂 NAS,把 `dataRoot` 指向共享层 | 需要时把用户从 01 **搬一次**(rsync,低频计划内) | **可迁移**(改归属 + drain,不搬数据) | ✅ 此时引入 |
| 2 | Worker-02..N + 容量准入 + 自动转移 | 按需迁移 | 300 人规模 + 故障转移 | ✅ |
> **好处**:① 第一步**不动现有数据** ⇒ 上线风险极低、随时可退回单机;② NAS 的性能风险(小文件)**推迟到 1b 再评估**,不阻塞主体工作;③ `RemoteSpawner` + agent 协议在 1a 就已经被真实流量验证(§14 要求的"哪怕同机也走远程协议"正好落在这里)。
>
> ⚠️ **1a 的唯一注意点**:Manager-01 兼任 Worker 时,**门户与实例抢资源**(实例 384–1024 MiB)。给该机留足内存,或把 Manager 的实例承载上限设为 0(只做控制/门户,不接实例)。后者更干净:**Manager 默认 `capacity=0`**,需要时显式开。
### 15.4 管理面(门户新增「集群」页,仅 admin)
沿用现有 admin 管理面形态(`admin.html` + `/api/admin/*`),新增一页:
| 区块 | 显示什么 | 能做什么 |
|---|---|---|
| 主机列表 | host_id / 内网地址 / 状态 / **版本** / 内存水位 / 实例数 / 最后心跳 | 置 `draining`、踢出集群、加机器指引 |
| 实例归属 | 用户 → 哪个 Worker / 端口 / epoch / 心跳 | **单用户迁移**、批量迁移整机、强制重启 |
| 存储 | 每台的后端类型 + 能力探测结果 + 降级项 | 只读(异常时告警) |
| 备份 | 上次成功时间 / 体积 / 保留策略 / 下次计划 | 手动触发、一键恢复演练 |
| 告警 | Worker down、心跳超时、容量水位、备份失败、**版本漂移** | 静音、确认 |
| 审计 | 迁移/接管/孤儿清理的 `audit_log` | 按用户/时间检索 |
**红线不变**:破坏性动作(删归属、删用户数据)仍要"**先出清单 + 二次确认**",与现有纪律一致。
### 15.5 一键自检与可观测(**把 §10 的实测清单变成可重复命令**)
| 工具 | 作用 |
|---|---|
| `dshs doctor` | 单机自检:隔离能力 / 存储后端与能力降级 / PG / nft / 版本 —— 把 §10 的 5 项实测固化成命令 |
| `dshs cluster status` | 全集群:每台状态/版本/水位/归属数/心跳 |
| `GET /metrics` + Prometheus | 控制面副本数、PG 连接、每 Worker 实例数、崩溃计数、租约抢主次数、迁移次数 |
| 集中日志(Loki/SLS) | 多 Worker 日志聚合(现在单机 journald 到集群就失效) |
| **统一 trace id** | 🔴 **一个请求从入口 → Manager → Worker → 实例要有同一个 id**,否则跨机排查只能靠人肉 grepping(这是集群化后最容易后悔的地方) |
| 告警规则 | 控制面 < 2 副本、PG 不可写、心跳超时、Worker 水位 > 85%、备份失败、**版本不一致** |
### 15.6 灰度升级与回滚(**集群化最大的运维红利**)
单机时代升级要停机;集群后:
```
① 置 Worker 为 draining(不再接新实例)
② 迁移 / 等待其上实例自然回收
③ 停服 → 换镜像/产物 → 起服 → 自检 → 置回 up
④ 观察一个周期再推下一台(**一次只动一台**)
```
- **Manager 升级**:先升不持控制主的那台 ⇒ 观察 ⇒ 触发切换 ⇒ 再升另一台(门户始终有活副本);
- **版本策略**:Worker 版本可与 Manager **不同大版本内**共存(滚动窗口),但 **Manager 必须能兼容 N-1 的 Worker** ⇒ 协议要向后兼容(新增字段可选、不删旧字段);
- **回滚**:镜像/产物按版本目录保留,systemd unit 指向"当前版本"符号链接 ⇒ 回滚 = 切链接 + 重启;
- ⚠️ **发布前必须 CI 绿**:现状 `scripts/ci.sh` 非 0 退出(`test/db.test.mjs` 一项长期红,属别人 lane)⇒ **上集群前必须让 CI 恢复可信**,否则"发布可信度"这个前提不成立。
### 15.7 运维任务表
| 频率 | 任务 |
|---|---|
| 每日(自动) | 心跳/水位/告警巡检、四层备份(§7)、租约过期扫描 |
| 每周 | 备份产物抽验、**版本漂移检查**、容量趋势 |
| 每月 | **恢复演练**、迁移演练(真迁一个用户)、fencing 演练(模拟老 holder 复活)、PG 备份校验、扩缩容评审 |
| 变更时 | 灰度升级(一次一台)、加/减 Worker、PG 升配或迁 RDS(逻辑复制) |
| 应急 | Worker down 接管(一期人工确认)、Manager 单点恢复、PG 故障切换、存储不可达降级 |
### 15.8 与现有运维资产的衔接(**都需要更新**)
| 现有资产 | 集群化后 |
|---|---|
| `DEPLOY-本部署.md` | 新增/派生 **`DEPLOY-集群.md`**(Manager 组 + Worker 组 + PG + 存储 + join 流程 + 灰度) |
| `02-运维手册.md` | 新增「集群运维」章(主机管理、迁移、接管、故障矩阵 §11.4、运维任务表) |
| `ops/`(nginx / scripts) | 增加 `join.sh`、`dshs doctor`、集群巡检脚本 |
| `scripts/backup-platform.sh` | 升级为四层备份编排(快照 + restic + WAL) |
| `03-路线图与待办.md` / `交接单/` | 按一期/二期拆单(本期只出方案,不动文档库 → 见文末说明) |
> ⚠️ **文档库改动纪律**:以上文档都在受保护根 `dsh-server-docs/` 内 ⇒ 动它们**必须先抢全局执行锁**,且按"档案只增不改"追加而非改写历史。
---
## 16. 域名与组网(**只有入口需要域名;跨机房能组网,但有代价**)
### 16.1 域名/证书需求矩阵(**结论:只有入口一处需要**)
| 角色 | 需要公网域名? | 需要证书? | 说明 |
|---|---|---|---|
| **入口 nginx**(现有腾讯云机) | ✅ **唯一需要**:`domain` + `*.domain` | ✅ 通配证书 | 用户只认这两个名字 |
| **Manager** | ❌ 不需要 | 可选(内部加密才用) | 只是入口的一个 upstream(内网 IP:3080 或 VIP 背后的成员) |
| **Worker** | ❌ 不需要 | ❌ 不需要 | 只被 Manager 以内网 IP 访问;**Worker 上不装 nginx、不配证书** |
| **PG** | ❌ 不需要 | ❌ | 内网地址 |
| **共享存储** | ❌ 不需要 | ❌ | 挂载点(内网 NFS 挂载) |
**三条容易被误解的点**:
1. **多 Manager 不需要多个域名、也不需要会话粘性** —— 登录态靠"**PG 会话表 + 同一个 `cookieDomain`**"共享,而不是靠每台有自己的域名。给 Manager 各配子域(`mgr1.domain`)**不需要也不建议**(只会引入粘性会话复杂度);
2. **Manager 之间不需要网络直连** —— 控制面互斥靠 **PG advisory lock**,只要"都能连同一个 PG"即可(这是本方案的一个结构性优点:Manager 组之间没有额外的对等网络要求);
3. **备案是域名的属性,不是服务器的属性** —— 服务器本身不需要"配置域名"。若在阿里云侧走**公网 SLB** 提供域名入口会被 ICP 校验拦(已记录,§2.4);现状生产是 CF 回源到 ECS 直连,不受影响。
> 建议(非必需):给 Manager/Worker 配**内网 DNS 名**(`mgr.internal`、`worker-01.internal`)只为运维好读;用 IP 完全等价 —— 但**防火墙白名单请写网段而不是单个 IP**(见 §16.5)。
### 16.2 组网三档("不在同一个 IP 下的服务器"能否组网)
**能,但要分清处在哪一档** —— 三档的能力与代价完全不同:
| 档 | 场景 | 互通方式 | 延迟 | 能否任意迁移用户 |
|---|---|---|---|---|
| **① 同 VPC / 同内网** | 同云同地域的多台 ECS | 默认内网互通 | 最低(0.1–1 ms) | ✅ 能 |
| **② 跨可用区(同 VPC)** | 同云同地域不同 AZ | 内网仍互通 | 略高(0.5–2 ms),跨 AZ 流量**可能计费** | ✅ 能(**存储在同一地域**) |
| **③ 跨云 / 跨地域 / 混合机房** | 阿里云 + 腾讯云、云 + 自建 | **必须建重叠网(overlay)**:WireGuard / Tailscale·ZeroTier / 专线·CEN | 10–30 ms+ | ❌ **不能(见 16.3)** |
**③ 的三种落地方式**:
| 方式 | 特点 | 适用 |
|---|---|---|
| **WireGuard** | 每个节点一个 wg 接口,组成加密内网(如 `10.99.0.0/24`);配置轻、性能好 | ✅ **首选**(自建、可控) |
| Tailscale / ZeroTier | 基于 WireGuard,自带 NAT 穿透与密钥管理 | 运维最省;节点多、网络复杂时 |
| 专线 / 云企业网 CEN / 云联网 | 企业级、稳定、不走公网 | 正式多地域生产 |
| SSH 隧道 | 应急 | ❌ 不做生产(连接数/稳定性差) |
| 纯公网直连 + IP 白名单 + HMAC | 能用但攻击面大 | ❌ 不推荐 |
### 16.3 🔴 跨云场景的三个硬约束(**必须写进设计,否则会白做**)
| # | 约束 | 后果 |
|---|---|---|
| 1 | **PG 必须与 Manager 同侧** | 跨云 SQL 10–30 ms + 抖动 ⇒ 每个请求都被放大(§13.3 已列为"绝对不要")。⇒ **跨云时 PG 跟着 Manager 走,不能跨**。 |
| 2 | **NAS 不能跨云挂载** | NFS over 公网既不安全也不现实 ⇒ **每个云一个存储域** ⇒ **用户不能跨云迁移**(数据在另一个云的 NAS 上)。⇒ **跨云只能"云内迁移"**。 |
| 3 | **代理流量全走 Manager** | 用户流量经 Manager → Worker ⇒ 跨云时**全部流量跨云**(带宽成本 + 延迟叠加)。⇒ Worker 应与 Manager 同地域。 |
> **⇒ 架构结论**:**"任意 Worker 可接手"这个能力,隐含前提是"同地域 + 同一存储域"**。
> 跨云不是不能做,而是会**降级成"每个云一个独立集群域"** —— 那时更像"两个集群"而不是"一个集群"。**若目标是"上千人",建议先在一个云内横向扩,不要过早跨云。**
### 16.4 LB 与 VIP 的实现前提(**跨 AZ 会踩**)
| 方案 | 前提 | 备注 |
|---|---|---|
| `keepalived` + VRRP VIP | **主备必须在同一二层网络** | 跨 AZ 通常不成立 ⇒ 别默认选它 |
| 云 HAVIP(高可用虚拟 IP) | 云厂商支持,可跨 AZ 漂移 | 需要该产品支持 |
| **云 SLB / ALB**(推荐) | 无二层要求 | 后端用 IP 注册,**不需要域名**;⚠️ 阿里云公网 SLB 有 ICP 校验(§2.4)⇒ 入口侧用腾讯云或内网 SLB |
### 16.5 白名单用**网段**,不要用单个 IP(配套"一条命令加机器")
若防火墙按"Manager 的单个 IP"写白名单,每次加机器/换 IP 都要改 nft ⇒ 与 §15 的"一条命令加机器"目标冲突。
**做法**:给 Manager 组一个**专用子网/overlay 网段**(如 `10.99.0.0/24`),Worker 侧只放行这一段:
```
# Worker 侧:只放行 Manager 网段 + 只暴露控制/代理口
tcp dport { 9000-9999 } ip saddr 10.99.0.0/24 accept
tcp dport { 9000-9999 } drop
```
⇒ **加机器 = 加到该网段即可,防火墙零改动**。
### 16.6 落地建议(按现状)
1. **域名侧零改动**:继续 `domain` + `*.domain` → 入口 → Manager(内网 IP/VIP);
2. **Manager 组与 Worker 组放同地域同 VPC**(档 ①),先不跨 AZ;
3. **PG 放同 VPC**(§13);
4. **暂不跨云**:跨云的三个代价(16.3)与"上千人"目标冲突,等单云跑稳再评估;
5. 若将来确有跨云需求:**先上 WireGuard 重叠网**,并接受"云内迁移"(放弃跨云迁移)。
---
## 17. 现网实测结论(2026-09-14 21:17–21:25)
> 按用户给定的实际服务器执行:**manager = 47.77.182.89**、**worker-01 = 47.77.182.89**(同机,即 §15.3 的 1a 形态)、**worker-02 = 106.54.21.172**。
> 实测方式:本机 SSH + 只读探测(无任何写操作、无端口新增)。
### 17.1 可达性(**结论:全都通,但是跨云**)
| 测试 | 结果 |
|---|---|
| 47.77.182.89 SSH | ✅ 可达 —— ⚠️ **端口是 32022,不是 22**(`bt-server` 别名已配) |
| 106.54.21.172 SSH | ✅ 可达(端口 22,专用密钥 `id_ed25519_test106`) |
| 47 → 106 TCP | ✅ 22 / 80 / 443 / 8888 **全开** |
| 106 → 47 TCP | ✅ 22 / 32022 / 80 / 443 / 58888 **全开** |
| **ICMP(ping)** | ❌ **双向被安全组拒绝**(100% packet loss) |
| **RTT** | 47→106 `connect=150 ms`;106→47 `connect=183 ms`;本机回环 `0.1 ms` |
| HTTP 往返 | 47→106 首页 `total=300 ms, code=200` |
**两条直接结论**:
1. 🔴 **实测 RTT 150–183 ms,比本文档原估的"10–30 ms"高 5–10 倍** ⇒ 跨云代理会让**每个请求多 300 ms 以上往返**,对话式 UI 与 WebSocket 流式体验会明显退化。**原 §16.3 的"不要跨云"结论不变,但理由从"贵"升级为"实测不可接受"。**
2. ⚠️ **ICMP 不可用 ⇒ 存活检测绝不能用 ping**,必须用 TCP/HTTP —— 本文档 §11 把心跳设计成 HTTP `GET /healthz`(而不是 ping)**恰好正确**,继续保持。
### 17.2 两台基线差异(**指向 §14.3「机器基线不能跟着迁移」**)
| 项 | 47.77.182.89(**阿里云**) | 106.54.21.172(**腾讯云**) |
|---|---|---|
| OS | Alibaba Cloud Linux 3.2104 U12 | OpenCloudOS 9.6 |
| 内核 | 5.10.134 | 6.6.119 |
| **cgroup** | **v1(tmpfs)** | **v2(cgroup2fs)** |
| **glibc** | **2.32** ⇒ 需挂 glibc 2.35 运行时(档案 76 / univer) | **2.38** ⇒ **不需要该 hack** |
| CPU / 内存 | **2 核 / 1870 MB(可用仅 773 MB)** | **4 核 / 3655 MB(可用 2655 MB)** |
| swap | 有(1 个) | 有(`/www/swap` 1G) |
| bwrap / setpriv / systemd-run / nft / node / dsh | 齐 | 齐(systemd 255) |
⇒ **同一用户在两台机器上可能表现不同**(最典型:47 上靠 glibc 运行时才能跑的插件,106 上原生可跑)。**这正是"整机镜像统一基线"(§15.1)必要性的实证**。
### 17.3 🔴 两个会影响设计的实测发现
**发现 1:uid 必须建号(原"待实测"项已有答案)**
```
setpriv --reuid 100001 ... -- id -u → 100001 ✅ 无需建号
setpriv --reuid 100001 ... -- node -e 'os.userInfo()' → FAIL ERR_SYSTEM_ERROR ❌
```
⇒ **uid 可以 setpriv,但 `os.userInfo()` 在无 `/etc/passwd` 条目时直接失败** ⇒ **每台 Worker 都必须为要跑的 uid 建号(或至少写入 passwd 条目)**。
⇒ 落地含义:**加机器时必须同步账号**(漏了的表现 = 实例启动即崩);这也解释了代码为何把 `/etc/passwd` 列为 bwrap 白名单必挂项(`orchestrator.ts:640`)。
⇒ 对应 **§14.2 #6 结论 = 需要建号**(§10 第 2 项已闭环)。
**发现 2:`systemd-run` 未限制 swap,而两台机器都有 swap** 🟠
- 代码现状(`orchestrator.ts:749-751`):只设 `MemoryMax=<n>M` + `TasksMax=128`,**没有 `MemorySwapMax`**;
- 有 swap 时 `MemoryMax` 超限会**先换出到 swap(变慢)**,而不是被 **OOM kill** ⇒ **"被 OOM 杀 → 熔断自愈"(档案 20/78)的语义在有 swap 的机器上不成立**;
- 且两台 swap 大小不同 ⇒ **同一用户在不同 Worker 行为不一致**。
- ⇒ 建议:Worker 统一 `-p MemorySwapMax=0`(或在 `join.sh` 自检里发现 swap 即告警/关闭)。
- ⚠️ 这一条是**现状单机就存在的隐患**(47 也有 swap),不是集群才引入的 —— 建议单独立项核查。
### 17.4 106.54.21.172 上已有的一套独立平台(**不是裸 Worker**)
- `/root/dsh-users-platform/`(含 `lib/`、`deploy/`、`Dockerfile.dsh`、`install.sh`、`ensure-role-profile-patch.cjs`)
- systemd `dsh-users-platform.service`:**active running**(已跑 16 h+),`EnvironmentFile=/etc/dsh-users-platform.env`,`User=root`,`Restart=always`
- 监听 `127.0.0.1:3080`(node);数据根**不在** `/var/lib/dshs`(由该 env 文件指定,本次未读取以免触碰密钥行)
- 另有宝塔面板(`:8888`)与 nginx(80/443)
⇒ **它不是一台"待接管的空机器",而是另一套完整平台**。要用它当 Worker-02 需要先定:
| 选项 | 含义 | 代价 |
|---|---|---|
| (a) **保留为独立测试环境**(推荐) | 集群另起 | 不干扰现有测试 |
| (b) 停掉它,改造为纯 Worker | 这台机器整体并入集群 | 会中断其上任何在跑的实例 |
| (c) 就地把它变成 1a 集群 | Manager-01 + Worker-01 都用它 | 测试环境与新集群合一 |
### 17.5 部署建议(**基于实测,与 §16.3 一致但更硬**)
| 结论 | 依据 |
|---|---|
| 🔴 **这两台机器不能组成"一个"集群** | 跨云 150–183 ms ⇒ 代理路径每个请求多 300 ms+,不可接受 |
| ⇒ **必须选一个云内扩容**(新机器与 Manager/Worker 同云) | §16.2 档①(同 VPC,0.1–1 ms) |
| ⇒ 或**各自独立**(106 测试环境 + 47 生产),不互联 | 等价于"每云一个集群域"(§16.3) |
| ⚠️ **47 不适合做 Manager** | 可用内存仅 **773 MB**,门户+代理+PG 客户端会挤掉实例余量(§11 内存账) |
| ✅ **若要试 1a,优先放 106** | 4 核 / 可用 2655 MB,且集群化所需的 bwrap/setpriv/nft/dsh 全部就绪 |
### 17.6 尚未做的实测(依赖共享存储,等 NAS/存储定下来再跑)
- 共享存储上的 watcher 行为(`skill-filesystem.watchUsePolling`)
- 共享存储冷启动耗时(`node_modules` 小文件)
- 每用户数据体积(定备份子集与容量)
- **跨机代理的信任栅栏验证**(§10 第 1 项):需 1a 落地后才可测 —— 现阶段已由源码判定(`api-request-trust.ts:103` 只看主机名)**通过**,实测留到 1a 联调时补。
---
## 18. 与 K8S 模式的对比(**回答"k8s 的优势到底是什么、代价多大"**)
### 18.1 先回答"镜像主要是什么" —— 是**两个**镜像,不止 dsh 主程序
| 镜像 | 谁用 | 说明 |
|---|---|---|
| `DSHS_DSH_IMAGE`(dsh 主程序/运行时) | 每用户 DSH Pod 的**主容器** | 官方 dsh + 我们的运行时基线 |
| `DSHS_CONTROL_PLANE_IMAGE`(dshs) | ① 控制面 Deployment ② **每用户 Pod 的 `tcp-bridge` sidecar** ③ **每用户 files Pod** ④ bootstrap Job | 复用同一镜像的子命令,是为了**避免依赖 docker.io**(ACK 拉不动 `alpine/socat`,见 `docs/k8s-deploy.md §7.5`) |
**镜像分发确实是成本**,但要说准:它在**磁盘/网络**上花钱(ACR 拉取、每节点缓存一份),**镜像层本身不额外占运行内存**(只读层跨容器共享 page cache)。真正多占内存的是下面的 18.2。
### 18.2 容器在"运行时"到底多占多少(**分三层算,别混在一起**)
| 层 | 开销 | 依据 |
|---|---|---|
| ① **每用户 sidecar** | **+90–120 MiB/用户** = `tcp-bridge`(Node 空转 ~30–40 MiB)+ `files` sidecar(Node+Fastify ~60–80 MiB,实测 64 MiB 跑不起来→提到 256 MiB) | `k8s-spawner.ts` 的两个 sidecar 容器 |
| ② **每节点固定** | **+300–500 MiB/节点**(kubelet + containerd + CNI + node-local-dns) | k8s 本身 |
| ③ **装箱效率**(**被低估的一项**) | Pod **requests** 决定装箱:每用户 `1Gi`(dsh)+ `32Mi`(bridge)+ `128Mi`(files)≈ **1.16 GiB request/用户**;而实际 RSS 只有 ~250–350 MiB ⇒ **账面内存比实际多 3–4 倍** | `k8s-spawner.ts` 的 `resources.requests` |
**对照自研版(local 语义)**:
- 每实例**没有 sidecar**(tcp-bridge / 文件面都在进程内或按需);
- cgroup 用的是 `MemoryMax`**上限而非预留** ⇒ **天然超卖、按真实可用内存装箱**(这就是为什么单台 1.87 G 的机器能跑 1–3 个实例);
- 每节点**没有** kubelet/CRI 固定开销。
> **⇒ 1000 并发的账面账**:k8s 需要 ~**1.16 TiB requests**;自研版按实际 RSS 约 **300–450 GiB** 规划。这个差距不是"加 5–8 台"的问题,而是**选型级**的差别。
> ⚠️ 公平地说:requests 是**可调**的(可降到 256–512 MiB 并接受超卖),但那等于主动放弃 k8s 最可靠的调度依据,回过头来又要自己写容量准入 —— 优势自我抵消。
### 18.3 K8S 真正的优势(**按"对我们这个形态有没有用"排序**)
| # | 优势 | 对我们有用吗 |
|---|---|---|
| 1 | **调度与装箱**(自动把实例放到有空闲的节点) | ⚠️ **有限** —— 我们的实例是**有状态**的(绑定用户 home、uid、会话日志),**不能被任意调度**,只能"同存储域内选择" |
| 2 | **节点故障自愈**(Pod 被重新调度到别的节点) | ✅ **有用** —— 这是我们自研版要自己写的那部分(心跳 + 租约 + 重建) |
| 3 | **声明式期望态 + 控制器** | ✅ 有用(但我们的 `dsh_instances` + reconcile 已经是同一思路) |
| 4 | **网络策略 / PSA / seccomp / 只读 rootfs** | ⚠️ 与 `bwrap` **机制不同、强度相当**;且我们已完成 bwrap 收窄实测(`/etc` 可见项 222→12,档案 39) |
| 5 | **滚动升级 + PDB** | ✅ 有用(我们自研靠 drain,效果等价) |
| 6 | **弹性伸缩(HPA / Cluster Autoscaler)** | ✅ 有用(但用户数增长是缓慢趋势,不是秒级波峰) |
| 7 | **多副本控制面 + Lease 选举** | ⚠️ 自研用 **PG advisory lock** 几行就够 |
| 8 | **可观测生态**(Prometheus/Loki/事件) | ✅ 有用(自研也能接,只是多写点) |
### 18.4 K8S 的代价(**5 条,都是实打实的**)
| # | 代价 | 说明 |
|---|---|---|
| 1 | **内存账面 +3–4 倍**(§18.2 ③) | 选型级差异 |
| 2 | **每用户 +2 个 sidecar 进程** | 每用户多 90–120 MiB |
| 3 | **每节点 300–500 MiB 固定开销** | 8 台节点 ≈ 白扔一台机器的内存 |
| 4 | **API 对象数** | 每用户 6–8 个对象(Pod/Svc/NP/Secret/ConfigMap/files×3)⇒ **1000 用户 ≈ 8000 对象**,还要分片命名空间(§6 P0-1/P1-5) |
| 5 | **平台自研能力要重写 6 处** | 模型落地直写 home、角色补丁/picker、`restartAndProbe`、`breakerInfo`/`quotaInfo`、`touch`/launchToken、备份与清理脚本(§18.5) |
### 18.5 判定:**本方案仍选自研 manager/worker**,理由三条
1. **我们的实例是"有状态长驻进程"** ⇒ k8s 的看家本领(任意调度、快速重建)**价值被削弱**,而它的固定代价(sidecar、requests 保守、节点开销)**全额付出**;
2. **隔离与平台能力已经在 local 语义上做完并实测过**(bwrap 收窄、模型落地、角色补丁、插件探活、配额观测、熔断)⇒ 走 k8s 等于**把这些逐条重写在容器语义上**(上一轮列过 6 处缺口);
3. **自研版需要自己写的,只有"调度 + 自愈 + 租约"三件事** —— 而这三件事的**语义在我们这里很窄**(同存储域内选机、TTL 租约、drain 重建),本方案 §3/§11 已给出可照抄的实现路径。
**什么时候该反过来选 k8s**:用户实例变成**短生命周期 / 无状态**(例如每次会话一个 Job、无用户私有 home),或者团队**已经有成熟 k8s 运维能力**且不想维护自研控制器时。**当前形态不属于这两种。**
> 一句话:**k8s 卖的是"你不用自己写调度器和自愈",而对我们这种"每用户一个有状态常驻进程"的形态,这两样恰好是我们能自己写、且已经设计完的部分 —— 却要为它付 3–4 倍的内存账面和一次全量能力重写。**
---
## 19. 本期验证策略与"访问模式兼容"约束
### 19.1 跨云环境当"极限场景测试床"(✅ 可行,且推荐)
47(阿里云)↔ 106(腾讯云)实测 **RTT 150–183 ms、ICMP 被禁、无内网** ⇒ 这正是一个现成的**恶劣网络环境**,用来验证容错比局域网更有价值:
| 用它测什么(✅ 有价值) | 用它测什么(❌ 会误判) |
|---|---|
| 心跳/租约在 150 ms RTT 下是否稳定(TTL 30 s 足够宽) | **生产性能基线**(150 ms 会污染结论) |
| 代理的**超时与重试**设置是否合理(半开连接、SSE/WS 长流) | 用户真实体验(会明显偏慢,不代表同云表现) |
| **断网/丢包故障注入**:fencing 是否真的拦下老 holder、重连是否自愈 | 存储小文件性能(共享存储未上,测不了) |
| "跨云不可用"的**判定验证**(把 §16.3 的结论从推理变成实测) | 容量/装箱结论(网络慢不影响内存) |
**但要立一条纪律**:**跨云 = 故障注入与容错测试床,不是性能基线环境**。所有性能/容量结论必须在同云同 VPC 环境取数,否则会被 150 ms 误导。
**这条对方案的价值**:§11.4 恢复矩阵里那些场景(心跳超时、Worker 失联、Manager 单点、PG 不可达、脑裂)**都可以在这个跨云环境里人为制造**,比在单机模拟更真实。
### 19.2 "47 限制只能 admin 访问,留足够内存"(✅ 可行)
**可行,且不需要改代码** —— 现有平台本来就是**注册审核制**(注册 → 待审 → admin 放行),把 47 设为"只有 admin 能用"就是**不放行新用户** + 必要时对存量用户按 `disable`(已有路由 `/api/admin/users/:id/disable`)。
**内存账(47:2 核 / 1870 MB,实测可用 773 MB —— 该值含当时在跑的实例)**:
| 项 | 量 |
|---|---|
| 系统基础 | ~300 MB |
| dshs **Manager**(门户+代理+PG 客户端) | ~200–300 MB |
| **admin 实例**(档案 81 配额 384–672 MB) | ~400–670 MB |
| 合计 | **900–1270 MB** |
| ⇒ 仅留 admin 后 | 停止其它实例可释放数百 MB ⇒ **可行,但余量很薄(< 300 MB)** |
**⚠️ 但有一个必须知道的坑(1a 形态的固有代价)**:
> `LocalSpawner` 构造函数会调 `cleanAllStaleScopes()`(`orchestrator.ts:164` + 档案 30:*"portal 启动即清掉遗留 scope"*)⇒ **Manager 每次重启都会停掉同机所有实例 scope**。
> ⇒ 在 1a(Manager 兼 Worker 同机)下,**升级/重启 Manager = 该机实例全断,随后按需重建**。
> **规避**:① 接受它(admin 自己用,可接受);② 把该机 `capacity=0`(不承载实例,只做门户/控制);③ 只有在共享存储就绪后才把"Manager 与实例同机"当成正式形态。
**结论**:47 可以做 Manager(门户),**但内存余量薄**,且必须接受"Manager 重启清实例"。**若求稳,Manager 应放在 106 那类 4 核/3.6 G 的机器上,47 只做实例承载。**
### 19.3 访问模式兼容清单(**改造后必须与现在的"单实例访问模式"一致**)
| 维度 | 现状 | 改造后必须保持 | 是否天然满足 |
|---|---|---|---|
| 门户 URL | `https://alotbuy.com` | 不变 | ✅(入口不动) |
| 实例 URL | `https://<用户名>.alotbuy.com` | 不变 | ✅(Host 路由不变) |
| Cookie | `Domain=.alotbuy.com`,会话查 DB | 不变 | ✅(已满足) |
| **登录直达会话**(`enter` 带 launch token) | 从实例 stdout 解析 token 拼 URL | 不变 | ⚠️ **需专门做**(§4.1 的 `launchToken` 回传,P0-6) |
| 实例侧 401 自愈(档案 24/50/51) | 代理透明重放 / 注入脚本 | 不变 | ⚠️ 依赖上一条 |
| 门户 API 路径(`/api/**`) | 不变 | 不变 | ✅ |
| 「我的文件」面板 | 读本机 `dataRoot/users/<id>/ws` | 跨机可读 | ⚠️ 需 file-service(P0-7) |
| **单机部署仍可跑** | `systemctl restart dshs` | **保留 `deployMode=local` 路径,代码不删** | ✅(§8.4 已定) |
| 用户目录结构 | `dataRoot/users/<id>/{home,ws}` | 不变 | ✅(§12.4 挂载点即切) |
| 管理员操作入口 | 门户 admin 页 | 新增「集群」页,**旧页面不动** | ✅ |
> ⇒ **"兼容单例访问模式"的落点集中在 3 处**:launch token 回传、401 自愈链路、文件面跨机。其余(URL/cookie/API/目录结构/单机可退)**天然满足**,不需要额外工作。