mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 22:59:46 +08:00
docs(docker): 梳理容器启动流程并清理遗留逻辑
This commit is contained in:
@@ -47,18 +47,6 @@ function apply_package_cache_env() {
|
||||
mkdir -p "${UV_CACHE_DIR}"
|
||||
}
|
||||
|
||||
function run_package_command() {
|
||||
if [ -n "${PROXY_HOST}" ]; then
|
||||
HTTP_PROXY="${PROXY_HOST}" \
|
||||
HTTPS_PROXY="${PROXY_HOST}" \
|
||||
http_proxy="${PROXY_HOST}" \
|
||||
https_proxy="${PROXY_HOST}" \
|
||||
"$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
function wait_backend_ready() {
|
||||
local entrypoint_start_time="${1:-$(date +%s)}"
|
||||
local backend_start_time="${2:-$(date +%s)}"
|
||||
|
||||
@@ -727,92 +727,6 @@ function configure_package_route() {
|
||||
done
|
||||
}
|
||||
|
||||
function fetch_latest_v3_release() {
|
||||
local response
|
||||
local releases
|
||||
local latest_release
|
||||
|
||||
response=$(curl ${CURL_OPTIONS} --compressed --fail --connect-timeout 5 --max-time 15 \
|
||||
"https://api.github.com/repos/jxxghp/MoviePilot/releases" \
|
||||
${CURL_HEADERS}) || return 1
|
||||
releases=$(printf '%s\n' "${response}" | jq -r '.[].tag_name') || return 1
|
||||
latest_release=$(printf '%s\n' "${releases}" | grep "^v3\." | sort -V | tail -n 1)
|
||||
[[ -n "${latest_release}" ]] || return 1
|
||||
printf '%s\n' "${latest_release}"
|
||||
}
|
||||
|
||||
# 版本号比较
|
||||
function compare_versions() {
|
||||
local v1="$1"
|
||||
local v2="$2"
|
||||
# 去掉开头的 v 或 V
|
||||
v1="${v1#[vV]}"
|
||||
v2="${v2#[vV]}"
|
||||
local current_ver_parts=()
|
||||
local release_ver_parts=()
|
||||
IFS='.-' read -ra current_ver_parts <<< "$v1"
|
||||
IFS='.-' read -ra release_ver_parts <<< "$v2"
|
||||
local i
|
||||
local current_ver
|
||||
local release_ver
|
||||
|
||||
for ((i = 0; i < ${#current_ver_parts[@]} || i < ${#release_ver_parts[@]}; i++)); do
|
||||
# 版本号不足位补 0
|
||||
local current_ver_part="${current_ver_parts[i]:-0}"
|
||||
local release_ver_part="${release_ver_parts[i]:-0}"
|
||||
current_ver=$(get_priority "$current_ver_part")
|
||||
release_ver=$(get_priority "$release_ver_part")
|
||||
|
||||
# 任意一个为-5,不在合法版本号内,无法比较
|
||||
if (( current_ver == -5 || release_ver == -5 )); then
|
||||
ERROR "存在不合法版本号,无法判断,跳过更新步骤..."
|
||||
return 1
|
||||
else
|
||||
if (( current_ver > release_ver )); then
|
||||
WARN "当前版本高于远程版本,跳过更新步骤..."
|
||||
return 1
|
||||
elif (( current_ver < release_ver )); then
|
||||
INFO "发现新版本,开始自动升级..."
|
||||
if install_backend_and_download_resources "tags/$2.zip"; then
|
||||
return 0
|
||||
fi
|
||||
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||
return 1
|
||||
else
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
done
|
||||
WARN "当前版本已是最新版本,跳过更新步骤..."
|
||||
}
|
||||
|
||||
# 优先级转换
|
||||
function get_priority() {
|
||||
local version="$1"
|
||||
if [[ $version =~ ^[0-9]+$ ]]; then
|
||||
echo $version
|
||||
else
|
||||
case $version in
|
||||
"stable")
|
||||
echo -1
|
||||
;;
|
||||
"rc")
|
||||
echo -2
|
||||
;;
|
||||
"beta")
|
||||
echo -3
|
||||
;;
|
||||
"alpha")
|
||||
echo -4
|
||||
;;
|
||||
# 非数字的不合法版本号
|
||||
*)
|
||||
echo -5
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
function run_moviepilot_update() {
|
||||
MOVIEPILOT_UPDATE_RESULT="noop"
|
||||
if [ -f "${PREPARED_UPDATE_MANIFEST}" ]; then
|
||||
|
||||
492
docs/docker-startup.md
Normal file
492
docs/docker-startup.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Docker 容器启动处理流程
|
||||
|
||||
本文档说明 MoviePilot V3 镜像从 Docker 创建容器到后端进入 `ready` 状态的完整处理链,覆盖
|
||||
`docker/` 下的控制脚本、更新事务、依赖自愈、浏览器和证书准备、Nginx、Python lifespan、
|
||||
异常保活与退出清理。
|
||||
|
||||
本文基于 `v3` 分支 2026-09-02 的实现整理。实际行为以当前源码为准。
|
||||
|
||||
## 1. 文件职责
|
||||
|
||||
| 文件 | 运行位置与职责 |
|
||||
| --- | --- |
|
||||
| `docker/Dockerfile` | 构建 Python 环境、后端、前端、插件、站点资源和容器控制面;声明 `tini` 入口与 readiness 健康检查。 |
|
||||
| `docker/launcher.sh` | 构建时复制为镜像根目录 `/entrypoint.sh`;以 root 校验、选择并固化本轮启动使用的控制脚本。 |
|
||||
| `docker/entrypoint.sh` | 真正的容器启动编排器;加载配置,驱动更新、权限、浏览器、证书、Nginx、后端和退出处理。 |
|
||||
| `docker/update.sh` | 被 `entrypoint.sh` source;处理未完成更新恢复、已准备 Release 安装、Dev 更新、依赖同步和载荷事务。 |
|
||||
| `docker/browser.sh` | 被 `entrypoint.sh` source;选择持久化 CloakBrowser 缓存、校正权限并按需安装浏览器内核。 |
|
||||
| `docker/cert.sh` | 被 `entrypoint.sh` source;校验证书、按需安装 acme.sh、签发证书并配置续期任务。 |
|
||||
| `docker/nginx.template.conf` | 由环境变量渲染为 `/etc/nginx/nginx.conf`,提供前端静态文件、API 和 SSE 反向代理。 |
|
||||
| `docker/nginx.common.conf` | HTTP/HTTPS server 共用的前端、API、SSE 和静态资源规则。 |
|
||||
| `docker/docker_http_proxy.conf` | 挂载 Docker Socket 时启动的本地只监听代理,后端通过 `127.0.0.1:38379` 访问 Docker API。 |
|
||||
|
||||
## 2. 总体调用链
|
||||
|
||||
```text
|
||||
Docker
|
||||
-> tini -g
|
||||
-> /entrypoint.sh # 镜像内置 launcher.sh
|
||||
-> 校验并选择控制脚本代际
|
||||
-> /run/moviepilot/control/<sha256>/entrypoint.sh
|
||||
-> 加载 /config/app.env
|
||||
-> 渲染 Nginx 配置
|
||||
-> source update.sh
|
||||
-> 恢复未完成更新
|
||||
-> 安装已准备的 Release/资源包,或执行 Dev 更新
|
||||
-> 必要时用更新后的控制脚本重新 exec 一次
|
||||
-> source browser.sh
|
||||
-> 映射 PUID/PGID
|
||||
-> 检查并按需恢复 Python 运行依赖
|
||||
-> 校正目录和缓存权限
|
||||
-> 准备 CloakBrowser 内核
|
||||
-> source cert.sh
|
||||
-> 启动前端 Nginx
|
||||
-> 可选启动 Docker Socket 代理
|
||||
-> gosu moviepilot python3 app/main.py
|
||||
-> Uvicorn/FastAPI lifespan
|
||||
-> 数据库迁移和全部生命周期组件
|
||||
-> /health/ready 返回 200
|
||||
```
|
||||
|
||||
镜像的 `ENTRYPOINT` 为:
|
||||
|
||||
```text
|
||||
/usr/bin/tini -g -- /entrypoint.sh
|
||||
```
|
||||
|
||||
`tini -g` 负责回收子进程并把停止信号发送给整个进程组。根目录 `/entrypoint.sh` 不是业务
|
||||
`entrypoint.sh`,而是构建时从 `launcher.sh` 复制出的最小可信启动器。
|
||||
|
||||
## 3. 控制脚本可信选择
|
||||
|
||||
### 3.1 为什么不直接执行 `/app/docker/entrypoint.sh`
|
||||
|
||||
Docker 的 Dev 更新可以在启动过程中整体替换 `/app`。如果当前 Shell 正在从 `/app/docker` 继续
|
||||
source 其他脚本,可能出现同一次启动混用新旧脚本的情况。因此 launcher 会先选择完整的一代控制脚本,
|
||||
再复制到只属于本轮启动的运行时快照目录。
|
||||
|
||||
候选目录:
|
||||
|
||||
| 优先级 | 目录 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `/app/docker` | 当前源码携带的控制脚本。 |
|
||||
| 特殊恢复 | `/app.__update_previous__/docker` | 更新停留在 `prepared` 且旧代完整可信时,用旧控制脚本恢复事务。 |
|
||||
| 兜底 | `/usr/local/lib/moviepilot/control` | 镜像构建时固化的控制脚本,不随 `/app` 更新。 |
|
||||
|
||||
### 3.2 信任检查
|
||||
|
||||
`source_bundle_is_trusted()` 对源码控制目录执行以下检查:
|
||||
|
||||
1. 目录及其上级路径不能是符号链接。
|
||||
2. 目录及其上级路径必须归 root 所有,且 group/other 不可写。
|
||||
3. 目录下所有 `.sh` 都必须是普通文件,不能是符号链接。
|
||||
4. 必须至少包含 `entrypoint.sh`、`update.sh`、`browser.sh`、`cert.sh`。
|
||||
5. 所有脚本必须通过 `bash -n`。
|
||||
6. 按文件名排序后计算每个脚本的 SHA-256,再计算整组 manifest 的 SHA-256 作为代际编号。
|
||||
|
||||
通过检查的脚本会复制到:
|
||||
|
||||
```text
|
||||
/run/moviepilot/control/<generation>/
|
||||
```
|
||||
|
||||
快照目录权限为 `0700`,脚本权限为 `0500`。launcher 导出 `MP_CONTROL_DIR` 和
|
||||
`MP_CONTROL_GENERATION` 后,`exec` 快照中的 `entrypoint.sh`。
|
||||
|
||||
如果源码脚本不可信或快照失败,会回退镜像内置脚本;镜像内置脚本自身也必须通过完整性和语法检查,
|
||||
否则容器拒绝启动。
|
||||
|
||||
### 3.3 更新恢复时的代际选择
|
||||
|
||||
`/config/temp/__update_pending__` 的值会影响 launcher:
|
||||
|
||||
| 状态 | 控制脚本选择原则 |
|
||||
| --- | --- |
|
||||
| `prepared` | 优先使用可信的旧代 `/app.__update_previous__/docker`;旧代不存在时继续检查当前源码。 |
|
||||
| `dependencies` / `blocked` | 不使用旧代控制脚本,避免数据库或当前载荷已经前进后执行缺少新迁移的旧逻辑。 |
|
||||
| `committed` 或无标记 | 使用当前可信源码,失败时回退镜像内置版本。 |
|
||||
|
||||
## 4. 配置加载和 Nginx 渲染
|
||||
|
||||
### 4.1 基础环境
|
||||
|
||||
业务 `entrypoint.sh` 首先设置:
|
||||
|
||||
- `VENV_PATH` 默认 `/opt/venv`,并前置到 `PATH`;
|
||||
- `UV_BIN` 默认 `/usr/local/bin/uv`;
|
||||
- `CONFIG_DIR` 默认 `/config`;
|
||||
- `PACKAGE_CACHE_ROOT` 默认 `/config/.cache`;
|
||||
- `UV_CACHE_DIR` 默认 `/config/.cache/uv`。
|
||||
|
||||
### 4.2 `app.env` 优先级
|
||||
|
||||
脚本解析 `${CONFIG_DIR}/app.env`,配置优先级为:
|
||||
|
||||
```text
|
||||
容器进程环境变量(包括显式空字符串)
|
||||
> /config/app.env
|
||||
> 脚本内置默认值
|
||||
```
|
||||
|
||||
普通变量只加载脚本白名单中的启动配置;`ACME_ENV_*` 变量额外透传给证书流程。`app.env` 使用 Python
|
||||
端 `set_key(..., quote_mode="always")` 对应的单引号格式,未按规范包裹的值只按字面量解析并输出告警。
|
||||
|
||||
### 4.3 Nginx 配置
|
||||
|
||||
`render_nginx_config()` 使用一次 `envsubst` 生成 `/etc/nginx/nginx.conf`:
|
||||
|
||||
- HTTP 默认监听 `NGINX_PORT=3000`;
|
||||
- 后端 upstream 默认指向 `127.0.0.1:3001`;
|
||||
- `ENABLE_SSL=true` 时追加 HTTPS server,证书固定读取 `/config/certs/latest/`;
|
||||
- `/` 服务 `/public` 下的前端文件;
|
||||
- `/api`、SSE 和 CookieCloud 路径转发到后端;
|
||||
- Service Worker、Manifest、普通静态资源分别使用适合更新语义的缓存策略。
|
||||
|
||||
## 5. 启动前更新处理
|
||||
|
||||
`entrypoint.sh` 在 `/` 目录 source 当前控制快照中的 `update.sh`。脚本只定义函数和初始化更新状态,
|
||||
随后由 entrypoint 按顺序调用恢复与更新入口。
|
||||
|
||||
### 5.1 一次性 Dev 更新
|
||||
|
||||
如果存在:
|
||||
|
||||
```text
|
||||
/config/temp/moviepilot.pending_dev_update
|
||||
```
|
||||
|
||||
entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_AUTO_UPDATE` 临时设为 `dev`。更新阶段结束后
|
||||
恢复原值,避免把一次性操作变成永久自动更新。
|
||||
|
||||
### 5.2 未完成更新恢复
|
||||
|
||||
更新事务使用以下目录:
|
||||
|
||||
```text
|
||||
/app.__update_previous__
|
||||
/public.__update_previous__
|
||||
/config/temp/__update_pending__
|
||||
```
|
||||
|
||||
状态含义与启动处理:
|
||||
|
||||
| 状态 | 含义 | 下次启动处理 |
|
||||
| --- | --- | --- |
|
||||
| `prepared` | 新载荷已准备,事务开始,但尚未确认依赖阶段或提交。 | 恢复旧载荷并清理事务;没有备份时按当前完整载荷结束未完成事务。 |
|
||||
| `dependencies` | 正在同步新清单对应的共享虚拟环境。 | 当前 `/app`、`/public` 完整时保留当前载荷,改记 `blocked`,交给启动前依赖自愈;载荷不完整时恢复旧备份。 |
|
||||
| `blocked` | 已决定保留当前载荷,等待依赖自愈。 | 继续保留当前载荷;依赖自愈成功后清理旧备份和事务标记。 |
|
||||
| `committed` | 新载荷已切换完成,只剩旧备份清理。 | 清理旧备份并删除事务标记。 |
|
||||
|
||||
依赖阶段不能盲目回退旧 `/app`:数据库可能已经由当前程序迁移到新 revision,旧程序未必包含对应
|
||||
Alembic migration。保留当前载荷并恢复其依赖,可以避免形成“新数据库 + 旧源码”的不可启动组合。
|
||||
|
||||
### 5.3 已准备的 Release/资源更新
|
||||
|
||||
稳定版更新不在容器启动时联网检查 GitHub Release,也不在 Shell 中比较版本号。后台更新服务会提前
|
||||
下载并校验制品,用户确认重启后生成:
|
||||
|
||||
```text
|
||||
/config/temp/moviepilot-update/prepared.json
|
||||
/config/temp/moviepilot-update/install.json
|
||||
/config/temp/moviepilot-update/state.json
|
||||
```
|
||||
|
||||
启动时 `install.json` 优先于 `MOVIEPILOT_AUTO_UPDATE`。处理顺序:
|
||||
|
||||
1. 识别 `application`、`resources` 目标。
|
||||
2. 校验后端、前端和资源文件存在且 SHA-256 一致。
|
||||
3. 应用更新时解压后端和前端到临时目录,保留当前插件运行目录和 V3 站点资源。
|
||||
4. 同时包含资源目标时,把已准备资源写入新后端的 `app/application/site/`。
|
||||
5. 只有资源目标时,使用临时目录和备份目录原子替换当前资源文件。
|
||||
6. 安装成功后逐项消费 `prepared.json`,删除 `install.json`;失败时写入 `state.json` 并保留可重试状态。
|
||||
|
||||
### 5.4 Dev 自动更新
|
||||
|
||||
仅当 `MOVIEPILOT_AUTO_UPDATE=dev` 时,启动脚本会联网获取 `v3` 分支源码和最新 V3 前端 Release。
|
||||
GitHub 访问按 `GITHUB_PROXY`、`PROXY_HOST`、直连顺序选择;包索引按 `PIP_PROXY`、`PROXY_HOST`、
|
||||
直连顺序选择。
|
||||
|
||||
后端或前端下载失败时不替换当前载荷。资源下载失败时优先复用当前资源;没有可用旧资源才终止更新。
|
||||
|
||||
### 5.5 载荷切换事务
|
||||
|
||||
应用更新的提交顺序为:
|
||||
|
||||
1. 下载、解压并验证后端和前端。
|
||||
2. 暂存插件和站点资源。
|
||||
3. 写入 `prepared`。
|
||||
4. 依赖清单变化时写入 `dependencies`,再同步临时后端声明的依赖。
|
||||
5. 把当前 `/app`、`/public` 移到旧代备份。
|
||||
6. 把临时 `App`、`dist` 移到 `/app`、`/public`。
|
||||
7. 写入 `committed`。
|
||||
8. 删除旧代备份和事务标记。
|
||||
|
||||
依赖同步固定使用当前虚拟环境解释器,并执行等价于:
|
||||
|
||||
```text
|
||||
uv sync --project <project> --locked --inexact --no-dev --no-install-project \
|
||||
--python /opt/venv/bin/python3 --no-default-groups --group <runtime-profile>
|
||||
```
|
||||
|
||||
`runtime-profile` 由 `app.runtime.dependencies.profile` 根据标准 CPython 或 free-threaded CPython 选择。
|
||||
回滚历史载荷时仍保留旧 `app/runtime/dependencies.py` 选择器兼容,防止旧快照无法恢复依赖。
|
||||
|
||||
### 5.6 控制脚本更新后重入
|
||||
|
||||
应用更新成功后,entrypoint 通过根目录 launcher 的 `--source-generation` 重新计算 `/app/docker` 代际。
|
||||
如果新代际与当前 `MP_CONTROL_GENERATION` 不同,会:
|
||||
|
||||
```text
|
||||
exec /entrypoint.sh --post-update-reexec
|
||||
```
|
||||
|
||||
launcher 设置“更新已完成”和“已经重入”标志,新 entrypoint 不会重复安装更新。单次启动只允许重入一次;
|
||||
如果控制脚本再次变化,会终止启动以避免无限循环。
|
||||
|
||||
## 6. 用户、依赖、浏览器和文件权限
|
||||
|
||||
### 6.1 运行用户映射
|
||||
|
||||
entrypoint 使用 `PUID`、`PGID` 修改镜像内 `moviepilot` 用户和组。后端、浏览器安装及 doctor 默认通过
|
||||
`gosu moviepilot:moviepilot` 执行;`START_NOGOSU=true` 仅用于不降权的特殊运行场景。
|
||||
|
||||
### 6.2 后端依赖自愈
|
||||
|
||||
启动后端前先执行轻量探针:
|
||||
|
||||
```text
|
||||
/opt/venv/bin/python3 -m app.doctor.dependencies
|
||||
```
|
||||
|
||||
探针检查 Web 栈、中文分词和转换等启动关键能力。成功时不会执行 `uv sync`;失败时才选择包源并对
|
||||
当前 `/app` 执行锁定依赖同步,随后再次探测。
|
||||
|
||||
包源不可用、同步失败或二次探测失败时,不再拉起后端,而是进入 Docker 诊断保活流程。
|
||||
|
||||
### 6.3 CloakBrowser 缓存
|
||||
|
||||
缓存目录选择顺序:
|
||||
|
||||
1. 显式绝对路径 `CLOAKBROWSER_CACHE_DIR`;
|
||||
2. 已可用的 `/config/.browser/cloakbrowser`;
|
||||
3. 已可用或单独挂载的历史 `${HOME}/.cloakbrowser`;
|
||||
4. 新安装默认 `/config/.browser/cloakbrowser`。
|
||||
|
||||
显式目录不能是 `/`、`CONFIG_DIR` 或 `HOME` 本身。脚本先确认目录可由 `moviepilot` 读写,再用
|
||||
`cloakbrowser.binary_info()` 判断内核是否可执行;缺失时以 `moviepilot` 用户安装,失败只告警,首次实际
|
||||
使用浏览器时仍可重试。
|
||||
|
||||
### 6.4 权限校正
|
||||
|
||||
默认启动避免递归扫描大体积浏览器缓存,只处理缓存根和直接子项。主要处理:
|
||||
|
||||
- V3 站点资源目录;
|
||||
- 插件运行目录根;
|
||||
- `HOME` 和 `CONFIG_DIR` 下除浏览器缓存外的内容;
|
||||
- 配置目录外的自定义 uv 缓存;
|
||||
- Nginx 运行和日志目录;
|
||||
- `/etc/hosts` 与 `/tmp`。
|
||||
|
||||
`MOVIEPILOT_FORCE_CHOWN=true` 才会递归修复 `/app`、`/public` 和浏览器缓存。修复 `/app` 时会保留
|
||||
`/app/docker` 为 root 所有且不可由 group/other 写入,保证下次 launcher 仍可信任源码控制脚本。
|
||||
|
||||
## 7. 证书与前置服务
|
||||
|
||||
### 7.1 证书处理
|
||||
|
||||
`cert.sh` 临时启用 `errexit` 和 `pipefail`,结束后恢复调用方原有 Shell 选项。
|
||||
|
||||
| 配置 | 行为 |
|
||||
| --- | --- |
|
||||
| `ENABLE_SSL=false` | 跳过证书处理。 |
|
||||
| `ENABLE_SSL=true`、`AUTO_ISSUE_CERT=false` | 要求 `/config/certs/latest/fullchain.pem` 和 `privkey.pem` 已存在。 |
|
||||
| `ENABLE_SSL=true`、`AUTO_ISSUE_CERT=true` | 要求 `SSL_DOMAIN` 和 `DNS_PROVIDER`;按需安装 acme.sh、加载 `ACME_ENV_*`、签发并安装证书。 |
|
||||
|
||||
自动签发证书保存到 `/config/certs/<domain>/`,并维护 `/config/certs/latest` 符号链接。存在 `cron` 时,
|
||||
脚本写入 `/etc/cron.d/acme`,每天 03:00 执行续期检查。续期任务配置失败不会阻断已有证书启动。
|
||||
|
||||
### 7.2 Nginx 和 Docker Proxy
|
||||
|
||||
证书检查完成后启动主 Nginx。若 `/var/run/docker.sock` 是 Unix Socket,再使用独立配置启动 root Nginx,
|
||||
监听 `127.0.0.1:38379` 代理 Docker API,之后重新修正 Nginx 目录权限。
|
||||
|
||||
## 8. Python 后端启动
|
||||
|
||||
entrypoint 设置 `UMASK` 后,以后台进程启动:
|
||||
|
||||
```text
|
||||
gosu moviepilot:moviepilot /opt/venv/bin/python3 app/main.py
|
||||
```
|
||||
|
||||
`app/main.py` 的主要步骤:
|
||||
|
||||
1. 修正直接执行脚本时的 `sys.path`,只让项目根目录作为应用导入入口。
|
||||
2. 设置进程标题并读取运行配置。
|
||||
3. 校验进程拓扑;全功能模式要求单 worker,安全模式允许降级拓扑。
|
||||
4. 注册 SIGINT/SIGTERM 处理器。
|
||||
5. 生产单进程模式创建 `MoviePilotServer` 并运行 Uvicorn。
|
||||
6. FastAPI 应用通过 `app.factory.create_app()` 安装公开健康探针、中间件、异常处理和 lifespan。
|
||||
|
||||
### 8.1 FastAPI lifespan 启动顺序
|
||||
|
||||
生命周期组件按 `start_order` 串行启动:
|
||||
|
||||
| 顺序 | 组件 | 关键处理 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | 文件日志 | 创建本次 lifespan 独占的文件日志 writer。 |
|
||||
| 5 | 后台任务登记器 | 建立统一的后台任务所有权和关停入口。 |
|
||||
| 10 | 数据库准备 | 校验迁移 lineage,按需备份,执行建表/Alembic upgrade,再确认唯一 head。 |
|
||||
| 20 | HTTP 基础能力 | 配置默认 User-Agent 和共享异步传输。 |
|
||||
| 25 | 站点访问端口 | 注入站点访问所需的网络、浏览器和 OCR 适配。 |
|
||||
| 26 | Chain 外部端口 | 注入 Chain 使用的外部系统能力。 |
|
||||
| 27 | Chain 网络端口 | 注入同步网络和系统适配。 |
|
||||
| 30 | 领域依赖装配 | 注入识别规则、DNS、系统和 Rust 能力。 |
|
||||
| 40 | 数据库引擎预热 | 物化同步与异步数据库引擎。 |
|
||||
| 50 | 数据库连接预算 | 校验当前部署的连接池预算。 |
|
||||
| 60 | 路由 | 注册主程序 API 路由。 |
|
||||
| 65 | 插件运行时装配 | 构造插件市场和插件运行时依赖。 |
|
||||
| 70 | 模块服务 | 构造唯一 HostRuntime、数据库 worker、配置、Agent/Chain/资源/事件等核心服务。 |
|
||||
| 72 | 插件服务装配 | 发布插件安装、目录和动态路由服务。 |
|
||||
| 75 | 消息队列 | 创建共享消息队列。 |
|
||||
| 80 | 插件备份恢复 | 普通模式下恢复启动前插件备份。 |
|
||||
| 90 | 插件 | 普通模式下加载插件。 |
|
||||
| 100 | 定时器 | 普通模式下启动 Scheduler 和 Agent 定时任务。 |
|
||||
| 110 | 监控器 | 普通模式下启动目录监控。 |
|
||||
| 120 | 待处理整理回放 | 普通模式下恢复未完成整理任务。 |
|
||||
| 130 | 命令服务 | 普通模式下注册命令入口。 |
|
||||
| 140 | 工作流 | 普通模式下启动工作流后台服务。 |
|
||||
| 150 | 插件同步与启动收尾 | 登记插件同步收尾任务,由 TaskRegistry 统一管理。 |
|
||||
|
||||
其中“模块服务”内部还会组装当前 lifespan 唯一的配置、数据库、认证、运行时、Agent、Chain、Outbox、
|
||||
Transfer、Workflow 和 MoviePilot Server 服务,注册站点资源版本读取器、事件处理器和模块管理器。
|
||||
|
||||
`MOVIEPILOT_SAFE_MODE=true` 时跳过标记为 `NORMAL_ONLY` 的插件、调度器、监控、命令和工作流等组件,
|
||||
但数据库、路由、核心模块服务和后台诊断入口仍会启动。
|
||||
|
||||
### 8.2 数据库就绪边界
|
||||
|
||||
数据库准备先读取当前 revision 和代码唯一 head:
|
||||
|
||||
- revision 不可识别、存在多个 current/head、或 current 不是目标 head 的祖先时直接失败;
|
||||
- 已标记的旧数据库需要迁移时,先 Alembic upgrade,再按当前元数据补表;
|
||||
- 新库或未标记旧库先建表,再执行 Alembic;
|
||||
- 配置允许时,迁移前创建数据库备份;
|
||||
- 迁移结束后再次验证数据库已经到达当前唯一 head。
|
||||
|
||||
只有该阶段完成,应用健康状态才标记 `database_ready`。
|
||||
|
||||
### 8.3 readiness
|
||||
|
||||
所有启用的 fail-fast 启动组件成功后,lifespan 才把应用标记为 `ready`。
|
||||
|
||||
entrypoint 同时在后台每秒请求:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:${PORT}/health/ready
|
||||
```
|
||||
|
||||
成功后输出容器总启动耗时和后端就绪耗时。默认等待 300 秒,可通过
|
||||
`MOVIEPILOT_BACKEND_READY_TIMEOUT` 调整。该等待任务只负责日志,不替代 Docker 健康状态。
|
||||
|
||||
Dockerfile 的 `HEALTHCHECK` 每 30 秒请求同一地址:数据库迁移和完整 lifespan 成功后返回 200;启动、
|
||||
失败或关停阶段返回 503。`/health/live` 只表示进程和事件循环仍可响应。
|
||||
|
||||
## 9. 异常、重启和退出
|
||||
|
||||
### 9.1 信号退出
|
||||
|
||||
entrypoint 捕获 SIGINT/SIGTERM 后按顺序:
|
||||
|
||||
1. 停止前端 Nginx;
|
||||
2. 等待 Python 完成 lifespan 逆序关停;
|
||||
3. 停止 Docker Proxy Nginx;
|
||||
4. 使用原退出码退出容器。
|
||||
|
||||
Python lifespan 会先撤销 readiness,再按组件声明的 `stop_order` 停止工作流、命令、插件、事件、Agent、
|
||||
整理任务、模块服务、数据库和日志等资源。启动中途失败时,只清理已经启动或正在启动的组件。
|
||||
|
||||
### 9.2 应用内重启
|
||||
|
||||
应用请求重启时写入:
|
||||
|
||||
```text
|
||||
/config/temp/moviepilot.intentional_restart
|
||||
```
|
||||
|
||||
Python 退出后,entrypoint 删除标记,并确保容器以非零状态退出,把真正的重新创建/重启交给 Docker
|
||||
restart policy。entrypoint 本身不在容器内递归拉起第二个 Python 主进程。
|
||||
|
||||
### 9.3 异常诊断保活
|
||||
|
||||
镜像默认:
|
||||
|
||||
```text
|
||||
MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=true
|
||||
```
|
||||
|
||||
后端非预期退出、依赖恢复失败或更新回滚无法完成时,entrypoint 会运行一次 `moviepilot doctor`,然后
|
||||
通过长时间 sleep 保持容器存活,便于执行:
|
||||
|
||||
```shell
|
||||
docker exec -it <container> moviepilot doctor
|
||||
```
|
||||
|
||||
设置 `MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false` 可恢复异常后直接退出容器的行为。保活只保留诊断
|
||||
入口,不代表服务健康;Docker `HEALTHCHECK` 仍会保持失败。
|
||||
|
||||
## 10. 关键运行文件
|
||||
|
||||
| 路径 | 作用 |
|
||||
| --- | --- |
|
||||
| `/entrypoint.sh` | 镜像内置 launcher。 |
|
||||
| `/usr/local/lib/moviepilot/control/` | 镜像构建时固化的控制脚本。 |
|
||||
| `/run/moviepilot/control/<generation>/` | 本轮启动执行的只读控制快照。 |
|
||||
| `/app` | 当前后端源码和插件运行目录。 |
|
||||
| `/public` | 当前前端静态文件。 |
|
||||
| `/config/app.env` | Docker 启动脚本和应用配置。 |
|
||||
| `/config/.cache/uv` | 默认 uv 持久缓存。 |
|
||||
| `/config/.browser/cloakbrowser` | 默认 CloakBrowser 持久缓存。 |
|
||||
| `/config/temp/__update_pending__` | 载荷切换事务状态。 |
|
||||
| `/app.__update_previous__` | 更新前后端备份。 |
|
||||
| `/public.__update_previous__` | 更新前前端备份。 |
|
||||
| `/config/temp/moviepilot-update/` | 后台下载的 Release/资源包及安装状态。 |
|
||||
| `/config/temp/moviepilot.pending_dev_update` | 单次 Dev 更新请求。 |
|
||||
| `/config/temp/moviepilot.intentional_restart` | 应用内重启请求。 |
|
||||
| `/config/certs/latest/` | Nginx 使用的稳定证书路径。 |
|
||||
|
||||
## 11. 关键环境变量
|
||||
|
||||
| 变量 | 默认值 | 影响 |
|
||||
| --- | --- | --- |
|
||||
| `CONFIG_DIR` | `/config` | 配置、缓存、更新状态和证书根目录。 |
|
||||
| `PUID` / `PGID` | `0` / `0` | 映射 `moviepilot` 运行用户。 |
|
||||
| `UMASK` | `000` | 后端进程文件权限掩码。 |
|
||||
| `PORT` | `3001` | 后端监听和 readiness 端口。 |
|
||||
| `NGINX_PORT` | `3000` | HTTP 前端入口。 |
|
||||
| `MOVIEPILOT_AUTO_UPDATE` | `false` | 只有 `dev` 会触发启动时分支更新;稳定版使用准备清单。 |
|
||||
| `MOVIEPILOT_SAFE_MODE` | `false` | 跳过普通模式专属的插件及后台控制面。 |
|
||||
| `MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE` | `true` | 后端异常后是否保留容器供 doctor 诊断。 |
|
||||
| `MOVIEPILOT_FORCE_CHOWN` | `false` | 是否执行大范围递归权限修复。 |
|
||||
| `MOVIEPILOT_BACKEND_READY_TIMEOUT` | `300` | entrypoint readiness 日志等待秒数。 |
|
||||
| `PACKAGE_CACHE_ROOT` | `/config/.cache` | 包管理缓存根目录。 |
|
||||
| `UV_CACHE_DIR` | `/config/.cache/uv` | uv 缓存目录。 |
|
||||
| `PIP_PROXY` | 空 | Python 包索引镜像。 |
|
||||
| `GITHUB_PROXY` | 空 | GitHub 下载 URL 前缀。 |
|
||||
| `PROXY_HOST` | 空 | GitHub/包命令使用的 HTTP(S) 代理。 |
|
||||
| `GITHUB_TOKEN` | 空 | Dev 更新访问 GitHub 时的令牌。 |
|
||||
| `CLOAKBROWSER_CACHE_DIR` | 自动选择 | 浏览器内核缓存位置。 |
|
||||
| `ENABLE_SSL` | `false` | 是否渲染并启用 HTTPS server。 |
|
||||
| `AUTO_ISSUE_CERT` | `false` | 是否自动签发证书。 |
|
||||
| `SSL_DOMAIN` / `SSL_EMAIL` / `DNS_PROVIDER` | 空 | acme.sh 签发参数。 |
|
||||
|
||||
## 12. 维护约束
|
||||
|
||||
后续修改 Docker 启动流程时应保持以下边界:
|
||||
|
||||
1. 控制脚本必须按完整代际执行,不能在同一次启动中直接混用更新前后的 `/app/docker/*.sh`。
|
||||
2. Release 更新由后台下载和用户确认驱动;启动脚本只消费已校验清单,不恢复启动时 GitHub Release 查询和 Shell 版本比较。
|
||||
3. 更新载荷与共享虚拟环境必须作为一个可恢复事务处理,不能留下新源码配旧依赖或旧源码配新数据库的混合状态。
|
||||
4. 标准 V3 与 V3t 依赖恢复必须复用 `app.runtime.dependencies.profile`,不能使用默认组覆盖当前 ABI profile。
|
||||
5. 站点资源只安装到 `app/application/site/`;历史目录仅用于更新旧载荷时读取兼容资源。
|
||||
6. readiness 只能在数据库 head 校验和完整 lifespan 成功后发布,不能退化为进程存在或普通接口可访问。
|
||||
7. 后端异常保活是诊断机制,不是成功状态;外部编排必须继续以 `/health/ready` 判断可接流量。
|
||||
@@ -958,9 +958,17 @@ def test_prepared_resource_update_is_applied_without_backend_or_release_lookup(t
|
||||
def test_release_mode_no_longer_checks_or_installs_during_restart(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Release 模式只能消费准备清单,不得保留启动时查版本的旧实现。"""
|
||||
updater = UPDATER.read_text(encoding="utf-8")
|
||||
for retired_function in (
|
||||
"fetch_latest_v3_release",
|
||||
"compare_versions",
|
||||
"get_priority",
|
||||
):
|
||||
assert f"function {retired_function}()" not in updater
|
||||
|
||||
package_probe = tmp_path / "package-probe"
|
||||
curl_log = tmp_path / "curl.log"
|
||||
comparison_log = tmp_path / "comparison.log"
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
@@ -968,28 +976,12 @@ def test_release_mode_no_longer_checks_or_installs_during_restart(
|
||||
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
|
||||
PACKAGE_PROBE="$2"
|
||||
CURL_LOG="$3"
|
||||
COMPARISON_LOG="$4"
|
||||
source {UPDATER!s}
|
||||
INFO() {{ :; }}
|
||||
WARN() {{ :; }}
|
||||
ERROR() {{ :; }}
|
||||
test_connectivity_package() {{ touch "${{PACKAGE_PROBE}}"; return 0; }}
|
||||
test_connectivity_github() {{ CURL_OPTIONS=-sL; GITHUB_LOG=test; return 0; }}
|
||||
compare_versions() {{ printf '%s|%s\n' "$1" "$2" > "${{COMPARISON_LOG}}"; return 1; }}
|
||||
grep() {{
|
||||
if [[ "$*" == *"/app/version.py"* ]]; then
|
||||
printf '%s\n' "APP_VERSION = 'v3.0.0'"
|
||||
return 0
|
||||
fi
|
||||
command grep "$@"
|
||||
}}
|
||||
sed() {{
|
||||
if [[ "$*" == *"APP_VERSION"* ]]; then
|
||||
printf '%s\n' 'v3.0.0'
|
||||
return 0
|
||||
fi
|
||||
command sed "$@"
|
||||
}}
|
||||
curl() {{
|
||||
printf '%s\n' "$*" >> "${{CURL_LOG}}"
|
||||
printf '%s\n' '[{{"tag_name":"v2.15.6"}},{{"tag_name":"v3.1.0-beta"}},{{"tag_name":"v3.1.0-rc"}},{{"tag_name":"v3.0.0"}}]'
|
||||
@@ -1008,7 +1000,6 @@ def test_release_mode_no_longer_checks_or_installs_during_restart(
|
||||
str(tmp_path / "config"),
|
||||
str(package_probe),
|
||||
str(curl_log),
|
||||
str(comparison_log),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
@@ -1018,7 +1009,13 @@ def test_release_mode_no_longer_checks_or_installs_during_restart(
|
||||
assert result.stdout == "noop\n"
|
||||
assert not package_probe.exists()
|
||||
assert not curl_log.exists()
|
||||
assert not comparison_log.exists()
|
||||
|
||||
|
||||
def test_entrypoint_does_not_keep_retired_package_command_wrapper() -> None:
|
||||
"""依赖恢复统一走 updater 的受控路由后,不再保留旧代理包装器。"""
|
||||
entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "function run_package_command()" not in entrypoint
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
Reference in New Issue
Block a user