Два процесса на одном хосте получали один WORKER_ID и делили персональную очередь: задачу без url в открытое окно забирал процесс без этого окна. Теперь при старте воркер занимает наименьшее свободное имя worker-N (SET NX на heartbeat-ключ), heartbeat продлевает его только при своём instance; если имя занял другой процесс — воркер останавливается. Имена и персональные очереди переиспользуются. WORKER_ID из env убран, в /health — instance, host, pid. make dev при выходе гасит и api, и worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
Развёртывание в Docker
Быстрый старт
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.<id> у каждого воркера |
redis |
redis:8.2-alpine |
задачи и результаты (TTL RESULT_TTL_HOURS), открытые окна (TTL = простой), heartbeat воркеров |
postgres |
postgres:17-alpine |
клиенты и API-ключи, журнал окон браузера и запросов |
Наружу открыт только порт api. Прокси и ключ 2captcha из запроса передаются только в сообщении очереди и в Redis
не сохраняются.
Воркеров можно запустить несколько (docker compose up -d --scale worker=2): каждый берёт задачи из общей очереди,
а задачи сессии с открытым окном api направляет в персональную очередь воркера, где это окно. Профили сессий лежат
в volume воркера — воркеры на одном хосте делят его.
Имя воркера (worker-1, worker-2, …) не настраивается: при старте каждый процесс занимает в Redis наименьшее
свободное имя, поэтому два воркера на одном хосте не делят персональную очередь, а после перезапусков имена и
очереди переиспользуются. Если имя за время недоступности Redis занял другой процесс, воркер останавливается,
и Docker перезапускает его с новым именем. Где работает воркер — поля host и pid в /health.
Клиенты и ключи
Без ключей API никого не пускает. Ключ показывается один раз при выпуске, в базе хранится только sha256.
make key-create name=acme # выпустить ключ клиенту
make key-list # клиенты: начало ключа, активен или отозван, последнее обращение
make key-revoke id=3 # отозвать
На сервере без make: docker compose exec api python -m db.cli create acme (и list, revoke <id>, stats).
Статистика использования
Воркер пишет в Postgres:
windows— окно браузера: клиент, задача, открывшая окно, время открытия, последнего обращения и закрытия;requests— задача: клиент,v1/v2, время постановки в очередь, начала и конца, итог (status,code,duration_ms), данные задачи без секретов (без логина и пароля прокси, ключа 2captcha, вводимых значений и значений cookies);clients— время создания клиента и последнего обращения.
Сводка по клиентам — число запросов, успешные, среднее и p95 время, окна и часы работы браузера, ошибки по code:
make stats days=7
Ошибка записи в журнал не роняет задачу — только предупреждение в логе воркера. Схема создаётся при старте api и воркера.
Что внутри образа 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/<session_id>— профили браузера (cookies, local storage, fingerprint) поsession_id;extra/geoip_cache.json— кэш геолокации прокси.
Воркер удаляет профили, которыми не пользовались PROFILE_TTL_DAYS дней (по умолчанию 7): при старте и затем раз
в час. Профиль с открытым окном не трогается. Клиент, вернувшийся позже, получает новый профиль и заново проходит
антибот. Удалить все профили вручную:
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; порт наружу не открыт).POSTGRES_PASSWORD— пароль пользователяrobotexв Postgres (по умолчаниюrobotex; порт наружу не открыт). RabbitMQ запускается сconsumer_timeout4 ч: задача может выполняться дольше 30 минут по умолчанию.stop_grace_period: 60sу worker — при остановке он доделывает текущие задачи и закрывает окна.restart: unless-stopped..envподключается, если существует (шаблон —env.example).
Переменные окружения приложения
| Переменная | По умолчанию | Описание |
|---|---|---|
BROWSER_SLOTS |
4 |
worker: сколько окон Chrome открыто одновременно — и выполняющих задачу, и удерживаемых. Задача без свободного слота ждёт в очереди |
WORKER_THREADS |
2 × BROWSER_SLOTS |
worker: потоков dramatiq |
RESULT_TTL_HOURS |
24 |
сколько хранится результат задачи |
PROFILE_TTL_DAYS |
7 |
worker: через сколько дней без использования удаляется профиль браузера |
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 задаётся сам |
DATABASE_URL |
postgresql+asyncpg://robotex:robotex@localhost:5432/robotex |
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).
Замер v2 (очередь, 2026-10-13)
Те же условия: freelancehunt за Cloudflare Turnstile, каждый запрос — новая сессия, прокси gost. Воркер:
4 CPU, 8 ГБ, BROWSER_SLOTS=4. Хост: 16 ядер.
| Режим | Параллельно | Успешно | Медиана, с | Всё, с | Пик CPU | Пик RAM | Прочее |
|---|---|---|---|---|---|---|---|
/v1/solve |
1 | 1/1 | 7 | 7 | 2,5 ядра | 0,6 ГБ | |
/v1/solve |
4 | 4/4 | 40 | 42 | 4,2 | 2,1 ГБ | |
/v1/solve |
8 | 4/8 | 41 | 42 | 4,2 | 2,2 ГБ | 4 — сразу 503 no_slots |
/v2/tasks |
4 | 3/4 | 40 | 144 | 4,2 | 2,2 ГБ | 1 застрял на Cloudflare |
/v2/tasks |
8 | 8/8 | 72 | 157 | 4,2 | 2,2 ГБ | лишние 4 ждали в очереди |
Выводы:
- Очередь работает как задумано: сверх слотов v1 отказывает сразу, v2 ставит задачи в очередь и выполняет все.
- Узкое место прежнее — CPU: 4 одновременных прохождения Cloudflare на 4 ядрах идут ~40 с вместо 7. Если почти
все задачи проходят Cloudflare, ставьте
BROWSER_SLOTS ≈ ядер / 2; удерживаемые окна (browser_idle_timeout) CPU почти не тратят, под них слоты можно добавить.
Повторить:
API_PORT=8001 docker compose --profile loadtest up -d
make key-create name=load
python tests/loadtest.py --api http://127.0.0.1:8001 --api-key rbx_... --levels 1 4 8
python tests/loadtest.py --api http://127.0.0.1:8001 --api-key rbx_... --mode v2 --levels 4 8
Параметры скрипта: --mode v1|v2, --api-key, --url, --selector, --captcha-type, --proxy, --levels.
Каждый запрос создаёт новый профиль в extra/user_data_dir; воркер удалит их через PROFILE_TTL_DAYS.
Reverse proxy
В проде контейнер ставится за Caddy (TLS). Пример Caddyfile:
api.example.com {
reverse_proxy localhost:8000
}
Таймауты прокси для /v1/solve должны быть не меньше timeout запроса (по умолчанию 120 с); запросы v2 короткие.