From 11ba83140d183ec6d0b2889a13e30f1986a1e0d5 Mon Sep 17 00:00:00 2001 From: protokey Date: Tue, 13 Oct 2026 22:02:59 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D0=BD=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=20v2,=20push=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=20(#86,=20#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadtest: у каждого запроса своя сессия, режим --mode v2 (задача + опрос), X-API-Key; замер v1/v2 в docs/Deployment.md. - make push ver=... — образы robotex-api и robotex-worker в docker.sokolab.xyz; prod-compose и make release ссылаются на образы реестра. Co-Authored-By: Claude Opus 5.5 --- Makefile | 20 ++++++++++++---- docker-compose.prod.yml | 4 ++-- docs/Deployment.md | 32 +++++++++++++++++++++---- tests/loadtest.py | 53 ++++++++++++++++++++++++++++++++--------- 4 files changed, 86 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index 1fcb6d4..a587fc4 100644 --- a/Makefile +++ b/Makefile @@ -5,12 +5,13 @@ WHEELS_DIR := wheels REQUIREMENTS := requirements.txt ver ?= latest -MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats +MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats push PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) -.PHONY: install freeze wheels sync sync-offline clean help venv build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats +.PHONY: install freeze wheels sync sync-offline clean help venv build release sync-dev test test-browser up down logs smoke key-create key-list key-revoke stats push # образы сервисов: api — без Chrome, worker — Chrome + Xvfb; rabbitmq и redis — сторонние, кладутся в релиз +REGISTRY := docker.sokolab.xyz API_IMAGE := robotex-api WORKER_IMAGE := robotex-worker DEPS_IMAGES := rabbitmq:4.1-alpine redis:8.2-alpine postgres:17-alpine @@ -25,6 +26,7 @@ help: @echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)" @echo "make clean - удалить $(WHEELS_DIR)/" @echo "make build [ver=latest] - собрать образы $(API_IMAGE): и $(WORKER_IMAGE):" + @echo "make push ver= - собрать образы и отправить в $(REGISTRY)" @echo "make release ver= - собрать образы и пакет для деплоя в build// (compose, .env, образы .tar.gz)" @echo "make up - собрать и запустить локально: api, worker, rabbitmq, redis" @echo "make down - остановить локальный стенд" @@ -71,6 +73,11 @@ build: docker build -f Dockerfile.api -t $(API_IMAGE):$(ver) . docker build -t $(WORKER_IMAGE):$(ver) . +push: build + for img in $(API_IMAGE) $(WORKER_IMAGE); do \ + docker tag $$img:$(ver) $(REGISTRY)/$$img:$(ver) && docker push $(REGISTRY)/$$img:$(ver) || exit 1; \ + done + up: build docker compose up -d @@ -111,7 +118,7 @@ test-browser: build RELEASE_DIR := build/$(ver) -ifneq ($(filter release,$(MAKECMDGOALS)),) +ifneq ($(filter release push,$(MAKECMDGOALS)),) ifeq ($(ver),latest) $(error Укажите версию: make release ver=v0.0.2) endif @@ -120,13 +127,16 @@ endif release: build @echo "make release $(ver) -> $(RELEASE_DIR)/" @mkdir -p $(RELEASE_DIR) - sed -e 's|image: $(API_IMAGE):.*|image: $(API_IMAGE):$(ver)|' -e 's|image: $(WORKER_IMAGE):.*|image: $(WORKER_IMAGE):$(ver)|' \ + sed -e 's|image: .*$(API_IMAGE):.*|image: $(REGISTRY)/$(API_IMAGE):$(ver)|' \ + -e 's|image: .*$(WORKER_IMAGE):.*|image: $(REGISTRY)/$(WORKER_IMAGE):$(ver)|' \ docker-compose.prod.yml > $(RELEASE_DIR)/docker-compose.yml cp dot_env $(RELEASE_DIR)/.env for img in $(DEPS_IMAGES); do docker image inspect $$img >/dev/null 2>&1 || docker pull $$img; done # TODO: я хочу парамтером управлять сохранить в файл или отправить в свой реджестри # if produce == file: - docker save $(API_IMAGE):$(ver) $(WORKER_IMAGE):$(ver) $(DEPS_IMAGES) | gzip > $(RELEASE_DIR)/robotex-$(ver).tar.gz + docker tag $(API_IMAGE):$(ver) $(REGISTRY)/$(API_IMAGE):$(ver) + docker tag $(WORKER_IMAGE):$(ver) $(REGISTRY)/$(WORKER_IMAGE):$(ver) + docker save $(REGISTRY)/$(API_IMAGE):$(ver) $(REGISTRY)/$(WORKER_IMAGE):$(ver) $(DEPS_IMAGES) | gzip > $(RELEASE_DIR)/robotex-$(ver).tar.gz # else: # docker push docker.sokolab.xyz/$(API_IMAGE):$(ver) docker.sokolab.xyz/$(WORKER_IMAGE):$(ver) docker.sokolab.xyz/$(DEPS_IMAGES) @printf '%s\n' \ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 16d035b..efef01f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -5,7 +5,7 @@ x-backend-env: &backend-env services: api: - image: robotex-api:latest + image: docker.sokolab.xyz/robotex-api:latest restart: unless-stopped ports: - "${API_PORT:-8000}:8000" @@ -19,7 +19,7 @@ services: postgres: {condition: service_healthy} worker: - image: robotex-worker:latest + image: docker.sokolab.xyz/robotex-worker:latest restart: unless-stopped env_file: - path: .env diff --git a/docs/Deployment.md b/docs/Deployment.md index a7e443b..e7a8413 100644 --- a/docs/Deployment.md +++ b/docs/Deployment.md @@ -147,17 +147,39 @@ docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*' - Удерживаемые окна (`browser_idle_timeout`) занимают слоты, но почти не тратят CPU. - Сверх `BROWSER_SLOTS` задачи v2 ждут в очереди, а `/v1/solve` сразу получает `503` с `code: no_slots`. -Замеры выше сделаны на v1 (один сервис, Chrome в api); схема нагрузки на Chrome с очередью та же. +Замеры выше сделаны на 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 -python tests/loadtest.py --api http://127.0.0.1:8001 --levels 1 5 10 +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 ``` -Параметры скрипта: `--url`, `--selector`, `--captcha-type`, `--proxy`, `--levels`. Каждый запрос создаёт новый профиль -в `extra/user_data_dir` — после прогона их стоит почистить. +Параметры скрипта: `--mode v1|v2`, `--api-key`, `--url`, `--selector`, `--captcha-type`, `--proxy`, `--levels`. +Каждый запрос создаёт новый профиль в `extra/user_data_dir`; воркер удалит их через `PROFILE_TTL_DAYS`. ## Reverse proxy diff --git a/tests/loadtest.py b/tests/loadtest.py index 2cf2abc..9c6626e 100644 --- a/tests/loadtest.py +++ b/tests/loadtest.py @@ -1,8 +1,9 @@ -"""Нагрузочный тест /v1/solve: N параллельных запросов на каждом уровне + пиковое потребление -контейнера по `docker stats`. +"""Нагрузочный тест: N параллельных запросов на каждом уровне (каждый — в своей сессии) + пиковое +потребление контейнера воркера по `docker stats`. docker compose --profile loadtest up -d - python tests/loadtest.py --levels 1 5 10 15 + python tests/loadtest.py --api-key rbx_... --levels 1 5 10 15 # /v1/solve + python tests/loadtest.py --api-key rbx_... --mode v2 --levels 4 8 # /v2/tasks + опрос Помимо status ответа, неуспех раскладывается по причинам: нет слотов, застряли на Cloudflare, упало действие, исключение до действий. @@ -82,12 +83,43 @@ class StatsSampler(threading.Thread): self.stop_event.wait(1) -async def one(client: httpx.AsyncClient, api: str, payload: dict) -> tuple[str, float]: +class TaskResult: + """Ответ /v2 в виде, понятном classify(): status_code и json().""" + + def __init__(self, status_code: int, data: dict): + self.status_code, self._data = status_code, data + + def json(self) -> dict: + return self._data + + +async def solve_v2(client: httpx.AsyncClient, api: str, payload: dict): + created = await client.post(f"{api}/v2/tasks", json=payload) + if created.status_code != 202: + return created + url = f"{api}{created.json()['url']}" + while True: + await asyncio.sleep(0.5) + task = (await client.get(url)).json() + if task["status"] == "done": + return TaskResult(200, task["result"]) + if task["status"] not in ("queued", "running"): + return TaskResult(200, {"status": "err", "code": task["status"], "error": task.get("error")}) + + +async def one(args, payload: dict) -> tuple[str, float]: + # у каждого запроса своя сессия: общий клиент запомнил бы cookie session_id, и задачи одной + # сессии выполнялись бы по очереди + headers = {"X-API-Key": args.api_key} if args.api_key else {} t0 = time.monotonic() - try: - resp = await client.post(f"{api}/v1/solve", json=payload) - except Exception as e: - resp = e + async with httpx.AsyncClient(timeout=args.http_timeout, trust_env=False, headers=headers) as client: + try: + if args.mode == "v2": + resp = await solve_v2(client, args.api, payload) + else: + resp = await client.post(f"{args.api}/v1/solve", json=payload) + except Exception as e: + resp = e return classify(resp), time.monotonic() - t0 @@ -95,9 +127,7 @@ async def run_level(n: int, args) -> None: sampler = StatsSampler(args.container) sampler.start() t0 = time.monotonic() - headers = {"X-API-Key": args.api_key} if args.api_key else {} - async with httpx.AsyncClient(timeout=args.http_timeout, trust_env=False, headers=headers) as client: - results = await asyncio.gather(*(one(client, args.api, build_payload(args)) for _ in range(n))) + results = await asyncio.gather(*(one(args, build_payload(args)) for _ in range(n))) wall = time.monotonic() - t0 sampler.stop_event.set() sampler.join() @@ -122,6 +152,7 @@ async def main(): p.add_argument("--selector", default='a[href*="/project/"]') p.add_argument("--proxy", default="http://proxy:8080") p.add_argument("--captcha-type", default="cloudflare") + p.add_argument("--mode", choices=["v1", "v2"], default="v1", help="v1 — /v1/solve, v2 — /v2/tasks с опросом") p.add_argument("--api-key", default=os.getenv("ROBOTEX_API_KEY"), help="X-API-Key (или ROBOTEX_API_KEY)") # Chrome работает в воркере — его docker stats и снимаем p.add_argument("--container", default="robotex-worker-1")