Files
dsh_ai1net_server/src/supervisor/spawner.ts
T
admin c70d5d860e 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
2026-09-15 18:47:02 +08:00

150 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Backend abstraction for per-user DSH lifecycle (docs/k8s.md §5.2).
*
* `LocalSpawner` spawns child processes (setuid/iptables). The route layer
* depends only on this interface. Shared instance/status types live here so
* no backend owns them.
* @module dshs/supervisor/spawner
*/
export type InstanceStatus = 'starting' | 'running' | 'crashed' | 'stopped' | 'failed'
export type InstanceRole = 'main' | 'watchdog'
/** A tracked DSH instance (main or watchdog). `port`/`pid` are local-only. */
export interface Instance {
id: string
userId: string
role: InstanceRole
folder: string
port?: number
status: InstanceStatus
pid?: number
exitCode?: number
lastError?: string
/** Rendered cordis patch **content**, not a path — see {@link Spawner.launch}. */
patch?: string
/** dsh web 一次性 launch token(本地模式从子进程 stdout 解析),用于拼装可直达的打开 URL。 */
launchToken?: string
/** systemd scope 名(bwrap 沙箱隔离时),stop 时用 systemctl stop 正确终止整个 scope。 */
unit?: string
/** 崩溃自动重启次数(档案 20 观测面,随实例重建归零)。 */
restarts?: number
/** 最近一次崩溃时间(ms epoch)。 */
lastCrashedAt?: number
}
/** Thrown when a user already has a running main DSH. */
export class AlreadyRunningError extends Error {
constructor(userId: string) {
super(`user ${userId} already has a running DSH`)
this.name = 'AlreadyRunningError'
}
}
/**
* 档案 78:该用户的实例刚因崩溃循环被熔断,冷却期内拒绝启动。
*
* 为什么需要它:`circuit-open` 之后若允许立刻重来,崩溃循环可无限重复
* (用户 F5 / 注入脚本自愈 / 脚本直铺都可能触发),且平台只留一行 stderr。
* 冷却过后只给一次干净预算。
*/
export class CrashBreakerOpenError extends Error {
readonly userId: string
/** 冷却结束时刻(ms epoch)。 */
readonly retryAt: number
/** 累计熔断次数。 */
readonly opens: number
constructor(userId: string, retryAt: number, opens: number) {
const waitSec = Math.max(0, Math.ceil((retryAt - Date.now()) / 1000))
super(`user ${userId} instance crash-breaker open (opens=${opens}, retry in ${waitSec}s)`)
this.name = 'CrashBreakerOpenError'
this.userId = userId
this.retryAt = retryAt
this.opens = opens
}
/** 距可重试还剩多少毫秒。 */
get retryAfterMs(): number {
return Math.max(0, this.retryAt - Date.now())
}
}
/** A user's main + watchdog pair. */
export interface UserStatus {
main?: Instance
watchdog?: Instance
}
/** The host:port the proxy forwards a user's DSH traffic to. */
export interface Endpoint {
host: string
port: number
}
/**
* The lifecycle seam the route layer delegates to.
*
* `endpointFor` is spawner-specific: `127.0.0.1:<port>` for the local backend
* (docs/k8s.md §5.4).
*
* `launch` takes the rendered cordis patch as **content**, not a path;
* `LocalSpawner` materializes it to a file inside the user's own volume.
*/
export interface Spawner {
/**
* 拉起实例。
*
* `opts.epoch`(T08 S4):**集群模式下 epoch 是 launch 契约的一部分** —— Manager 先抢占
* 归属拿到 epoch,再把它随 launch 下发,worker 记下来用于 **self-fencing**
* (收到更高 epoch 就停掉自己那个实例)。本地模式忽略该字段。
*
* `opts.hostId`(T08 S6):**多 worker 时指定落到哪台** —— 由上层选好机、并已用它认领租约,
* 因此这里必须与租约的 `host_id` 一致(否则归属与实例分离)。单机/1a 忽略。
*/
launch(
userId: string,
folder: string,
patch?: string,
opts?: { force?: boolean; epoch?: number; hostId?: string },
): Promise<Instance>
restartMain(userId: string): Promise<Instance | undefined>
/**
* 档案 78:熔断观测面(可选 —— 熔断是**本地模式**概念,k8s 模式没有)。
* 返回 null = 该用户未被熔断冷却;非 null = 正在冷却(含累计熔断次数与解冻时刻)。
*/
breakerInfo?(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null
/**
* 档案 84:实例内存配额观测面(可选 —— 「配额随插件集合推导」是**本地模式**概念)。
* 返回 `instanceMemMb()` / `heapMbFor()` 的**同源结果**,供实例内的「功能管理」
* 直接显示真值,而不是各自维护一份必然会漂的估算表。
*/
quotaInfo?(userId: string): { baseMb: number; memMb: number; heapMb: number } | null
/** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */
restartAllMains(): Promise<void>
spawnWatchdog(userId: string): Promise<Instance | undefined>
status(userId: string): Promise<UserStatus>
endpointFor(userId: string): Promise<Endpoint | undefined>
stop(userId: string, hostId?: string): Promise<void>
teardown(): Promise<void>
/** 等待该用户 main 实例打印 launch token(本地模式 = 启动完成的信号)。无实例 /
* 已崩溃 / 已停 → 立即返回;k8s 模式无 token 概念 → no-op。供 enter 复用分支在返回
* 打开 URL 前等待,避免把浏览器导向「HTTP 已监听但路由未就绪 → 404」的启动窗口。 */
waitForLaunchTokenForUser(userId: string, timeoutMs?: number): Promise<void>
/**
* 档案 34:重启该用户实例并**探活**——功能插件启用后判定实例是否还起得来。
* `ok:false` 时调用方必须回滚/隔离该插件(否则会把实例拖进崩溃循环,档案 25 教训)。
*/
restartAndProbe(userId: string, settleMs?: number): Promise<{ ok: boolean; reason: string }>
/** Record user activity (proxied traffic / entering the workspace) so idle
* reaping keeps warm instances that are genuinely in use. Local mode tracks
* this in memory; k8s mode relies on its own session-based reconcile. */
touch(userId: string): void
/** Make the user's file sidecar exist and be ready (k8s). No-op under local,
* where the control plane touches the volume in-process. */
ensureFileService(userId: string): Promise<void>
}