Как решать капчу в Locust, не искажая статистику нагрузочного теста

locust captcha - How to Solve CAPTCHAs in Locust Without Skewing Your Stats

Решение капчи в Locust должно держаться подальше от двух мест: от вашей статистики и от пути, который выполняется на каждой итерации. Locust отчитывается о каждом запросе, отправленном через self.client, поэтому решение через него уронит время ответа решателя прямо в тот отчёт, который вы пытаетесь читать. А решение, помещённое внутрь задачи, выполняется на каждой итерации каждого пользователя. Избежать и того и другого несложно, если знать, где проходят границы.

Что понадобится

  • Locust 2.x на Python 3.10 или новее, ту же версию требует и клиент CapSkip.
  • CapSkip, запущенный на машине с Windows, и клиент для Python, установленный рядом с вашим locustfile.
  • sitekey и URL страницы с защищённой формой, которые передаются заранее, а не выясняются заново каждым пользователем.
  • Режим Server, если Locust работает не на машине самого решателя, а это любой контейнер и любая машина-воркер. Это одна настройка в разделе «Настройки подключения».
# pip install capskip
pip install -U locust capskip

Шаг 1: держите решение капчи в стороне от self.client

Именно эта деталь незаметно портит отчёт. Документация Locust прямо говорит, чем является self.client: это экземпляр HttpSession, то есть подкласс и обёртка над requests.Session, и добавляет он отправку результатов запросов в отчётность Locust. Успех и неудача, время ответа, размер ответа, имя. Всё, что отправлено через него, попадает в таблицу статистики.

Решение капчи занимает секунды. Эндпоинты вашего приложения отвечают за миллисекунды. Поместите одно в ту же таблицу, что и другое, и ваш девяносто пятый процентиль превратится в измерение того, сколько заняла капча, а такого числа никто не просил.

Хорошая новость в том, что чинить ничего не нужно. У клиента CapSkip собственный HTTP-транспорт, он никогда не обращается к self.client, поэтому решение капчи по умолчанию невидимо для статистики Locust. Locust пишет в лог только то, что прошло через его собственную сессию.

# pip install capskip
import os
from capskip import CapSkip

# CAPSKIP_HOST is the solver machine. 127.0.0.1 only works when
# Locust and CapSkip run on the same Windows box.
solver = CapSkip(
    host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
    port=int(os.environ.get("CAPSKIP_PORT", "8080")),
    apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
)

def fresh_token(sitekey, page_url):
    result = solver.recaptcha(sitekey=sitekey, url=page_url)
    return result["code"]   # the token, and Locust never sees it

Если вы предпочитаете работать напрямую с сырыми эндпоинтами, правило то же самое: используйте обычный requests.Session, а не self.client. Запросы, сделанные напрямую библиотекой requests, Locust не логирует, и здесь это ровно то, что нужно. Оба эндпоинта описаны в документации CapSkip API.

Поступать наоборот стоит только в одном случае: когда вы намеренно нагружаете сам решатель. Тогда отправляйте запрос через self.client с аргументом name, чтобы все решения группировались в одну строку, а не по строке на каждый sitekey, и читайте эту строку отдельно от остальных.

Шаг 2: как часто на самом деле выполняется ваше решение капчи?

Locust даёт четыре места, куда его можно положить, и они различаются на порядки. Посчитайте множитель, прежде чем выбирать.

Где находится решение капчиСколько раз оно выполняется
Внутри функции задачиОдин раз на итерацию каждого пользователя. Тысячи раз в минуту
В методе on_startОдин раз на виртуального пользователя. Пятьсот пользователей дают пятьсот решений
В слушателе test_startОдин раз на узел, а это не одно решение на запуск. Смотрите шаг 3
В слушателе test_start с проверкой на мастерОдин раз на запуск, и потом результат надо раздать

Ответ по умолчанию почти для всех: on_start. Пользователь вызывает on_start, когда начинает работу, поэтому каждый виртуальный пользователь получает свой токен, держит его на свою сессию, и ничего не нужно передавать между гринлетами. И именно так ведёт себя настоящий пользователь.

from locust import HttpUser, task, between

class Signup(HttpUser):
    wait_time = between(1, 3)

    def on_start(self):
        # One solve per simulated user, off the statistics.
        self.token = fresh_token(SITEKEY, PAGE_URL)

    @task
    def submit(self):
        self.client.post("/signup", data={
            "email": "[email protected]",
            "g-recaptcha-response": self.token,
        })

Пятьсот решений звучат дорого, и с сервисом, где платят за каждое решение, так и есть. Именно поэтому большинство руководств по нагрузочному тестированию выкручиваются, решая капчу один раз и раздавая результат. Когда решатель работает на вашем собственном оборудовании, это число перестаёт быть вопросом бюджета и становится вопросом запаса мощности, а на него ответить куда проще: запустите нарастание нагрузки и посмотрите на машину.

Шаг 3: test_start срабатывает на каждом узле, а не один раз за запуск

Это та деталь Locust, которая удивляет людей, и проявляется она как число решений, кратное чему-то ровному. Документация говорит, что test_start срабатывает на каждом узле при старте нового нагрузочного теста. Запустите четыре рабочих процесса, и у вас будет пять узлов, поэтому решение капчи в простом слушателе test_start выполнится пять раз.

Закройте его проверкой типа раннера. В Locust ровно для этого есть MasterRunner и WorkerRunner, и в собственном руководстве по распределённому запуску используется именно такая проверка того, на каком узле вы находитесь.

from locust import events
from locust.runners import WorkerRunner

@events.init.add_listener
def on_init(environment, **kwargs):
    # Workers listen. Registering here runs before the test starts.
    if isinstance(environment.runner, WorkerRunner):
        environment.runner.register_message("captcha_token", take_token)

@events.test_start.add_listener
def on_test_start(environment, **kwargs):
    # The master solves once and broadcasts. Workers skip this.
    if not isinstance(environment.runner, WorkerRunner):
        environment.runner.send_message(
            "captcha_token", fresh_token(SITEKEY, PAGE_URL)
        )

def take_token(environment, msg, **kwargs):
    environment.shared_token = msg.data

Два замечания об этом блоке. Сигнатура обработчика фиксирована: он принимает окружение, сообщение и именованные аргументы, а полезная нагрузка приходит в msg.data. И если обработчик будет работать долго, регистрируйте его с concurrent, выставленным в True, чтобы он выполнялся в своём гринлете и не блокировал heartbeat Locust и другие системные сообщения. Обработчику, который только сохраняет строку, это не нужно. Обработчику, который решает капчу внутри себя, нужно.

Прочитайте следующий раздел, прежде чем всё это строить, потому что одно решение на запуск обычно и так неверная цель.

Шаг 4: токен не переживает длинный тест

Токен reCAPTCHA живёт около двух минут. Нагрузочный тест обычно длится дольше двух минут. Поэтому аккуратная архитектура, где капча решается один раз на старте теста и результат рассылается всем воркерам, даёт запуск, в котором первые пару минут всё проходит нормально, а всё, что после, падает на истёкшем токене, и эти падения списываются на ваше приложение.

Эту ошибку стоит узнавать с первого взгляда, и она же ловит тех, кто выстраивает цепочки заданий в очереди. Она разобрана в руководстве по истечению срока действия токена reCAPTCHA.

Поэтому используйте схему с общим токеном только когда тест короткий или когда токен нужен один раз во время нарастания нагрузки, а не на каждой итерации. В остальных случаях решайте капчу отдельно для каждого пользователя в on_start, а для многочасового теста на выносливость обновляйте токен по расписанию внутри пользователя.

import time

class Signup(HttpUser):
    wait_time = between(1, 3)

    def on_start(self):
        self.token = fresh_token(SITEKEY, PAGE_URL)
        self.solved_at = time.monotonic()

    @task
    def submit(self):
        # Refresh before the token ages out, not after it fails.
        if time.monotonic() - self.solved_at > 90:
            self.token = fresh_token(SITEKEY, PAGE_URL)
            self.solved_at = time.monotonic()
        self.client.post("/signup", data={
            "g-recaptcha-response": self.token,
        })

Девяносто секунд, а не сто двадцать, чтобы обновление происходило, пока токен ещё действителен.

Шаг 5: Locust построен на gevent, поэтому берите синхронный клиент

Locust запускает каждого пользователя в отдельном гринлете и работает на событиях, используя gevent. Его документация подчёркивает, что именно это позволяет писать тесты обычным блокирующим кодом на Python, а не через колбэки. Блокирующий стиль здесь родной, поэтому и брать стоит обычный клиент CapSkip.

Не тянитесь за AsyncCapSkip в locustfile. В Python это полноценная асинхронная реализация, а не псевдоним, что делает её правильным клиентом в программе на asyncio, а Locust такой программой не является. Здесь нет цикла событий, который её ждёт, и поднимать по отдельному циклу на каждого пользователя внутри гринлета слишком хлопотно ради того, что gevent уже умеет.

Здесь помогает и поведение опроса. Клиент не опрашивает с постоянным интервалом. Он начинает с четверти секунды и постепенно уходит к pollingInterval, поэтому быстрое решение возвращается быстро, а не ждёт фиксированной задержки. На пятистах гринлетах, которые нарастают одновременно, эта разница и составляет почти всё время нарастания. Если же вам нужно решить пачку капч сразу в программе на asyncio, этот случай разобран в руководстве по параллельному решению капчи на Python.

Шаг 6: запуск решателя там, где до него дотянутся воркеры

Генераторы нагрузки редко стоят на той же машине, что и всё остальное. Им дают отдельную машину, или несколько, или пул контейнеров, именно чтобы нагрузка была настоящей. CapSkip работает на Windows, а ваши воркеры могут работать не на ней.

Есть два режима подключения. Local слушает 127.0.0.1 и отвечает только этому устройству. Server слушает ваш сетевой адрес или публичный IP, поэтому другая машина, хост контейнеров или облачный раннер может обратиться к той же машине с Windows по API. Оба режима находятся в разделе Настройки подключения, а Server mode меняет только то, по какому адресу слушает решатель. Оборудование по-прежнему ваше, и распознавание капчи по-прежнему безлимитное.

Где работает LocustКакой режим подключения
Та же машина с Windows, что и CapSkip, один процессLocal mode, хост остаётся 127.0.0.1
Рабочие процессы на других машинах вашей сетиServer mode с локальным адресом решателя
Контейнеры или облачный раннерServer mode со статическим публичным IP и правилом брандмауэра

Читайте адрес из окружения, а не из locustfile. Клиент для Python сам не читает ни CAPSKIP_HOST, ни CAPSKIP_PORT, ни CAPSKIP_API_KEY, именно поэтому решатель в коде выше читает их и передаёт клиенту, так что один и тот же файл без изменений работает и на вашем ноутбуке, и на парке воркеров.

Частые ошибки и что они означают

Что вы видитеПричинаИсправить
Строки решателя в таблице статистики LocustРешение капчи прошло через self.clientИспользуйте клиент CapSkip или обычный requests.Session
Процентили сильно выше того, что приложение делает на самом делеТа же причина. Время решения капчи включается в среднееТо же исправление. Больше ничего менять не нужно
Число решений кратно числу воркеровtest_start срабатывает на каждом узлеЗакройте слушатель проверкой на WorkerRunner
Через пару минут после старта падает всёОдин токен решили на старте теста, и он истёкРешайте капчу на каждого пользователя или обновляйте токен внутри задачи
NetworkException сразу у всех пользователейCapSkip слушает loopback, а Locust работает в другом местеПереключитесь в режим Server и задайте CAPSKIP_HOST
Предупреждения о heartbeat во время распределённого запускаМедленный обработчик сообщений блокирует раннерРегистрируйте его с concurrent, выставленным в True
ERROR_WRONG_USER_KEY внутри ApiExceptionCAPSKIP_API_KEY не задан в окружении воркераЗадайте его на воркерах и перезапустите их

У последней строки есть собственное руководство, потому что один и тот же ответ покрывает и отсутствующий ключ, и просто неверный: как исправить ERROR_WRONG_USER_KEY.

FAQ

Решать капчу один раз на тест или один раз на пользователя?

Один раз на пользователя, если только весь тест не короче двух минут. Токен истекает задолго до конца настоящего нагрузочного теста, поэтому вариант с общим токеном незаметно превращается в тест вашей обработки ошибок. Решение на каждого пользователя честнее и как симуляция, ведь реальные пользователи приходят каждый со своим токеном. Единственная причина этого избегать: плата за каждое решение, а решатель на вашем собственном оборудовании эту причину снимает.

Учитывается ли решение капчи в моих запросах в секунду?

Нет, пока оно не идёт через self.client. Locust строит статистику по своей собственной сессии, поэтому всё, что отправлено клиентом CapSkip или обычным requests.Session, для отчёта невидимо. Это поведение по умолчанию, и настраивать ничего не нужно.

Работает ли это с FastHttpUser?

Да, и ничего не меняется. FastHttpUser подменяет клиент более быстрой реализацией, и это имеет смысл, когда узким местом становится сам генератор нагрузки, но решение капчи и так никогда не шло через этот клиент. Метод on_start и слушатели событий ведут себя точно так же.

Чем это отличается от руководства по k6?

Советы получаются почти противоположными, и на то есть причина. k6 не может установить пакет Node, поэтому то руководство идёт через сырой HTTP API и решает капчу один раз на стадии setup, ведь повторять это там по-настоящему неудобно. Locust написан на Python, клиент ставится обычным образом, а решение на каждого пользователя и проще, и точнее. Сравнение приведено в руководстве по нагрузочному тестированию в k6.

Коротко

Решайте капчу клиентом CapSkip, чтобы запрос никогда не попадал ни в self.client, ни в вашу статистику. Ставьте вызов в on_start, чтобы получить по одному токену на каждого виртуального пользователя, и обновляйте его внутри задачи, если запуск длится дольше примерно девяноста секунд. Если вы всё же решаете капчу в слушателе test_start, закройте его проверкой на WorkerRunner, потому что это событие срабатывает на каждом узле. Оставайтесь на синхронном клиенте, потому что параллелизм Locust построен на gevent. Запускайте CapSkip в режиме Server всегда, когда генераторы нагрузки находятся не на машине самого решателя.

Ещё одно, что стоит прояснить до выбора размера нагрузки: CapSkip представляет собой безлимитный сервис распознавания капчи на оборудовании, которое у вас уже есть, поэтому тысяча виртуальных пользователей, каждый из которых решает свою капчу, обойдётся ровно во столько же, во сколько десять.