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:
1 parent
68c0a320ed
commit
c70d5d860e
47 files changed
+5367
-14
No files matched your search
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user