Files
UHD_Media_Tool/docs/runbook/deploy.md
T
rootandClaude Opus 5.5 34e8751b45 feat(ops): W4-B single-container packaging, DATA_DIR, backup/restore
- DATA_DIR (/data: agent-sessions, backups, exports, tmp); entrypoint chowns it
  to PUID/PGID then drops privileges via gosu; /app binary stays root read-only
- compile with --allow-read --allow-write so mounted library roots are writable
- preflight: DATA_DIR/session dir must be writable (fatal); enabled library
  roots and downloader path mappings checked read/write (warn + audit)
- --role export-mappings (mapping manifest JSON, --compare row counts) and
  --role read-only-on (automation.readOnlyReconcile=true + audit)
- scripts/backup.sh (pg_dump custom + mappings + agent sessions, SHA256SUMS,
  BACKUP_KEEP rotation) and scripts/restore.sh (checksum, refuses with live
  connections, psql single-transaction replay compatible with PG16, read-only
  reconcile on, row-count verification)
- compose: app + postgres only, data volume, env example with key generation
- docs: runbook deploy.md, backup-restore.md with verification record

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:27:55 +08:00

10 KiB
Raw Blame History

部署运行手册(W4-B)

形态:除 PostgreSQL 外全部在一个容器里。镜像 uhd-media-tool 内一个 deno compile 出的二进制 umt-server, 容器里同时跑 api 与 worker 两个进程,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 版服务端)。
  • /app 下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。
  • 二进制编译时固定权限:--allow-net --allow-env --allow-sys --allow-read --allow-write,不给 --allow-run (库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读 /app + 非 root 运行)。

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):

  1. 以 root 启动:建 DATA_DIR(/data)下的 agent-sessions/ backups/ exports/ tmp/,属主不是 PUID:PGID 时 chown -R, 然后 gosu PUID:PGID 降权重跑自己。PUID=0 直接拒绝。
  2. --role migrate:业务表迁移 + 任务引擎 schema 迁移。
  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 拉起。

健康检查:镜像 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 覆盖(api 与 worker 在同一容器里,天然共享)。

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 会话目录
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,一行一条;role 字段区分 api / worker。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;21 个迁移 + 引擎迁移,启动自检通过 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 单事务回放,复测通过。