fix(system): 编译产物读 /proc、/sys 走 cat 兜底,运行状态资源监控与沙箱内存看门狗恢复
Deno 2 不带 --allow-all 时读 /proc、/sys 一律 NotCapable,部署环境资源监控一直无数据。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
708e99746c
commit
9268a4d920
9 files changed
+84
-19
No files matched your search
@@ -4,8 +4,8 @@
|
|||||||
"exports": { ".": "./src/main.ts" },
|
"exports": { ".": "./src/main.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
|
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
|
||||||
"start": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,deno --env-file=../../.env src/main.ts",
|
"start": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,deno --env-file=../../.env src/main.ts",
|
||||||
"compile": "deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,/app/umt-server --include ../../packages/db/migrations --include ../../packages/agent/skills --output ../../dist/umt-server src/main.ts",
|
"compile": "deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server --include ../../packages/db/migrations --include ../../packages/agent/skills --output ../../dist/umt-server src/main.ts",
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
|
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { availableParallelism, cpus } from "node:os";
|
import { availableParallelism, cpus } from "node:os";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
|
import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
|
||||||
|
import { readProcText } from "../../proc-read.ts";
|
||||||
|
|
||||||
// 资源监控(界面第二轮 V 线,参考 MP 仪表盘 CPU / 内存 / 网络组件):api 进程每 5 秒读一次 /proc 与 cgroup v2,
|
// 资源监控(界面第二轮 V 线,参考 MP 仪表盘 CPU / 内存 / 网络组件):api 进程每 5 秒读一次 /proc 与 cgroup v2,
|
||||||
// 相邻两次读数的差算速率,采样只放内存(环形,保留 6 小时)。读不到的文件按 null 降级,不报错。
|
// 相邻两次读数的差算速率,采样只放内存(环形,保留 6 小时)。读不到的文件按 null 降级,不报错。
|
||||||
@@ -14,13 +15,8 @@ const MAX_POINTS = 360;
|
|||||||
|
|
||||||
export type ReadText = (path: string) => Promise<string | null>;
|
export type ReadText = (path: string) => Promise<string | null>;
|
||||||
|
|
||||||
export const defaultReadText: ReadText = async (path) => {
|
/** /proc 与 /sys 需特权读,编译产物走 cat 兜底(见 proc-read.ts) */
|
||||||
try {
|
export const defaultReadText: ReadText = readProcText;
|
||||||
return await Deno.readTextFile(path);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 解析(纯函数) ──
|
// ── 解析(纯函数) ──
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// 读 /proc 与 /sys 的伪文件。
|
||||||
|
// Deno 2 把这两处当特权路径:不带 --allow-all 时 Deno.readTextFile 一律 NotCapable(--allow-read 及按路径授权都不行)。
|
||||||
|
// 开发 / 测试用 -A 能直接读;编译产物按最小权限运行,只能借 `cat` 子进程读(编译与 start 任务的 --allow-run 已含 cat)。
|
||||||
|
// 第一次遇到 NotCapable 后记住,后续直接走 cat。
|
||||||
|
|
||||||
|
let viaCat = false;
|
||||||
|
|
||||||
|
async function catText(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const o = await new Deno.Command("cat", { args: [path], stdout: "piped", stderr: "null" }).output();
|
||||||
|
return o.success ? new TextDecoder().decode(o.stdout) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读不到(不存在、无权限、cat 不可用)返回 null */
|
||||||
|
export async function readProcText(path: string): Promise<string | null> {
|
||||||
|
if (viaCat) return await catText(path);
|
||||||
|
try {
|
||||||
|
return await Deno.readTextFile(path);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Deno.errors.NotCapable) {
|
||||||
|
viaCat = true;
|
||||||
|
return await catText(path);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。
|
// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。
|
||||||
import { basename } from "node:path";
|
import { basename } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { readProcText } from "../proc-read.ts";
|
||||||
import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
|
import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
|
||||||
|
|
||||||
export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn";
|
export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn";
|
||||||
@@ -52,13 +53,9 @@ function command(memoryMb: number): { cmd: string; args: string[] } {
|
|||||||
|
|
||||||
/** 子进程实际内存(KB);读不到返回 null */
|
/** 子进程实际内存(KB);读不到返回 null */
|
||||||
async function rssKb(pid: number): Promise<number | null> {
|
async function rssKb(pid: number): Promise<number | null> {
|
||||||
try {
|
const s = await readProcText(`/proc/${pid}/status`);
|
||||||
const s = await Deno.readTextFile(`/proc/${pid}/status`);
|
const m = s ? /VmRSS:\s+(\d+)\s+kB/.exec(s) : null;
|
||||||
const m = /VmRSS:\s+(\d+)\s+kB/.exec(s);
|
return m ? Number(m[1]) : null;
|
||||||
return m ? Number(m[1]) : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSandbox<J extends SandboxJobName>(
|
export async function runSandbox<J extends SandboxJobName>(
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// 生产权限下读 /proc、/sys:Deno 2 不带 --allow-all 时 readTextFile 一律 NotCapable,
|
||||||
|
// 编译产物的资源监控与沙箱内存看门狗曾因此静默取不到数(v0.3.34 运行状态页一直无数据)。
|
||||||
|
// 这里用与 apps/server/deno.json compile 相同的权限起子进程,确认走 cat 兜底能读到。
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const here = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
const probe = `
|
||||||
|
import { readProcText } from "${here}../../src/proc-read.ts";
|
||||||
|
import { readResources } from "${here}../../src/modules/system/resources.ts";
|
||||||
|
const stat = await readProcText("/proc/stat");
|
||||||
|
const r = await readResources();
|
||||||
|
console.log(JSON.stringify({ stat: !!stat && stat.startsWith("cpu"), host: r.host != null, mem: r.meminfo != null }));
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe.skipIf(Deno.build.os !== "linux")("readProcText 在生产权限下", () => {
|
||||||
|
it("走 cat 兜底读到 /proc 与资源读数", async () => {
|
||||||
|
const out = await new Deno.Command(Deno.execPath(), {
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--no-prompt",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-env",
|
||||||
|
"--allow-sys",
|
||||||
|
"--allow-read",
|
||||||
|
"--allow-write",
|
||||||
|
"--allow-run=ffprobe,sha256sum,cat",
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
stdin: "piped",
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).spawn();
|
||||||
|
const w = out.stdin.getWriter();
|
||||||
|
await w.write(new TextEncoder().encode(probe));
|
||||||
|
await w.close();
|
||||||
|
const o = await out.output();
|
||||||
|
const text = new TextDecoder().decode(o.stdout).trim();
|
||||||
|
expect(o.success, new TextDecoder().decode(o.stderr)).toBe(true);
|
||||||
|
expect(JSON.parse(text.split("\n").at(-1)!)).toEqual({ stat: true, host: true, mem: true });
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -178,5 +178,5 @@ deno task ci # typecheck(tsc 7.0.2 + deno check)→ biome → vitest
|
|||||||
|
|
||||||
讨论过换 Bun / Node 24,结论是不换。
|
讨论过换 Bun / Node 24,结论是不换。
|
||||||
|
|
||||||
- 实际权限:生产编译参数为 `--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,/app/umt-server`,只有 `--allow-run` 白名单真正起限制,上文"读路径白名单"没有落实;选 Deno 的理由以直接跑 TS、单二进制、集中的 workspace 配置为主。
|
- 实际权限:生产编译参数为 `--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server`,只有 `--allow-run` 白名单真正起限制,上文"读路径白名单"没有落实;选 Deno 的理由以直接跑 TS、单二进制、集中的 workspace 配置为主。
|
||||||
- 已知摩擦都有固定做法:web 测试用 Node 跑 vitest、编译产物自检兜底漏嵌、`deno.lock` 被常驻进程改写(见 `docs/plan/lessons.md`)。
|
- 已知摩擦都有固定做法:web 测试用 Node 跑 vitest、编译产物自检兜底漏嵌、`deno.lock` 被常驻进程改写(见 `docs/plan/lessons.md`)。
|
||||||
@@ -46,3 +46,4 @@
|
|||||||
29. 两路各自合理的改动合起来出问题:B 路以「种子被占用」取消计划,C 路让「取消的计划立即让位」,合并后会取消 → 重评 → 再取消空转 → **规则**:协调者合并时专门看跨线交互(状态取值、原因码、让位 / 重试规则),补一条跨线测试。
|
29. 两路各自合理的改动合起来出问题:B 路以「种子被占用」取消计划,C 路让「取消的计划立即让位」,合并后会取消 → 重评 → 再取消空转 → **规则**:协调者合并时专门看跨线交互(状态取值、原因码、让位 / 重试规则),补一条跨线测试。
|
||||||
|
|
||||||
30. 发布构建在镜像里下载 Deno 运行时(`dl.deno.land`),构建容器直连外网卡了两个多小时没报错(v0.3.29)→ **规则**:`.env.deploy` 配 `BUILD_PROXY`,`release.sh` 自动带构建代理参数;发布超过 10 分钟没动静先看构建日志卡在哪。
|
30. 发布构建在镜像里下载 Deno 运行时(`dl.deno.land`),构建容器直连外网卡了两个多小时没报错(v0.3.29)→ **规则**:`.env.deploy` 配 `BUILD_PROXY`,`release.sh` 自动带构建代理参数;发布超过 10 分钟没动静先看构建日志卡在哪。
|
||||||
|
31. 运行状态页的 CPU / 内存一直没有数据(v0.3.32–0.3.34):Deno 2 把 `/proc`、`/sys` 当特权路径,不带 `--allow-all` 时 `readTextFile` 一律 NotCapable(按路径授权也不行);开发和测试都用 `-A`,只有编译产物按最小权限运行,于是只在部署环境静默失效(沙箱的内存看门狗同样失效)→ **规则**:读 `/proc`、`/sys` 一律用 `readProcText`(`cat` 兜底);依赖运行权限的功能要有一条按编译权限起子进程的测试(见 `test/system/proc-read.test.ts`)。
|
||||||
@@ -18,7 +18,7 @@ APP_VERSION=v0.3.11 docker compose --env-file .env.deploy build app # 示例:
|
|||||||
外加 `/usr/local/bin/ffprobe`:从 `mwader/static-ffmpeg:9.0.2` 拷单个静态二进制(约 135 MB;Debian `ffmpeg` 包要 200+ 个依赖约 450 MB)。
|
外加 `/usr/local/bin/ffprobe`:从 `mwader/static-ffmpeg:9.0.2` 拷单个静态二进制(约 135 MB;Debian `ffmpeg` 包要 200+ 个依赖约 450 MB)。
|
||||||
换来源用 `--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>`。
|
换来源用 `--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>`。
|
||||||
- `/app` 下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。
|
- `/app` 下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。
|
||||||
- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,/app/umt-server`
|
- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server`
|
||||||
(库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读 `/app` + 非 root 运行)。
|
(库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读 `/app` + 非 root 运行)。
|
||||||
子进程只放三个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算,
|
子进程只放三个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算,
|
||||||
不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里 `hashVia` 记录实际方式)、二进制自身
|
不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里 `hashVia` 记录实际方式)、二进制自身
|
||||||
|
|||||||
+1
-1
@@ -139,7 +139,7 @@ curl localhost:4380/api/v1/health
|
|||||||
|
|
||||||
- 镜像:`denoland/deno:2.9.7` 构建 web 与 `deno compile` 出二进制,运行时 `debian:trixie-slim` + gosu + postgresql-client + 静态 ffprobe,root 启动修 `/data` 属主后降权到 PUID(缺省 10001)。
|
- 镜像:`denoland/deno:2.9.7` 构建 web 与 `deno compile` 出二进制,运行时 `debian:trixie-slim` + gosu + postgresql-client + 静态 ffprobe,root 启动修 `/data` 属主后降权到 PUID(缺省 10001)。
|
||||||
- 入口 `docker/entrypoint.sh`:迁移 → 自检 → 同时起 api、worker、worker:agent 三个进程;任一退出则停掉其余并以非 0 退出,由 `restart: unless-stopped` 拉起。
|
- 入口 `docker/entrypoint.sh`:迁移 → 自检 → 同时起 api、worker、worker:agent 三个进程;任一退出则停掉其余并以非 0 退出,由 `restart: unless-stopped` 拉起。
|
||||||
- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum`;镜像带静态 ffprobe;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。
|
- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat`;镜像带静态 ffprobe;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。
|
||||||
- 构建版本号:`APP_VERSION=v0.1.0 docker compose build`(写入 web 的 `version.json`)。
|
- 构建版本号:`APP_VERSION=v0.1.0 docker compose build`(写入 web 的 `version.json`)。
|
||||||
|
|
||||||
## 测试数据库
|
## 测试数据库
|
||||||
|
|||||||
Reference in new issue
Block a user