21 KiB
部署运行手册
形态:核心应用在一个容器里,PostgreSQL 与浏览器分别部署。镜像 uhd-media-tool 内一个 bun build --compile 出的二进制 umt-server,
容器里同时跑 api、worker(io 组)、worker:agent(Agent 会话)三个进程,api 同端口托管 web 静态产物(WEB_ROOT=/app/public),对外只有 4380 一个端口。
compose 默认有三个服务:app + postgres + camofox(浏览器服务,只在内网,不映射端口;可关掉或换成自有实例,见 §2.1);tls profile 可额外启用 Caddy 提供 HTTPS。
1. 构建镜像
构建阶段需要 Bun 镜像、依赖包与构建工具等资源。直连不稳时在 .env.deploy 配 BUILD_PROXY(如经宿主中继 http://172.17.0.1:7891),bun run release 会自动带上 --build-arg HTTP(S)_PROXY;代理只用于构建,不进镜像。
docker build -t uhd-media-tool:latest . # 或 docker compose --env-file .env.deploy build
APP_VERSION=v0.3.11 docker compose --env-file .env.deploy build app # 示例:发布时填写对应版本,写进 web 的 version.json
- 多阶段:
oven/bun:1.4.3(装依赖 → web 构建 → server 编译)→debian:trixie-slim运行镜像。 - 运行镜像额外装
gosu(降权)及 PG 客户端动态库,从固定的postgres:18.6-trixie官方镜像复制 pg_dump/pg_restore/psql 与 libpq 18, 外加/usr/local/bin/ffprobe:从mwader/static-ffmpeg:9.0.2拷单个静态二进制(约 135 MB;Debianffmpeg包要 200+ 个依赖约 450 MB)。 换来源用--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>。 /app下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。- 构建同时生成解析隔离模块
sandbox-policy.node,与二进制放在/app。主进程以非 root 运行,外部字节解析另起子进程并安装 seccomp 策略;详见 ADR-005 与 ADR-007。Bun 主进程不提供 Deno 的运行时权限开关。 - 镜像带静态 ffprobe;全量文件哈希优先走 sha256sum 子进程,不可用时回退流式哈希,整理结果的
hashVia记录实际方式。 - 文件操作通过
/app/umt-server --role fsworker一次性子进程执行,进度停滞超过FS_HANG_TIMEOUT_MS后先 SIGTERM、再按FS_KILL_GRACE_MS发送 SIGKILL。FS_PROXY_MODE缺省subprocess,启动失败报错;只有显式选择auto才允许进程内回退。业务服务决定重试与恢复,通信层不自动重做副作用。
2. 启动
部署目录须包含完整仓库构建上下文。首次启用默认浏览器前,先完成 浏览器手册 §1.1 的基础镜像构建,再执行以下 compose 命令。部署配置统一放 .env.deploy,开发配置仍用仓库根 .env。
mkdir -p /opt/umt && cd /opt/umt # 部署目录(完整仓库构建上下文与 .env.deploy)
cp <仓库>/docker/compose.env.example .env.deploy # 部署配置独立于开发用 .env
sed -i "s/^SECRET_MASTER_KEY=.*/SECRET_MASTER_KEY=$(openssl rand -hex 32)/" .env.deploy
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(openssl rand -hex 16)/" .env.deploy
docker compose --env-file .env.deploy up -d --build
docker compose --env-file .env.deploy ps # app 变成 healthy 即可访问
curl -s localhost:4380/api/v1/health
2.1 浏览器服务(camofox)
缺省随栈起一个 camofox-browser 容器(jo-inc/camofox-browser v1.18.0,commit f94c81b),给原站视图与 Agent 浏览器能力用。
app 启动时按 BROWSER_PROVIDER_URL(缺省 http://camofox:9377)自动登记名为 camofox-env 的浏览器服务,
depends_on 只要求已启动、不等健康:camofox 挂了 app 照常运行,界面标"浏览器不可用"。
- 镜像:上游不发布镜像。先按 浏览器手册 §1.1,用
docker/camofox/Dockerfile构建基础镜像camofox-browser:1.18.0(锁上游 git tag,修正构建与信号转发)。 compose 再用docker/browser-desktop/Dockerfile从该基础镜像构建默认桌面镜像umt-browser-desktop:1.18.0。基础镜像首次构建需要 GitHub 源码和约 630 MB 的 Camoufox 包;桌面层复用浏览器包。 - 访问凭据:
.env.deploy里设CAMOFOX_ACCESS_KEY(openssl rand -hex 24),camofox 与 app 共用;不设则内网免认证。 - 资源:当前默认桌面镜像
mem_limit为 3g、shm_size为 2gb;每个站点会话一个 BrowserContext,另有桌面常驻进程。 早期约 200 MB 的空闲测量属于纯截图镜像,不能直接作为桌面模式的内存基准。 - 登录状态:按站点/账号存在卷
umt-camofox(/root/.camofox/profiles,cookies + localStorage), 在会话空闲回收、关闭会话与docker compose --env-file .env.deploy stop/restart(SIGTERM)时落盘;容器被 SIGKILL / OOM 杀掉时, 上次落盘之后的新登录会丢(需重新登录)。 - 接自有实例:
.env.deploy里BROWSER_PROVIDER_URL=<地址>、BROWSER_ACCESS_KEY=<它的 CAMOFOX_ACCESS_KEY>、CAMOFOX_REPLICAS=0。 - 不要浏览器:
CAMOFOX_REPLICAS=0+BROWSER_ENABLED=false。
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 --env-file .env.deploy 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(postgres) |
命名卷 umt-pg18-data,内部 18/docker |
PG18 数据库;旧16卷不能直接挂入 | — |
/root/.camofox(camofox) |
命名卷 umt-camofox |
浏览器登录状态(profiles/,按站点/账号)、上游 cookies/uploads/traces 目录 |
— |
-
媒体目录在
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_FILE_CONCURRENCY / WORKER_NETWORK_CONCURRENCY / WORKER_MAINTENANCE_CONCURRENCY |
空 | io worker 文件 / 网络 / 维护三条道的并发;空 = 用 设置 → 系统 →「后台任务并发」(缺省 2 / 4 / 1,热生效),设了以变量为准。见 dev.md「IO Worker 分道」 |
WORKER_CONCURRENCY |
空 | 旧变量(原 io 组总并发),现映射到网络道(WORKER_NETWORK_CONCURRENCY 未设时) |
WORKER_AGENT_CONCURRENCY |
2 |
agent 组 worker 并发槽(每个 Agent 会话占一槽直到结束) |
ORGANIZE_WALK_MAX_FILES |
5000 |
手动按路径整理时递归遍历的文件数上限;dryRun 超出返回 truncated: true,正式执行拒绝 |
BACKUP_KEEP |
14 |
备份保留份数,0 不轮转 |
LOG_LEVEL |
info |
pino 级别 |
CAMOFOX_ACCESS_KEY |
空 | camofox 访问凭据(camofox 与 app 共用);空 = 内网免认证 |
BROWSER_PROVIDER_URL |
http://camofox:9377 |
app 登记的浏览器服务地址(camofox-env);接自有实例时改 |
BROWSER_ACCESS_KEY |
CAMOFOX_ACCESS_KEY |
该服务的访问凭据,只在进程内使用,不入库 |
BROWSER_ENABLED |
true |
false = 停用 camofox-env,界面标"浏览器不可用" |
BROWSER_KEEPALIVE_MS |
120000 |
桌面接管 / 人工接管 / 智能体正在用的浏览器会话的保活间隔,0 = 关闭;要比 camofox 标签页空闲回收(5 分钟)短(docs/runbook/browser.md §12) |
CAMOFOX_REPLICAS |
1 |
0 = 不起 camofox 容器 |
CAMOFOX_SESSION_TIMEOUT_MS / CAMOFOX_BROWSER_IDLE_TIMEOUT_MS |
1800000 / 300000 |
会话空闲回收(落盘登录状态)/ 浏览器空闲退出 |
CAMOFOX_MAX_SESSIONS / CAMOFOX_MEM_LIMIT |
8 / 2g |
最多并发会话 / 容器内存上限 |
6. 升级
发布前检查旧镜像的实际在途任务:在任务页核对 queued / running / waiting,尤其是本次变更的任务名称和版本。优先等它们完成;需要中止的任务先保留原输入、冻结计划和操作记录,确认中止后通过现有入口重新发起。已经产生文件操作证据的任务要先核实执行结果,再决定后续处理。没有旧在途任务时不长期保留旧 handler;业务表前滚不能替代任务版本兼容判断。
整理来源新增后先建立基线,存量扫描必须单独触发;迁移本身不扫描或整理已有目录。
本机测试环境的标准做法:
cd <仓库> && git pull
bun run release # 工作区须干净、CHANGELOG 已有本版条目;打 tag → 构建 → 只替换 app → 核对健康版本
scripts/release.sh --no-build 用已有镜像只重建容器。真有要保留的数据时,先 docker compose --env-file .env.deploy exec app umt backup 再发布。手动等价命令:
docker compose --env-file .env.deploy up -d --build app # 重建镜像并替换容器;启动时自动迁移
docker compose --env-file .env.deploy ps && docker compose --env-file .env.deploy logs --tail=50 app
迁移只前滚。升级失败要回退时:用旧镜像 + 升级前的备份恢复(见 backup-restore.md),不要用旧镜像直接连新库。
7. 日志与排障
docker compose --env-file .env.deploy 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 --env-file .env.deploy exec app bash(root);以运行用户执行命令用gosu 10001:10001 <cmd>。 - 一次性子命令:
docker compose --env-file .env.deploy exec app umt migrate|preflight|export-mappings|backup、docker compose --env-file .env.deploy run --rm --no-deps app restore <备份>。
容器经宿主代理访问境外站点(可选)
宿主上的代理(如 mihomo)常只监听 127.0.0.1,容器从网桥够不到。改动最小的做法:加一个只绑在 docker 网桥地址上的中继,再放行 docker 网段到该端口;代理本身配置不动。写在本机 docker-compose.override.yml(不进 git):
services:
proxy-relay:
image: alpine/socat:1.8.0.0
network_mode: host
restart: unless-stopped
command: ["TCP-LISTEN:7891,bind=172.17.0.1,fork,reuseaddr", "TCP:127.0.0.1:7890"]
app:
environment:
HTTP_PROXY: http://172.17.0.1:7891
HTTPS_PROXY: http://172.17.0.1:7891
# 同网络的服务名都要列上(含下载器:qbittorrent / transmission / deluge / aria2 等),与 compose 缺省 UMT_NO_PROXY 一致
NO_PROXY: localhost,127.0.0.1,::1,postgres,camofox,proxy-relay,qbittorrent,qbittorrent-nox,qb,transmission,deluge,aria2,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
camofox:
environment: { HTTP_PROXY: http://172.17.0.1:7891, HTTPS_PROXY: http://172.17.0.1:7891, NO_PROXY: localhost,127.0.0.1,app,10.0.0.0/8,172.16.0.0/12 }
compose 已给 app 设了缺省 NO_PROXY(UMT_NO_PROXY 可改),override 里重写 NO_PROXY 时别漏掉下载器的服务名。
防火墙只放行 docker 网段:ufw allow from 172.16.0.0/12 to 172.17.0.1 port 7891 proto tcp。compose 默认网络已固定在 UMT_SUBNET(缺省 172.30.0.0/24,与本机其他网络冲突时在 .env.deploy 改成 172.16.0.0/12 内的空闲网段,如 172.16.80.0/24),否则 compose 自动分配的 192.168.x 网段不在放行范围内。应用的外部请求统一读取 HTTP(S)_PROXY/NO_PROXY;TMDB、境外 Pi 上游、camofox 访问境外站都经此代理,站内(postgres/camofox)不走代理。
测试用 qB(override 示例)
只用于本地模拟"下载完成 → 自动整理"(SIM-1):qB 与 app 共用 compose 网络,app 经 http://qbittorrent:8080 访问;不映射公网端口,本机调试只绑 127.0.0.1:8081。写在本机 docker-compose.override.yml(不进 git):
services:
app:
environment:
# 配了 HTTP(S)_PROXY 时必须把 qbittorrent 加进 NO_PROXY:代理按主机名匹配,不会因解析到内网 IP 而直连
NO_PROXY: localhost,127.0.0.1,::1,postgres,camofox,proxy-relay,qbittorrent,qbittorrent-nox,qb,transmission,deluge,aria2,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:5.2.4 # v4 / 5.0–5.2 都兼容(QB-1 实测 5.1.2、5.2.4)
restart: unless-stopped
environment: { PUID: "10001", PGID: "10001", TZ: Asia/Shanghai, WEBUI_PORT: "8080" }
volumes:
- /opt/umt-demo/downloads:/downloads # 与 app 挂同一路径,路径映射 /downloads ↔ /downloads
- umt-qb-config:/config
ports:
- "127.0.0.1:8081:8080"
volumes:
umt-qb-config:
docker compose --env-file .env.deploy --profile tls up -d --no-deps qbittorrent # 只起 qB
docker compose --env-file .env.deploy --profile tls logs qbittorrent | grep 'temporary password' # 首次启动的临时密码
# 改 NO_PROXY 后只重建 app 容器、不重建镜像(APP_VERSION 与当前运行镜像一致):
APP_VERSION=<当前版本> docker compose --env-file .env.deploy --profile tls up -d --no-deps --no-build app
- 本机经 8081 访问时 qB 的 Host 头校验要求端口与
WEBUI_PORT一致:curl 加-H 'Host: localhost:8080',否则 401。 要从宿主直接用映射端口访问,就让两边端口相同(WEBUI_PORT=18082+127.0.0.1:18082:18082)。 - 用临时密码登录后
POST /api/v2/app/setPreferences:web_ui_password(随机强密码,存本机凭据文件)、save_path=/downloads、dht/pex/lsd/upnp=false、up_limit/dl_limit限速、auto_tmm_enabled=false、torrent_content_layout=Original。 - 控制台登记:下载器实例
http://qbittorrent:8080、管理标签umt、settings.savePath=/downloads;路径映射 downloader/downloads↔/downloads; 自动整理要求库根为managed(register_only时no_library_root)。 - 模拟完成:按发布名把样片放进下载目录,用真实 piece 哈希做种子(testkit
makeTorrent({ realPieces: true })返回contents, 写进保存目录;缺省 pieces 是伪值,qB 校验不到 100%), 带标签umt加入 qB,校验后即 100% 做种,downloads.sync接管。
下载器认证与登录兼容(QB-1)
控制台 设置 → qBittorrent 实例 的「认证方式」三选一,存 downloader_instance.settings.authKind,凭证只写、存 secret_ref、不回显:
| 认证方式 | 凭证 | 请求 | 适用 |
|---|---|---|---|
用户名和密码(password) |
密码 | POST /api/v2/auth/login 取会话 cookie,之后带 cookie;会话失效(401/403)自动重登一次 |
所有版本 |
API Key(api_key) |
WebUI 生成的 qbt_… |
每个请求带 Authorization: Bearer <key>,不调 /auth/login |
qB 5.2+ |
无认证(none) |
无(切到它时删除已存凭证) | 不登录、不带凭证 | qB 开了"对本地主机 / 白名单子网跳过认证"的内网 |
旧实例没有 authKind:有用户名按账密,无用户名但有凭证按 API Key,都没有按无认证(与改前行为一致)。
API Key 在 qB 5.2+ WebUI 选项 → WebUI → API Key 处生成 / 轮换(等价 POST /api/v2/app/rotateAPIKey,返回 {"apiKey":"qbt_…"};
轮换后旧 Key 立即失效,记得回控制台更新)。
登录判定(对照 qbittorrent-api auth_log_in):
- 成功:HTTP 200 且体为
Ok.(≤5.1),或 204 空体(5.2+)。会话 cookie 名不固定(≤5.1SID,5.2+QBT_SID_<容器内 WebUI 端口>, 也可能被自定义),一律取Set-Cookie里的会话 cookie 原名原值回传。 - 失败分类:200
Fails./ 401 → 凭据错误(auth);403 "IP 已被封禁" / 429 → 限流(rate,默认连续 5 次失败封 1 小时, 等封禁过期或重启 qB);API Key 错误 qB 返回 403 →auth;无认证被要求认证 →auth;5xx →protocol。 - 实测矩阵(
packages/adapters/src/qbittorrent/qbittorrent.real.test.ts,设QB_REAL才跑):5.1.2 账密 / 无认证 / 失败分类, 5.2.4 账密 / API Key / 无认证 / 失败分类,各跑 登录 → 添加(testkitmakeTorrent({ realPieces: true })真实 piece 哈希, 内容写进保存目录,qB 校验即完成)→ 查询 → 标签 → 暂停恢复 → 删除(不删数据)。
本机测试入口
本机测试部署由 Caddy 提供 https://<服务器地址>:4380,同时保留 443 HTTPS;app 的本机 HTTP 调试入口为 http://127.0.0.1:4381。配置在 .env.deploy 与本机 docker-compose.override.yml,证书由 Caddy 内部 CA 签发。发布时按配置核对健康接口版本。