Files
UHD_Media_Tool/docs/runbook/deploy.md
T

265 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 部署运行手册
形态:**核心应用在一个容器里,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`;代理只用于构建,不进镜像。
```sh
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;Debian `ffmpeg` 包要 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](browser.md#11-部署自带缺省) 的基础镜像构建,再执行以下 compose 命令。部署配置统一放 `.env.deploy`,开发配置仍用仓库根 `.env`。
```sh
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](browser.md#11-部署自带缺省),用 `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`):
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`(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(重启会换新的,旧的作废):
```sh
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`:
```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` 覆盖,或在控制台 设置 → 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;业务表前滚不能替代任务版本兼容判断。
整理来源新增后先建立基线,存量扫描必须单独触发;迁移本身不扫描或整理已有目录。
本机测试环境的标准做法:
```sh
cd <仓库> && git pull
bun run release # 工作区须干净、CHANGELOG 已有本版条目;打 tag → 构建 → 只替换 app → 核对健康版本
```
`scripts/release.sh --no-build` 用已有镜像只重建容器。真有要保留的数据时,先 `docker compose --env-file .env.deploy exec app umt backup` 再发布。手动等价命令:
```sh
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](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):
```yaml
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):
```yaml
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:
```
```sh
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.1 `SID`,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 / 无认证 / 失败分类,各跑 登录 → 添加(testkit `makeTorrent({ 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 签发。发布时按配置核对健康接口版本。