From e6f531d47fbcc9f45b9fb7ae7a6b6410ea67e7e0 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 6 Sep 2026 21:21:06 +0800 Subject: [PATCH] refactor: manage Docker processes with supervisor --- app/doctor/checks.py | 4 +- app/runtime/config.py | 3 - app/runtime/state.py | 219 ++++-------------- docker/Dockerfile | 6 +- docker/backend.sh | 12 + docker/docker_http_proxy.conf | 44 ---- docker/entrypoint.sh | 185 +-------------- docker/supervisord.conf | 47 ++++ docs/docker-startup.md | 65 ++---- docs/doctor.md | 13 +- pyproject.toml | 2 +- scripts/perf/free_threaded_ab.py | 2 - scripts/perf/moviepilot_docker_ab.py | 2 - .../architecture/concurrency-baseline.json | 6 - .../architecture/dependency-policy.json | 9 - tests/test_architecture_egress.py | 4 - tests/test_docker_bootstrap.py | 16 ++ tests/test_docker_entrypoint_permissions.py | 74 +----- tests/test_system_utils.py | 112 +++------ uv.lock | 4 +- 20 files changed, 208 insertions(+), 621 deletions(-) create mode 100644 docker/backend.sh delete mode 100644 docker/docker_http_proxy.conf create mode 100644 docker/supervisord.conf diff --git a/app/doctor/checks.py b/app/doctor/checks.py index 396cad2da..3095177c5 100644 --- a/app/doctor/checks.py +++ b/app/doctor/checks.py @@ -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 moviepilot doctor` 诊断。", + recommendation="后端异常由 supervisor 自动拉起;启动失败时可通过 `docker exec moviepilot doctor` 诊断。", ) diff --git a/app/runtime/config.py b/app/runtime/config.py index 9665989b6..ba80ef115 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -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" diff --git a/app/runtime/state.py b/app/runtime/state.py index 5244204e3..ec1b92821 100644 --- a/app/runtime/state.py +++ b/app/runtime/state.py @@ -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): """ 设置系统已修改标志 diff --git a/docker/Dockerfile b/docker/Dockerfile index c047cd2c3..37a82f6f1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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/ diff --git a/docker/backend.sh b/docker/backend.sh new file mode 100644 index 000000000..59b13754d --- /dev/null +++ b/docker/backend.sh @@ -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 diff --git a/docker/docker_http_proxy.conf b/docker/docker_http_proxy.conf deleted file mode 100644 index d9c56c0e0..000000000 --- a/docker/docker_http_proxy.conf +++ /dev/null @@ -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; - } - } -} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4969ddb96..1ff8f423d 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 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 diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 000000000..9854e2caa --- /dev/null +++ b/docker/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 diff --git a/docs/docker-startup.md b/docs/docker-startup.md index 7ba102359..8c1281720 100644 --- a/docs/docker-startup.md +++ b/docs/docker-startup.md @@ -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//`,并维护 `/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 moviepilot doctor -``` - -设置 `MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false` 可恢复异常后直接退出容器的行为。保活只保留诊断 -入口,不代表服务健康;Docker `HEALTHCHECK` 仍会保持失败。 +后端异常退出由 supervisor 自动拉起;启动前的依赖、更新或迁移失败会直接终止容器启动并保留日志。 ## 10. 关键运行文件 @@ -451,7 +424,7 @@ docker exec -it 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 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 包索引镜像。 | diff --git a/docs/doctor.md b/docs/doctor.md index 61e30474a..6964de65a 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -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 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。单纯确认进程 diff --git a/pyproject.toml b/pyproject.toml index 8dbf57e4d..8deb89d0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/perf/free_threaded_ab.py b/scripts/perf/free_threaded_ab.py index ee2836150..e41a2c3f2 100644 --- a/scripts/perf/free_threaded_ab.py +++ b/scripts/perf/free_threaded_ab.py @@ -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, diff --git a/scripts/perf/moviepilot_docker_ab.py b/scripts/perf/moviepilot_docker_ab.py index 233174993..2d5270bf8 100644 --- a/scripts/perf/moviepilot_docker_ab.py +++ b/scripts/perf/moviepilot_docker_ab.py @@ -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", diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index 38800af78..08975a69c 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -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 diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index 676b0a26d..1a3bee064 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -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", diff --git a/tests/test_architecture_egress.py b/tests/test_architecture_egress.py index a749a956c..905cd15a0 100644 --- a/tests/test_architecture_egress.py +++ b/tests/test_architecture_egress.py @@ -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", } diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index 5f17a4d1d..fc61e4b42 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -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"), ( diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index 2a846f155..a5438c7d0 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -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 diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index c0b61931f..dc30e1e73 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -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(): diff --git a/uv.lock b/uv.lock index 7187f050d..a21aa8e02 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },