feat(docker): многоступенчатая сборка с Nuitka и офлайн-режим

api и worker компилируются Nuitka (--mode=standalone) в builder-стадии; в финальном
образе — только скомпилированный бинарник и системные библиотеки, без исходников,
pip и build-инструментов. api/runtime — debian:bookworm-slim без Python вообще;
worker/runtime — python:3.12-slim-bookworm, но Python там нужен только на шаг
установки Chrome. db.cli объединён с сервером в один бинарник (entry_api.bin dbcli ...).

Заодно убрано 51 неиспользуемый пакет из requirements.txt (camoufox, PySide6, moz*-
стек и др., подтверждено графом зависимостей) — он давал больше веса, чем сама
компиляция. make build offline=1 ставит python-зависимости из wheels/ без PyPI.

make test-browser адаптирован под новую сборку: pytest не может подключиться к
internals скомпилированного бинарника, поэтому гоняется против отдельной стадии
test (Chrome из runtime + исходники как обычный интерпретируемый Python).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
s0k0landClaude Sonnet 5 committed 2026-11-06 13:52:28 +03:00
1 parent f21483489a
commit 0a4da80ca9
12 files changed
+218 -100

No files matched your search

+74 -15
View File
@@ -1,39 +1,98 @@
# Сервис worker (#78): выполняет задачи из очереди в Google Chrome под Xvfb.
FROM python:3.12-slim-bookworm
#
# builder: компилирует src/ (через entry_worker.py) в самодостаточный standalone-бинарник Nuitka —
# в финальный образ не попадают ни исходники .py, ни gcc/pip, ни сам requirements.txt.
FROM python:3.12-slim-bookworm AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# build-essential (gcc + libc6-dev — заголовки Python.h тянут stdlib.h/limits.h) — компилятор для
# сгенерированного Nuitka C-кода; patchelf — правит RPATH бинарника в standalone-режиме;
# ccache — ускоряет повторные сборки при пересборке образа
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential patchelf ccache \
&& rm -rf /var/lib/apt/lists/*
# OFFLINE=1: ставить зависимости только из wheels/ (make wheels), без обращения к PyPI — см. docs/Deployment.md
ARG OFFLINE=0
WORKDIR /build
COPY requirements.txt requirements-build.txt ./
COPY wheels/ ./wheels/
RUN set -e; \
if [ "$OFFLINE" = "1" ]; then PIP_OPTS="--no-index --find-links=./wheels"; else PIP_OPTS=""; fi; \
pip install $PIP_OPTS -r requirements.txt -r requirements-build.txt
COPY src/ ./src/
# standalone: entry_worker.dist/ — изолированная папка со своим Python-рантаймом и всеми пакетами, которые
# реально нужны воркеру (patchright, playwright, browserforge, dramatiq, db, tasks, engine, antibot, captcha,
# полный api/ — см. worker/windows.py). worker.actors попадает в сборку только из-за явного import в
# entry_worker.py: dramatiq грузит его по строке "worker.actors" динамически, статика Nuitka это не видит
# --include-package-data: Nuitka копирует только код, а patchright/browserforge везут с собой
# небольшой код + большие данные не-.py (драйвер patchright — Node.js бинарник и JS, browserforge и
# apify_fingerprint_datapoints — zip/json датасеты отпечатков), без них падают с FileNotFoundError в runtime
RUN python -m nuitka --mode=standalone \
--include-package=worker --include-package=engine --include-package=antibot --include-package=captcha \
--include-package=db --include-package=tasks --include-package=api \
--include-package-data=patchright --include-package-data=browserforge \
--include-package-data=apify_fingerprint_datapoints \
--include-data-files=src/api/js/canvas_noise.js=api/js/canvas_noise.js \
--output-dir=/build/dist \
src/entry_worker.py
# runtime: Python тут остаётся — он нужен только на один шаг (установка Chrome через `patchright install`,
# тот же механизм, что и раньше), а дальше запускается готовый standalone-бинарник, а не исходники из
# requirements.txt. Полный интерпретатор здесь — осознанный компромисс: переносить apt-зависимости Chrome
# между стадиями вручную хрупко (patchright сам решает, что ставить под конкретную версию Chrome).
FROM python:3.12-slim-bookworm AS runtime
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Xvfb — браузер запускается в headful-режиме (headless=False), нужен виртуальный дисплей
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini xvfb xauth fonts-liberation fonts-noto-color-emoji ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
# real_chrome=True → channel "chrome": ставим Google Chrome stable вместе с системными зависимостями
RUN patchright install --with-deps chrome \
&& rm -rf /var/lib/apt/lists/*
# real_chrome=True → channel "chrome": ставим Google Chrome stable вместе с системными зависимостями.
# Версия patchright — как в requirements.txt (#109): её CLI и сам пакет worker'у в runtime не нужны,
# только Chrome и его системные библиотеки, которые он поставит через apt
ARG OFFLINE=0
COPY wheels/ /tmp/wheels/
RUN set -e; \
if [ "$OFFLINE" = "1" ]; then PIP_OPTS="--no-index --find-links=/tmp/wheels"; else PIP_OPTS=""; fi; \
pip install $PIP_OPTS patchright==1.63.0 \
&& patchright install --with-deps chrome \
&& pip uninstall -y patchright \
&& rm -rf /tmp/wheels /var/lib/apt/lists/* /root/.cache
RUN useradd --create-home --uid 1000 app \
&& mkdir -p /app/extra \
&& chown -R app:app /app
COPY --chown=app:app src/ ./src/
WORKDIR /app
COPY --from=builder --chown=app:app /build/dist/entry_worker.dist/ ./
USER app
WORKDIR /app/src
# профили браузера (extra/user_data_dir) и geoip-кэш — в volume
VOLUME ["/app/extra"]
# tini как PID 1: пробрасывает сигналы и подбирает осиротевшие процессы Chrome/Xvfb (иначе — зомби)
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
# один процесс: открытые окна браузера живут в его памяти; потоков — с запасом над слотами, чтобы
# задачи в уже открытые окна не ждали, пока потоки заняты задачами без свободного слота
CMD ["sh", "-c", "exec xvfb-run -a --server-args='-screen 0 1920x1080x24' dramatiq worker.actors --processes 1 --threads ${WORKER_THREADS:-$((${BROWSER_SLOTS:-4} * 2))}"]
# BROWSER_SLOTS/WORKER_THREADS читает сам entry_worker.bin (--processes 1, потоков — вдвое больше слотов)
CMD ["sh", "-c", "exec xvfb-run -a --server-args='-screen 0 1920x1080x24' ./entry_worker.bin"]
# test (make test-browser; не публикуется, не используется по умолчанию — docker build должен указывать
# --target=runtime): entry_worker.bin — скомпилированный бинарник, pytest/tests/harness.py не может
# подключиться к его internals напрямую. Этой стадии нужны тот же Chrome/Xvfb из runtime и исходники
# как обычный интерпретируемый Python рядом с бинарником — они не конфликтуют (разные пути)
FROM runtime AS test
USER root
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=app:app src/ ./src/
USER app
WORKDIR /app