SiteInteractor 的 Chromium 是进程级单例,此前启动后即假定永远活着:全仓唯一的 is_connected() 探活在 scraping 侧,交易侧既不探活也不重启。容器里 Chromium 崩溃 是有真实前提的(/dev/shm 不足、OOM kill、seccomp 挡 sandbox,docker-compose.yml 里已有相关注释),一旦发生,进程还活着但之后每一单都会失败,且 /health 恒返回 ok,restart: unless-stopped 永远不会被触发。 更隐蔽的一条:clear_cart / enter_checkout / pay 等处的 new_page()、context.request 写在 try 之外,掉线抛的 TargetClosedError 不是 AppError,会穿过 runner 的 except AppError 落到主循环那个只记日志的兜底里——任务一次都不上报,网关侧要干等 整个 lease_ttl(默认 300s)才被 sweep 置 stale。clear_cart 是 execute() 的 step 0, 浏览器死时最先撞上的正是它。 分三层处理: - 任务边界自愈。_launch() 从 start() 抽出,_context_options() 统一 context 参数 (重建必须与启动完全一致,指纹漂移就是一次风控事件);_refresh_context_if_stale 改名 _ensure_context_ready(),先探活重建再做原有的 storage_state mtime 检查 (顺序不可换,mtime 重建要用 self._browser)。重建前丢弃 _checkout_pages 里的 残留确认页并记 warning——那些 Page 已随浏览器一起没了。 - 中途掉线不重建,抛 BrowserDeadError(新增,5006)。新增 _new_page() 与 _request() 两个壳收口裸异常;_request() 只在确认浏览器真死了时才改写异常, 站点 5xx 这类正常业务失败原样抛出。submit_order / pay 入口用 _require_live_browser() 直接拒绝:这两步复用 enter_checkout 留存的 Page, 重建救不回服务端订单草稿,而 pay 跑的时候订单已经真的提交了。顺带修掉一个 误诊——浏览器死时 submit_order 原先报「未找到确认按钮」,把「浏览器崩了」 说成「站点改版了」,两者的处置方式完全不同。 - runner 把 BrowserDeadError 转 needs_human 而非 failed,except 分支排在 except AppError 之前(子类,顺序反了就报 failed)。掉线发生在动作中途, 站点侧生效与否无从判断,不能给上游「明确失败」的结论。 /health 暴露 browser 状态,掉线时 degraded + HTTP 503 + code 5006,让 Dockerfile.trading 的 HEALTHCHECK 探到并重启容器。重启不会导致重复下单:网关侧 任务绝不自动重投,租约过期只置 stale 等人工 reclaim(docs/order-gateway.md §5), 重启只是恢复领新任务的能力。启动窗口期 started=False 不算掉线。 README 错误码表补 5005(此前遗漏)与 5006。 新增 15 个用例覆盖探活三态、is_connected() 自身抛错、边界重建/不重建/丢弃残留页/ 重建失败、两个包装壳的分支、submit/pay 拒绝、runner 转 needs_human、/health 503。 真实 Chromium 崩溃无法在离线测试里制造,用例模拟的是 is_connected() 返回 False 这个唯一可观测信号,覆盖的是代码对该信号的反应而非崩溃本身;容器 HEALTHCHECK 真的触发重启这条链路尚未实跑验证。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
9.2 KiB
Docker
185 lines
9.2 KiB
Docker
# 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 只读缓存登录态,不触发站点请求,适合高频探活。
|
|
# 交易服务的 /health 在 Playwright 浏览器掉线时返回 503(curl -f 视为失败),
|
|
# 于是这里的 retries 用尽后容器被 restart 掉——进程还活着但浏览器已死时,所有站点
|
|
# 操作都做不了,重启是最后一道兜底。重启不会导致重复下单:网关侧任务绝不自动重投
|
|
# (租约过期只置 stale 等人工 reclaim,见 docs/order-gateway.md §5)。
|
|
# start-period 覆盖启动期,那时 browser 还没 start(),不算掉线。
|
|
# 端口取 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"]
|