mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-08-31 02:31:27 -07:00
fix(stability): 重启/自愈幂等(keepImage)+ 代理双侧 TCP keepalive —— 远程桌面稳定性审查
重大逻辑问题:「重启」「卡死自愈」「看门狗自愈」都走 runInstance 按本地 :latest 重建—— 只要本地镜像被任何一次拉取更新过(新建实例/升级任一实例),下一次重启/自愈就把该实例 【隐式换镜像】。若本地新镜像恰好是坏的(如 v1.3.0 时期),一次看门狗自愈就能弄坏一个 从没点过升级的实例("我什么都没动它怎么坏了")。修:runInstance 增加 keepImage 选项, 重启/自愈沿用容器当前镜像重建(且跳过 ensureImage,离线也能重启);换镜像只走显式升级。 已实测:keepImage 重建后镜像不变;不带 keepImage(升级)换到 latest;新容器 LogConfig 生效。 守则新增 R10(重启必须幂等)。 代理加固:面板↔实例 双侧 ws 套接字启用 TCP keepalive(30s)。客户端断网/切网(WiFi→4G、 NAS 休眠)时 TCP 不会主动通知,半开死连接可挂数小时——对 KasmVNC 表现为幽灵会话占坑, 与新连接并存正是历史上"Xvnc 卡死需重启容器"的诱因之一;30s 探测让死连接分钟级回收。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,3 +82,9 @@
|
||||
- **升级路径测试:拿上一个 release 的容器+数据卷,原地升级到新镜像,应用仍可用**——用户做的是升级,不是全新安装,而我们过去只测了后者。
|
||||
|
||||
学费:DPI bug 逃过了「微信启动正常」的冒烟,因为聊天主窗口是原生的,崩的是「点开公众号」这个动作。
|
||||
|
||||
## R10 重启必须幂等:自愈/重启绝不隐式换版本
|
||||
|
||||
「重启」「卡死自愈」「看门狗自愈」重建容器时必须沿用该实例**当前正在跑的镜像**(`runInstance(inst, { keepImage: true })`),绝不因本地 `:latest` 恰好被某次拉取更新过就悄悄换镜像。换镜像只允许发生在显式「升级实例」。同理,任何"恢复类"操作(自愈、重连、重启)都不得顺带引入版本/配置变更——用户没点升级,系统就不能升级。
|
||||
|
||||
学费:v1.3.0 坏镜像时期,本地 `:latest` 被某次操作拉新后,一次看门狗自愈/用户点重启就把**从没点过升级**的实例隐式换到坏镜像——"我什么都没动它怎么坏了"。
|
||||
|
||||
@@ -202,18 +202,26 @@ async function ensureImage(): Promise<void> {
|
||||
}
|
||||
|
||||
// 创建并启动一个微信实例容器。若同名容器已存在则先移除(仅容器,不动卷)。
|
||||
export async function runInstance(inst: Instance): Promise<void> {
|
||||
// keepImage(稳定性关键):重启/自愈必须幂等——沿用该实例当前正在跑的镜像重建,
|
||||
// 绝不因"本地 :latest 恰好被某次拉取更新过"就悄悄换镜像(那等于一次没人要求的隐式升级;
|
||||
// 若本地新镜像恰好是坏的,一次看门狗自愈就能弄坏一个用户从没升级过的实例)。
|
||||
// 换镜像只允许发生在显式「升级实例」(不带 keepImage)。
|
||||
export async function runInstance(inst: Instance, opts?: { keepImage?: boolean }): Promise<void> {
|
||||
const net = await ensureNetwork();
|
||||
await ensureImage();
|
||||
let imageOverride: string | undefined;
|
||||
try {
|
||||
const existing = docker.getContainer(inst.containerName);
|
||||
await existing.inspect();
|
||||
const info = await existing.inspect();
|
||||
if (opts?.keepImage && info.Image) imageOverride = String(info.Image);
|
||||
// 删除前先把旧容器最后日志快照进持久日志,否则随容器删除就看不到"上次为何停/崩"。
|
||||
await snapshotContainerLog(inst, '容器重建(重启/升级/自愈),保留上一容器最后日志');
|
||||
await existing.remove({ force: true });
|
||||
} catch {
|
||||
/* 不存在,正常 */
|
||||
}
|
||||
// 沿用旧镜像重建时无需 ensureImage(镜像 id 一定在本地——容器刚在用它);
|
||||
// 也避免"离线 + 本地无 :latest"时连重启都失败。
|
||||
if (!imageOverride) await ensureImage();
|
||||
// 摄像头设备(探测不到则为空数组 → 仅摄像头不可用,音频/麦克风照常)
|
||||
const vids = videoDevices();
|
||||
const dris = ENABLE_GPU ? driDevices() : [];
|
||||
@@ -251,7 +259,7 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
const mac = realisticMac(inst.id);
|
||||
const createOpts: Docker.ContainerCreateOptions = {
|
||||
name: inst.containerName,
|
||||
Image: WECHAT_IMAGE,
|
||||
Image: imageOverride || WECHAT_IMAGE,
|
||||
// 内部 hostname 伪装成"个人电脑"名(不再用 woc-wx-<hex>,那是容器/服务器特征)。
|
||||
// 反代靠容器名 name 寻址,与此 hostname 无关。
|
||||
Hostname: realisticHostname(inst.id),
|
||||
|
||||
@@ -373,7 +373,7 @@ app.post('/api/instances/:id/heal', async (req, reply) => {
|
||||
lastHealAt.set(id, now);
|
||||
appendPanelLog('WARN', `实例「${inst.name}」(id=${id}) 由 ${u.username} 触发卡死自愈(VNC 连不上 → 重启容器,数据保留)`);
|
||||
try {
|
||||
await runInstance(inst);
|
||||
await runInstance(inst, { keepImage: true }); // 自愈=重启,幂等:沿用当前镜像,绝不隐式换版
|
||||
return { ok: true, restarted: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 卡死自愈重启失败:${e?.message || e}`);
|
||||
@@ -631,7 +631,7 @@ app.post('/api/admin/instances/:id/restart', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
appendPanelLog('INFO', `重启实例「${inst.name}」(id=${inst.id})`);
|
||||
await runInstance(inst);
|
||||
await runInstance(inst, { keepImage: true }); // 重启必须幂等:沿用当前镜像,换镜像只走显式「升级」
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `重启实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
@@ -1280,6 +1280,16 @@ proxy.on('proxyReqWs', (proxyReq, req) => {
|
||||
const instId = (req as any)._wocInstId;
|
||||
if (instId) proxyReq.on('upgrade', () => appendInstanceLog(instId, '[vnc] 上游已接受(101) · 桌面连接建立'));
|
||||
});
|
||||
// 上游(面板→实例)套接字 TCP keepalive:客户端断网/切网(WiFi→4G、NAS 休眠)时 TCP 不会主动通知,
|
||||
// 半开死连接可挂数小时——对 KasmVNC 表现为"幽灵会话"占坑,与新连接并存是历史上 Xvnc 卡死的诱因之一。
|
||||
// 30s 探测让死连接分钟级被回收,而不是小时级。
|
||||
proxy.on('open', (proxySocket) => {
|
||||
try {
|
||||
proxySocket.setKeepAlive(true, 30_000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
// 兜底:剥掉 KasmVNC 401 的 WWW-Authenticate 头,避免浏览器弹出原生 Basic Auth 登录框。
|
||||
// 正常路径下我们已注入正确凭据(不会 401);万一凭据失配,宁可桌面加载失败也绝不把登录弹窗暴露给用户。
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
@@ -1412,6 +1422,12 @@ app.server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) =>
|
||||
const ip = (req.socket && req.socket.remoteAddress) || '?';
|
||||
const uname = (u as any).username || '?';
|
||||
appendInstanceLog(inst.id, `[vnc] 连接尝试 user=${uname} ip=${ip}`);
|
||||
// 客户端侧 TCP keepalive(与上游侧成对,见 proxy.on('open')):及时回收断网客户端留下的半开死连接
|
||||
try {
|
||||
socket.setKeepAlive(true, 30_000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const t0 = Date.now();
|
||||
socket.on('close', () => appendInstanceLog(inst.id, `[vnc] 连接关闭(持续 ${Math.round((Date.now() - t0) / 1000)}s)`));
|
||||
proxy.ws(req, socket, head, { target: instanceTarget(inst) }, (err: any) => {
|
||||
@@ -1478,7 +1494,7 @@ if (WATCHDOG_ENABLED) {
|
||||
appendPanelLog('WARN', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈重启(${reason}):${detail}`);
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
await runInstance(inst);
|
||||
await runInstance(inst, { keepImage: true }); // 自愈幂等:沿用当前镜像,绝不因本地 :latest 变了就隐式升级
|
||||
healthFails.delete(inst.id);
|
||||
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`);
|
||||
} catch (e: any) {
|
||||
|
||||
Reference in New Issue
Block a user