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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 21:27:55 +08:00
1 parent 8c6a922cfb
commit 34e8751b45
18 files changed
+918 -43

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
# 备份与恢复运行手册(W4-B)
需求 §8.5:路径不携带完整身份,数据库里的映射(作品 ↔ 外部身份 ↔ 路径绑定 ↔ 资产位置 ↔ Slot ↔ 整理记录)就是关键资产。
只靠重新扫描目录恢复不了 Slot、审批、人工纠错和历史关系,所以备份以整库为准,映射清单做人工可读的副本与恢复校验。
## 备份内容
`scripts/backup.sh`(镜像内 `/app/scripts/backup.sh`,入口命令 `umt backup`)在 `DATA_DIR/backups/<UTC 时间戳>/` 下生成:
| 文件 | 内容 |
|---|---|
| `db.dump` | `pg_dump --format=custom` 整库:业务表、`secret` 表(密文)、任务引擎 schema、审计 |
| `mappings.json` | `--role export-mappings` 导出的映射清单:`library_root`、`path_mapping`、`media_entity`、`external_identity`、`path_binding`、`asset_location`、`acquisition_slot`、`organization_record` 的关键列 + 每表行数(`counts`) |
| `agent-sessions.tar.gz` | Agent Pi JSONL 会话(有才打包) |
| `manifest.txt` | 时间、pg_dump 版本、服务端版本、"主密钥未包含"标记 |
| `SHA256SUMS` | 以上文件校验和,恢复前校验 |
先写到 `.<时间戳>.partial/`,全部成功才改名,不会留下半截备份。`BACKUP_KEEP=N`(compose 缺省 14)时只保留最近 N 份。
**SECRET_MASTER_KEY 不在备份里。** 它和备份必须分开保管:备份泄露时密文仍然安全;主密钥丢失时备份里的站点/下载器/模型凭证无法解密(需要重新录入)。
恢复到新机器时,`.env` 里的 `SECRET_MASTER_KEY` 必须与备份时一致。
## 日常备份
```sh
docker compose exec app umt backup # 在线备份(pg_dump 一致性快照,不停服)
docker compose exec app ls /data/backups
# 拷到机器外(示例):
docker compose cp app:/data/backups/20260927T120000Z ./offsite/
```
定时:宿主 cron `0 4 * * * cd /opt/umt && docker compose exec -T app umt backup >> /var/log/umt-backup.log 2>&1`。
单独导出映射清单:`docker compose exec app umt export-mappings`(写到 `/data/exports/`)。
## 恢复
恢复会删除并重建备份里的全部对象。**先停 app**,脚本检测到库里还有其它连接会拒绝(`--force` 可跳过,不建议)。
```sh
docker compose stop app
docker compose run --rm --no-deps app restore 20260927T120000Z --yes # 或绝对路径 /data/backups/...
docker compose up -d app
```
`restore.sh` 依次:
1. 校验 `SHA256SUMS`;
2. `pg_restore --clean --if-exists --no-owner` 生成 SQL,经 `psql --single-transaction -v ON_ERROR_STOP=1` 回放(失败整体回滚,库保持恢复前状态)。镜像里是 17 版客户端,回放前去掉 16 版服务端不认识的 `SET transaction_timeout`;
3. 还原 `agent-sessions.tar.gz`;
4. `--role read-only-on`:把 `automation.readOnlyReconcile` 置为 true(保留 automation 其它字段),写审计 `ops.read_only_reconcile_on`;
5. 重新导出映射清单到 `DATA_DIR/tmp/restore-check-*.json`,逐表与备份里的 `counts` 比对,不一致以退出码 3 结束;
6. 打印"恢复后先对账再关闭只读"提示。
从别的机器恢复:把备份目录拷进目标机器的 `umt-data` 卷(`docker compose cp ./20260927T120000Z app:/data/backups/`),
`.env` 用原来的 `SECRET_MASTER_KEY`,空库上照上面的命令执行。备份早于当前镜像版本时,`up -d app` 会自动跑后续迁移。
## 恢复后:先对账,再关闭只读
只读对账模式下,自动下载提交、自动整理、订阅调度与 Agent/API Key 的自动执行一律拒绝(控制台首页有提示),查询和人工操作不受影响。
1. 库根:设置 → 媒体库根 → 逐个"测试"(哨兵文件在、目录可写)。
2. 下载器:下载页核对 qB 里的任务与库里的下载任务(托管标签)一致;路径映射"验证"。
3. 整理记录 / 资产位置:抽查最近的整理记录,目标文件确实存在。
4. 凭证:打开一个站点/下载器"测试",能通过说明主密钥一致。
5. 确认无误后关闭:设置 → 自动化 → 关闭"只读对账",或
`PATCH /api/v1/settings {"automation":{"readOnlyReconcile":false}}`。
## 开发机上用
脚本不依赖容器,只要有 `pg_dump/pg_restore/psql`:
```sh
cd apps/server && deno task compile && cd ../..
set -a; . ./.env; set +a
DATA_DIR=./data UMT_BIN=dist/umt-server scripts/backup.sh
DATA_DIR=./data UMT_BIN=dist/umt-server scripts/restore.sh <时间戳> --yes
```
## 验证记录
见 [deploy.md#验证记录](deploy.md#验证记录)(同一次演练)。
+148
View File
@@ -0,0 +1,148 @@
# 部署运行手册(W4-B)
形态:**除 PostgreSQL 外全部在一个容器里**。镜像 `uhd-media-tool` 内一个 `deno compile` 出的二进制 `umt-server`,
容器里同时跑 api 与 worker 两个进程,api 同端口托管 web 静态产物(`WEB_ROOT=/app/public`),对外只有 **4380** 一个端口。
compose 只有 `app` + `postgres` 两个服务。
## 1. 构建镜像
```sh
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. 启动
```sh
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(重启会换新的,旧的作废):
```sh
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`:
```yaml
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. 升级
```sh
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](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 单事务回放,复测通过。
+2 -2
View File
@@ -62,9 +62,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`,非 root(uid 10001),约 270 MB。
- 镜像:`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=/app`,不给 `--allow-run` / 写权限。
- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write`,不给 `--allow-run`;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。
- 构建版本号:`APP_VERSION=v0.1.0 docker compose build`(写入 web 的 `version.json`)。
## 测试数据库