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

+284
View File
@@ -0,0 +1,284 @@
/**
* 给任意 `Spawner` 套上**归属租约**(T08 S4;设计 §1.2/§3.2/§11.5)。
*
* 它补上集群模式下 Manager 侧最关键的一环:**"能不能拉起"必须先问过归属**。
* 本地模式靠"进程内 Map + 单机"天然保证单写者;多机后这个保证只能落在 DB 的原子 CAS 上
* —— 两个 Manager 各持一把租约、抢同一个用户,就是**双写同一个 home** = 数据损坏。
*
* 三个动作:
* 1. `launch` 前 **claim**:拿不到就抛 `LeaseBusyError`(**退让**,不是接管 —— 见 R9);
* 2. 心跳里 **renewAll**:续租;同时把"我已失权"的实例用 **`/fence`** 通知 worker 停掉
* (self-fencing 的 Manager 侧对齐,设计 §11.5);
* 3. `stop` 时 **release**:归属交还,别人立刻可以接管(不用等 TTL)。
*
* ⚠️ 归属只由 Manager 写(设计 §1.3 数据分层的判据 1)。worker 侧只被通知。
*
* @module dshs/supervisor/leased-spawner
*/
import { AGENT_TOKEN_HEADER } from '../worker/agent.js'
import type { DbAdapter } from '../db/adapter.js'
import type { Endpoint, Instance, Spawner, UserStatus } from './spawner.js'
import { InstanceLease, type LeaseOptions } from './lease.js'
/** 归属被别人持有时抛出 —— 调用方应**退让**(等待/报告),**不得接管**(R9)。 */
export class LeaseBusyError extends Error {
readonly userId: string
readonly holder: string | null
readonly leaseUntil: number
constructor(userId: string, holder: string | null, leaseUntil: number) {
super(`instance ${userId} is held by ${holder ?? 'someone'} until ${new Date(leaseUntil).toISOString()}`)
this.name = 'LeaseBusyError'
this.userId = userId
this.holder = holder
this.leaseUntil = leaseUntil
}
}
export interface LeasedSpawnerOptions extends LeaseOptions {
/** 本机(= 它所属的 worker)在 `dsh_hosts.id` 里的标识。 */
hostId: string
/** worker agent 基址。 */
agentUrl: string
/** 与 agent 约定的共享密钥。 */
agentToken: string
/** 心跳间隔(ms)。默认 = 续租间隔。 */
heartbeatMs?: number
/** 该 worker 的内存预算(MB);**0 = 不承载实例**(只做门户/控制,设计 §15.3)。 */
capacityMb?: number
/**
* **选机**(T08 S6):返回这次要把实例放到的 `hostId`。
*
* ⚠️ 实现必须**先粘性、再容量**(2026-09-15 生产切换时补的设计缺口):
* 用户的工作区是**跟机器走的**(本地盘)⇒ 把"已有历史数据在某台"的用户调度到另一台,
* 他打开实例会看到**空工作区**。所以:有历史归属且那台还 `up` ⇒ **留在原地**;
* 只有"从没有过归属"(新用户)才按容量挑最空的。
* 传入 `userId` 就是为了让实现能做这件事。
*/
selectHost?: (userId?: string) => Promise<string | undefined>
/** **hostId → agent 地址/密钥**(多机时 fence 要发给"实例所在的那台")。 */
agentFor?: (hostId: string) => { agentUrl: string; token: string } | undefined
/**
* 启动时把自己注册进 `dsh_hosts`(幂等)。
*
* ⚠️ **一个 agent 只应有一条 host 记录**:`registerSelf` 只在"本 Manager 与 worker 同机"
* (1a 形态)时该开。**专用 Manager 部署必须关掉**(`DSHS_CLUSTER_REGISTER_SELF=0`),
* 否则会多出一条指向同一 agent 的 host 记录 ⇒ 同一个用户可能被两个 hostId 各自认领。
*/
registerSelf?: boolean
/** 关闭心跳(测试里手动 tick 用)。 */
manual?: boolean
}
/** 心跳里上报给 `dsh_hosts` 的本机观测值。 */
export interface HostObservation {
ok: boolean
instances: number
lastError?: string
}
export class LeasedSpawner implements Spawner {
private readonly lease: InstanceLease
private timer: NodeJS.Timeout | undefined
private lastObservation: HostObservation | undefined
private readonly heartbeatMs: number
constructor(
private readonly inner: Spawner,
private readonly db: DbAdapter,
private readonly options: LeasedSpawnerOptions,
) {
this.lease = new InstanceLease(db, options.hostId, options)
this.heartbeatMs = options.heartbeatMs ?? this.lease.renewMs
}
get hostId(): string {
return this.options.hostId
}
/** 最近一次心跳观测(管理面/诊断用)。 */
observation(): HostObservation | undefined {
return this.lastObservation
}
/** 本 Manager 当前持有的实例(userId → {epoch, hostId})。 */
holdings(): ReadonlyMap<string, { epoch: number; hostId: string }> {
return this.lease.holdings()
}
/** 注册本机 + 起心跳。窗口未开时先注册一次(否则管理面看不到这台 worker)。 */
async start(): Promise<void> {
if (this.options.registerSelf !== false) {
await this.db.upsertDshHost({
id: this.options.hostId,
endpoint: this.options.agentUrl,
agentToken: this.options.agentToken,
capacityMb: this.options.capacityMb ?? 0,
})
}
await this.tick() // 立即一次,管理面马上能看到心跳
if (this.options.manual === true) return
this.timer = setInterval(() => void this.tick(), this.heartbeatMs)
this.timer.unref?.()
}
/** 只停**心跳定时器**(不改实例)—— 注意别和 `Spawner.stop(userId)` 混淆,故另起名。 */
stopHeartbeat(): void {
if (this.timer !== undefined) {
clearInterval(this.timer)
this.timer = undefined
}
}
/** 一次心跳:续租 → 失权则 fence → 上报本机状态。 */
async tick(): Promise<void> {
// ① 续租;失权的 userId 会被清出本地记录
const lost = await this.lease.renewAll()
for (const userId of lost) {
// ② 我已失权 ⇒ 让 worker 停掉那个实例(下发的 epoch 取 DB 当前值 +1,确保高于它的记录)
await this.fenceOnAgent(userId)
}
await this.reportHost()
}
private async fenceOnAgent(userId: string): Promise<void> {
try {
const inst = await this.db.findUserInstance(userId, 'main')
// 多机(T08 S6):必须发给**实例所在的那台** —— 发错 host 等于没拦(旧持有者继续写)
const target = inst?.hostId === null || inst?.hostId === undefined
? { agentUrl: this.options.agentUrl, token: this.options.agentToken }
: (this.options.agentFor?.(inst.hostId) ?? { agentUrl: this.options.agentUrl, token: this.options.agentToken })
await this.post(`/fence`, { userId, epoch: (inst?.epoch ?? 0) + 1 }, target)
} catch {
// 通知失败不抛:下一轮心跳会重试;即便一直失败,租约已过期 ⇒ 新持有者会重建实例
}
}
private async reportHost(): Promise<void> {
const base = this.options.agentUrl.replace(/\/$/, '')
try {
const res = await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(this.heartbeatMs) })
const body = (await res.json()) as { ok?: boolean; instances?: number }
this.lastObservation = { ok: body.ok === true, instances: body.instances ?? 0 }
await this.db.setDshHostStatus(
this.options.hostId,
this.lastObservation.ok ? 'up' : 'down',
undefined,
Date.now(),
)
} catch (err) {
this.lastObservation = { ok: false, instances: 0, lastError: err instanceof Error ? err.message : String(err) }
await this.db.setDshHostStatus(this.options.hostId, 'down', undefined, Date.now())
}
}
private async post(
path: string,
body: Record<string, unknown>,
target?: { agentUrl: string; token: string },
): Promise<unknown> {
const use = target ?? { agentUrl: this.options.agentUrl, token: this.options.agentToken }
const base = use.agentUrl.replace(/\/$/, '')
const res = await fetch(`${base}${path}`, {
method: 'POST',
headers: { [AGENT_TOKEN_HEADER]: use.token, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) throw new Error(`agent POST ${path} → ${res.status}`)
return res.json()
}
// ── Spawner 实现 ────────────────────────────────────────────────────────
async launch(
userId: string,
folder: string,
patch?: string,
opts?: { force?: boolean; epoch?: number; hostId?: string },
): Promise<Instance> {
// **先选机、再认领**(T08 S6):租约的 host_id 必须与"实例真正落在哪台"一致,
// 否则续租/释放会指向错误的对象(多机下就是静默的脑裂入口)。
// 显式 `opts.hostId`(迁移的目标机)优先于自动选机。
const hostId = opts?.hostId ?? (await this.options.selectHost?.(userId)) ?? this.options.hostId
const claim = await this.lease.acquire(userId, hostId, { folder, patch })
if (!claim.ok) throw new LeaseBusyError(userId, claim.holder, claim.leaseUntil)
try {
return await this.inner.launch(userId, folder, patch, { ...opts, epoch: claim.epoch, hostId })
} catch (err) {
// 拉起失败就**立刻交还归属** —— 否则要白等一个 TTL 才能重试(用户侧表现为"卡住")
await this.lease.release(userId)
throw err
}
}
async restartMain(userId: string): Promise<Instance | undefined> {
const current = await this.inner.status(userId)
if (current.main === undefined) return undefined
const { folder, patch } = current.main
await this.stop(userId)
return this.launch(userId, folder, patch)
}
/** 只重启**本 Manager 持有**的实例 —— 别人的归属不该被我重启(会与其持有者抢同一个 home)。 */
async restartAllMains(): Promise<void> {
for (const userId of [...this.lease.holdings().keys()]) {
try {
await this.restartMain(userId)
} catch {
// 单个失败不打断其余
}
}
}
async spawnWatchdog(userId: string): Promise<Instance | undefined> {
return this.inner.spawnWatchdog(userId)
}
async status(userId: string): Promise<UserStatus> {
return this.inner.status(userId)
}
async endpointFor(userId: string): Promise<Endpoint | undefined> {
return this.inner.endpointFor(userId)
}
async stop(userId: string, hostId?: string): Promise<void> {
// 显式 host 优先;否则用**我认领时那台**(认领记录里有)—— 别让 stop 落到别的 worker 上
const target = hostId ?? this.lease.holdings().get(userId)?.hostId
await this.inner.stop(userId, target)
await this.lease.release(userId)
}
async teardown(): Promise<void> {
this.stopHeartbeat()
await this.inner.teardown()
}
async waitForLaunchTokenForUser(userId: string, timeoutMs?: number): Promise<void> {
return this.inner.waitForLaunchTokenForUser(userId, timeoutMs)
}
async restartAndProbe(userId: string, settleMs?: number): Promise<{ ok: boolean; reason: string }> {
return this.inner.restartAndProbe(userId, settleMs)
}
touch(userId: string): void {
this.inner.touch(userId)
}
async ensureFileService(userId: string): Promise<void> {
return this.inner.ensureFileService(userId)
}
/**
* 透传可选观测面。`Spawner` 里这两个是**可选**方法 ⇒ 这里做条件委托:
* 内层有就转发(熔断/配额是 worker 本地自管的概念,设计 §1.2),没有就回 null。
*/
breakerInfo(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null {
return this.inner.breakerInfo?.(userId) ?? null
}
quotaInfo(userId: string): { baseMb: number; memMb: number; heapMb: number } | null {
return this.inner.quotaInfo?.(userId) ?? null
}
}