Files

1096 lines
81 KiB
Markdown
Raw Permalink Normal View History

# 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/目录结构/单机可退)**天然满足**,不需要额外工作。