起因:用户 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 代码面)。
81 KiB
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"):
- 是"归属/租约"吗? ⇒ 是:只有 Manager 能写(否则就是双写归属 = 脑裂)。
- 跟着用户走吗? ⇒ 是:放实例 home / 位置无关存储,不要塞控制面 PG。
- 是本机运维用的吗? ⇒ 是:放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)三条铁律
- Worker 的 9000–9999 只允许 Manager 组 IP;其余一律 drop(替代现在单机的
portGuardiptables owner-match 语义,见 §6 P0-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")。 - 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 自然由它持有 ⇒ 控制层零改动 单活只需注意两处(都不是新增代码):
- 会话/门户状态必须落 PG(已满足:会话查 DB、
cookieDomain跨子域)⇒ 否则切机即掉登录;- 长连接断开后前端要能自动重连(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)
-- 主机(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 租约的原子抢占(这是整套方案的承重墙)
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 心跳的三个职责(别只当存活检测)
- 存活:连续失败计数 → 决定是否标记
down; - 容量上报:
used_mb/ 实例数 → 供选机(§5 容量准入); - 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 个参数
- 是否支持 RWX(多挂载点)与跨可用区;
- 小文件(4 KB 级)随机写 IOPS/延迟(不要看顺序吞吐的宣传值);
- inode 上限与扩容方式;
- 快照能力与恢复粒度(整卷 / 单文件);
- 单价口径(容量 / 吞吐 / IOPS 分别计费的部分);
- 与 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 无论选哪个都必须先做的回归
- 数据迁移脚本(SQLite → PG):注意
identity列与uid列(db/pg.ts用BIGINT GENERATED ALWAYS AS IDENTITY,baseUid + row_id的 uid 派生依赖它); - 跑通现有 smoke:
scripts/smoke-*.mjs全套(admin/auth/dsh/fs/domain/isolation)在 PG 下重跑一遍; - 会话与凭据回归:登录态跨 Manager 生效、
credential_vault加解密读写一致; - 回滚演练: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 条命令。
# ① 用平台镜像起机(或用 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 挂载) |
三条容易被误解的点:
- 多 Manager 不需要多个域名、也不需要会话粘性 —— 登录态靠"PG 会话表 + 同一个
cookieDomain"共享,而不是靠每台有自己的域名。给 Manager 各配子域(mgr1.domain)不需要也不建议(只会引入粘性会话复杂度); - Manager 之间不需要网络直连 —— 控制面互斥靠 PG advisory lock,只要"都能连同一个 PG"即可(这是本方案的一个结构性优点:Manager 组之间没有额外的对等网络要求);
- 备案是域名的属性,不是服务器的属性 —— 服务器本身不需要"配置域名"。若在阿里云侧走公网 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 落地建议(按现状)
- 域名侧零改动:继续
domain+*.domain→ 入口 → Manager(内网 IP/VIP); - Manager 组与 Worker 组放同地域同 VPC(档 ①),先不跨 AZ;
- PG 放同 VPC(§13);
- 暂不跨云:跨云的三个代价(16.3)与"上千人"目标冲突,等单云跑稳再评估;
- 若将来确有跨云需求:先上 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 |
两条直接结论:
- 🔴 实测 RTT 150–183 ms,比本文档原估的"10–30 ms"高 5–10 倍 ⇒ 跨云代理会让每个请求多 300 ms 以上往返,对话式 UI 与 WebSocket 流式体验会明显退化。原 §16.3 的"不要跨云"结论不变,但理由从"贵"升级为"实测不可接受"。
- ⚠️ 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,理由三条
- 我们的实例是"有状态长驻进程" ⇒ k8s 的看家本领(任意调度、快速重建)价值被削弱,而它的固定代价(sidecar、requests 保守、节点开销)全额付出;
- 隔离与平台能力已经在 local 语义上做完并实测过(bwrap 收窄、模型落地、角色补丁、插件探活、配额观测、熔断)⇒ 走 k8s 等于把这些逐条重写在容器语义上(上一轮列过 6 处缺口);
- 自研版需要自己写的,只有"调度 + 自愈 + 租约"三件事 —— 而这三件事的语义在我们这里很窄(同存储域内选机、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/目录结构/单机可退)天然满足,不需要额外工作。