feat(cluster): 集群化落地 —— Manager/Worker 拆分 + 归属租约 + 跨机验证(T08)

背景:把平台从「单机单进程」改造成「1 组 Manager + N 台 Worker + 共享归属状态」,
硬约束 = 全程兼容单例模式(deployMode 默认 local;生产切换前 47 一行未动)。

主要改动
1) 数据模型 v7(SQLite 与 PG 两方言同步):新增 dsh_hosts 注册表 +
   dsh_instances.{host_id,epoch,heartbeat_at,lease_until};claimInstance 原子抢占
   (UPDATE … WHERE host_id IS NULL OR lease_until < now)+ pinInstanceHost 钉住归属。
2) 租约与 fencing:src/supervisor/lease.ts(acquire/renew/release + stillHolder 判据 +
   ttl > 2×renew 硬校验);心跳里续租,失权即向 worker 下发更高 epoch(self-fencing)。
   ⚠️ release 只清租约(lease_until),**保留 host_id** —— host_id 是「用户数据在哪台」的锚点。
3) Worker agent(src/worker/agent.ts,子命令 dshs worker):实例生命周期 + 文件面 /fs/*
   + 幂等键(operationId)+ 鉴权(timingSafeEqual);Worker 不写控制面数据
   (apiKey/uid 由 Manager 随 launch 投递,R5 收窄)。
4) 远端 Spawner + LeasedSpawner:按 host 路由(**粘性优先**:有历史归属且那台 up 就留在原地,
   否则按容量选最空的)+ 容量准入 + deployMode=cluster 装配(systemd drop-in,可回滚)。
5) bwrap 修正:**所有挂载点的中间目录统一前置 + 去重 + 由外到内**(「就近创建」会在嵌套前缀下
   遮掉已绑挂载点 ⇒ bwrap: Can't chdir);且**只能用 --tmpfs**,用 --perms 会让 47 的
   bwrap 0.4.0 直接拒启动(沙箱全挂)。
6) 跨机隧道 src/worker/tunnel.ts:SSH ControlMaster + 动态 -R 转发;**自愈由 agent 本地
   20s 定时器驱动**(不能只放 /healthz —— 心跳本身经隧道进来,断了就没人触发它)。
7) 文件面按归属路由(RemoteUserFs):实例与文件必须落在同一台机器,否则实例看不到自己的文件。
8) 观测面:dshs doctor / dshs cluster status。

验证(本次均已实跑)
- test/lease.test.mjs:SQLite 10/10 == PG 10/10
- 组件级端到端 5 个:verify-cluster-{agent,lease,fs,migrate,live}.mjs
- 真跨机(47 Manager / 106 Worker,跨云 + 反向隧道)verify-cluster-cross.mjs 九步全绿
- 域名形态访问 verify-cluster-domain.mjs(<user>.域名 → Manager → 远端实例;越权 403)
- 冒烟 scripts/smoke-*:6/8,失败项与改动前基线完全相同(无回归)
- 生产切换与回滚剧本见 dsh-server-docs/交接单/T08-集群化落地-兼容单例模式.md §16
This commit is contained in:
admin committed 2026-09-15 18:47:02 +08:00
1 parent 68c0a320ed
commit c70d5d860e
47 files changed
+5367 -14

No files matched your search

+97
View File
@@ -155,4 +155,101 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
}
})
// ── 集群管理面(T08 S6):worker 注册表 + 实例迁移 ────────────────────────
/**
* Worker 列表。
* ⚠️ **绝不下发 `agentToken`** —— 它是内网共享密钥,只回 `hasToken` 供排查"配没配"。
*/
app.get('/api/admin/hosts', { preHandler: requireAdmin }, async () => {
const hosts = await app.db.listDshHosts()
return {
deployMode: app.config.deployMode,
hosts: hosts.map((h) => ({
id: h.id,
endpoint: h.endpoint,
capacityMb: h.capacityMb,
usedMb: h.usedMb,
status: h.status,
lastHeartbeat: h.lastHeartbeat,
hasToken: h.agentToken !== '',
})),
}
})
/** 注册/更新一台 worker(join 脚本调用;**幂等**:同 id 重复执行 = 更新并标回 `up`)。 */
app.post('/api/admin/hosts', { preHandler: requireAdmin }, async (request, reply) => {
const body = request.body as {
id?: string
endpoint?: string
token?: string
capacityMb?: number
}
if (body.id === undefined || body.endpoint === undefined || body.token === undefined) {
return reply.code(400).send({ error: 'id, endpoint and token are required' })
}
const host = await app.db.upsertDshHost({
id: body.id,
endpoint: body.endpoint,
agentToken: body.token,
capacityMb: Number(body.capacityMb ?? 0),
})
await app.db.audit(request.user?.id ?? null, 'host.upsert', JSON.stringify({ id: host.id, endpoint: host.endpoint }))
return {
ok: true,
host: { id: host.id, endpoint: host.endpoint, capacityMb: host.capacityMb, status: host.status },
}
})
/**
* **计划内迁移**(T08 S6;设计 §4.2):drain → 目标机拉起 → 归属原子更新(epoch+1)。
*
* 顺序不可换:**先停源、再在目标机拉起**。如果反序,两台上会同时有实例(同一个 home ⇒ 双写)。
* 归属的原子性由租约保证(`claimInstance` 会把 `host_id` 换成目标机并 `epoch+1`),
* 所以旧机即便复活也会被 fencing 挡住(设计 §11.5)。
*
* ⚠️ **数据不搬家**:`folder` 是实例眼里的绝对路径,能在目标机上生效的前提是
* **两台 worker 的 dataRoot 同路径 + 用户数据位置无关**(共享存储或已同步)——
* 这正是设计 §12/§14.3 的前提,不是本路由能替你保证的。
*/
app.post('/api/admin/users/:id/dsh/migrate', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const { targetHost } = request.body as { targetHost?: string }
if (targetHost === undefined || targetHost === '') {
return reply.code(400).send({ error: 'targetHost is required' })
}
const before = await app.db.findUserInstance(id, 'main')
if (before === undefined) return reply.code(404).send({ error: 'not_found', detail: '该用户没有 main 实例记录' })
if (before.hostId === targetHost) return reply.code(409).send({ error: 'already_there' })
const target = await app.db.findDshHost(targetHost)
if (target === undefined) return reply.code(404).send({ error: 'unknown_host' })
if (target.status === 'down') return reply.code(409).send({ error: 'target_down' })
const source = before.hostId
// fail-loud:没有 folder 就没法在目标机上复现启动(空 cwd 会让 bwrap 直接崩)
if ((before.folder ?? '') === '') {
return reply.code(409).send({ error: 'no_folder_recorded', detail: '该实例没有记录 folder,无法复现启动' })
}
// ① drain:停源机实例(优雅停机 → 会话落盘;同时释放归属)
if (source !== null) await app.supervisor.stop(id, source)
// ② 目标机拉起(走租约:以 targetHost 认领 → epoch+1)
const instance = await app.supervisor.launch(id, before.folder ?? '', before.patch ?? undefined, {
hostId: targetHost,
})
const after = await app.db.findUserInstance(id, 'main')
await app.db.audit(
request.user?.id ?? null,
'dsh.migrate',
JSON.stringify({ userId: id, from: source, to: after?.hostId, epoch: after?.epoch }),
)
return {
ok: true,
from: source,
to: after?.hostId ?? null,
epoch: after?.epoch ?? 0,
port: instance.port ?? null,
}
})
}