build(docker): static ffprobe, --allow-run=ffprobe,sha256sum, three supervised processes

entrypoint runs api + worker (io) + worker:agent; compose/runbooks document
WORKER_CONCURRENCY, WORKER_AGENT_CONCURRENCY, ORGANIZE_WALK_MAX_FILES.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 22:15:43 +08:00
1 parent f6457a29db
commit 3792868816
8 files changed
+78 -21

No files matched your search

+6 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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"
}
+6 -1
View File
@@ -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:
+6
View File
@@ -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
+8 -5
View File
@@ -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
+28 -6
View File
@@ -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` |
+21 -5
View File
@@ -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`)。
## 测试数据库