- 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
|
||||
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):<ver> и $(WORKER_IMAGE):<ver>"
|
||||
@echo "make push ver=<ver> - собрать образы и отправить в $(REGISTRY)"
|
||||
@echo "make release ver=<ver> - собрать образы и пакет для деплоя в build/<ver>/ (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' \
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-5
@@ -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
|
||||
|
||||
|
||||
+42
-11
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user