- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a4bd8d672
commit
11ba83140d
4 files changed
+86
-23
No files matched your search
@@ -5,12 +5,13 @@ WHEELS_DIR := wheels
|
|||||||
REQUIREMENTS := requirements.txt
|
REQUIREMENTS := requirements.txt
|
||||||
ver ?= latest
|
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))
|
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 — сторонние, кладутся в релиз
|
# образы сервисов: api — без Chrome, worker — Chrome + Xvfb; rabbitmq и redis — сторонние, кладутся в релиз
|
||||||
|
REGISTRY := docker.sokolab.xyz
|
||||||
API_IMAGE := robotex-api
|
API_IMAGE := robotex-api
|
||||||
WORKER_IMAGE := robotex-worker
|
WORKER_IMAGE := robotex-worker
|
||||||
DEPS_IMAGES := rabbitmq:4.1-alpine redis:8.2-alpine postgres:17-alpine
|
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 wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
|
||||||
@echo "make clean - удалить $(WHEELS_DIR)/"
|
@echo "make clean - удалить $(WHEELS_DIR)/"
|
||||||
@echo "make build [ver=latest] - собрать образы $(API_IMAGE):<ver> и $(WORKER_IMAGE):<ver>"
|
@echo "make build [ver=latest] - собрать образы $(API_IMAGE):<ver> и $(WORKER_IMAGE):<ver>"
|
||||||
|
@echo "make push ver=<ver> - собрать образы и отправить в $(REGISTRY)"
|
||||||
@echo "make release ver=<ver> - собрать образы и пакет для деплоя в build/<ver>/ (compose, .env, образы .tar.gz)"
|
@echo "make release ver=<ver> - собрать образы и пакет для деплоя в build/<ver>/ (compose, .env, образы .tar.gz)"
|
||||||
@echo "make up - собрать и запустить локально: api, worker, rabbitmq, redis"
|
@echo "make up - собрать и запустить локально: api, worker, rabbitmq, redis"
|
||||||
@echo "make down - остановить локальный стенд"
|
@echo "make down - остановить локальный стенд"
|
||||||
@@ -71,6 +73,11 @@ build:
|
|||||||
docker build -f Dockerfile.api -t $(API_IMAGE):$(ver) .
|
docker build -f Dockerfile.api -t $(API_IMAGE):$(ver) .
|
||||||
docker build -t $(WORKER_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
|
up: build
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
@@ -111,7 +118,7 @@ test-browser: build
|
|||||||
|
|
||||||
RELEASE_DIR := build/$(ver)
|
RELEASE_DIR := build/$(ver)
|
||||||
|
|
||||||
ifneq ($(filter release,$(MAKECMDGOALS)),)
|
ifneq ($(filter release push,$(MAKECMDGOALS)),)
|
||||||
ifeq ($(ver),latest)
|
ifeq ($(ver),latest)
|
||||||
$(error Укажите версию: make release ver=v0.0.2)
|
$(error Укажите версию: make release ver=v0.0.2)
|
||||||
endif
|
endif
|
||||||
@@ -120,13 +127,16 @@ endif
|
|||||||
release: build
|
release: build
|
||||||
@echo "make release $(ver) -> $(RELEASE_DIR)/"
|
@echo "make release $(ver) -> $(RELEASE_DIR)/"
|
||||||
@mkdir -p $(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
|
docker-compose.prod.yml > $(RELEASE_DIR)/docker-compose.yml
|
||||||
cp dot_env $(RELEASE_DIR)/.env
|
cp dot_env $(RELEASE_DIR)/.env
|
||||||
for img in $(DEPS_IMAGES); do docker image inspect $$img >/dev/null 2>&1 || docker pull $$img; done
|
for img in $(DEPS_IMAGES); do docker image inspect $$img >/dev/null 2>&1 || docker pull $$img; done
|
||||||
# TODO: я хочу парамтером управлять сохранить в файл или отправить в свой реджестри
|
# TODO: я хочу парамтером управлять сохранить в файл или отправить в свой реджестри
|
||||||
# if produce == file:
|
# 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:
|
# else:
|
||||||
# docker push docker.sokolab.xyz/$(API_IMAGE):$(ver) docker.sokolab.xyz/$(WORKER_IMAGE):$(ver) docker.sokolab.xyz/$(DEPS_IMAGES)
|
# docker push docker.sokolab.xyz/$(API_IMAGE):$(ver) docker.sokolab.xyz/$(WORKER_IMAGE):$(ver) docker.sokolab.xyz/$(DEPS_IMAGES)
|
||||||
@printf '%s\n' \
|
@printf '%s\n' \
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ x-backend-env: &backend-env
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
image: robotex-api:latest
|
image: docker.sokolab.xyz/robotex-api:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-8000}:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
postgres: {condition: service_healthy}
|
postgres: {condition: service_healthy}
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
image: robotex-worker:latest
|
image: docker.sokolab.xyz/robotex-worker:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env
|
- path: .env
|
||||||
|
|||||||
+27
-5
@@ -147,17 +147,39 @@ docker compose exec worker sh -c 'rm -rf /app/extra/user_data_dir/*'
|
|||||||
- Удерживаемые окна (`browser_idle_timeout`) занимают слоты, но почти не тратят CPU.
|
- Удерживаемые окна (`browser_idle_timeout`) занимают слоты, но почти не тратят CPU.
|
||||||
- Сверх `BROWSER_SLOTS` задачи v2 ждут в очереди, а `/v1/solve` сразу получает `503` с `code: no_slots`.
|
- Сверх `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
|
```bash
|
||||||
API_PORT=8001 docker compose --profile loadtest up -d
|
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`. Каждый запрос создаёт новый профиль
|
Параметры скрипта: `--mode v1|v2`, `--api-key`, `--url`, `--selector`, `--captcha-type`, `--proxy`, `--levels`.
|
||||||
в `extra/user_data_dir` — после прогона их стоит почистить.
|
Каждый запрос создаёт новый профиль в `extra/user_data_dir`; воркер удалит их через `PROFILE_TTL_DAYS`.
|
||||||
|
|
||||||
## Reverse proxy
|
## Reverse proxy
|
||||||
|
|
||||||
|
|||||||
+42
-11
@@ -1,8 +1,9 @@
|
|||||||
"""Нагрузочный тест /v1/solve: N параллельных запросов на каждом уровне + пиковое потребление
|
"""Нагрузочный тест: N параллельных запросов на каждом уровне (каждый — в своей сессии) + пиковое
|
||||||
контейнера по `docker stats`.
|
потребление контейнера воркера по `docker stats`.
|
||||||
|
|
||||||
docker compose --profile loadtest up -d
|
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,
|
Помимо status ответа, неуспех раскладывается по причинам: нет слотов, застряли на Cloudflare,
|
||||||
упало действие, исключение до действий.
|
упало действие, исключение до действий.
|
||||||
@@ -82,12 +83,43 @@ class StatsSampler(threading.Thread):
|
|||||||
self.stop_event.wait(1)
|
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()
|
t0 = time.monotonic()
|
||||||
try:
|
async with httpx.AsyncClient(timeout=args.http_timeout, trust_env=False, headers=headers) as client:
|
||||||
resp = await client.post(f"{api}/v1/solve", json=payload)
|
try:
|
||||||
except Exception as e:
|
if args.mode == "v2":
|
||||||
resp = e
|
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
|
return classify(resp), time.monotonic() - t0
|
||||||
|
|
||||||
|
|
||||||
@@ -95,9 +127,7 @@ async def run_level(n: int, args) -> None:
|
|||||||
sampler = StatsSampler(args.container)
|
sampler = StatsSampler(args.container)
|
||||||
sampler.start()
|
sampler.start()
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
headers = {"X-API-Key": args.api_key} if args.api_key else {}
|
results = await asyncio.gather(*(one(args, build_payload(args)) for _ in range(n)))
|
||||||
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)))
|
|
||||||
wall = time.monotonic() - t0
|
wall = time.monotonic() - t0
|
||||||
sampler.stop_event.set()
|
sampler.stop_event.set()
|
||||||
sampler.join()
|
sampler.join()
|
||||||
@@ -122,6 +152,7 @@ async def main():
|
|||||||
p.add_argument("--selector", default='a[href*="/project/"]')
|
p.add_argument("--selector", default='a[href*="/project/"]')
|
||||||
p.add_argument("--proxy", default="http://proxy:8080")
|
p.add_argument("--proxy", default="http://proxy:8080")
|
||||||
p.add_argument("--captcha-type", default="cloudflare")
|
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)")
|
p.add_argument("--api-key", default=os.getenv("ROBOTEX_API_KEY"), help="X-API-Key (или ROBOTEX_API_KEY)")
|
||||||
# Chrome работает в воркере — его docker stats и снимаем
|
# Chrome работает в воркере — его docker stats и снимаем
|
||||||
p.add_argument("--container", default="robotex-worker-1")
|
p.add_argument("--container", default="robotex-worker-1")
|
||||||
|
|||||||
Reference in new issue
Block a user