Как решать капчу в воркере BullMQ (Node.js)

Воркер BullMQ, который решает капчу, с первого раза отрабатывает правильно, а потом подводит двумя незаметными способами. Опция воркера, которая задаёт число одновременно выполняемых задач, по умолчанию равна 1, поэтому накопившаяся очередь решений разбирается по одной задаче. А если обработчик хоть раз займёт event loop, BullMQ решит, что задача зависла, отдаст её другому воркеру, и одна и та же капча будет решена дважды. Ни то, ни другое не баг. В обоих случаях виноваты значения по умолчанию, и каждое меняется одной строкой.
Что понадобится
- Redis, а также BullMQ, установленный в проекте, где запускаются ваши воркеры.
- CapSkip, запущенный на машине с Windows, и Node-клиент в том же проекте.
- sitekey и URL страницы, которые приходят в данных задачи, а не зашиты в код, чтобы одна очередь обслуживала любые формы.
- Режим подключения, выбранный до масштабирования, потому что воркеры обычно оказываются на большем числе машин, чем решатель.
# npm install capskip npm install bullmq capskip
Шаг 1: где работает воркер и какой режим подключения для этого нужен
С этим стоит определиться первым делом, потому что документация BullMQ сама советует держать флот воркеров на множестве разных машин, а как только вы так делаете, loopback перестаёт означать то, что означал на вашем ноутбуке.
Режима подключения два. Local привязывается к 127.0.0.1 и отвечает только этому устройству, и это правильный выбор, когда ваша автоматизация и решатель работают на одной машине. Server привязывается к вашему сетевому или публичному IP, поэтому другая машина, VPS или облачная платформа обращаются к той же машине с Windows по API. Режим Server меняет только то, какой адрес слушает решатель. Это по-прежнему ваше железо, и платы за каждое решение по-прежнему нет. Оба режима находятся в разделе Настройки подключения.
| Где работает процесс воркера | Какой режим подключения |
|---|---|
| На машине с CapSkip, один процесс | Режим Local. 127.0.0.1 действительно верен |
| Флот воркеров в вашей собственной сети | Server mode с локальным адресом решателя |
| Воркеры в контейнерах или на VPS | Server mode с доступным адресом и правилом файрвола |
Для воркера на VPS стоит завести статический публичный IP, чтобы адрес не уехал под работающим развёртыванием. Держите адрес в переменной окружения, а не в исходном коде, потому что вашему ноутбуку и вашим воркерам нужны разные значения. Клиент сам не читает никаких переменных окружения, поэтому читайте CAPSKIP_HOST в своём коде и передавайте его клиенту, как это делает воркер ниже.
// npm install capskip
import { CapSkip } from "capskip";
// One client, shared by every job this worker handles.
// CAPSKIP_HOST is 127.0.0.1 locally and the solver's
// address on every other machine.
export const solver = new CapSkip({
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: 8080,
});Шаг 2: по умолчанию concurrency равен одной задаче за раз
Воркер BullMQ обрабатывает по одной задаче за раз, пока вы не скажете иначе. Для нагрузки на CPU такое умолчание разумно, а для решения капчи никуда не годится, потому что решение почти целиком состоит из ожидания. BullMQ говорит об этом прямо: параллелизм (concurrency) возможен только тогда, когда воркеры выполняют асинхронные операции, например обращение к базе данных или к внешнему HTTP-сервису. Опрос решателя по HTTP устроен ровно так, поэтому event loop всё это время остаётся свободным.
Арифметику стоит посчитать один раз. Если решение reCAPTCHA занимает двадцать секунд, один воркер со значением по умолчанию разбирает три задачи в минуту. Тот же воркер при concurrency 20 разбирает шестьдесят, на том же железе, потому что девятнадцать из этих задач висят на чтении сокета, а не борются за CPU.
import { Worker } from "bullmq";
import { solver } from "./solver.js";
// A solve is an awaited HTTP call, so raising this costs
// almost no CPU. Size it for the solver machine and for
// what the target site will accept.
const worker = new Worker("captcha", async (job) => {
const { sitekey, pageUrl } = job.data;
const result = await solver.recaptcha(sitekey, pageUrl);
return await submitForm(pageUrl, result.code);
}, { connection: { host: "127.0.0.1", port: 6379 }, concurrency: 20 });У сервиса с оплатой за каждое решение это число работает скорее как ограничитель расходов, поэтому в примерах его так часто ставят робко. Здесь же речь идёт о ёмкости одной машины и о том, как быстро принимает запросы сайт, куда вы отправляете форму. Второе обычно ограничивает сильнее.
Шаг 3: из-за чего задача зависает и почему зависание означает двойное решение капчи
Именно эта поломка стоит людям целого утра, потому что по логам кажется, будто очередь работает, а собственный лог решателя говорит обратное.
Когда задача попадает к воркеру, BullMQ ставит на неё блокировку, чтобы к ней больше никто не притронулся, и воркер обязан регулярно сообщать очереди, что он всё ещё работает. Эта блокировка задаётся параметром lockDuration, по умолчанию 30000 мс, и воркер продлевает её через половину этого интервала. Отдельная проверка stalledInterval запускается каждые 30000 мс и ищет блокировки, которые никто не продлил. Если воркер был слишком занят и не успел продлить блокировку вовремя, задача помечается как зависшая, возвращается в ожидание и обрабатывается другим воркером. Как только число зависаний превысит то, что допускает maxStalledCount, а по умолчанию это единица, задача уходит в набор failed.
То есть зависшая задача не равна упавшей, а повторный запуск не равен retry. Очередь совершенно правильно исходит из того, что воркер, который держал эту задачу, умер. Ваш решатель видит две отправки на одну капчу, и в дело идёт только второй токен.
| Настройка воркера | По умолчанию | За что отвечает |
|---|---|---|
| "concurrency" | 1 | Сколько задач один воркер берёт одновременно |
| "lockDuration" | 30000 ms | Сколько блокировка живёт без продления |
| "lockRenewTime" | половина lockDuration | Как часто воркер продлевает блокировку |
| "stalledInterval" | 30000 ms | Как часто подбираются непродлённые блокировки |
| "maxStalledCount" | 1 | Сколько повторных запусков до падения задачи |
Причина всегда одна: обработчик занял CPU. HTTP-вызов с await его не занимает, поэтому встроенный опрос клиента безопасен. А вот самописный цикл, который активно ждёт ответа от эндпоинта результата, опасен, как и синхронное декодирование большой картинки перед отправкой. Доверьте опрос клиенту: он начинает с 250 мс и сам увеличивает интервал, а не спит через равные промежутки. Любой по-настоящему нагружающий CPU шаг вынесите из обработчика или перенесите в изолированный обработчик.
Поднимать lockDuration первым делом неправильно. Это лечит симптом, а длинная блокировка на воркере, который действительно умер, оставляет задачу нетронутой всё это время.
Шаг 4: повторы и токен, который нельзя хранить
По умолчанию BullMQ не делает повторов. Опция attempts равна 1, то есть одна попытка и сразу набор failed. Для решения капчи это обычно верно, потому что большинство сбоев здесь вызвано либо ошибкой в параметрах, которая будет повторяться всегда одинаково, либо сайтом, который уже изменился. Повторы оправданы там, где воркер ненадолго потерял связь с решателем, поэтому задайте им backoff и не ставьте большое количество.
await queue.add("signup", { sitekey, pageUrl }, {
// Two tries, spaced out, for a solver that was
// briefly unreachable. Not for a bad sitekey.
attempts: 2,
backoff: { type: "exponential", delay: 5000 },
// Do not leave finished jobs sitting in Redis forever.
removeOnComplete: { age: 3600, count: 1000 },
});С этим связаны два правила. Никогда не переносите токен между попытками: токен reCAPTCHA живёт около двух минут, поэтому решайте капчу внутри той попытки, которая его отправляет, и прочитайте руководство по сроку жизни токена перед тем, как поддаться соблазну закэшировать токен. И не возвращайте токен как результат задачи. BullMQ по умолчанию хранит завершённые задачи, поэтому такой результат попадает в Redis и остаётся там. Возвращайте вместо токена итог отправки формы: именно на него вы потом и захотите посмотреть.
Полный рабочий пример
Один продюсер, один воркер, и решение капчи находится в том же обработчике, что и потребитель токена.
// npm install capskip
import { Queue, Worker } from "bullmq";
import { CapSkip, ValidationException } from "capskip";
const connection = { host: "127.0.0.1", port: 6379 };
const queue = new Queue("captcha", { connection });
const solver = new CapSkip({
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: 8080,
});
new Worker("captcha", async (job) => {
const { sitekey, pageUrl, email } = job.data;
try {
// Solve and submit together. The token is short lived.
const result = await solver.recaptcha(sitekey, pageUrl);
const res = await postSignup(pageUrl, email, result.code);
return { status: res.status };
} catch (err) {
// A bad sitekey fails the same way on every attempt.
if (err instanceof ValidationException) {
await job.discard();
}
throw err;
}
}, { connection, concurrency: 20 });Этот вызов относится к reCAPTCHA v2. Остальные типы устроены так же: передайте invisible или enterprise со значением 1, либо version со значением v3 и действие, либо вызовите turnstile или geetest. Полный набор возможностей описан на странице сервиса распознавания капчи для Node.js.
Частые ошибки и что они означают
| Что вы видите | Причина | Исправить |
|---|---|---|
| Очередь разбирается гораздо медленнее, чем может решатель | У воркера concurrency всё ещё равен единице по умолчанию | Поднимите concurrency. Решение капчи упирается в ожидание ввода-вывода, а не в CPU |
| В логе два решения на одну задачу с разницей в секунды | Блокировку не продлили, поэтому задача посчиталась зависшей | Перестаньте блокировать event loop в обработчике |
| Задача попадает в набор failed без выброшенной ошибки | Задача зависала чаще, чем разрешает максимум | Тот же блокирующий код. Чините его, а не счётчик |
| На ноутбуке работает, на машине воркера NetworkException | 127.0.0.1 на той машине указывает на саму эту машину | Server mode, и задайте CAPSKIP_HOST для воркера |
| Токены отклоняются только на второй попытке | Токен перенесли с первой попытки | Решайте капчу внутри той попытки, которая отправляет форму |
| ERROR_WRONG_USER_KEY внутри ApiException | CAPSKIP_API_KEY не задан в окружении воркера | Задайте переменную там, где работает воркер, и перезапустите этот воркер |
| CAPCHA_NOT_READY при самописном опросе | Результат прочитали до того, как он был готов | Доверьте опрос клиенту. Он сам увеличивает интервал |
Последний ответ пишется именно так, как выглядит, и пропущенная буква не является опечаткой с нашей стороны: API действительно возвращает его в таком виде. Полностью это объясняется в руководстве по CAPCHA_NOT_READY.
FAQ
Могут ли воркеры работать не на той машине, где стоит решатель?
Да, и это обычная схема, как только вы выходите за пределы одного процесса. Переключите CapSkip в режим Server в разделе настроек подключения, чтобы он слушал ваш сетевой адрес, а не loopback, а затем задайте CAPSKIP_HOST в окружении каждого воркера. Внутри вашей собственной сети это локальный адрес, и выставлять что-либо в интернет не нужно. Если воркер стоит на VPS, используйте статический публичный IP с правилом файрвола, которое разрешает только ожидаемые адреса.
Какое значение concurrency ставить на практике?
Начните с десяти и следите за двумя вещами: за машиной решателя и за тем, как отвечает сайт, куда вы отправляете запросы. Поскольку решение капчи сводится к ожиданию ввода-вывода, сам процесс воркера ограничивает редко. Обычно ограничивает сайт, и он сообщит об этом лимитом по частоте запросов задолго до того, как у Node закончится запас. Здесь ничто не стоит в очереди за балансом, поэтому это число выбирается по ёмкости, а не по бюджету.
Почему одна и та же капча решилась дважды, если attempts я вообще не задавал?
Потому что повторный запуск и не был retry. Зависшая задача возвращается в очередь независимо от опции attempts, исходя из того, что воркер, который её держал, умер. Срабатывает это из-за блокировки, которую не продлили вовремя, а так бывает, когда обработчик занимает CPU вместо ожидания. Найдите синхронную работу в обработчике и вынесите её, и дубль пропадёт.
Стоит ли выносить решение капчи в отдельную очередь, к которой обращаются другие задачи?
Обычно нет. При таком разделении токен пересекает границу очереди и лежит в Redis, пока родительская задача возобновляется, а это самый быстрый способ потратить уже истёкший токен. Держите решение капчи и то, что его потребляет, в одном обработчике и возвращайте итог, а не сам токен. Отдельная очередь оправдана только тогда, когда обратно она отдаёт вовсе не токен.
Коротко
Поднимите concurrency у воркера: умолчание в одну задачу за раз без всякой причины душит очередь из HTTP-вызовов с await. Не нагружайте CPU в обработчике, чтобы блокировка продолжала продлеваться, ведь зависшую задачу перезапускает другой воркер, и расплачиваетесь вы впустую потраченной пропускной способностью. Держите attempts низким и никогда не переносите токен между попытками. Пропишите адрес в CAPSKIP_HOST и переключите решатель в Server mode, как только воркер оказывается на другой машине.
- Сырые эндпоинты, которые стоят за клиентом, описаны в документации CapSkip API.
- Сама задача с галочкой разобрана на странице сервиса распознавания reCAPTCHA v2.
Перед выбором значения concurrency стоит взвесить вот что. CapSkip выступает как безлимитный сервис распознавания капчи и работает на железе, которое у вас уже есть, поэтому повышение этого числа расходует ресурсы одной машины и ничего не стоит в расчёте на решение.
