mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
refactor: manage Docker processes with supervisor
This commit is contained in:
@@ -1111,9 +1111,9 @@ def _check_docker(runner: DoctorRunnerProtocol) -> None:
|
||||
title="Docker 诊断入口可用",
|
||||
detail=(
|
||||
f"CONFIG_DIR={get_runtime_setting('CONFIG_PATH')};VENV_PATH={os.getenv('VENV_PATH', '/opt/venv')};"
|
||||
f"MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE={os.getenv('MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE', 'true')}"
|
||||
"supervisor 托管 Nginx 和后端进程"
|
||||
),
|
||||
recommendation="主进程异常退出后容器会保活,仍可通过 `docker exec <container> moviepilot doctor` 诊断。",
|
||||
recommendation="后端异常由 supervisor 自动拉起;启动失败时可通过 `docker exec <container> moviepilot doctor` 诊断。",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -677,9 +677,6 @@ class ConfigModel(BaseModel):
|
||||
# 对阿里云盘进行快照对比时,是否检查文件夹的修改时间(默认关闭,因为阿里云盘目录时间不随子文件变更而更新)
|
||||
ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = False
|
||||
|
||||
# ==================== Docker配置 ====================
|
||||
# Docker Client API地址
|
||||
DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379"
|
||||
# Playwright浏览器类型,供智能体浏览器工具和插件直接使用 Playwright 时读取
|
||||
PLAYWRIGHT_BROWSER_TYPE: str = "chromium"
|
||||
|
||||
|
||||
+48
-171
@@ -4,11 +4,9 @@ import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import docker
|
||||
import psutil
|
||||
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
@@ -40,11 +38,9 @@ class SystemHelper(ConfigReloadMixin):
|
||||
__one_shot_dev_update_flag_file = (
|
||||
get_runtime_setting('TEMP_PATH') / "moviepilot.pending_dev_update"
|
||||
)
|
||||
__docker_restart_intent_file = (
|
||||
get_runtime_setting('TEMP_PATH') / "moviepilot.intentional_restart"
|
||||
)
|
||||
__graceful_shutdown_monitor_lock = threading.Lock()
|
||||
__graceful_shutdown_monitor: Optional[threading.Thread] = None
|
||||
__supervisor_config = Path("/etc/supervisor/supervisord.conf")
|
||||
__supervisorctl = Path("/usr/bin/supervisorctl")
|
||||
__supervisor_socket = Path("/run/moviepilot/supervisor.sock")
|
||||
|
||||
def on_config_changed(self):
|
||||
"""配置变化后重新应用日志设置。"""
|
||||
@@ -56,10 +52,17 @@ class SystemHelper(ConfigReloadMixin):
|
||||
|
||||
@staticmethod
|
||||
def can_restart() -> bool:
|
||||
"""
|
||||
判断是否可以内部重启
|
||||
"""
|
||||
return is_docker() or SystemHelper._is_local_cli_managed() or (is_windows() and not is_frozen())
|
||||
"""判断当前部署是否具备宿主无关的进程重启能力。"""
|
||||
return (
|
||||
(
|
||||
is_docker()
|
||||
and SystemHelper.__supervisor_config.exists()
|
||||
and SystemHelper.__supervisorctl.exists()
|
||||
and SystemHelper.__supervisor_socket.exists()
|
||||
)
|
||||
or SystemHelper._is_local_cli_managed()
|
||||
or (is_windows() and not is_frozen())
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_runtime_file(path: Path) -> Optional[dict]:
|
||||
@@ -170,81 +173,31 @@ class SystemHelper(ConfigReloadMixin):
|
||||
logger.info(f"已创建本地 CLI 重启任务,辅助进程 PID: {process.pid}")
|
||||
|
||||
@staticmethod
|
||||
def _get_container_id() -> str:
|
||||
"""
|
||||
获取当前容器ID
|
||||
"""
|
||||
container_id = None
|
||||
try:
|
||||
with open("/proc/self/mountinfo", "r", encoding="utf-8", errors="replace") as f:
|
||||
data = f.read()
|
||||
index_resolv_conf = data.find("resolv.conf")
|
||||
if index_resolv_conf != -1:
|
||||
index_second_slash = data.rfind("/", 0, index_resolv_conf)
|
||||
index_first_slash = data.rfind("/", 0, index_second_slash) + 1
|
||||
container_id = data[index_first_slash:index_second_slash]
|
||||
if len(container_id) < 20:
|
||||
index_resolv_conf = data.find("/sys/fs/cgroup/devices")
|
||||
if index_resolv_conf != -1:
|
||||
index_second_slash = data.rfind(" ", 0, index_resolv_conf)
|
||||
index_first_slash = (
|
||||
data.rfind("/", 0, index_second_slash) + 1
|
||||
)
|
||||
container_id = data[index_first_slash:index_second_slash]
|
||||
except Exception as e:
|
||||
logger.debug(f"获取容器ID失败: {str(e)}")
|
||||
return container_id.strip() if container_id else None
|
||||
def _schedule_supervisor_restart() -> None:
|
||||
"""延迟调用本地 supervisor,确保重启接口有机会完成响应。"""
|
||||
def restart_backend() -> None:
|
||||
command = [
|
||||
str(SystemHelper.__supervisorctl),
|
||||
"-c",
|
||||
str(SystemHelper.__supervisor_config),
|
||||
"restart",
|
||||
"all",
|
||||
]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as err:
|
||||
logger.error(f"调用 supervisor 重启后端失败: {err}")
|
||||
|
||||
@staticmethod
|
||||
def _check_restart_policy() -> bool:
|
||||
"""
|
||||
检查当前容器是否配置了自动重启策略
|
||||
"""
|
||||
try:
|
||||
# 获取当前容器ID
|
||||
container_id = SystemHelper._get_container_id()
|
||||
if not container_id:
|
||||
return False
|
||||
|
||||
# 创建 Docker 客户端
|
||||
client = docker.DockerClient(
|
||||
base_url=get_runtime_setting('DOCKER_CLIENT_API')
|
||||
)
|
||||
# 获取容器信息
|
||||
container = client.containers.get(container_id)
|
||||
restart_policy = container.attrs.get('HostConfig', {}).get('RestartPolicy', {})
|
||||
policy_name = restart_policy.get('Name', 'no')
|
||||
# 检查是否有有效的重启策略
|
||||
auto_restart_policies = ['always', 'unless-stopped', 'on-failure']
|
||||
has_restart_policy = policy_name in auto_restart_policies
|
||||
|
||||
logger.info(f"容器重启策略: {policy_name}, 支持自动重启: {has_restart_policy}")
|
||||
return has_restart_policy
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"检查重启策略失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _mark_docker_intentional_restart() -> None:
|
||||
"""写入 Docker 主动重启标记,避免守护逻辑误判崩溃。"""
|
||||
try:
|
||||
SystemHelper.__docker_restart_intent_file.parent.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
SystemHelper.__docker_restart_intent_file.write_text(
|
||||
str(os.getpid()), encoding="utf-8"
|
||||
)
|
||||
except OSError as err:
|
||||
logger.warning(f"写入内置重启标记失败: {err}")
|
||||
|
||||
@staticmethod
|
||||
def _clear_docker_intentional_restart() -> None:
|
||||
"""清理 Docker 主动重启标记。"""
|
||||
try:
|
||||
SystemHelper.__docker_restart_intent_file.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
logger.warning(f"清理内置重启标记失败: {err}")
|
||||
restart_timer = threading.Timer(0.5, restart_backend)
|
||||
restart_timer.daemon = True
|
||||
restart_timer.start()
|
||||
|
||||
@staticmethod
|
||||
def _windows_restart() -> tuple[bool, str]:
|
||||
@@ -269,9 +222,7 @@ class SystemHelper(ConfigReloadMixin):
|
||||
|
||||
@staticmethod
|
||||
def restart() -> Tuple[bool, str]:
|
||||
"""
|
||||
执行Docker重启操作
|
||||
"""
|
||||
"""请求容器内 supervisor 重启受管的前后端进程。"""
|
||||
if not is_frozen() and is_windows():
|
||||
success, message = SystemHelper._windows_restart()
|
||||
return success, message
|
||||
@@ -287,28 +238,15 @@ class SystemHelper(ConfigReloadMixin):
|
||||
logger.error(f"本地 CLI 重启失败: {str(err)}")
|
||||
return False, f"本地 CLI 重启失败:{str(err)}"
|
||||
|
||||
try:
|
||||
# 检查容器是否配置了自动重启策略
|
||||
has_restart_policy = SystemHelper._check_restart_policy()
|
||||
if has_restart_policy:
|
||||
# 有重启策略,使用优雅退出方式
|
||||
logger.info("检测到容器配置了自动重启策略,使用优雅重启方式...")
|
||||
SystemHelper._mark_docker_intentional_restart()
|
||||
# 启动优雅退出超时监控
|
||||
SystemHelper._start_graceful_shutdown_monitor()
|
||||
# 发送SIGTERM信号给当前进程,触发优雅停止
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
return True, ""
|
||||
else:
|
||||
# 没有重启策略,使用Docker API强制重启
|
||||
logger.info("容器未配置自动重启策略,使用Docker API重启...")
|
||||
return SystemHelper._docker_api_restart()
|
||||
except Exception as err:
|
||||
logger.error(f"重启失败: {str(err)}")
|
||||
SystemHelper._clear_docker_intentional_restart()
|
||||
# 降级为Docker API重启
|
||||
logger.warning("降级为Docker API重启...")
|
||||
return SystemHelper._docker_api_restart()
|
||||
if not (
|
||||
SystemHelper.__supervisor_config.exists()
|
||||
and SystemHelper.__supervisorctl.exists()
|
||||
and SystemHelper.__supervisor_socket.exists()
|
||||
):
|
||||
return False, "容器内 supervisor 未安装"
|
||||
logger.info("请求容器内 supervisor 重启后端服务")
|
||||
SystemHelper._schedule_supervisor_restart()
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def upgrade_dev() -> Tuple[bool, str]:
|
||||
@@ -326,67 +264,6 @@ class SystemHelper(ConfigReloadMixin):
|
||||
return False, message
|
||||
return True, "已安排 Dev 更新并重启"
|
||||
|
||||
@staticmethod
|
||||
def _start_graceful_shutdown_monitor():
|
||||
"""
|
||||
启动唯一的优雅退出超时监控。
|
||||
|
||||
如果 180 秒内进程没有退出,则使用 Docker API 强制重启;重复重启请求
|
||||
复用当前 monitor,避免并行触发多次容器重启。
|
||||
"""
|
||||
|
||||
def monitor_thread():
|
||||
try:
|
||||
time.sleep(180)
|
||||
logger.warning("优雅退出超时180秒,使用Docker API强制重启...")
|
||||
try:
|
||||
SystemHelper._docker_api_restart()
|
||||
except Exception as e:
|
||||
logger.error(f"强制重启失败: {str(e)}")
|
||||
finally:
|
||||
with SystemHelper.__graceful_shutdown_monitor_lock:
|
||||
if (
|
||||
SystemHelper.__graceful_shutdown_monitor
|
||||
is threading.current_thread()
|
||||
):
|
||||
SystemHelper.__graceful_shutdown_monitor = None
|
||||
|
||||
with SystemHelper.__graceful_shutdown_monitor_lock:
|
||||
running = SystemHelper.__graceful_shutdown_monitor
|
||||
if running is not None and running.is_alive():
|
||||
logger.debug("优雅退出超时监控已在运行,跳过重复启动")
|
||||
return
|
||||
thread = threading.Thread(
|
||||
target=monitor_thread,
|
||||
name="MoviePilot-GracefulRestartFallback",
|
||||
daemon=True,
|
||||
)
|
||||
SystemHelper.__graceful_shutdown_monitor = thread
|
||||
try:
|
||||
thread.start()
|
||||
except BaseException:
|
||||
SystemHelper.__graceful_shutdown_monitor = None
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _docker_api_restart() -> Tuple[bool, str]:
|
||||
"""
|
||||
使用Docker API重启容器,并尝试优雅停止
|
||||
"""
|
||||
try:
|
||||
# 创建 Docker 客户端
|
||||
client = docker.DockerClient(
|
||||
base_url=get_runtime_setting('DOCKER_CLIENT_API')
|
||||
)
|
||||
container_id = SystemHelper._get_container_id()
|
||||
if not container_id:
|
||||
return False, "获取容器ID失败!"
|
||||
# 重启容器
|
||||
client.containers.get(container_id).restart()
|
||||
return True, ""
|
||||
except Exception as docker_err:
|
||||
return False, f"重启时发生错误:{str(docker_err)}"
|
||||
|
||||
def set_system_modified(self):
|
||||
"""
|
||||
设置系统已修改标志
|
||||
|
||||
+4
-2
@@ -90,6 +90,7 @@ RUN apt-get update \
|
||||
nano \
|
||||
unar \
|
||||
openssl \
|
||||
supervisor \
|
||||
postgresql-client-18 \
|
||||
libchromaprint-tools \
|
||||
libjemalloc2 \
|
||||
@@ -278,7 +279,8 @@ RUN mkdir -p \
|
||||
&& cp -f launcher.sh /bundle/rootfs/entrypoint.sh \
|
||||
&& cp -f nginx.common.conf /bundle/rootfs/etc/nginx/common.conf \
|
||||
&& cp -f nginx.template.conf /bundle/rootfs/etc/nginx/nginx.template.conf \
|
||||
&& cp -f docker_http_proxy.conf /bundle/rootfs/etc/nginx/docker_http_proxy.conf \
|
||||
&& mkdir -p /bundle/rootfs/etc/supervisor \
|
||||
&& cp -f supervisord.conf /bundle/rootfs/etc/supervisor/supervisord.conf \
|
||||
&& printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'cd /app' 'exec "${VENV_PATH:-/opt/venv}/bin/python3" -m app.cli "$@"' > /bundle/rootfs/usr/local/bin/moviepilot \
|
||||
&& bash -n /bundle/rootfs/entrypoint.sh \
|
||||
&& for control_script in /bundle/rootfs/usr/local/lib/moviepilot/control/*.sh; do bash -n "${control_script}" || exit 1; done \
|
||||
@@ -289,7 +291,7 @@ RUN mkdir -p \
|
||||
FROM prepare_package AS final
|
||||
|
||||
ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so" \
|
||||
MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE="true"
|
||||
MOVIEPILOT_DOCKER_SUPERVISOR="true"
|
||||
|
||||
# 引入支持 amr 编码的静态 ffmpeg
|
||||
COPY --from=ffmpeg /ffmpeg /ffprobe /usr/local/bin/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
umask "${UMASK:-000}"
|
||||
|
||||
if [ "${START_NOGOSU:-false}" = "true" ]; then
|
||||
exec "${VENV_PATH:-/opt/venv}/bin/python3" app/main.py
|
||||
fi
|
||||
|
||||
exec gosu moviepilot:moviepilot "${VENV_PATH:-/opt/venv}/bin/python3" app/main.py
|
||||
@@ -1,44 +0,0 @@
|
||||
worker_processes 1;
|
||||
user root;
|
||||
daemon on;
|
||||
pid /var/run/nginx_proxy.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
upstream docker {
|
||||
server unix:/var/run/docker.sock fail_timeout=0;
|
||||
}
|
||||
server {
|
||||
listen 127.0.0.1:38379;
|
||||
server_name localhost;
|
||||
|
||||
access_log /dev/stdout combined;
|
||||
error_log /dev/stdout;
|
||||
|
||||
location / {
|
||||
proxy_pass http://docker;
|
||||
proxy_redirect off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
client_max_body_size 10m;
|
||||
client_body_buffer_size 128k;
|
||||
|
||||
proxy_connect_timeout 90;
|
||||
proxy_send_timeout 120;
|
||||
proxy_read_timeout 120;
|
||||
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 4 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
proxy_temp_file_write_size 64k;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-173
@@ -20,8 +20,6 @@ function WARN() {
|
||||
echo -e "${WARN} ${1}"
|
||||
}
|
||||
|
||||
ENTRYPOINT_START_TIME="$(date +%s)"
|
||||
|
||||
function normalize_env_value() {
|
||||
printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
@@ -47,42 +45,6 @@ function apply_package_cache_env() {
|
||||
mkdir -p "${UV_CACHE_DIR}"
|
||||
}
|
||||
|
||||
function wait_backend_ready() {
|
||||
local entrypoint_start_time="${1:-$(date +%s)}"
|
||||
local backend_start_time="${2:-$(date +%s)}"
|
||||
local python_pid="${3:-}"
|
||||
local backend_port="${PORT:-3001}"
|
||||
local web_port="${NGINX_PORT:-3000}"
|
||||
local timeout="${MOVIEPILOT_BACKEND_READY_TIMEOUT:-300}"
|
||||
local ready_url="http://127.0.0.1:${backend_port}/health/ready"
|
||||
local deadline
|
||||
if ! [[ "${timeout}" =~ ^[0-9]+$ ]] || [ "$((10#${timeout}))" -le 0 ]; then
|
||||
WARN "→ MOVIEPILOT_BACKEND_READY_TIMEOUT=${timeout} 无效,使用默认 300 秒。"
|
||||
timeout=300
|
||||
else
|
||||
timeout=$((10#${timeout}))
|
||||
fi
|
||||
deadline=$(( $(date +%s) + timeout ))
|
||||
|
||||
while [ "$(date +%s)" -lt "${deadline}" ]; do
|
||||
if [ -n "${python_pid}" ] && ! kill -0 "${python_pid}" >/dev/null 2>&1; then
|
||||
WARN "→ 后端服务启动完成探测已停止:后端进程已退出。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if curl -fsS --max-time 2 "${ready_url}" >/dev/null 2>&1; then
|
||||
local now
|
||||
now="$(date +%s)"
|
||||
INFO "→ MoviePilot Web 已可访问,启动总耗时 $(( now - entrypoint_start_time )) 秒,后端就绪耗时 $(( now - backend_start_time )) 秒,后端端口 ${backend_port},前端端口 ${web_port}。"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
WARN "→ 后端服务启动完成探测超时,已等待 ${timeout} 秒,后端端口 ${backend_port},继续等待进程日志..."
|
||||
return 1
|
||||
}
|
||||
|
||||
# 环境变量补全
|
||||
# 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值
|
||||
# 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义)
|
||||
@@ -100,7 +62,6 @@ function load_config_from_app_env() {
|
||||
["PROXY_HOST"]=""
|
||||
["GITHUB_TOKEN"]=""
|
||||
["MOVIEPILOT_AUTO_UPDATE"]="false"
|
||||
["MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE"]="true"
|
||||
["MOVIEPILOT_FORCE_CHOWN"]="false"
|
||||
["MOVIEPILOT_SAFE_MODE"]="false"
|
||||
["BROWSER_EMULATION"]="cloakbrowser"
|
||||
@@ -250,77 +211,6 @@ EOF
|
||||
envsubst '${NGINX_PORT}${PORT}${NGINX_CLIENT_MAX_BODY_SIZE}${HTTPS_SERVER_CONF}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf
|
||||
}
|
||||
|
||||
# 优雅退出
|
||||
function graceful_exit() {
|
||||
local exit_code=${1:-0}
|
||||
local reason=${2:-python_exit}
|
||||
|
||||
if [ "$reason" = "signal" ]; then
|
||||
INFO "→ 收到停止信号,执行精准清理程序..."
|
||||
elif [ "$reason" = "intentional_restart" ]; then
|
||||
INFO "→ 检测到内置重启流程,执行清理程序..."
|
||||
else
|
||||
INFO "→ 主进程已退出 (代码: $exit_code),执行清理程序..."
|
||||
fi
|
||||
|
||||
# 第一步:停止前端 Nginx
|
||||
# 默认配置启动的 Nginx,默认 PID 在 /var/run/nginx.pid
|
||||
INFO "→ [1/3] 正在关闭前端 Nginx..."
|
||||
nginx -c /etc/nginx/nginx.conf -s stop 2>/dev/null || true
|
||||
|
||||
# 第二步:等待 Python 退出
|
||||
# 由于使用了 tini -g,Python 已经收到了信号,我们只需等待
|
||||
if [ -n "$PYTHON_PID" ] && ps -p "$PYTHON_PID" > /dev/null; then
|
||||
INFO "→ [2/3] 正在等待 Python (PID: $PYTHON_PID) 完成清理..."
|
||||
# 这里的 wait 会阻塞,直到 Python 真正退出
|
||||
wait "$PYTHON_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 第三步:最后关闭 Docker Proxy
|
||||
# 必须指定配置文件路径,否则 nginx -s stop 找不到它
|
||||
INFO "→ [3/3] 后端已安全退出,正在关闭 Docker Proxy..."
|
||||
if [ -S "/var/run/docker.sock" ]; then
|
||||
nginx -c /etc/nginx/docker_http_proxy.conf -s stop 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 根据退出码判断最终日志性质
|
||||
# 0: 正常退出
|
||||
# 130/143: 被系统信号终止(通常也视为预期的清理退出)
|
||||
if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ] || [ "$reason" = "intentional_restart" ]; then
|
||||
INFO "→ 所有服务已按序清理,容器正常退出 (ExitCode: $exit_code)。"
|
||||
else
|
||||
# 非预期退出码,使用 ERROR 级别并加重提示
|
||||
ERROR "→ 清理完成,但主进程检测到异常退出 (ExitCode: $exit_code)!"
|
||||
fi
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
# 后端异常退出时默认保留容器,避免无法 docker exec 进入容器运行 doctor。
|
||||
function diagnostic_keepalive() {
|
||||
local exit_code=${1:-1}
|
||||
local keepalive
|
||||
keepalive="$(normalize_env_value "${MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE:-true}")"
|
||||
|
||||
if [ "${keepalive}" = "false" ] || [ "${keepalive}" = "0" ] || [ "${keepalive}" = "no" ]; then
|
||||
graceful_exit "$exit_code" "python_exit"
|
||||
fi
|
||||
|
||||
ERROR "→ 后端主进程异常退出 (ExitCode: ${exit_code}),容器将保持运行以便执行 moviepilot doctor。"
|
||||
WARN "→ 可运行:docker exec <container> moviepilot doctor"
|
||||
WARN "→ 如需恢复旧行为,可设置 MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false。"
|
||||
|
||||
if [ "${START_NOGOSU:-false}" = "true" ]; then
|
||||
"${VENV_PATH}/bin/python3" -m app.cli doctor || true
|
||||
else
|
||||
gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" -m app.cli doctor || true
|
||||
fi
|
||||
|
||||
while true; do
|
||||
sleep 3600 &
|
||||
wait $! || true
|
||||
done
|
||||
}
|
||||
|
||||
# 启动前先检查后端核心依赖是否仍然可导入。
|
||||
# 插件依赖和主程序共用同一套 venv 时,历史安装记录可能已经污染环境,
|
||||
# 这里优先在真正拉起后端前做一次自愈,避免容器反复起不来。
|
||||
@@ -336,18 +226,18 @@ function ensure_backend_runtime_dependencies() {
|
||||
WARN "→ 检测到后端核心依赖异常,开始尝试恢复主程序依赖..."
|
||||
if ! configure_package_route; then
|
||||
ERROR "→ 无法选择可用的主程序依赖源,后端无法启动。"
|
||||
diagnostic_keepalive 1
|
||||
exit 1
|
||||
fi
|
||||
PACKAGE_ROUTE_READY="true"
|
||||
INFO "依赖源:${PACKAGE_LOG}"
|
||||
if ! sync_project_dependencies_for "/app" > /dev/stdout 2> /dev/stderr; then
|
||||
ERROR "→ 自动恢复主程序依赖失败,后端无法启动。"
|
||||
diagnostic_keepalive 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! "${VENV_PATH}/bin/python3" -m "${probe_module}" >/dev/null 2>&1; then
|
||||
ERROR "→ 主程序依赖恢复后仍然异常,后端无法启动。"
|
||||
diagnostic_keepalive 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INFO "→ 已自动恢复主程序依赖,继续启动后端。"
|
||||
@@ -551,8 +441,8 @@ cd /
|
||||
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"
|
||||
if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]; then
|
||||
if ! recover_pending_update; then
|
||||
ERROR "→ 上一次容器更新未能恢复,容器将保持运行以便执行 moviepilot doctor。"
|
||||
diagnostic_keepalive 1
|
||||
ERROR "→ 上一次容器更新未能恢复,停止启动。"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${UPDATE_RECOVERY_COMPLETED:-false}" = "true" ]; then
|
||||
INFO "→ 已恢复到更新前版本,本次启动跳过自动更新。"
|
||||
@@ -567,8 +457,8 @@ if [ "${ONE_SHOT_DEV_UPDATE}" = "true" ]; then
|
||||
MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}"
|
||||
fi
|
||||
if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then
|
||||
ERROR "→ 容器更新回滚未完成,容器将保持运行以便执行 moviepilot doctor。"
|
||||
diagnostic_keepalive 1
|
||||
ERROR "→ 容器更新回滚未完成,停止启动。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
maybe_reexec_control_bundle
|
||||
@@ -581,7 +471,7 @@ groupmod -o -g "${PGID}" moviepilot
|
||||
usermod -o -u "${PUID}" moviepilot
|
||||
|
||||
# 启动前优先确认主运行环境仍然健康,避免插件依赖污染导致服务直接起不来。
|
||||
ensure_backend_runtime_dependencies
|
||||
ensure_backend_runtime_dependencies || exit 1
|
||||
|
||||
# 依赖阶段恢复会保留当前程序,待自愈成功后再清理旧代际备份和事务标记。
|
||||
if [ "${UPDATE_RECOVERY_BLOCKED:-false}" = "true" ]; then
|
||||
@@ -608,58 +498,7 @@ ensure_browser_kernel
|
||||
# 证书管理
|
||||
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/cert.sh"
|
||||
|
||||
# 启动前端nginx服务
|
||||
INFO "→ 启动前端nginx服务..."
|
||||
nginx
|
||||
|
||||
# 捕获信号并跳转到函数
|
||||
trap 'graceful_exit 130 "signal"' SIGINT
|
||||
trap 'graceful_exit 143 "signal"' SIGTERM
|
||||
|
||||
# 启动docker http proxy nginx
|
||||
if [ -S "/var/run/docker.sock" ]; then
|
||||
INFO "→ 启动 Docker Proxy..."
|
||||
nginx -c /etc/nginx/docker_http_proxy.conf
|
||||
# 上面nginx是通过root启动的,会将目录权限改成root,所以需要重新再设置一遍权限
|
||||
chown -R moviepilot:moviepilot \
|
||||
/var/lib/nginx \
|
||||
/var/log/nginx
|
||||
fi
|
||||
|
||||
# 设置后端服务权限掩码
|
||||
umask "${UMASK}"
|
||||
|
||||
# 启动后端服务
|
||||
INFO "→ 启动后端服务..."
|
||||
BACKEND_START_TIME="$(date +%s)"
|
||||
if [ "${START_NOGOSU:-false}" = "true" ]; then
|
||||
"${VENV_PATH}/bin/python3" app/main.py > /dev/stdout 2> /dev/stderr &
|
||||
else
|
||||
gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" app/main.py > /dev/stdout 2> /dev/stderr &
|
||||
fi
|
||||
PYTHON_PID=$!
|
||||
wait_backend_ready "${ENTRYPOINT_START_TIME}" "${BACKEND_START_TIME}" "${PYTHON_PID}" &
|
||||
|
||||
# 等待 Python 进程退出。
|
||||
# 如果收到信号,trap 会中断 wait,并执行 graceful_exit。
|
||||
# 如果 Python 正常退出,wait 会结束,然后我们手动调用 graceful_exit。
|
||||
wait "$PYTHON_PID" 2>/dev/null
|
||||
exit_code=$?
|
||||
|
||||
# 如果 Python 自己退出了(非信号触发),执行清理
|
||||
INTENTIONAL_RESTART_FLAG="${CONFIG_DIR}/temp/moviepilot.intentional_restart"
|
||||
if [ -f "${INTENTIONAL_RESTART_FLAG}" ]; then
|
||||
rm -f "${INTENTIONAL_RESTART_FLAG}"
|
||||
restart_exit_code="$exit_code"
|
||||
if [ "$restart_exit_code" -eq 0 ]; then
|
||||
restart_exit_code=1
|
||||
fi
|
||||
WARN "→ 检测到内置手动重启标记,退出容器并交给 Docker 重启策略处理..."
|
||||
graceful_exit "$restart_exit_code" "intentional_restart"
|
||||
fi
|
||||
|
||||
if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ]; then
|
||||
graceful_exit "$exit_code" "python_exit"
|
||||
fi
|
||||
|
||||
diagnostic_keepalive "$exit_code"
|
||||
# supervisord 常驻前台并统一托管 Nginx 与后端;容器停止信号由它转发给两个进程组。
|
||||
install -d -m 0755 /run/moviepilot
|
||||
INFO "→ 启动容器进程 supervisor..."
|
||||
exec /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
[unix_http_server]
|
||||
file=/run/moviepilot/supervisor.sock
|
||||
chmod=0770
|
||||
chown=root:moviepilot
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/dev/null
|
||||
pidfile=/run/moviepilot/supervisord.pid
|
||||
childlogdir=/tmp
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///run/moviepilot/supervisor.sock
|
||||
|
||||
[program:moviepilot-nginx]
|
||||
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf
|
||||
priority=10
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=1
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=30
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:moviepilot-backend]
|
||||
command=/bin/bash %(ENV_MP_CONTROL_DIR)s/backend.sh
|
||||
directory=/app
|
||||
priority=20
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=1
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=120
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
+18
-47
@@ -12,13 +12,14 @@
|
||||
| --- | --- |
|
||||
| `docker/Dockerfile` | 构建 Python 环境、后端、前端、插件、站点资源和容器控制面;声明 `tini` 入口与 readiness 健康检查。 |
|
||||
| `docker/launcher.sh` | 构建时复制为镜像根目录 `/entrypoint.sh`;以 root 校验、选择并固化本轮启动使用的控制脚本。 |
|
||||
| `docker/entrypoint.sh` | 真正的容器启动编排器;加载配置,驱动更新、权限、浏览器、证书、Nginx、后端和退出处理。 |
|
||||
| `docker/entrypoint.sh` | 真正的容器启动编排器;加载配置,驱动更新、权限、浏览器和证书准备,最后启动 supervisor。 |
|
||||
| `docker/backend.sh` | supervisor 托管的后端进程命令,负责工作目录、权限和 Python 进程。 |
|
||||
| `docker/supervisord.conf` | 容器内 supervisor 配置,同时托管 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. 总体调用链
|
||||
|
||||
@@ -41,8 +42,9 @@ Docker
|
||||
-> 准备 CloakBrowser 内核
|
||||
-> source cert.sh
|
||||
-> 启动前端 Nginx
|
||||
-> 可选启动 Docker Socket 代理
|
||||
-> gosu moviepilot python3 app/main.py
|
||||
-> supervisord
|
||||
-> Nginx
|
||||
-> gosu moviepilot python3 app/main.py
|
||||
-> Uvicorn/FastAPI lifespan
|
||||
-> 数据库迁移和全部生命周期组件
|
||||
-> /health/ready 返回 200
|
||||
@@ -305,10 +307,11 @@ entrypoint 使用 `PUID`、`PGID` 修改镜像内 `moviepilot` 用户和组。
|
||||
自动签发证书保存到 `/config/certs/<domain>/`,并维护 `/config/certs/latest` 符号链接。存在 `cron` 时,
|
||||
脚本写入 `/etc/cron.d/acme`,每天 03:00 执行续期检查。续期任务配置失败不会阻断已有证书启动。
|
||||
|
||||
### 7.2 Nginx 和 Docker Proxy
|
||||
### 7.2 Nginx 和 supervisor
|
||||
|
||||
证书检查完成后启动主 Nginx。若 `/var/run/docker.sock` 是 Unix Socket,再使用独立配置启动 root Nginx,
|
||||
监听 `127.0.0.1:38379` 代理 Docker API,之后重新修正 Nginx 目录权限。
|
||||
证书检查完成后,entrypoint 以前台模式启动 supervisor。supervisor 同时托管 `moviepilot-nginx` 与
|
||||
`moviepilot-backend`,两者异常退出时自动拉起。控制 socket 位于 `/run/moviepilot/supervisor.sock`,权限为
|
||||
`root:moviepilot`、`0770`,后端运行用户可访问;镜像不再挂载或代理 Docker Socket。
|
||||
|
||||
## 8. Python 后端启动
|
||||
|
||||
@@ -379,60 +382,30 @@ Transfer、Workflow 和 MoviePilot Server 服务,注册站点资源版本读
|
||||
|
||||
所有启用的 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;启动、
|
||||
Dockerfile 的 `HEALTHCHECK` 每 30 秒请求 `http://127.0.0.1:${PORT}/health/ready`:数据库迁移和完整 lifespan 成功后返回 200;启动、
|
||||
失败或关停阶段返回 503。`/health/live` 只表示进程和事件循环仍可响应。
|
||||
|
||||
## 9. 异常、重启和退出
|
||||
|
||||
### 9.1 信号退出
|
||||
|
||||
entrypoint 捕获 SIGINT/SIGTERM 后按顺序:
|
||||
容器的 supervisor 接收 SIGINT/SIGTERM 后按顺序停止受管进程:
|
||||
|
||||
1. 停止前端 Nginx;
|
||||
2. 等待 Python 完成 lifespan 逆序关停;
|
||||
3. 停止 Docker Proxy Nginx;
|
||||
4. 使用原退出码退出容器。
|
||||
3. supervisor 退出,容器按原退出状态结束。
|
||||
|
||||
Python lifespan 会先撤销 readiness,再按组件声明的 `stop_order` 停止工作流、命令、插件、事件、Agent、
|
||||
整理任务、模块服务、数据库和日志等资源。启动中途失败时,只清理已经启动或正在启动的组件。
|
||||
|
||||
### 9.2 应用内重启
|
||||
|
||||
应用请求重启时写入:
|
||||
应用请求重启时,通过本地 `supervisorctl restart all` 同时重启 Nginx 和后端。supervisor 先向旧进程组发送
|
||||
SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不访问 Docker API,也不依赖 Docker restart policy。
|
||||
|
||||
```text
|
||||
/config/temp/moviepilot.intentional_restart
|
||||
```
|
||||
### 9.3 异常诊断
|
||||
|
||||
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` 仍会保持失败。
|
||||
后端异常退出由 supervisor 自动拉起;启动前的依赖、更新或迁移失败会直接终止容器启动并保留日志。
|
||||
|
||||
## 10. 关键运行文件
|
||||
|
||||
@@ -451,7 +424,7 @@ docker exec -it <container> moviepilot doctor
|
||||
| `/public.__update_previous__` | 更新前前端备份。 |
|
||||
| `/config/temp/moviepilot-update/` | 后台下载的 Release/资源包及安装状态。 |
|
||||
| `/config/temp/moviepilot.pending_dev_update` | 单次 Dev 更新请求。 |
|
||||
| `/config/temp/moviepilot.intentional_restart` | 应用内重启请求。 |
|
||||
| `/run/moviepilot/supervisor.sock` | 容器内 supervisor 控制 socket,权限为 `root:moviepilot`、`0770`。 |
|
||||
| `/config/certs/latest/` | Nginx 使用的稳定证书路径。 |
|
||||
|
||||
## 11. 关键环境变量
|
||||
@@ -465,9 +438,7 @@ docker exec -it <container> moviepilot doctor
|
||||
| `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 包索引镜像。 |
|
||||
|
||||
+4
-9
@@ -76,21 +76,16 @@ MoviePilot V3 的全功能模式只支持 `API_WORKERS=1`。配置更大的值
|
||||
lifespan 都会在数据库迁移及后台任务启动前拒绝运行,Doctor 同时给出失败项。安全模式因跳过
|
||||
控制面而允许临时使用多 worker,但 Doctor 会将其标记为降级;故障排除后应恢复单 worker。
|
||||
|
||||
## Docker 诊断保活
|
||||
## Docker 诊断
|
||||
|
||||
Docker 镜像默认设置 `MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=true`。当后端主进程非正常退出时,entrypoint 不会立刻退出容器,而是打印一次 doctor 报告并保持容器运行,方便执行:
|
||||
Docker 镜像由容器内 supervisor 托管 Nginx 和后端进程,后端异常退出时 supervisor 会自动拉起。需要诊断时可执行:
|
||||
|
||||
```shell
|
||||
docker exec -it <container> moviepilot doctor
|
||||
```
|
||||
|
||||
如果需要恢复旧行为,可设置:
|
||||
|
||||
```env
|
||||
MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false
|
||||
```
|
||||
|
||||
Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。是否自动重启仍由 Docker Compose、NAS 平台或 Docker restart policy 决定。
|
||||
Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。应用进程重启由容器内 supervisor 负责,
|
||||
Docker restart policy 只影响整个容器的恢复。
|
||||
|
||||
镜像健康检查与 entrypoint 的后端就绪等待统一访问公开的 `/health/ready`:只有数据库迁移、
|
||||
Alembic head 校验和生命周期启动完成后才返回 200;启动失败或关停时返回 503。单纯确认进程
|
||||
|
||||
+1
-1
@@ -28,7 +28,6 @@ dependencies = [
|
||||
"dateparser~=1.4.0",
|
||||
"ddgs~=9.14.4",
|
||||
"discord.py==2.7.1",
|
||||
"docker~=7.1.0",
|
||||
"fast-bencode~=1.1.8",
|
||||
"fastapi~=0.141.1",
|
||||
"google-genai~=2.8.0",
|
||||
@@ -102,6 +101,7 @@ dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"cython~=3.2.5",
|
||||
"docker~=7.1.0",
|
||||
"mypy~=1.18.2",
|
||||
"pylint~=4.0.6",
|
||||
"pytest~=9.0.3",
|
||||
|
||||
@@ -79,7 +79,6 @@ LAB_ENVIRONMENT = {
|
||||
"DEBUG": "false",
|
||||
"MOVIEPILOT_SAFE_MODE": "false",
|
||||
"MOVIEPILOT_AUTO_UPDATE": "false",
|
||||
"MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE": "false",
|
||||
"AUTO_UPDATE_RESOURCE": "false",
|
||||
"PLUGIN_MARKET": "",
|
||||
"PLUGIN_AUTO_RELOAD": "false",
|
||||
@@ -923,7 +922,6 @@ def startup_sample(client, args: argparse.Namespace, image: str, variant: str, i
|
||||
try:
|
||||
volume = client.volumes.create(name=volume_name, labels=labels)
|
||||
environment = dict(LAB_ENVIRONMENT)
|
||||
environment["MOVIEPILOT_BACKEND_READY_TIMEOUT"] = str(args.ready_timeout)
|
||||
container = client.containers.create(
|
||||
image,
|
||||
name=name,
|
||||
|
||||
@@ -588,8 +588,6 @@ def fixed_environment(args: argparse.Namespace, instrument: bool) -> dict[str, s
|
||||
"DEBUG": "false",
|
||||
"MOVIEPILOT_SAFE_MODE": "false",
|
||||
"MOVIEPILOT_AUTO_UPDATE": "false",
|
||||
"MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE": "false",
|
||||
"MOVIEPILOT_BACKEND_READY_TIMEOUT": str(args.ready_timeout),
|
||||
"AUTO_UPDATE_RESOURCE": "false",
|
||||
"PLUGIN_MARKET": "",
|
||||
"PLUGIN_AUTO_RELOAD": "false",
|
||||
|
||||
@@ -588,12 +588,6 @@
|
||||
},
|
||||
"target": "asyncio.to_thread"
|
||||
},
|
||||
"app/runtime/state.py:threading.Thread": {
|
||||
"owners": {
|
||||
"SystemHelper._start_graceful_shutdown_monitor": 1
|
||||
},
|
||||
"target": "threading.Thread"
|
||||
},
|
||||
"app/runtime/tasks.py:asyncio.create_task": {
|
||||
"owners": {
|
||||
"TaskRegistry.create": 1
|
||||
|
||||
@@ -209,15 +209,6 @@
|
||||
{"source": "app.startup.lifecycle", "target": "urllib3", "kind": "raw_transport", "fingerprint": "cb6f0a314aeb1e2d3e76c240aa20460ac0c36d9f5c18c1a6ea3170f64dd3366b"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"classification": "approved_exception",
|
||||
"reason_code": "daemon_control_plane",
|
||||
"owner": "$source",
|
||||
"reason": "runtime state 连接配置指定的 Docker daemon 控制面,并由该进程能力 owner 收口。",
|
||||
"facts": [
|
||||
{"source": "app.runtime.state", "target": "docker", "kind": "network_sdk", "fingerprint": "20a91ec521f7dfe6a0153dfd8ea49c4bac7f0a16b55f1c0655dfb33f54a01215"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"classification": "approved_exception",
|
||||
"reason_code": "test_network_guard",
|
||||
|
||||
@@ -78,9 +78,6 @@ FROZEN_EGRESS_EDGES_BY_REASON = {
|
||||
("app.modules.zspace.zspace", "requests"),
|
||||
("app.startup.lifecycle", "urllib3"),
|
||||
},
|
||||
"daemon_control_plane": {
|
||||
("app.runtime.state", "docker"),
|
||||
},
|
||||
"test_network_guard": {
|
||||
("app.testing.network", "socket.getaddrinfo"),
|
||||
},
|
||||
@@ -141,7 +138,6 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
|
||||
("app.modules.webpush", "pywebpush"): "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76",
|
||||
("app.modules.wechat.wechatbot", "websocket"): "1bae78270eadce0571e2caaa111a5c0a9065ba2da97ebb26b8a5b76d3ed5eef6",
|
||||
("app.modules.zspace.zspace", "requests"): "9df3fd27b9696d45a72e7c8f67b5a9ad79a7371d1fe690bbaa17485bd1960d51",
|
||||
("app.runtime.state", "docker"): "20a91ec521f7dfe6a0153dfd8ea49c4bac7f0a16b55f1c0655dfb33f54a01215",
|
||||
("app.startup.lifecycle", "urllib3"): "cb6f0a314aeb1e2d3e76c240aa20460ac0c36d9f5c18c1a6ea3170f64dd3366b",
|
||||
("app.testing.network", "socket.getaddrinfo"): "ebf33718b54c81e1f575401da4a0bef5aa0e1ddc284e201f557897feeca71c6d",
|
||||
}
|
||||
|
||||
@@ -1018,6 +1018,22 @@ def test_entrypoint_does_not_keep_retired_package_command_wrapper() -> None:
|
||||
assert "function run_package_command()" not in entrypoint
|
||||
|
||||
|
||||
def test_entrypoint_delegates_restart_to_external_supervisor() -> None:
|
||||
"""容器入口使用内部 supervisor 托管前后端且不包含 Docker 控制面。"""
|
||||
entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8")
|
||||
supervisor = (ROOT / "docker" / "supervisord.conf").read_text(encoding="utf-8")
|
||||
|
||||
assert "docker_http_proxy" not in entrypoint
|
||||
assert "/var/run/docker.sock" not in entrypoint
|
||||
assert "docker_http_proxy" not in dockerfile
|
||||
assert "exec /usr/bin/supervisord -n" in entrypoint
|
||||
assert "supervisor" in dockerfile
|
||||
assert "[program:moviepilot-nginx]" in supervisor
|
||||
assert "[program:moviepilot-backend]" in supervisor
|
||||
assert supervisor.count("autorestart=true") == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pyproject_changed", "lock_changed", "expected_route_calls", "expected_sync_calls"),
|
||||
(
|
||||
|
||||
@@ -540,59 +540,6 @@ def test_site_resource_permissions_are_repaired_even_when_owner_matches(tmp_path
|
||||
assert not any(line.startswith("-R ") and f"{tmp_path}/public" in line for line in lines)
|
||||
|
||||
|
||||
def test_backend_ready_log_uses_configured_ports(tmp_path: Path) -> None:
|
||||
curl_log = tmp_path / "curl.log"
|
||||
output = _run_entrypoint_case(
|
||||
tmp_path,
|
||||
"""
|
||||
INFO() { printf '[INFO] %s\\n' "$1"; }
|
||||
curl() {
|
||||
printf '%s\\n' "$*" > "${CURL_LOG}"
|
||||
return 0
|
||||
}
|
||||
PORT=4321 NGINX_PORT=8765 wait_backend_ready 1 2 "$$"
|
||||
""",
|
||||
env={"CURL_LOG": str(curl_log)},
|
||||
)
|
||||
|
||||
assert curl_log.read_text(encoding="utf-8") == (
|
||||
"-fsS --max-time 2 http://127.0.0.1:4321/health/ready\n"
|
||||
)
|
||||
assert "MoviePilot Web 已可访问" in output
|
||||
assert "后端就绪耗时" in output
|
||||
assert "后端端口 4321" in output
|
||||
assert "前端端口 8765" in output
|
||||
|
||||
|
||||
def test_backend_ready_timeout_falls_back_to_default_for_invalid_value(tmp_path: Path) -> None:
|
||||
output = _run_entrypoint_case(
|
||||
tmp_path,
|
||||
"""
|
||||
WARN() { printf '[WARN] %s\\n' "$1"; }
|
||||
curl() { return 1; }
|
||||
MOVIEPILOT_BACKEND_READY_TIMEOUT=invalid wait_backend_ready 1 2 999999 || true
|
||||
""",
|
||||
)
|
||||
|
||||
assert "MOVIEPILOT_BACKEND_READY_TIMEOUT=invalid 无效,使用默认 300 秒" in output
|
||||
assert "后端服务启动完成探测已停止:后端进程已退出" in output
|
||||
|
||||
|
||||
def test_backend_ready_timeout_accepts_leading_zero_decimal(tmp_path: Path) -> None:
|
||||
output = _run_entrypoint_case(
|
||||
tmp_path,
|
||||
"""
|
||||
INFO() { printf '[INFO] %s\\n' "$1"; }
|
||||
WARN() { printf '[WARN] %s\\n' "$1"; }
|
||||
curl() { return 0; }
|
||||
MOVIEPILOT_BACKEND_READY_TIMEOUT=08 wait_backend_ready 1 2 "$$"
|
||||
""",
|
||||
)
|
||||
|
||||
assert "MOVIEPILOT_BACKEND_READY_TIMEOUT=08 无效" not in output
|
||||
assert "MoviePilot Web 已可访问" in output
|
||||
|
||||
|
||||
def test_backend_dependency_recovery_uses_runtime_profile_sync(tmp_path: Path) -> None:
|
||||
"""启动自愈必须复用按当前解释器选择依赖组的同步入口。"""
|
||||
venv_bin = tmp_path / "venv" / "bin"
|
||||
@@ -633,14 +580,15 @@ def test_backend_dependency_recovery_uses_runtime_profile_sync(tmp_path: Path) -
|
||||
assert marker.exists()
|
||||
|
||||
|
||||
def test_backend_failure_keepalive_contract_is_explicit() -> None:
|
||||
"""后端异常默认保活诊断,显式关闭后才退出容器。"""
|
||||
content = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
function = content.split(
|
||||
"function diagnostic_keepalive() {", 1
|
||||
)[1].split("\n}", 1)[0]
|
||||
def test_supervisor_manages_backend_and_nginx() -> None:
|
||||
"""后端异常恢复和应用重启都由容器内 supervisor 托管。"""
|
||||
entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
supervisor = (ROOT / "docker" / "supervisord.conf").read_text(encoding="utf-8")
|
||||
|
||||
assert 'MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE:-true' in function
|
||||
assert 'if [ "${keepalive}" = "false" ]' in function
|
||||
assert 'graceful_exit "$exit_code" "python_exit"' in function
|
||||
assert "容器将保持运行以便执行 moviepilot doctor" in function
|
||||
assert "supervisord -n" in entrypoint
|
||||
assert "[program:moviepilot-nginx]" in supervisor
|
||||
assert "[program:moviepilot-backend]" in supervisor
|
||||
assert "file=/run/moviepilot/supervisor.sock" in supervisor
|
||||
assert "chmod=0770" in supervisor
|
||||
assert "chown=root:moviepilot" in supervisor
|
||||
assert supervisor.count("autorestart=true") == 2
|
||||
|
||||
+31
-81
@@ -6,7 +6,6 @@ import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
@@ -15,7 +14,7 @@ import psutil
|
||||
import pytest
|
||||
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.runtime.config import ConfigModel, Settings, settings
|
||||
from app.runtime.config import ConfigModel, Settings
|
||||
from app.adapters.system.host import SystemUtils
|
||||
|
||||
|
||||
@@ -86,94 +85,45 @@ def test_execute_with_subprocess_reports_empty_failure_output():
|
||||
assert "无标准输出或错误输出" in message
|
||||
|
||||
|
||||
def test_docker_restart_policy_marks_intent_before_sigterm():
|
||||
"""Docker 优雅重启前应写入意图标记,避免 entrypoint 误进入 doctor 保活。"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
original_config_dir = settings.CONFIG_DIR
|
||||
original_intent_file = SystemHelper._SystemHelper__docker_restart_intent_file
|
||||
settings.CONFIG_DIR = temp_dir
|
||||
SystemHelper._SystemHelper__docker_restart_intent_file = (
|
||||
settings.TEMP_PATH / "moviepilot.intentional_restart"
|
||||
)
|
||||
try:
|
||||
with patch("app.runtime.state.is_docker", return_value=True), \
|
||||
patch.object(SystemHelper, "_check_restart_policy", return_value=True), \
|
||||
patch.object(SystemHelper, "_start_graceful_shutdown_monitor"), \
|
||||
patch("app.runtime.state.os.kill") as kill_mock:
|
||||
ret, msg = SystemHelper.restart()
|
||||
def test_docker_restart_delegates_to_supervisor():
|
||||
"""容器内重启只委托 supervisor,不向当前进程或 Docker daemon 发信号。"""
|
||||
with patch("app.runtime.state.is_docker", return_value=True), \
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisor_config") as supervisor_config, \
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisorctl") as supervisorctl, \
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisor_socket") as supervisor_socket, \
|
||||
patch.object(SystemHelper, "_schedule_supervisor_restart") as restart_mock, \
|
||||
patch("app.runtime.state.os.kill") as kill_mock:
|
||||
supervisor_config.exists.return_value = True
|
||||
supervisorctl.exists.return_value = True
|
||||
supervisor_socket.exists.return_value = True
|
||||
ret, msg = SystemHelper.restart()
|
||||
|
||||
assert ret
|
||||
assert msg == ""
|
||||
assert (settings.TEMP_PATH / "moviepilot.intentional_restart").exists()
|
||||
kill_mock.assert_called_once()
|
||||
finally:
|
||||
SystemHelper._SystemHelper__docker_restart_intent_file = original_intent_file
|
||||
settings.CONFIG_DIR = original_config_dir
|
||||
assert ret
|
||||
assert msg == ""
|
||||
restart_mock.assert_called_once_with()
|
||||
kill_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_graceful_shutdown_monitor_has_single_owner_and_releases_it(monkeypatch):
|
||||
"""重复重启请求应共享唯一兜底线程,线程结束后必须释放 owner。"""
|
||||
sleep_started = threading.Event()
|
||||
release_sleep = threading.Event()
|
||||
restart = MagicMock(return_value=(True, ""))
|
||||
monitor_attr = "_SystemHelper__graceful_shutdown_monitor"
|
||||
original_monitor = getattr(SystemHelper, monitor_attr)
|
||||
thread = None
|
||||
def test_supervisor_restart_command_restarts_frontend_and_backend(monkeypatch):
|
||||
"""延迟任务必须通过本地 supervisor 同时重启前后端进程。"""
|
||||
callback = None
|
||||
|
||||
def wait_for_shutdown(_seconds: float) -> None:
|
||||
"""用事件屏障模拟 180 秒等待,确保第二次启动发生在首线程存活期间。"""
|
||||
sleep_started.set()
|
||||
release_sleep.wait(timeout=1)
|
||||
|
||||
setattr(SystemHelper, monitor_attr, None)
|
||||
try:
|
||||
monkeypatch.setattr("app.runtime.state.time.sleep", wait_for_shutdown)
|
||||
monkeypatch.setattr(SystemHelper, "_docker_api_restart", restart)
|
||||
|
||||
SystemHelper._start_graceful_shutdown_monitor()
|
||||
assert sleep_started.wait(timeout=1)
|
||||
thread = getattr(SystemHelper, monitor_attr)
|
||||
SystemHelper._start_graceful_shutdown_monitor()
|
||||
|
||||
assert getattr(SystemHelper, monitor_attr) is thread
|
||||
release_sleep.set()
|
||||
thread.join(timeout=1)
|
||||
|
||||
assert thread.is_alive() is False
|
||||
assert getattr(SystemHelper, monitor_attr) is None
|
||||
restart.assert_called_once_with()
|
||||
finally:
|
||||
release_sleep.set()
|
||||
if thread is not None:
|
||||
thread.join(timeout=1)
|
||||
setattr(SystemHelper, monitor_attr, original_monitor)
|
||||
|
||||
|
||||
def test_graceful_shutdown_monitor_releases_owner_when_thread_start_fails(monkeypatch):
|
||||
"""兜底线程启动失败时必须释放 owner,允许后续请求重试。"""
|
||||
monitor_attr = "_SystemHelper__graceful_shutdown_monitor"
|
||||
original_monitor = getattr(SystemHelper, monitor_attr)
|
||||
|
||||
class FailingThread:
|
||||
"""模拟在登记 owner 后启动失败的线程对象。"""
|
||||
|
||||
def __init__(self, **_kwargs):
|
||||
"""接收真实 Thread 构造参数,但不创建系统线程。"""
|
||||
class ImmediateTimer:
|
||||
def __init__(self, _delay, timer_callback):
|
||||
nonlocal callback
|
||||
callback = timer_callback
|
||||
self.daemon = False
|
||||
|
||||
def start(self):
|
||||
"""模拟底层线程资源不足导致的启动失败。"""
|
||||
raise RuntimeError("thread start failed")
|
||||
callback()
|
||||
|
||||
setattr(SystemHelper, monitor_attr, None)
|
||||
try:
|
||||
monkeypatch.setattr("app.runtime.state.threading.Thread", FailingThread)
|
||||
popen_mock = MagicMock()
|
||||
monkeypatch.setattr("app.runtime.state.threading.Timer", ImmediateTimer)
|
||||
monkeypatch.setattr("app.runtime.state.subprocess.Popen", popen_mock)
|
||||
|
||||
with pytest.raises(RuntimeError, match="thread start failed"):
|
||||
SystemHelper._start_graceful_shutdown_monitor()
|
||||
SystemHelper._schedule_supervisor_restart()
|
||||
|
||||
assert getattr(SystemHelper, monitor_attr) is None
|
||||
finally:
|
||||
setattr(SystemHelper, monitor_attr, original_monitor)
|
||||
assert popen_mock.call_args.args[0][-2:] == ["restart", "all"]
|
||||
|
||||
|
||||
def test_execute_with_subprocess_passes_env_to_subprocess():
|
||||
|
||||
@@ -1669,7 +1669,6 @@ dependencies = [
|
||||
{ name = "dateparser" },
|
||||
{ name = "ddgs" },
|
||||
{ name = "discord-py" },
|
||||
{ name = "docker" },
|
||||
{ name = "fast-bencode" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "google-genai" },
|
||||
@@ -1743,6 +1742,7 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "cython" },
|
||||
{ name = "docker" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pytest" },
|
||||
@@ -1788,7 +1788,6 @@ requires-dist = [
|
||||
{ name = "dateparser", specifier = "~=1.4.0" },
|
||||
{ name = "ddgs", specifier = "~=9.14.4" },
|
||||
{ name = "discord-py", specifier = "==2.7.1" },
|
||||
{ name = "docker", specifier = "~=7.1.0" },
|
||||
{ name = "fast-bencode", specifier = "~=1.1.8" },
|
||||
{ name = "fastapi", specifier = "~=0.141.1" },
|
||||
{ name = "google-genai", specifier = "~=2.8.0" },
|
||||
@@ -1862,6 +1861,7 @@ requires-dist = [
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "cython", specifier = "~=3.2.5" },
|
||||
{ name = "docker", specifier = "~=7.1.0" },
|
||||
{ name = "mypy", specifier = "~=1.18.2" },
|
||||
{ name = "pylint", specifier = "~=4.0.6" },
|
||||
{ name = "pytest", specifier = "~=9.0.3" },
|
||||
|
||||
Reference in New Issue
Block a user