feat(panel): 持久化实例日志,跨容器重建保留

实例容器日志随重建(重启/升级/看门狗自愈)即丢失,用户看不到"上次为何
重启/崩溃"(浏览器实例常因内存触顶被看门狗重启)。现把生命周期事件 + 重启
原因 + 重建前的容器日志快照,追加到面板数据卷 /data/logs/<id>.log,跨重建保留。

- docker.ts: appendInstanceLog/readInstanceLog/snapshotContainerLog/deleteInstanceLog;
  日志目录与 accounts.json 同卷(宿主 ./data-panel 持久化),单实例上限 ~400KB
  超限截半保留最近;id 十六进制校验防路径注入。
- runInstance 删旧容器前先快照其最后日志、启动后记"容器已启动";
  stopInstance 记"容器已停止";removeInstance 彻底删除时清理日志文件。
- 看门狗 recover() 写入自愈原因(hard/soft/health + 内存明细)。
- 日志接口返回「持久化历史 + 本次容器实时日志」两段。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-06-14 21:53:11 +08:00
Unverified
parent 167a80a0c3
commit 2e201ff058
3 changed files with 76 additions and 7 deletions
+61 -1
View File
@@ -1,5 +1,6 @@
import { hostname } from 'node:os'; import { hostname } from 'node:os';
import { existsSync, readdirSync } from 'node:fs'; import { existsSync, readdirSync, appendFileSync, mkdirSync, statSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { dirname } from 'node:path';
import http from 'node:http'; import http from 'node:http';
import zlib from 'node:zlib'; import zlib from 'node:zlib';
import Docker from 'dockerode'; import Docker from 'dockerode';
@@ -140,6 +141,8 @@ export async function runInstance(inst: Instance): Promise<void> {
try { try {
const existing = docker.getContainer(inst.containerName); const existing = docker.getContainer(inst.containerName);
await existing.inspect(); await existing.inspect();
// 删除前先把旧容器最后日志快照进持久日志,否则随容器删除就看不到"上次为何停/崩"。
await snapshotContainerLog(inst, '容器重建(重启/升级/自愈),保留上一容器最后日志');
await existing.remove({ force: true }); await existing.remove({ force: true });
} catch { } catch {
/* 不存在,正常 */ /* 不存在,正常 */
@@ -183,6 +186,7 @@ export async function runInstance(inst: Instance): Promise<void> {
const container = await docker.createContainer(createOpts); const container = await docker.createContainer(createOpts);
try { try {
await container.start(); await container.start();
appendInstanceLog(inst.id, '容器已启动');
} catch (e) { } catch (e) {
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"—— // 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。 // 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
@@ -241,6 +245,7 @@ export async function regenInstanceMachineId(inst: Instance): Promise<void> {
export async function stopInstance(inst: Instance): Promise<void> { export async function stopInstance(inst: Instance): Promise<void> {
try { try {
await docker.getContainer(inst.containerName).stop({ t: 5 } as any); await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
appendInstanceLog(inst.id, '容器已停止');
} catch { } catch {
/* 已停止或不存在 */ /* 已停止或不存在 */
} }
@@ -259,6 +264,7 @@ export async function removeInstance(inst: Instance, purgeVolume: boolean): Prom
} catch { } catch {
/* 卷可能不存在 */ /* 卷可能不存在 */
} }
deleteInstanceLog(inst.id); // 彻底删除时一并清掉持久日志
} }
} }
@@ -580,6 +586,60 @@ export async function instanceLogs(inst: Instance, tail = 600): Promise<string>
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用 return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
} }
// ---------- 持久化日志(跨容器重建保留,存在面板数据卷里) ----------
// docker logs 随容器重建(重启/升级/看门狗自愈)即丢失,看不到"上次为何重启/崩溃"。这里把
// 重启原因 + 重建前的容器日志快照 + 生命周期事件,追加到面板数据卷的 /…/logs/<id>.log,跨重建保留。
// 与 store.ts 的 accounts.json 同目录(面板数据卷,宿主 ./data-panel 持久化)。fallback 须与 store.ts 一致。
const LOG_DIR = `${dirname(process.env.PANEL_DATA || '/data/panel/accounts.json')}/logs`;
const LOG_CAP = 400 * 1024; // 每实例日志上限 ~400KB,超限截掉前半保留最近
function logPath(id: string): string {
return `${LOG_DIR}/${id}.log`;
}
export function appendInstanceLog(id: string, line: string): void {
if (!/^[0-9a-f]{1,32}$/.test(id)) return; // 防路径注入;实例 id 为十六进制
try {
mkdirSync(LOG_DIR, { recursive: true });
const p = logPath(id);
appendFileSync(p, `[${new Date().toISOString()}] ${line}\n`);
const sz = statSync(p).size;
if (sz > LOG_CAP) writeFileSync(p, readFileSync(p).subarray(sz - Math.floor(LOG_CAP / 2)));
} catch {
/* 写持久日志失败不影响主流程 */
}
}
export function readInstanceLog(id: string): string {
if (!/^[0-9a-f]{1,32}$/.test(id)) return '';
try {
const p = logPath(id);
return existsSync(p) ? readFileSync(p, 'utf8') : '';
} catch {
return '';
}
}
// 实例彻底删除(连数据卷一并清除)时,顺手删掉它的持久日志文件,避免遗留孤儿。
export function deleteInstanceLog(id: string): void {
if (!/^[0-9a-f]{1,32}$/.test(id)) return;
try {
rmSync(logPath(id), { force: true });
} catch {
/* 忽略 */
}
}
// 把"即将被删/重建"的容器最后日志快照进持久日志(否则随容器删除丢失)。
export async function snapshotContainerLog(inst: Instance, reason: string): Promise<void> {
try {
const logs = (await instanceLogs(inst, 200)).trimEnd();
appendInstanceLog(inst.id, `──── ${reason} ────\n${logs}\n──── 上一容器日志快照结束 ────`);
} catch {
/* 容器可能已不可读,忽略 */
}
}
// 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。 // 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。
// 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。 // 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。
export async function typeInInstance(inst: Instance, text: string): Promise<void> { export async function typeInInstance(inst: Instance, text: string): Promise<void> {
+14 -5
View File
@@ -50,6 +50,8 @@ import {
downloadFromInstance, downloadFromInstance,
deleteInstanceFile, deleteInstanceFile,
instanceLogs, instanceLogs,
appendInstanceLog,
readInstanceLog,
typeInInstance, typeInInstance,
keyInInstance, keyInInstance,
listOrphanVolumes, listOrphanVolumes,
@@ -669,14 +671,20 @@ app.get('/api/admin/instances/:id/logs', async (req, reply) => {
if (!requireAdmin(req, reply)) return; if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id); const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' }); if (!inst) return reply.code(404).send({ error: '实例不存在' });
reply.header('content-type', 'text/plain; charset=utf-8');
// 持久化历史(重启原因 + 上一容器日志快照,跨重建保留)+ 本次容器实时日志。
const history = readInstanceLog(inst.id).trimEnd();
let live = '';
try { try {
const text = await instanceLogs(inst); live = (await instanceLogs(inst)).trimEnd();
reply.header('content-type', 'text/plain; charset=utf-8');
return reply.send(text || '(暂无日志)');
} catch (e: any) { } catch (e: any) {
reply.header('content-type', 'text/plain; charset=utf-8'); live = '获取本次容器日志失败:' + (e?.message || e);
return reply.send('获取日志失败:' + (e?.message || e));
} }
if (!history && !live) return reply.send('(暂无日志)');
if (!history) return reply.send(live);
return reply.send(
`═══ 历史日志(持久化 · 跨重启保留)═══\n${history}\n\n═══ 本次容器日志(实时)═══\n${live || '(本次容器暂无日志)'}`,
);
}); });
// ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ---------- // ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ----------
@@ -1011,6 +1019,7 @@ if (WATCHDOG_ENABLED) {
const recover = async (inst: Instance, reason: string, detail: string) => { const recover = async (inst: Instance, reason: string, detail: string) => {
recovering.add(inst.id); recovering.add(inst.id);
app.log.warn(`[watchdog] ${inst.containerName} ${detail}`); app.log.warn(`[watchdog] ${inst.containerName} ${detail}`);
appendInstanceLog(inst.id, `[看门狗] 自愈重启(${reason}):${detail}`);
try { try {
await stopInstance(inst); await stopInstance(inst);
await runInstance(inst); await runInstance(inst);
+1 -1
View File
@@ -1007,7 +1007,7 @@ function InstanceAdminCard({
<button className="btn-text" onClick={onAssign}> <button className="btn-text" onClick={onAssign}>
</button> </button>
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志"> <button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例日志(含历史:重启原因 + 上一容器日志快照,跨重启保留)">
</button> </button>
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈"> <button className="btn-text" onClick={onSecurity} title="内存阈值自愈">