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
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Worker 侧的**反向隧道管理器**(T08 跨机演练)。
|
||||
*
|
||||
* 为什么需要它:Manager 要连 Worker 上的两样东西 —— **agent 端口**与**每个实例的端口**
|
||||
* (实例只监听 `127.0.0.1`,这是 portGuard 的设计前提)。而 Worker 公网入方向通常被
|
||||
* **云安全组**挡住(实测:106 的 19100 从 47 与本机都连不上),放通只能在控制台点。
|
||||
*
|
||||
* 绕法:**让 Worker 主动拨 Manager**,用 SSH 反向转发把两边的 `127.0.0.1:<port>` 接起来。
|
||||
* 好处(实测):
|
||||
* · **两端都不用新开端口** —— 只用已开放的 SSH 端口(47 是 32022);
|
||||
* · 链路是加密的,且 Manager 侧落在 loopback(`GatewayPorts no` 默认)⇒ 不对外暴露;
|
||||
* · 实例端口是**动态**的(`findFreePort()`)⇒ 用 **ControlMaster + `ssh -O forward/cancel`**
|
||||
* 在**同一条长连接**上加/减转发,不必为每个端口重开连接。
|
||||
*
|
||||
* ⚠️ 定位:这是**演练级**传输(生产长期方案见设计 §2.3:受控网段白名单或隧道服务)。
|
||||
* ⚠️ 默认**关闭**:只有设了 `DSHS_TUNNEL_TARGET` 才启用 ⇒ 对同机/单机形态零影响。
|
||||
*
|
||||
* @module dshs/worker/tunnel
|
||||
*/
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync, unlinkSync } from 'node:fs'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const run = promisify(execFile)
|
||||
|
||||
export interface TunnelOptions {
|
||||
/** 拨入目标,形如 `[email protected]:32022`。 */
|
||||
target: string
|
||||
/** 私钥路径(建议专用、且在 Manager 侧用 `restrict,port-forwarding` 限权)。 */
|
||||
identity: string
|
||||
/** ControlMaster socket 路径(同一路径复用同一条连接)。 */
|
||||
controlPath: string
|
||||
/** 启动时就转发的端口(agent 自身;还可带控制面 PG 等)。 */
|
||||
staticPorts?: number[]
|
||||
/** `ssh` 可执行文件路径。 */
|
||||
sshBin?: string
|
||||
}
|
||||
|
||||
export class SshTunnel {
|
||||
/** 内部一律用**已补默认值**的具体类型(否则 `sshBin` 会是 `string | undefined`)。 */
|
||||
private readonly opts: {
|
||||
target: string
|
||||
identity: string
|
||||
controlPath: string
|
||||
staticPorts: number[]
|
||||
sshBin: string
|
||||
}
|
||||
private readonly forwarded = new Set<number>()
|
||||
private readonly hostPart: string
|
||||
private readonly portPart: number | undefined
|
||||
|
||||
constructor(options: TunnelOptions) {
|
||||
// `user@host:port` 里的 port 是 **SSH 端口**(不是转发的端口)—— 47 上用 32022,
|
||||
// 必须经 `-p` 传,否则会去连 22 而失败。
|
||||
const [hostPart, portPart] = options.target.split(':')
|
||||
this.hostPart = hostPart
|
||||
this.portPart = portPart === undefined ? undefined : Number(portPart)
|
||||
this.opts = {
|
||||
target: options.target,
|
||||
identity: options.identity,
|
||||
controlPath: options.controlPath,
|
||||
staticPorts: options.staticPorts ?? [],
|
||||
sshBin: options.sshBin ?? '/usr/bin/ssh',
|
||||
}
|
||||
}
|
||||
|
||||
/** 所有 ssh 调用的公共参数(`-p` 只在目标里显式给了端口时才加)。 */
|
||||
private baseArgs(): string[] {
|
||||
return this.portPart === undefined ? [] : ['-p', String(this.portPart)]
|
||||
}
|
||||
|
||||
/** 当前已转发的端口(诊断用)。 */
|
||||
get ports(): number[] {
|
||||
return [...this.forwarded]
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立(或复用)ControlMaster 长连接,并把 `staticPorts` 转发上去。
|
||||
* 幂等:socket 已存在且 master 还活着就直接返回。
|
||||
*/
|
||||
async ensureMaster(): Promise<void> {
|
||||
if (existsSync(this.opts.controlPath)) {
|
||||
try {
|
||||
await run(this.opts.sshBin, [...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'check', this.hostPart])
|
||||
// master 活着 ⇒ 只需补齐静态转发
|
||||
for (const port of this.opts.staticPorts ?? []) await this.forward(port)
|
||||
return
|
||||
} catch {
|
||||
try {
|
||||
unlinkSync(this.opts.controlPath) // 僵尸 socket:清掉重建
|
||||
} catch {
|
||||
/* 无所谓 */
|
||||
}
|
||||
}
|
||||
}
|
||||
const args = [
|
||||
'-M',
|
||||
'-N',
|
||||
'-f',
|
||||
...this.baseArgs(),
|
||||
'-S',
|
||||
this.opts.controlPath,
|
||||
'-i',
|
||||
this.opts.identity,
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=accept-new',
|
||||
'-o',
|
||||
'ExitOnForwardFailure=yes',
|
||||
'-o',
|
||||
'ServerAliveInterval=15',
|
||||
'-o',
|
||||
'ServerAliveCountMax=4',
|
||||
]
|
||||
for (const port of this.opts.staticPorts ?? []) args.push('-R', `${port}:127.0.0.1:${port}`)
|
||||
args.push(this.hostPart)
|
||||
await run(this.opts.sshBin, args, { timeout: 20_000 })
|
||||
for (const port of this.opts.staticPorts ?? []) this.forwarded.add(port)
|
||||
}
|
||||
|
||||
/**
|
||||
* master 是否还活着(`ssh -O check`)。
|
||||
*
|
||||
* 为什么需要:**对端 SSH 重启/断链后,转发会全部消失,而本地 `forwarded` 集合并不知情**
|
||||
* ⇒ 若只看本地状态,会以为"隧道还好",实际 Manager 已经连不上这台 Worker
|
||||
* (2026-09-15 收口"以跑通为目的"时补)。
|
||||
*/
|
||||
async isMasterAlive(): Promise<boolean> {
|
||||
try {
|
||||
await run(this.opts.sshBin, [...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'check', this.hostPart], {
|
||||
timeout: 8_000,
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// check 失败 ⇒ master 不在了;顺手清掉本地记账,避免"以为还转着"
|
||||
this.forwarded.clear()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态加一条反向转发(实例起来时调用)。
|
||||
* 用**同一个端口号**:实例在 Worker 上是 `127.0.0.1:<port>`,反向转发落到 Manager 的
|
||||
* `127.0.0.1:<port>` ⇒ Manager 侧无需端口映射表,`endpointFor` 直接回 `127.0.0.1`。
|
||||
*/
|
||||
async forward(port: number): Promise<boolean> {
|
||||
if (this.forwarded.has(port)) return true
|
||||
try {
|
||||
await run(
|
||||
this.opts.sshBin,
|
||||
[...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'forward', '-R', `${port}:127.0.0.1:${port}`, this.hostPart],
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
this.forwarded.add(port)
|
||||
return true
|
||||
} catch {
|
||||
return false // 失败不抛:实例本身仍在本机可用,只是跨机代理这跳不可用
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤销一条转发(实例停止/退出时调用)。 */
|
||||
async cancel(port: number): Promise<void> {
|
||||
if (!this.forwarded.has(port)) return
|
||||
try {
|
||||
await run(
|
||||
this.opts.sshBin,
|
||||
[...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'cancel', '-R', `${port}:127.0.0.1:${port}`, this.hostPart],
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
} catch {
|
||||
/* 连接已断也一样算撤销 */
|
||||
}
|
||||
this.forwarded.delete(port)
|
||||
}
|
||||
|
||||
/** 关闭 master(进程退出时)。 */
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await run(this.opts.sshBin, [...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'exit', this.hostPart], {
|
||||
timeout: 10_000,
|
||||
})
|
||||
} catch {
|
||||
/* 已退出 */
|
||||
}
|
||||
this.forwarded.clear()
|
||||
}
|
||||
|
||||
/** 目标 SSH 端口(`root@h:32022` → 32022)。 */
|
||||
get targetPort(): number | undefined {
|
||||
return this.portPart
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user