# Развёртывание в Docker ## Быстрый старт ```bash make up # собрать образы и запустить api, worker, rabbitmq, redis make smoke # проверить: /health, /v1/solve, /v2/tasks, удержание окна make logs # логи api и worker make down ``` API доступно на `http://localhost:8000`. ## Сервисы ``` клиент ─▶ api ──▶ RabbitMQ ──▶ worker (Chrome + Xvfb) ──▶ Redis ◀── api ``` | Сервис | Образ | Что делает | |---|---|---| | `api` | `robotex-api` (`Dockerfile.api`, без Chrome) | принимает задачи, ставит в очередь, отдаёт результаты из Redis; `/v1/solve` ждёт результат сам | | `worker` | `robotex-worker` (`Dockerfile`) | `dramatiq worker.actors`: выполняет задачи в Chrome, держит окна сессий (`browser_idle_timeout`) | | `rabbitmq` | `rabbitmq:4.1-alpine` | очереди: общая `robotex` и персональная `robotex.worker.` у каждого воркера | | `redis` | `redis:8.2-alpine` | задачи и результаты (TTL `RESULT_TTL_HOURS`), открытые окна (TTL = простой), heartbeat воркеров | Наружу открыт только порт `api`. Прокси и ключ 2captcha из запроса передаются только в сообщении очереди и в Redis не сохраняются. Воркеров можно запустить несколько (`docker compose up -d --scale worker=2`): каждый берёт задачи из общей очереди, а задачи сессии с открытым окном api направляет в персональную очередь воркера, где это окно. Профили сессий лежат в volume воркера — воркеры на одном хосте делят его. ## Что внутри образа worker | Компонент | Зачем | |---|---| | `python:3.12-slim-bookworm` | базовый образ, Python той же версии, что и в `.venv` | | `requirements.txt` | ставится из PyPI (папка `wheels/` в образ не попадает — она под Ubuntu Noble и неполная) | | Google Chrome stable (`patchright install --with-deps chrome`) | сессия запускается с `real_chrome=True` → channel `chrome` | | Xvfb (`xvfb-run`) | браузер работает в headful-режиме (`headless=False`), нужен виртуальный дисплей 1920x1080x24 | | `tini` (ENTRYPOINT, PID 1) | пробрасывает сигналы и подбирает осиротевшие процессы Chrome/Xvfb — без него копятся зомби-процессы | | пользователь `app` (uid 1000) | Chrome не запускается от root без `--no-sandbox` | Рабочая директория — `/app/src`. Воркер — один процесс dramatiq (`--processes 1`: открытые окна живут в его памяти), потоков `WORKER_THREADS` (по умолчанию `2 × BROWSER_SLOTS`). ## Данные и volume `/app/extra` воркера вынесен в volume (`extra` локально, `api-data` в релизе): - `extra/user_data_dir/` — профили браузера (cookies, local storage, fingerprint) по `session_id`; - `extra/geoip_cache.json` — кэш геолокации прокси. Профили со временем накапливаются — чистить вручную: ```bash docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*' ``` ## Настройки compose - `shm_size: 2gb` у worker — Chrome активно использует `/dev/shm`, дефолтных 64 МБ не хватает (вкладки падают с «Aw, Snap»). - `API_PORT` — порт api на хосте (по умолчанию 8000): `API_PORT=8001 docker compose up -d`. - `WORKER_CPUS` / `WORKER_MEM_LIMIT` — потолок ресурсов воркера (по умолчанию 4 CPU / 8 ГБ, swap выключен). При переборе памяти OOM-killer убивает процесс Chrome внутри контейнера, хост не страдает. - `RABBITMQ_PASSWORD` — пароль пользователя `robotex` в RabbitMQ (по умолчанию `robotex`; порт наружу не открыт). RabbitMQ запускается с `consumer_timeout` 4 ч: задача может выполняться дольше 30 минут по умолчанию. - `stop_grace_period: 60s` у worker — при остановке он доделывает текущие задачи и закрывает окна. - `restart: unless-stopped`. - `.env` подключается, если существует (шаблон — `env.example`). ## Переменные окружения приложения | Переменная | По умолчанию | Описание | |---|---|---| | `BROWSER_SLOTS` | `4` | worker: сколько окон Chrome открыто одновременно — и выполняющих задачу, и удерживаемых. Задача без свободного слота ждёт в очереди | | `WORKER_THREADS` | `2 × BROWSER_SLOTS` | worker: потоков dramatiq | | `WORKER_ID` | hostname | worker: имя воркера и его персональной очереди | | `RESULT_TTL_HOURS` | `24` | сколько хранится результат задачи | | `V1_QUEUE_WAIT` | `30` | api: сколько `/v1/solve` ждёт, пока воркер возьмёт задачу; дальше `503` | | `NO_SLOTS_RETRY_AFTER` | `5` | api: значение `Retry-After` (с) в ответе `503` | | `RABBITMQ_URL` | `amqp://guest:guest@localhost:5672/` | api, worker; в compose задаётся сам | | `REDIS_URL` | `redis://localhost:6379/0` | api, worker; в compose задаётся сам | | `LOG_LEVEL` | `INFO` | `DEBUG` — полный дамп запроса/ответа (секреты клиента маскируются) | ## Нагрузка Замер: `freelancehunt.com/projects` за Cloudflare Turnstile, `captcha_type: cloudflare`, ожидание списка проектов, каждый запрос — новый профиль. Прокси — `gost` из профиля `loadtest`. Хост: 16 ядер, 31 ГБ. | Лимит | Параллельно | Успешно | Медиана, с | Пик CPU | Пик RAM | |---|---|---|---|---|---| | 4 CPU | 1 | 1/1 | 5 | 3,5 ядра | 0,5 ГБ | | 4 CPU | 5 | 3/5 | 68 | 4,1 | 2,8 ГБ | | 4 CPU | 10 | 4/10 | 229 | 4,2 | 6,0 ГБ | | 4 CPU | 15 | 3/15 | 261 | 4,2 | 6,1 ГБ | | 12 CPU | 3 | 3/3 | 8 | 7,2 | 1,4 ГБ | | 12 CPU | 5 | 4/5 | 8 | 12,1 | 2,4 ГБ | | 12 CPU | 10 | 9/10 | 23 | 12,3 | 4,7 ГБ | Выводы: - Узкое место — **CPU**. Один Chrome на прохождении Turnstile съедает 2–3,5 ядра. Когда процессора не хватает, время растёт в десятки раз, а Cloudflare перестаёт пропускать (на 4 CPU и 10 параллельных — 6 из 10 застряли на челлендже). - Память — около **0,5–0,6 ГБ** на браузер. - Правило подбора: `BROWSER_SLOTS ≈ число ядер`, `WORKER_MEM_LIMIT ≈ 0,6 ГБ × BROWSER_SLOTS + 1 ГБ`. Пример: сервер 4 ядра / 8 ГБ → 3–4 слота. `BROWSER_SLOTS` задаётся в `.env`. - Удерживаемые окна (`browser_idle_timeout`) занимают слоты, но почти не тратят CPU. - Сверх `BROWSER_SLOTS` задачи v2 ждут в очереди, а `/v1/solve` сразу получает `503` с `code: no_slots`. Замеры выше сделаны на v1 (один сервис, Chrome в api); схема нагрузки на Chrome с очередью та же. Повторить замер: ```bash API_PORT=8001 docker compose --profile loadtest up -d python tests/loadtest.py --api http://127.0.0.1:8001 --levels 1 5 10 ``` Параметры скрипта: `--url`, `--selector`, `--captcha-type`, `--proxy`, `--levels`. Каждый запрос создаёт новый профиль в `extra/user_data_dir` — после прогона их стоит почистить. ## Reverse proxy В проде контейнер ставится за Caddy (TLS). Пример `Caddyfile`: ``` api.example.com { reverse_proxy localhost:8000 } ``` Таймауты прокси для `/v1/solve` должны быть не меньше `timeout` запроса (по умолчанию 120 с); запросы v2 короткие.