- main.ts: `--role fsworker` dispatched before the rest of the module graph is loaded (other imports became dynamic); runs fsWorkerMain from the new `@umt/adapters/fsworker` export (~150 ms startup in the compiled binary) - fsproxy: default worker command picked per runtime (deno CLI -> `deno run fsworker.ts`, compiled binary -> `<execPath> --role fsworker`); stats expose workerKind; UMT_FS_TEST_HANG_AFTER_BYTES acceptance switch for containers - compile: --allow-run adds /app/umt-server (spawn self); start task allows deno - organize: replaced old file moved into the file-manager trash layout (<root>/.umt-trash/<day>/<uuid>/<rel>) and registered in trash_item, ledger result follows; organize.transfer log carries fsproxy stats - settings: autoInvestigateMode (investigate/recover) + max recover attempts - deploy.md: container verification (subprocess self, TIMEOUT -> SIGKILL x3) Co-Authored-By: Claude Opus 5.5 <[email protected]>
15 KiB
部署运行手册(W4-B)
形态:除 PostgreSQL 外全部在一个容器里。镜像 uhd-media-tool 内一个 deno compile 出的二进制 umt-server,
容器里同时跑 api、worker(io 组)、worker:agent(Agent 会话)三个进程,api 同端口托管 web 静态产物(WEB_ROOT=/app/public),对外只有 4380 一个端口。
compose 只有 app + postgres 两个服务。
1. 构建镜像
docker build -t uhd-media-tool:latest . # 或 docker compose build
APP_VERSION=v0.1.0 docker compose build # 版本号写进 web 的 version.json
- 多阶段:
denoland/deno:2.9.7(装依赖 → web 构建 → server 编译)→debian:trixie-slim运行镜像。 - 运行镜像只额外装
gosu(降权)与postgresql-client(备份/恢复用 pg_dump/pg_restore/psql,17 版客户端兼容 16 版服务端), 外加/usr/local/bin/ffprobe:从mwader/static-ffmpeg:7.1.1拷单个静态二进制(约 135 MB;Debianffmpeg包要 200+ 个依赖约 450 MB)。 换来源用--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>。 /app下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。- 二进制编译时固定权限:
--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,/app/umt-server(库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读/app+ 非 root 运行)。 子进程只放三个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算, 不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里hashVia记录实际方式)、二进制自身 (fsproxy 挂死隔离:入库的每个文件操作起一次/app/umt-server --role fsworker,约 150 ms;进度停滞超过FS_HANG_TIMEOUT_MS→ SIGTERM →FS_KILL_GRACE_MS后 SIGKILL → 按 TIMEOUT 有限次重试,超限建待处理)。Deno 的--allow-run按绝对路径放行, 所以二进制必须位于/app/umt-server;挪到别处运行时 spawn 被拒(NotCapable),自动回退进程内看门狗(卡死的系统调用线程无法回收), 日志organize.transfer的fsproxy.mode/workerKind/fallbackReason可看出实际方式。
2. 启动
mkdir -p /opt/umt && cd /opt/umt # 部署目录(放 docker-compose.yml 与 .env)
cp <仓库>/docker/compose.env.example .env
sed -i "s/^SECRET_MASTER_KEY=.*/SECRET_MASTER_KEY=$(openssl rand -hex 32)/" .env
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(openssl rand -hex 16)/" .env
docker compose up -d --build
docker compose ps # app 变成 healthy 即可访问
curl -s localhost:4380/api/v1/health
SECRET_MASTER_KEY 生成一次后不再更换:站点 Cookie、下载器密码、模型 API Key 都用它加密存在 secret 表。
把它存进密码管理器或离线介质,和数据库备份分开保管;备份里不含它,丢了它备份中的凭证无法解密。
容器启动顺序(docker/entrypoint.sh):
- 以 root 启动:建
DATA_DIR(/data)下的agent-sessions/ backups/ exports/ tmp/,属主不是PUID:PGID时chown -R, 然后gosu PUID:PGID降权重跑自己。PUID=0直接拒绝。 --role migrate:业务表迁移 + 任务引擎 schema 迁移。--role preflight:密钥不是占位符、非 root、数据库可达、WEB_ROOT/index.html存在、DATA_DIR 各子目录与 Agent 会话目录可写 (失败即退出);再逐个检查已启用的库根与下载器路径映射(view_kind=downloader的 local 前缀)可读可写—— 这些是用户挂载的目录,不可用只打warn日志并写审计preflight.path_unavailable,不阻止启动。- 同时起
--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。
3. 首次初始化管理员
api 首次启动时在日志里打印一次性 setup token(重启会换新的,旧的作废):
docker compose logs app | grep 'setup token'
浏览器打开 http://<主机>:4380,登录页进入初始化模式,填 token、用户名、密码(≥10 字符)。
命令行等价:POST /api/v1/auth/setup {setupToken, username, password}。之后在 设置 → API Key 创建给外部程序用的 Key。
4. 卷与路径约定
| 容器内路径 | 来源 | 用途 | 权限要求 |
|---|---|---|---|
/data |
命名卷 umt-data(或宿主目录) |
agent-sessions/(Pi JSONL 会话,崩溃恢复点)、backups/、exports/(映射清单)、tmp/(TMPDIR) |
entrypoint 自动 chown 给 PUID:PGID |
/media/... |
用户 bind mount | 媒体库根:控制台 设置 → 媒体库根 登记的 localPath |
需对 PUID:PGID 可读写;不会被自动 chown |
/downloads/... |
用户 bind mount | 下载缓存:路径映射的 local 前缀(下载器报告的 remote 路径 → 容器内路径) | 同上 |
/var/lib/postgresql/data(postgres) |
命名卷 umt-pg-data |
数据库 | — |
-
媒体目录在
docker-compose.yml的app.volumes里取消注释或另写docker-compose.override.yml:services: app: volumes: - /srv/media:/media - /srv/downloads:/downloads -
bind mount 的相对路径按第一个 compose 文件所在目录解析,不是按当前目录;不确定时写绝对路径。 源目录不存在时 Docker 会以 root 属主新建它,库根登记就会因哨兵写不进而失败。
-
路径映射:下载器容器里看到的是
/downloads/complete/...,本容器也挂成/downloads时 remote 前缀与 local 前缀相同; 挂到别处就登记remote_prefix=/downloads、local_prefix=<本容器内路径>。硬链接要求下载目录与库根在同一文件系统、同一挂载点内。 -
登记库根时服务端会在目录里写哨兵文件
.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覆盖,或在控制台 设置 → Pi Agent → Agent 运行 → 会话目录 (settings.agent.sessionDir,优先于环境变量)改成别的绝对路径(三个进程在同一容器里,天然共享)。改到/data之外时:目录要对PUID:PGID可写(entrypoint 不会 chown 它),umt backup只打包DATA_DIR/agent-sessions,需自行备份;有运行中的会话时不要改。
5. 常用环境变量
| 变量 | 缺省 | 说明 |
|---|---|---|
SECRET_MASTER_KEY |
必填 | 凭证加密主密钥,openssl rand -hex 32 |
POSTGRES_PASSWORD |
必填 | compose 拼进 DATABASE_URL |
UMT_PORT / UMT_BIND |
4380 / 0.0.0.0 |
宿主映射端口与绑定地址;放在反代后面时绑 127.0.0.1 |
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 级别 |
6. 升级
cd <仓库> && git pull
docker compose exec app umt backup # 升级前先备份
docker compose up -d --build app # 重建镜像并替换容器;启动时自动迁移
docker compose ps && docker compose logs --tail=50 app
迁移只前滚。升级失败要回退时:用旧镜像 + 升级前的备份恢复(见 backup-restore.md),不要用旧镜像直接连新库。
7. 日志与排障
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。 - 进容器排查:
docker compose exec app bash(root);以运行用户执行命令用gosu 10001:10001 <cmd>。 - 一次性子命令:
docker compose exec app umt migrate|preflight|export-mappings|backup、docker compose run --rm --no-deps app restore <备份>。
验证记录
2026-09-27,W4-B,Docker 29 / compose v2,在干净 worktree(HEAD 8c6a922 + 本次改动)构建,
临时项目名 umtw4b、app 端口 127.0.0.1:4390、pg 端口 127.0.0.1:55440,/media 绑定一个属主 10001 的临时目录。
| 步骤 | 结果 |
|---|---|
docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn |
成功(缓存命中 2 分 24 秒);镜像 489 MB(压缩后 107 MB):二进制 213 MB、gosu+postgresql-client 79 MB、web 2 MB |
compose up -d |
postgres healthy 后 app 启动,约 6 秒 healthy;22 个迁移(编号 0017 空缺)+ 引擎迁移,启动自检通过 dataDir=/data sessionDir=/data/agent-sessions |
| 进程 | 两个 umt-server(api、worker)uid/gid 均为 10001;/data/* 属主 10001;/app/umt-server 为 r-xr-xr-x root,运行用户写入 → Permission denied |
GET / 与 /console/settings |
200,SPA 由同端口托管 |
setup token(日志)→ POST /auth/setup → POST /auth/login |
成功,拿到会话与 csrf cookie |
会话 + csrf POST /api-keys {scopes:[admin]} |
返回 umt_… 明文一次 |
API Key GET /organization-records |
200 |
API Key POST /library-roots {localPath:/media} |
成功,宿主目录出现 .uhd-root(属主 10001);容器内 root 属主目录登记 → 400 库根不可写 … EACCES |
PATCH /settings {pi:{provider:fake}} → POST /agent/runs {mode:plan_only} |
run succeeded,会话文件 /data/agent-sessions/<runId>.jsonl(18.7 KB,属主 10001) |
宿主 chmod 555 媒体目录后 umt preflight |
退出码 0,warn 挂载路径不可用: verify-media 不可写: EACCES,审计 preflight.path_unavailable 一条;恢复权限后 warnings=0 |
umt backup |
db.dump 245 KB + mappings.json + agent-sessions.tar.gz + manifest.txt + SHA256SUMS;pg_dump 17.11 对服务端 16.14 |
| 清库 | drop schema public cascade + 删 openworkflow schema → 0 张表 |
app 运行中 umt restore |
拒绝:数据库还有 13 个其它连接 |
compose stop app → compose run --rm --no-deps app restore <ts> --yes |
退出码 0;63 张表恢复;抽查 admin_user 1 / api_key 1 / library_root 1 / media_entity 3 / external_identity 3 / agent_run 1 / agent_run_event 9 与备份前一致(audit_event +1 为恢复审计、settings +1 为 automation 行);映射清单逐表比对"一致";automation={"readOnlyReconcile": true},审计 ops.read_only_reconcile_on |
恢复后 up -d app |
healthy;GET /settings → automation {enabled: true, readOnlyReconcile: true} |
BACKUP_KEEP=3 连续备份 3 次 |
最旧一份被轮转删除,剩 3 份 |
PUID=1234 PGID=1234 空卷启动 |
/data 及子目录 chown 为 1234,进程 uid 1234;PUID=0 → 退出码 2 |
compose stop app |
0.8 秒,api/worker 均收到 SIGTERM 正常退出 |
| 清理 | compose down -v、删除测试镜像;未 prune 全局 build cache |
首次演练发现:镜像里的 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 |
2026-09-27,W5-E(生产 fsworker),临时项目名 umtw5e、app 127.0.0.1:4395、pg 127.0.0.1:55442,/media 绑定属主 10001 的临时目录;
在干净 worktree(HEAD 89ea684 + 本次改动)构建。FS_HANG_TIMEOUT_MS=3000、FS_KILL_GRACE_MS=500。
| 步骤 | 结果 |
|---|---|
docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn |
成功(约 2 分 30 秒,缓存命中依赖层) |
setup → login → admin API Key → POST /library-roots {localPath:/media} → 改 managed;SQL 插 3 个 movie 作品 |
成功 |
手动整理 POST /organization-records dryRun → 不带 dryRun 执行(Copy,1.5 GB 稀疏文件 /media/_incoming/Check.Movie.2021…mkv) |
记录 succeeded,1.8 s;日志 organize.transfer 带 fsproxy:{mode:"subprocess",workerKind:"self",spawned:5,timeouts:0,fallbackReason:null} |
以 UMT_FS_TEST_HANG_AFTER_BYTES=1048576 重建 app 后整理 60 MB 文件 |
容器内 /proc 看到 /app/umt-server --role fsworker(uid 10001,父进程为 --role worker)存活约 3–4 s 后消失,无残留子进程 |
| 超时链路 | 每次尝试:停滞 3 s → SIGTERM(worker 屏蔽)→ SIGKILL → TIMEOUT;共 3 次(FS_TRANSFER_MAX_ATTEMPTS)后账本 {"code":"TIMEOUT","message":"fs copy 挂死:进度停滞超过 3000 ms"},fsproxy.timeouts=3 killed=3,待处理「文件操作挂死,重试已达上限」;目标目录只留被 KILL 的 .uhd-partial(下次写前按年龄清理),无最终名半成品 |
| 清理 | compose down -v、删除测试镜像与临时目录 |
UMT_FS_TEST_HANG_AFTER_BYTES 只用于这类验收(让数据类操作的 worker 读到 n 字节后卡死 JS 线程并屏蔽 SIGTERM),生产不要设置。