# syntax=docker/dockerfile:1.7 # # 有状态端镜像:交易服务(下单购买 + 订单查询/监控),默认 python -m app.trading.main,:31108。 # # 为什么不复用根目录 Dockerfile(抓取服务镜像): # 1. 下单/结算/加购必须用**有头** Chromium——site_interact.py 里 headless 是硬编码 # False(2026-08-14 实测:无头会让购物车/结算 SPA 失灵)。抓取镜像里没有任何 X # 显示,交易进程一 launch 就崩。这里内置 Xvfb 虚拟显示解决。 # 2. 这条链路上有设计好的人工接管点(登录验证码、3DS/OTP、needs_human),远程主机上 # 必须能真的看见那个浏览器窗口 → 内置可选 x11vnc(默认关闭)。 # 3. 页面是日文,证据截图要有日文字形 → fonts-ipafont(抓取侧只取 HTML,不需要)。 # # 同一镜像也能跑下单任务网关(python -m app.gateway.main,:31109):网关不碰浏览器, # 用 RAKUTEN_XVFB_ENABLED=false 关掉虚拟显示即可。 # # 交易服务与网关都**只能单实例**:登录态 cookie 全局唯一、订单监控是常驻轮询、 # SQLite 单连接。不要 --scale,也不要在前面挂多副本负载均衡。 # ----------------------------- builder ----------------------------- FROM registry.jerryyan.top/library/python:3.13-slim AS builder ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ UV_LINK_MODE=copy \ UV_COMPILE_BYTECODE=1 \ # 国内 PyPI 镜像:避免从官方 PyPI 拉包缓慢 UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ # 与根 Dockerfile 相同来源:python3.13-* 标签的 uv 在 /usr/local/bin/ COPY --from=registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ WORKDIR /app # 只装运行期依赖:playwright / aiosqlite 都在 [project].dependencies 主表里, # 交易服务不需要 --extra browser(那是抓取侧的可选兜底)。 COPY uv.lock pyproject.toml ./ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev COPY app ./app RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev # ----------------------------- runtime ----------------------------- FROM registry.jerryyan.top/library/python:3.13-slim AS runtime ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ VIRTUAL_ENV=/app/.venv \ PATH="/app/.venv/bin:${PATH}" \ # rakuten 用户是 --no-create-home 建的;Chromium 启动要写 $HOME/.config、~/.pki, # HOME 指向不存在的目录时会 CHECK 失败直接 SIGTRAP(int3)自杀 HOME=/home/rakuten \ PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright \ # 落库时间戳全是 UTC aware(local_db/task_queue 用 datetime.now(timezone.utc)), # 这里设日本时区只影响日志与调试快照文件名的可读性,不改变任何持久化语义。 TZ=Asia/Tokyo \ # 虚拟显示:Chromium 有头模式必须有 DISPLAY。跑网关时置 false 可完全跳过 Xvfb。 DISPLAY=:99 \ RAKUTEN_XVFB_ENABLED=true \ RAKUTEN_XVFB_SCREEN=1280x1024x24 \ # 远程接管(验证码 / 3DS / needs_human 时肉眼看浏览器):默认关闭。 # 开启前务必设 RAKUTEN_VNC_PASSWORD,且端口只对内网/SSH 隧道开放—— # 这个屏幕上是已登录的真实账号与结算页。 RAKUTEN_VNC_ENABLED=false \ RAKUTEN_VNC_PORT=5900 \ RAKUTEN_TRADING_HOST=0.0.0.0 \ RAKUTEN_TRADING_PORT=31108 \ # 与根镜像保持一致:默认导出 traces 到自建 OTLP(裸跑无鉴权), # 服务名由 app/trading/main.py 显式设为 rakuten-trading。 RAKUTEN_OTEL_ENABLED=true \ RAKUTEN_OTEL_ENDPOINT=https://oltp.jerryyan.top/v1/traces # 有头 Chromium 运行依赖 + Xvfb + 日文字体。 # 显式列包而不用 `playwright install-deps`(后者装的是一整套 apt 源里的当前版本, # 构建结果随源漂移)。Debian t64 过渡把一批库改名(libasound2 → libasound2t64 等), # 所以先整表装一次,失败再逐包回退试 ${pkg}t64,让镜像在 bookworm/trixie 基础镜像上都能建起来。 RUN set -eu; \ { \ if [ -f /etc/apt/sources.list ]; then \ sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list; \ fi; \ if [ -f /etc/apt/sources.list.d/debian.sources ]; then \ sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources; \ fi; \ }; \ PKGS="ca-certificates curl tzdata \ xvfb x11vnc \ fonts-liberation fonts-ipafont-gothic fonts-ipafont-mincho \ libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 \ libcairo2 libcairo-gobject2 libcups2 libdbus-1-3 libdrm2 libgbm1 \ libgdk-pixbuf-2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 \ libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 \ libxkbcommon0 libxrandr2 libxshmfence1 xdg-utils"; \ apt-get update; \ if ! apt-get install -y --no-install-recommends $PKGS; then \ for pkg in $PKGS; do \ apt-get install -y --no-install-recommends "$pkg" \ || apt-get install -y --no-install-recommends "${pkg}t64"; \ done; \ fi; \ rm -rf /var/lib/apt/lists/* # 非 root 账号(uid/gid 与根镜像一致,方便共用宿主机上的 .auth / data 目录属主) RUN groupadd --system --gid 10001 rakuten \ && useradd --system --uid 10001 --gid rakuten --no-create-home --shell /usr/sbin/nologin rakuten WORKDIR /app COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/app /app/app # 只带 login.py:容器里首次产出登录态要用它(配合 VNC 人工过验证码), # scripts/ 下其余都是探针脚本,属开发工具,不进镜像。 COPY scripts/login.py /app/scripts/login.py # 入口:拉起 Xvfb(可选 x11vnc)后 exec 真正的进程,保证信号直达 PID 1。 COPY --chmod=0755 <<'ENTRYPOINT_SH' /usr/local/bin/rakuten-entrypoint.sh #!/bin/sh set -e if [ "${RAKUTEN_XVFB_ENABLED:-true}" = "true" ]; then screen_num="${DISPLAY#:}" socket="/tmp/.X11-unix/X${screen_num%%.*}" Xvfb "$DISPLAY" -screen 0 "${RAKUTEN_XVFB_SCREEN:-1280x1024x24}" -nolisten tcp & # 等 X socket 就绪再放行;否则 Chromium 会以 "Missing X server" 直接失败 waited=0 while [ ! -e "$socket" ]; do waited=$((waited + 1)) if [ "$waited" -ge 100 ]; then echo "Xvfb 10 秒内未就绪($socket 不存在),放弃启动" >&2 exit 1 fi sleep 0.1 done echo "Xvfb 已就绪:DISPLAY=$DISPLAY screen=${RAKUTEN_XVFB_SCREEN:-1280x1024x24}" if [ "${RAKUTEN_VNC_ENABLED:-false}" = "true" ]; then # 有密码就用密码,没有则明文无鉴权——后者只允许在 SSH 隧道/内网里用 if [ -n "${RAKUTEN_VNC_PASSWORD:-}" ]; then mkdir -p /tmp/.vnc x11vnc -storepasswd "$RAKUTEN_VNC_PASSWORD" /tmp/.vnc/passwd >/dev/null 2>&1 auth_args="-rfbauth /tmp/.vnc/passwd" else echo "警告:RAKUTEN_VNC_ENABLED=true 但未设 RAKUTEN_VNC_PASSWORD,VNC 无鉴权" >&2 auth_args="-nopw" fi # shellcheck disable=SC2086 x11vnc -display "$DISPLAY" -forever -shared -bg -quiet \ -rfbport "${RAKUTEN_VNC_PORT:-5900}" $auth_args echo "x11vnc 已启动:端口 ${RAKUTEN_VNC_PORT:-5900}(屏幕上是真实已登录账号,勿暴露公网)" fi fi exec "$@" ENTRYPOINT_SH # 可写目录:logs(Loguru)、data(订单 SQLite + 证据)、.auth(登录态 cookie)、 # .browser-data(login.py 的持久化浏览器目录)、/ms-playwright(Chromium 二进制)、 # /tmp/.X11-unix(Xvfb socket,非 root 也要能建)、/home/rakuten(Chromium 的 ~/.config 等) RUN mkdir -p /app/logs /app/data /app/.auth /app/.browser-data /ms-playwright /tmp/.X11-unix /home/rakuten \ && chmod 1777 /tmp/.X11-unix \ && chown -R rakuten:rakuten /app /ms-playwright /home/rakuten USER rakuten RUN /app/.venv/bin/playwright install chromium # /health 只读缓存登录态,不触发站点请求,适合高频探活。 # 端口取 RAKUTEN_HEALTH_PORT,未设时用交易端口;跑网关的容器把它设成 31109。 HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ CMD curl -fsS http://127.0.0.1:${RAKUTEN_HEALTH_PORT:-${RAKUTEN_TRADING_PORT}}/health || exit 1 # 31108 交易服务,31109 下单任务网关(同镜像换 command),5900 可选 VNC EXPOSE 31108 31109 5900 ENTRYPOINT ["/usr/local/bin/rakuten-entrypoint.sh"] CMD ["python", "-m", "app.trading.main"]