Files
rakuten-api/Dockerfile.trading
T
q792602257 912e10086f fix(docker): 为容器内 rakuten 用户建 home 目录,修复 Chromium 启动 SIGTRAP
rakuten 用户是 useradd --no-create-home 建的,HOME 指向不存在的目录。
Chromium 启动时要写 $HOME/.config、~/.pki,写不了就 CHECK 失败触发
int3(SIGTRAP)自杀,Playwright 侧表现为 BrowserType.launch: Target closed。

- Dockerfile / Dockerfile.trading:ENV 增加 HOME=/home/rakuten,
  mkdir + chown 补上 /home/rakuten
- 抓取镜像同样修:浏览器兜底(browser_fallback)也是非 root 跑 Chromium,
  属同一隐患

实测验证:docker run 加 -e HOME=/tmp 后 chrome 可正常 --dump-dom 输出
2026-08-16 20:59:28 +08:00

180 lines
8.7 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 只读缓存登录态,不触发站点请求,适合高频探活。
# 端口取 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"]