diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8d8e9b86c..81195129c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -294,6 +294,12 @@ function source_control_generation() { /entrypoint.sh --source-generation 2>/dev/null } +# 更新会原子替换 /app;重入前切到稳定目录,避免新 Bash 进程继承已删除的工作目录。 +function reexec_entrypoint() { + cd / || exit 1 + exec /entrypoint.sh --post-update-reexec +} + function maybe_reexec_control_bundle() { [ "${MOVIEPILOT_UPDATE_RESULT:-noop}" = "updated" ] || return 0 @@ -309,7 +315,7 @@ function maybe_reexec_control_bundle() { "${next_control_generation}" \ "${MOVIEPILOT_BOOTSTRAP_REEXECUTED:-0}"; then INFO "→ 检测到容器控制脚本更新,使用新版本继续本次启动。" - exec /entrypoint.sh --post-update-reexec + reexec_entrypoint elif [ "$?" -eq 2 ]; then ERROR "→ 容器控制脚本在单次启动中重复变化,已终止以避免重启循环。" exit 1 @@ -505,7 +511,7 @@ if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ] \ && [ -f "${CONFIG_DIR}/temp/moviepilot-update/install.json" ]; then if apply_pending_release_update_at_startup; then INFO "→ 未完成的 Release 更新已安装,重新执行入口加载新代码。" - exec /entrypoint.sh --post-update-reexec + reexec_entrypoint fi fi @@ -543,6 +549,10 @@ ensure_browser_kernel # 证书管理 source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/cert.sh" +# /app 会被 Release worker 原子替换;常驻入口必须留在稳定目录,避免 worker 关闭 supervisor 后 +# 当前 shell 继承已删除的旧工作目录,导致重入时 Bash 连续报告 getcwd 错误。 +cd / || exit 1 + # supervisord 常驻前台并统一托管 Nginx 与后端;带更新标记的 shutdown 会回到本入口消费更新包。 install -d -m 0755 /run/moviepilot # Supervisor 的控制面只在容器内使用;未显式传入时生成本次容器启动专用的随机凭据,避免固定密码进入镜像。 @@ -584,13 +594,13 @@ while true; do exit 1 fi INFO "→ 更新代码已落盘,重新执行容器入口以加载新版本。" - exec /entrypoint.sh --post-update-reexec + reexec_entrypoint fi if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then if run_pending_dev_update_after_supervisor_shutdown; then INFO "→ 更新包已安装,重新执行容器入口以加载新版本。" - exec /entrypoint.sh --post-update-reexec + reexec_entrypoint fi if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then ERROR "→ 更新请求未能完成且标记仍存在,停止启动。" diff --git a/docker/update-worker.sh b/docker/update-worker.sh index e215c5219..b9c2e9143 100644 --- a/docker/update-worker.sh +++ b/docker/update-worker.sh @@ -25,6 +25,12 @@ if ! "${VENV_PATH}/bin/python3" -m app.cli apply-prepared-update; then exit 1 fi +# /app 可能刚刚被替换,切回稳定目录后再调用 supervisor 控制面。 +if ! cd /; then + ERROR "→ 更新后无法切回稳定工作目录。" + exit 1 +fi + if ! mkdir -p "$(dirname "${RESTART_REQUEST_FILE}")"; then ERROR "→ 无法记录更新后的重启请求。" exit 1 diff --git a/docs/docker-startup.md b/docs/docker-startup.md index 72d9a053b..83867cbe5 100644 --- a/docs/docker-startup.md +++ b/docs/docker-startup.md @@ -425,7 +425,8 @@ Python lifespan 会先撤销 readiness,再按组件声明的 `stop_order` 停 普通应用重启通过本地 `supervisorctl restart all` 同时重启 Nginx 和后端。确认安装 Release 时, `SystemHelper` 只启动 root `moviepilot-update-worker`;worker 先替换 `/app`、`/public` 或站点资源, 再写入 `moviepilot.pending_supervisor_restart` 并执行 `supervisorctl shutdown`。外层 entrypoint 看到标记后 -重新执行 launcher,加载新代码。Dev 更新仍通过一次性 Dev 标记关闭 Supervisor,再由 entrypoint 调用 +重新执行 launcher,加载新代码。载荷替换期间外层 entrypoint、worker 的控制面调用和所有入口重入都使用 `/` +作为稳定工作目录,避免旧 `/app` 被删除后产生 `getcwd` 错误。Dev 更新仍通过一次性 Dev 标记关闭 Supervisor,再由 entrypoint 调用 `update.sh`。这样更新包不会因只重启受管进程而停留在暂存目录;整个过程不访问 Docker API,也不依赖 Docker restart policy。 diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index 954a8d819..b8510c63f 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -942,6 +942,24 @@ def test_release_update_worker_applies_before_supervisor_shutdown() -> None: assert worker.index("apply-prepared-update") < worker.index("shutdown") +def test_update_reexec_uses_stable_working_directory() -> None: + """更新替换 /app 后,入口重入和 worker 控制面调用必须离开旧工作目录。""" + entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + worker = (ROOT / "docker" / "update-worker.sh").read_text(encoding="utf-8") + + reexec = entrypoint.split("function reexec_entrypoint()", 1)[1].split("}", 1)[0] + assert reexec.index("cd / || exit 1") < reexec.index("exec /entrypoint.sh") + assert entrypoint.count("exec /entrypoint.sh --post-update-reexec") == 1 + assert entrypoint.count("reexec_entrypoint") == 5 + + supervisor_start = entrypoint.index("/usr/bin/supervisord -n") + assert entrypoint.rfind("cd / || exit 1", 0, supervisor_start) > entrypoint.rfind( + "cd /app || exit", 0, supervisor_start + ) + worker_stable_cwd = worker.index("cd /", worker.index("apply-prepared-update")) + assert worker_stable_cwd < worker.index("supervisorctl") + + @pytest.mark.parametrize( ("pyproject_changed", "lock_changed", "expected_route_calls", "expected_sync_calls"), (