diff --git a/Dockerfile b/Dockerfile index 1df4ab6c..cd9bcf3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ # 单容器镜像:一个 deno compile 出的 umt-server 二进制,同时承担 API(含 web 静态 SPA)与 worker。 # PostgreSQL 不在本镜像内,见 docker-compose.yml。 # 构建:docker build -t uhd-media-tool . 运行时版本锁定 Deno 2.9.7(ADR-004) +# ffprobe 取自静态构建镜像(单个 static-pie 二进制,约 135 MB;Debian 的 ffmpeg 包要拉 200+ 个依赖、约 450 MB) +ARG FFPROBE_IMAGE=mwader/static-ffmpeg:7.1.1 +FROM ${FFPROBE_IMAGE} AS ffprobe FROM denoland/deno:2.9.7 AS build WORKDIR /src @@ -10,7 +13,8 @@ ARG APP_VERSION="" ENV APP_VERSION=${APP_VERSION} RUN deno task check:tokens && cd apps/web && deno task build # --node-modules-dir=none + --exclude-unused-npm:只嵌入 server 依赖图可达的 npm 包(约 110 MB,而不是整个 node_modules) -# 权限在编译时固定:读写不限路径(库根/下载目录由用户挂载,位置不固定;写保护靠 /app root 只读 + 非 root 运行),不给 --allow-run +# 权限在编译时固定:读写不限路径(库根/下载目录由用户挂载,位置不固定;写保护靠 /app root 只读 + 非 root 运行); +# --allow-run 只放 ffprobe(入库探测)与 sha256sum(强制全量哈希走子进程,coreutils 自带) RUN cd apps/server && deno task compile FROM debian:trixie-slim @@ -27,6 +31,7 @@ RUN if [ -n "$APT_MIRROR" ]; then sed -i "s|deb.debian.org|$APT_MIRROR|g" /etc/a && mkdir -p /data && chown 10001:10001 /data WORKDIR /app # /app 全部 root 属主、只读:运行用户(缺省 10001,可用 PUID/PGID 覆盖)改不了二进制与静态产物 +COPY --from=ffprobe --chown=root:root --chmod=0555 /ffprobe /usr/local/bin/ffprobe COPY --from=build --chown=root:root --chmod=0555 /src/dist/umt-server /app/umt-server COPY --from=build --chown=root:root /src/apps/web/build/client /app/public COPY --chown=root:root --chmod=0555 docker/entrypoint.sh /app/entrypoint.sh diff --git a/README.md b/README.md index 415e2aab..6c8cf0d3 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ SMOKE_BASE_URL=http://127.0.0.1:5180 SMOKE_USER=<管理员> SMOKE_PASSWORD=<密 ## 部署 -除 PostgreSQL 外全部在一个容器里(api + worker + web 静态,单端口 4380);compose 只有 `app` + `postgres`。 +除 PostgreSQL 外全部在一个容器里(api + worker + worker:agent 三个进程 + web 静态,单端口 4380);compose 只有 `app` + `postgres`。 ```sh cp docker/compose.env.example .env # 填 SECRET_MASTER_KEY=$(openssl rand -hex 32)、POSTGRES_PASSWORD diff --git a/apps/server/deno.json b/apps/server/deno.json index 002bcc45..aadb22f3 100644 --- a/apps/server/deno.json +++ b/apps/server/deno.json @@ -4,8 +4,8 @@ "exports": { ".": "./src/main.ts" }, "tasks": { "dev": "deno run -A --watch --env-file=../../.env src/main.ts", - "start": "deno run --allow-net --allow-env --allow-sys --allow-read --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 --include ../../packages/db/migrations --output ../../dist/umt-server src/main.ts", + "start": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum --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 --include ../../packages/db/migrations --output ../../dist/umt-server src/main.ts", "test": "deno run -A npm:vitest run", "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" } diff --git a/docker-compose.yml b/docker-compose.yml index 4ca40e4c..3e02dbca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# 生产/试运行部署:只有两个服务 —— app(单容器:API + worker + web 静态,单端口 4380)+ postgres。 +# 生产/试运行部署:只有两个服务 —— app(单容器:API + worker(io 组)+ worker:agent + web 静态,单端口 4380)+ postgres。 # 用法(详见 docs/runbook/deploy.md): # cp docker/compose.env.example .env # 按注释生成 SECRET_MASTER_KEY / POSTGRES_PASSWORD # docker compose up -d --build @@ -50,6 +50,11 @@ services: PUID: ${PUID:-10001} PGID: ${PGID:-10001} BACKUP_KEEP: ${BACKUP_KEEP:-14} + # worker 并发:io 组(下载提交/入库复制/清理/对账/抓取等)与 agent 组(runAgent/resolveDecision)分池 + WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4} + WORKER_AGENT_CONCURRENCY: ${WORKER_AGENT_CONCURRENCY:-2} + # 手动按路径整理时递归遍历的文件数上限 + ORGANIZE_WALK_MAX_FILES: ${ORGANIZE_WALK_MAX_FILES:-5000} ports: - "${UMT_BIND:-0.0.0.0}:${UMT_PORT:-4380}:4380" volumes: diff --git a/docker/compose.env.example b/docker/compose.env.example index 45434508..b45b2e95 100644 --- a/docker/compose.env.example +++ b/docker/compose.env.example @@ -20,3 +20,9 @@ BACKUP_KEEP=14 LOG_LEVEL=info # APP_VERSION=v0.1.0 # 国内网络构建换源(可选):APT_MIRROR=mirrors.ustc.edu.cn + +# worker 并发(可选):io 组(下载提交/入库复制/缓存清理/资产对账/抓取)与 agent 组(Agent 会话)分池 +# WORKER_CONCURRENCY=4 +# WORKER_AGENT_CONCURRENCY=2 +# 手动按路径整理时递归遍历的文件数上限(dryRun 超出返回 truncated,正式执行拒绝) +# ORGANIZE_WALK_MAX_FILES=5000 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 59bf275d..7919f1da 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # 容器入口。 # root 启动:建好 DATA_DIR 子目录、按 PUID/PGID 修正属主,再用 gosu 降权重跑自己(二进制与 /app 保持 root 只读)。 -# serve(缺省):迁移 → 启动自检 → api 与 worker 两个进程互相看护。 +# serve(缺省):迁移 → 启动自检 → api、worker(io 组)、worker:agent(Agent 会话)三个进程互相看护。 # 任一进程退出就停掉另一个并以非 0 退出,把"半死容器"变成整体退出,交给 restart 策略自愈。 # backup / restore <备份目录> / migrate / preflight / export-mappings ...:一次性子命令(降权后执行)。 # 其它参数原样 exec(例如 bash)。 @@ -45,14 +45,17 @@ esac api_pid=$! "$BIN" --role worker & worker_pid=$! +"$BIN" --role worker:agent & +agent_pid=$! +pids=("$api_pid" "$worker_pid" "$agent_pid") stop_all() { - kill -TERM "$api_pid" "$worker_pid" 2>/dev/null || true - wait "$api_pid" "$worker_pid" 2>/dev/null || true + kill -TERM "${pids[@]}" 2>/dev/null || true + wait "${pids[@]}" 2>/dev/null || true } on_signal() { - echo "entrypoint: 收到 $1,停止 api 与 worker" >&2 + echo "entrypoint: 收到 $1,停止 api / worker / worker:agent" >&2 stop_all exit 0 } @@ -60,7 +63,7 @@ trap 'on_signal SIGTERM' TERM trap 'on_signal SIGINT' INT set +e -wait -n "$api_pid" "$worker_pid" +wait -n "${pids[@]}" code=$? set -e echo "entrypoint: 有进程退出(code=$code),停止其余进程" >&2 diff --git a/docs/runbook/deploy.md b/docs/runbook/deploy.md index 84fef91a..1da37676 100644 --- a/docs/runbook/deploy.md +++ b/docs/runbook/deploy.md @@ -1,7 +1,7 @@ # 部署运行手册(W4-B) 形态:**除 PostgreSQL 外全部在一个容器里**。镜像 `uhd-media-tool` 内一个 `deno compile` 出的二进制 `umt-server`, -容器里同时跑 api 与 worker 两个进程,api 同端口托管 web 静态产物(`WEB_ROOT=/app/public`),对外只有 **4380** 一个端口。 +容器里同时跑 api、worker(io 组)、worker:agent(Agent 会话)三个进程,api 同端口托管 web 静态产物(`WEB_ROOT=/app/public`),对外只有 **4380** 一个端口。 compose 只有 `app` + `postgres` 两个服务。 ## 1. 构建镜像 @@ -12,10 +12,14 @@ APP_VERSION=v0.1.0 docker compose build # 版本号写 ``` - 多阶段:`denoland/deno:2.9.7`(装依赖 → web 构建 → server 编译)→ `debian:trixie-slim` 运行镜像。 -- 运行镜像只额外装 `gosu`(降权)与 `postgresql-client`(备份/恢复用 pg_dump/pg_restore/psql,17 版客户端兼容 16 版服务端)。 +- 运行镜像只额外装 `gosu`(降权)与 `postgresql-client`(备份/恢复用 pg_dump/pg_restore/psql,17 版客户端兼容 16 版服务端), + 外加 `/usr/local/bin/ffprobe`:从 `mwader/static-ffmpeg:7.1.1` 拷单个静态二进制(约 135 MB;Debian `ffmpeg` 包要 200+ 个依赖约 450 MB)。 + 换来源用 `--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>`。 - `/app` 下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。 -- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write`,不给 `--allow-run` +- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum` (库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读 `/app` + 非 root 运行)。 + 子进程只放两个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算, + 不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里 `hashVia` 记录实际方式)。 ## 2. 启动 @@ -40,7 +44,9 @@ curl -s localhost:4380/api/v1/health 3. `--role preflight`:密钥不是占位符、非 root、数据库可达、`WEB_ROOT/index.html` 存在、**DATA_DIR 各子目录与 Agent 会话目录可写** (失败即退出);再逐个检查已启用的**库根**与**下载器路径映射**(`view_kind=downloader` 的 local 前缀)可读可写—— 这些是用户挂载的目录,不可用只打 `warn` 日志并写审计 `preflight.path_unavailable`,不阻止启动。 -4. 同时起 `--role api` 与 `--role worker`;任一退出就停掉另一个并以非 0 退出,由 `restart: unless-stopped` 拉起。 +4. 同时起 `--role api`、`--role worker`(io 组:下载提交/入库复制/缓存清理/资产对账/抓取等 + outbox 中继 + 后台编排循环)、 + `--role worker:agent`(只跑 runAgent/resolveDecision);任一退出就停掉其余并以非 0 退出,由 `restart: unless-stopped` 拉起。 + 两组 worker 只认领自己的任务,Agent 长会话不占文件复制的并发槽。 健康检查:镜像 `HEALTHCHECK` 用二进制自带 fetch 打 `127.0.0.1:4380/api/v1/health`(`--role probe`),不装 curl。 @@ -81,7 +87,9 @@ docker compose logs app | grep 'setup token' - 登记库根时服务端会在目录里写哨兵文件 `.uhd-root`,写不进去直接 400(提示检查挂载与属主)。 - **PUID/PGID**:设成宿主媒体目录的属主(`stat -c '%u:%g' /srv/media`)。改 PUID/PGID 后重启容器,entrypoint 会把 `/data` 重新 chown。 也可以 `docker run --user` 直接指定(此时跳过 chown,`/data` 需事先可写)。 -- Agent 会话目录缺省 `DATA_DIR/agent-sessions`,可用 `AGENT_SESSION_DIR` 覆盖(api 与 worker 在同一容器里,天然共享)。 +- Agent 会话目录缺省 `DATA_DIR/agent-sessions`,可用 `AGENT_SESSION_DIR` 覆盖,或在控制台 设置 → Pi Agent → Agent 运行 → 会话目录 + (`settings.agent.sessionDir`,优先于环境变量)改成别的绝对路径(三个进程在同一容器里,天然共享)。改到 `/data` 之外时:目录要对 + `PUID:PGID` 可写(entrypoint 不会 chown 它),`umt backup` 只打包 `DATA_DIR/agent-sessions`,需自行备份;有运行中的会话时不要改。 ## 5. 常用环境变量 @@ -93,6 +101,9 @@ docker compose logs app | grep 'setup token' | `PUID` / `PGID` | `10001` | 运行用户 | | `DATA_DIR` | `/data` | 应用数据目录(开发机缺省 `./data`) | | `AGENT_SESSION_DIR` | `DATA_DIR/agent-sessions` | Agent 会话目录 | +| `WORKER_CONCURRENCY` | `4` | io 组 worker 并发槽 | +| `WORKER_AGENT_CONCURRENCY` | `2` | agent 组 worker 并发槽(每个 Agent 会话占一槽直到结束) | +| `ORGANIZE_WALK_MAX_FILES` | `5000` | 手动按路径整理时递归遍历的文件数上限;dryRun 超出返回 `truncated: true`,正式执行拒绝 | | `BACKUP_KEEP` | `14` | 备份保留份数,0 不轮转 | | `LOG_LEVEL` | `info` | pino 级别 | @@ -109,7 +120,8 @@ docker compose ps && docker compose logs --tail=50 app ## 7. 日志与排障 -- `docker compose logs -f app`:pino JSON,一行一条;`role` 字段区分 api / worker。json-file 驱动,单文件 10 MB × 5 份。 +- `docker compose logs -f app`:pino JSON,一行一条;worker 日志带 `group`(io / agent)。 +- `worker 事件循环延迟超过阈值`(warn,`lagMs`):worker 主线程被同步重活占住超过 5s,租约续期可能受影响,带上前后日志排查。json-file 驱动,单文件 10 MB × 5 份。 - entrypoint 自身的输出(`entrypoint: ...`)说明哪个进程先退出、退出码多少。 - 自检失败(`check=data_dir`):`/data` 卷不可写,检查是否挂了只读卷或 `--user` 与卷属主不一致。 - 挂载路径告警(`挂载路径不可用`):看审计页 `preflight.path_unavailable` 事件,核对 bind mount 与 PUID/PGID。 @@ -146,3 +158,13 @@ docker compose ps && docker compose logs --tail=50 app 首次演练发现:镜像里的 pg_restore 17 直连 16 版服务端会因 `SET transaction_timeout` 失败(单事务回滚,库未被破坏), 已改为 pg_restore 输出 SQL、过滤该行后由 psql 单事务回放,复测通过。 + +2026-09-27,W4-D,临时项目名 `umtw4d`、app 127.0.0.1:4391、pg 127.0.0.1:55441(验证后已 `down -v` 并删除镜像)。 + +| 步骤 | 结果 | +|---|---| +| `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功;镜像内容 160.5 MB 压缩(W4-B 107 MB),新增层:静态 ffprobe 135 MB(未压缩),二进制 214 MB | +| `compose up -d` | 约 8 秒 healthy;`/api/v1/health` → `status=ok role=api db=ok` | +| 进程 | `entrypoint.sh serve` 下三个 `umt-server`:`--role api`、`--role worker`(日志 `group=io`,9 个任务,并发 4,模块循环 4 个)、`--role worker:agent`(`runAgent@1`/`resolveDecision@1`,并发 2,无模块循环),均为 uid 10001 | +| ffprobe | 容器内 `gosu 10001 ffprobe` 探测一个 2 KB WAV → `pcm_s16le`;`ffprobe version 7.1.1` | +| 编译权限 | 同一组编译参数的探针二进制在容器内以 10001 运行:node `execFile("ffprobe")` 成功、`Deno.Command("sha256sum")` 成功、`Deno.Command("ls")` → `NotCapable` | diff --git a/docs/runbook/dev.md b/docs/runbook/dev.md index 9a84aa91..2fb6cafd 100644 --- a/docs/runbook/dev.md +++ b/docs/runbook/dev.md @@ -47,9 +47,25 @@ curl -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events? ## 进程角色 -`umt-server --role api|worker|all`(源码:`deno run ... apps/server/src/main.ts --role ...`)。`all` 下 worker 心跳会发布到进程内事件总线,SSE 可见;分进程部署时事件总线需改为 PG 投影(W2)。 +`umt-server --role api|worker|worker:agent|all`(源码:`deno run ... apps/server/src/main.ts --role ...`)。 -启动时 `bootstrap()` 装配 `apps/server/src/modules/` 下的四个业务模块(见 apps/server/README.md),并连接任务引擎;`api` 角色也连接引擎(入队/信号/取消),`worker`/`all` 角色额外启动引擎 worker 与 OutboxRelay。数据库不可达时进程启动失败并退出。 +| 角色 | 引擎 worker | OutboxRelay / 模块后台循环 | +|---|---|---| +| `api` | 不起(只入队/信号/取消) | 不跑 | +| `worker`(io 组) | 除 `runAgent`/`resolveDecision` 外的全部任务,并发 `WORKER_CONCURRENCY`(缺省 4) | 跑 | +| `worker:agent` | 只跑 `runAgent`/`resolveDecision`,并发 `WORKER_AGENT_CONCURRENCY`(缺省 2) | 不跑 | +| `all` | io 组 + agent 组两个池(开发缺省) | 跑 | + +分组 worker 只认领自己的任务(`@umt/tasks` 按任务名过滤认领),Agent 长会话不占文件复制的槽,反之亦然。 +worker 里有事件循环延迟探针:偏差超过 5s 打 `worker 事件循环延迟超过阈值` warn,次数随心跳事件(`loopLagWarnings`)发出。 + +启动时 `bootstrap()` 装配 `apps/server/src/modules/` 下的四个业务模块(见 apps/server/README.md),并连接任务引擎。数据库不可达时进程启动失败并退出。 + +acquisition-organize 的后台循环只做编排:qB 同步(含阈值暂停/恢复)一个定时器;另一个定时器按阈值入队 `cleanupDownloadCache`、 +巡检补投 `submitDownload`/`verifyAndImport`、每 6h 入队 `reconcileLibrary`,重活都在任务里跑。 +`POST /download-cache/cleanup`、`POST /library/reconcile` 同样只入队,返回 `{taskId, runId}`(202)。 +HTTP 调用外部 IO 型能力(`release.search` 实时搜索、`release.inspect` 刷新详情、`identity.resolve`、`catalog.lookup` includeRemote) +限时 15s,站点并发 2、TMDB 并发 4,超时 502 / 排队满 429。 一次性子命令(容器入口用):`--role migrate`(迁移到最新,迁移文件已编进二进制;随后迁移任务引擎的 openworkflow schema)、`--role preflight`(启动自检:生产环境拒绝占位符密钥与 root、DB 可达、`WEB_ROOT/index.html` 存在)、`--role probe`(打本机 `/api/v1/health`,HEALTHCHECK 用)。 @@ -63,9 +79,9 @@ docker compose up -d --build # 服务:app(4380)+ postgres(数据卷 umt-p curl localhost:4380/api/v1/health ``` -- 镜像:`denoland/deno:2.9.7` 构建 web 与 `deno compile` 出二进制,运行时 `debian:trixie-slim` + gosu + postgresql-client,root 启动修 `/data` 属主后降权到 PUID(缺省 10001),约 490 MB。 -- 入口 `docker/entrypoint.sh`:迁移 → 自检 → 同时起 api 与 worker 两个进程;任一退出则停掉另一个并以非 0 退出,由 `restart: unless-stopped` 拉起。 -- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write`,不给 `--allow-run`;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。 +- 镜像:`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` 拉起。 +- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum`;镜像带静态 ffprobe;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。 - 构建版本号:`APP_VERSION=v0.1.0 docker compose build`(写入 web 的 `version.json`)。 ## 测试数据库