chore: нагрузочный замер v2, push образов в реестр (#86, #85)

- 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:
protokeyandClaude Opus 5.5 committed 2026-10-13 22:02:59 +04:00
1 parent 1a4bd8d672
commit 11ba83140d
4 files changed
+86 -23

No files matched your search

+15 -5
View File
@@ -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' \
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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")