Два процесса на одном хосте получали один 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>
199 lines
14 KiB
Markdown
199 lines
14 KiB
Markdown
# Развёртывание в 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.<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.
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```bash
|
||
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): при старте и затем раз
|
||
в час. Профиль с открытым окном не трогается. Клиент, вернувшийся позже, получает новый профиль и заново проходит
|
||
антибот. Удалить все профили вручную:
|
||
|
||
```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`; порт наружу не открыт).
|
||
- `POSTGRES_PASSWORD` — пароль пользователя `robotex` в Postgres (по умолчанию `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 |
|
||
| `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 почти не тратят, под них слоты можно добавить.
|
||
|
||
Повторить:
|
||
|
||
```bash
|
||
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 короткие.
|