Files
rakuten-api/Dockerfile.trading
T
q792602257andClaude Opus 5 3284e086ad feat(trading): 浏览器掉线兜底——任务边界自愈重建,中途掉线转 needs_human
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>
2026-08-20 16:01:47 +08:00

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"]