# syntax=docker/dockerfile:1 ARG MOVIEPILOT_PYTHON_VARIANT="standard" ARG MOVIEPILOT_PYTHON_VERSION="3.14.7" FROM ghcr.io/astral-sh/uv:latest AS uv FROM rclone/rclone:latest AS rclone FROM mwader/static-ffmpeg:8.1.1@sha256:735f84b905e00d5c618b667f0b053f83b1096f5fc404c607e6134bf2275a0e0a AS ffmpeg FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base FROM rust:slim-trixie AS rust_toolchain # 准备外部制品所需的最小工具集 FROM base AS prepare_payload RUN apt-get update \ && apt-get install -y --no-install-recommends bash busybox ca-certificates curl jq \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # 按镜像目标架构编译中文索引扩展,编译器不进入最终镜像。 FROM base AS prepare_cjk RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential \ && rm -rf /var/lib/apt/lists/* COPY native/fts5_cjk/ /build/fts5_cjk/ RUN python3 /build/fts5_cjk/install.py --output /native/libfts5_cjk.so # 准备软件包 FROM base AS prepare_package ENV LANG="C.UTF-8" \ TZ="Asia/Shanghai" \ HOME="/moviepilot" \ CONFIG_DIR="/config" \ TERM="xterm" \ DISPLAY=:987 \ PUID=0 \ PGID=0 \ UMASK=000 \ PYTHON_THREAD_INHERIT_CONTEXT=0 \ VENV_PATH="/opt/venv" ENV PATH="${VENV_PATH}/bin:${PATH}" RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && install -d -m 0755 /usr/share/postgresql-common/pgdg \ && curl -fsSL \ --connect-timeout 10 \ --max-time 30 \ --retry 3 \ --retry-all-errors \ --retry-delay 2 \ --retry-max-time 90 \ "https://www.postgresql.org/media/keys/ACCC4CF8.asc" \ -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ && chmod 0644 /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ && printf '%s\n' \ 'deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt trixie-pgdg main' \ > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ && apt-get install -y --no-install-recommends \ nginx \ gettext-base \ locales \ procps \ gosu \ bash \ wget \ git \ gh \ busybox \ tini \ cron \ jq \ ripgrep \ less \ unzip \ fuse3 \ rsync \ openssh-client \ sshpass \ iproute2 \ util-linux \ netcat-openbsd \ lsof \ nano \ unar \ supervisor \ postgresql-client-18 \ libchromaprint-tools \ libjemalloc2 \ && dpkg-reconfigure --frontend noninteractive tzdata \ && ln -s /usr/lib/*-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so \ && apt-get autoremove -y \ && apt-get clean \ && rm -rf \ /tmp/* \ /var/lib/apt/lists/* \ /var/tmp/* # 从锁文件提取浏览器系统依赖所需的 Playwright 版本与哈希。 # 本阶段随 uv.lock 每次变化重跑,但输出只含 Playwright 一行;BuildKit 按内容缓存后续 COPY, # 因此只有 Playwright 版本变化才会让浏览器依赖层失效,其他依赖变化不再迫使用户重新下载该层。 FROM base AS prepare_playwright_requirement COPY uv.lock /tmp/uv.lock RUN python3 -c "import tomllib; \ packages = [item for item in tomllib.load(open('/tmp/uv.lock', 'rb'))['package'] if item['name'] == 'playwright']; \ assert len(packages) == 1, 'uv.lock 必须且只能锁定一个 Playwright 版本'; \ package = packages[0]; \ hashes = sorted({item['hash'] for item in package.get('wheels', [])}); \ assert hashes, 'uv.lock 缺少 Playwright wheel 哈希'; \ open('/playwright-requirement.txt', 'w').write('playwright==' + package['version'] + ' ' + ' '.join('--hash=' + item for item in hashes) + '\\n')" \ && cat /playwright-requirement.txt # 浏览器运行依赖:只依赖系统软件包层和锁定的 Playwright 版本,不依赖虚拟环境内容。 # Playwright 只装进临时目录读取依赖清单并执行 apt 安装,随后删除,不进入最终镜像。 # 禁止写字节码,避免临时 pip 进程把系统 Python 标准库的 .pyc 留在该层。 FROM prepare_package AS prepare_browser_deps COPY --from=prepare_playwright_requirement /playwright-requirement.txt /tmp/playwright-requirement.txt RUN export PYTHONDONTWRITEBYTECODE=1 \ && python3 -m venv /tmp/playwright-cli \ && /tmp/playwright-cli/bin/pip install \ --disable-pip-version-check \ --no-cache-dir \ --no-deps \ --require-hashes \ --only-binary :all: \ -r /tmp/playwright-requirement.txt \ && /tmp/playwright-cli/bin/python -m playwright install-deps chromium \ && apt-get autoremove -y \ && apt-get clean \ && rm -rf \ /tmp/* \ /var/lib/apt/lists/* \ /var/tmp/* # 准备 Python 环境的公共输入 FROM base AS prepare_venv_common # 设置环境变量 ENV LANG="C.UTF-8" \ TZ="Asia/Shanghai" \ VENV_PATH="/opt/venv" ENV PATH="${VENV_PATH}/bin:${PATH}" # 安装系统构建依赖 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ curl \ busybox \ jq \ wget WORKDIR /app COPY --from=uv /uv /usr/local/bin/uv COPY pyproject.toml uv.lock ./ # 准备标准 Python 运行环境 FROM prepare_venv_common AS prepare_venv_standard RUN mkdir -p /opt/python \ && python3 -m venv --without-pip ${VENV_PATH} \ && UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync \ --locked \ --no-default-groups \ --group runtime-standard \ --no-install-project # 自由线程版 Python 的原生依赖需要 Rust 和 C 构建工具链。 FROM prepare_venv_common AS prepare_venv_free-threaded ARG MOVIEPILOT_PYTHON_VERSION ENV CARGO_HOME="/usr/local/cargo" \ RUSTUP_HOME="/usr/local/rustup" \ UV_PYTHON_INSTALL_DIR="/opt/python" ENV PATH="${CARGO_HOME}/bin:${VENV_PATH}/bin:${PATH}" COPY --from=rust_toolchain /usr/local/cargo /usr/local/cargo COPY --from=rust_toolchain /usr/local/rustup /usr/local/rustup RUN apt-get update \ && apt-get install -y --no-install-recommends libpq-dev \ && rm -rf /var/lib/apt/lists/* \ && uv python install --no-bin "${MOVIEPILOT_PYTHON_VERSION}t" \ && python_bin="$(uv python find --managed-python "${MOVIEPILOT_PYTHON_VERSION}t")" \ && "${python_bin}" -m venv --without-pip ${VENV_PATH} \ && UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync \ --locked \ --no-default-groups \ --group runtime-free-threaded \ --no-install-project \ --python "${python_bin}" FROM prepare_venv_standard AS verify_venv_standard COPY app/doctor/dependencies.py /tmp/moviepilot-runtime-dependencies.py RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full FROM prepare_venv_free-threaded AS verify_venv_free-threaded COPY app/doctor/dependencies.py /tmp/moviepilot-runtime-dependencies.py RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full ARG MOVIEPILOT_PYTHON_VARIANT FROM verify_venv_${MOVIEPILOT_PYTHON_VARIANT} AS prepare_venv COPY --from=prepare_cjk /native/ ${VENV_PATH}/lib/moviepilot/ COPY --from=prepare_cjk /build/fts5_cjk/install.py /tmp/verify-cjk.py RUN "${VENV_PATH}/bin/python" /tmp/verify-cjk.py --check \ && rm /tmp/verify-cjk.py # 准备后端源码 FROM base AS prepare_backend WORKDIR /app COPY . . RUN rm -rf /app/frontend-dist # 准备前端制品 FROM prepare_payload AS prepare_frontend ARG MOVIEPILOT_FRONTEND_VERSION="" ARG MOVIEPILOT_FRONTEND_SHA256="" COPY version.py /tmp/moviepilot-version.py COPY frontend-dist/ /tmp/frontend-dist/ RUN set -eu; \ mkdir -p /public; \ if find /tmp/frontend-dist -mindepth 1 -type f ! -name '.gitkeep' -print -quit | grep -q .; then \ rm -f /tmp/frontend-dist/.gitkeep; \ cp -a /tmp/frontend-dist/. /public/; \ else \ frontend_version="${MOVIEPILOT_FRONTEND_VERSION:-$(sed -n "s/^FRONTEND_VERSION[[:space:]]*=[[:space:]]*'\([^']*\)'/\1/p" /tmp/moviepilot-version.py)}"; \ test -n "${frontend_version}"; \ curl -fsSL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" -o /tmp/frontend.zip; \ if [ -n "${MOVIEPILOT_FRONTEND_SHA256}" ]; then \ printf '%s %s\n' "${MOVIEPILOT_FRONTEND_SHA256}" /tmp/frontend.zip | sha256sum -c -; \ fi; \ busybox unzip -q /tmp/frontend.zip -d /tmp/frontend-release; \ cp -a /tmp/frontend-release/dist/. /public/; \ fi; \ test -f /public/index.html # 准备镜像内置插件 FROM prepare_payload AS prepare_plugins ARG MOVIEPILOT_PLUGINS_REF="main" RUN set -eu; \ test -n "${MOVIEPILOT_PLUGINS_REF}"; \ curl -fsSL "https://github.com/jxxghp/MoviePilot-Plugins/archive/${MOVIEPILOT_PLUGINS_REF}.zip" -o /tmp/plugins.zip; \ busybox unzip -q /tmp/plugins.zip -d /tmp/plugins-src; \ plugin_root="$(find /tmp/plugins-src -mindepth 1 -maxdepth 1 -type d -print -quit)"; \ test -n "${plugin_root}"; \ mkdir -p /plugins; \ cp -a "${plugin_root}/plugins.v3/." /plugins/ # 准备站点资源 FROM prepare_payload AS prepare_resources ARG TARGETARCH ARG MOVIEPILOT_RESOURCES_REF="main" ARG MOVIEPILOT_PYTHON_VARIANT="standard" RUN set -eu; \ test -n "${MOVIEPILOT_RESOURCES_REF}"; \ case "${MOVIEPILOT_PYTHON_VARIANT}" in \ standard) python_ver="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')" ;; \ free-threaded) python_ver="cpython-314t" ;; \ *) printf 'Unsupported Python variant: %s\n' "${MOVIEPILOT_PYTHON_VARIANT}" >&2; exit 1 ;; \ esac; \ target_arch="${TARGETARCH:-$(uname -m)}"; \ case "${target_arch}" in \ arm64|aarch64) suffix="aarch64-linux-gnu" ;; \ amd64|x86_64) suffix="x86_64-linux-gnu" ;; \ *) printf 'Unsupported architecture: %s\n' "${target_arch}" >&2; exit 1 ;; \ esac; \ mkdir -p /resources; \ curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/${MOVIEPILOT_RESOURCES_REF}/resources.v3/user.sites.v3.bin" -o /resources/user.sites.v3.bin; \ curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/${MOVIEPILOT_RESOURCES_REF}/resources.v3/sites.${python_ver}-${suffix}.so" -o "/resources/sites.${python_ver}-${suffix}.so" # 准备容器控制面 FROM prepare_payload AS prepare_control WORKDIR /control COPY docker/ ./ RUN mkdir -p \ /bundle/rootfs/etc/nginx \ /bundle/rootfs/usr/local/bin \ /bundle/rootfs/usr/local/lib/moviepilot/control \ && find . -maxdepth 1 -type f -name '*.sh' ! -name 'launcher.sh' \ -exec cp -f -t /bundle/rootfs/usr/local/lib/moviepilot/control {} + \ && 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 \ && 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 \ && chmod 755 /bundle/rootfs/entrypoint.sh /bundle/rootfs/usr/local/bin/moviepilot \ && chmod 500 /bundle/rootfs/usr/local/lib/moviepilot/control/*.sh # final 阶段安装运行时依赖并配置最终镜像 FROM prepare_browser_deps AS final # jemalloc 默认按 CPU 数×4 建 arena,空闲 arena 的脏页会长期计入 RSS。脏页按 5 秒衰减归还 # (muzzy 直接归还,避免 MADV_FREE 页继续计入 RSS);arena 限制为 8 个,带 GIL 的 Python 大块分配竞争很小。 # 后台回收线程由主进程启动时自行开启,不能写在这里:子进程继承 MALLOC_CONF 后 Chromium 无法启动。 # 用户可在 compose 中覆盖 MALLOC_CONF。 ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so" \ MALLOC_CONF="narenas:8,dirty_decay_ms:5000,muzzy_decay_ms:0" \ MOVIEPILOT_DOCKER_SUPERVISOR="true" # 引入支持 amr 编码的静态 ffmpeg COPY --from=ffmpeg /ffmpeg /ffprobe /usr/local/bin/ COPY --from=rclone /usr/local/bin/rclone /usr/bin/rclone # Python 运行环境 COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH} COPY --from=prepare_venv /opt/python /opt/python COPY --from=uv /uv /usr/local/bin/uv # 准备容器控制面和运行目录 COPY --link --from=prepare_control /bundle/rootfs/ / RUN mkdir -p ${HOME} \ && groupadd -r moviepilot -g 918 \ && useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \ && site_packages="$(${VENV_PATH}/bin/python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')" \ && echo "/app/" > "${site_packages}/app.pth" \ && echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \ && echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \ && echo "zh_CN.UTF-8 UTF-8" >> /etc/locale.gen \ && locale-gen zh_CN.UTF-8 # 系统安全更新层:放在所有大层之后、--link 载荷层之前,刷新时只重建这一小层。 # CI 传入 "-<系统基线>" 作为刷新键,每周最多重建一次;docker/system-baseline # 变化时 COPY 内容变化,同样立即重建。未传键的本地构建按普通缓存规则复用。 # 基线文件写入镜像,供应用内更新判断当前镜像是否满足目标版本的 MIN_SYSTEM_BASELINE。 ARG MOVIEPILOT_SYSTEM_REFRESH_ID="" COPY docker/system-baseline /etc/moviepilot/system-baseline RUN set -eu; \ printf 'System refresh key: %s, baseline: %s\n' \ "${MOVIEPILOT_SYSTEM_REFRESH_ID:-unset}" "$(cat /etc/moviepilot/system-baseline)"; \ apt-get update; \ DEBIAN_FRONTEND=noninteractive apt-get -y --no-install-recommends \ -o Dpkg::Options::=--force-confdef \ -o Dpkg::Options::=--force-confold \ upgrade; \ apt-get clean; \ rm -rf /var/lib/apt/lists/* /var/tmp/* # 载荷按独立更新频率写入镜像,后端源码保持在最后一层。 WORKDIR /app COPY --link --from=prepare_frontend /public /public COPY --link --from=prepare_plugins /plugins /app/app/plugins COPY --link --from=prepare_resources /resources /app/app/application/site COPY --link --from=prepare_backend /app /app EXPOSE 3000 VOLUME [ "${CONFIG_DIR}" ] HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD /usr/bin/curl -fsS "http://127.0.0.1:${PORT:-3001}/health/ready" >/dev/null || exit 1 ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]