Files
robotex/docs/Deployment.md
T
protokeyandClaude Opus 5.5 502b6e1003 fix: имя воркера — свободное worker-N из Redis, а не hostname (#110)
Два процесса на одном хосте получали один 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>
2026-10-14 15:02:30 +04:00

199 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Развёртывание в 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 короткие.