Captchas in einem BullMQ-Worker lösen (Node.js)

Ein BullMQ-Captcha-Worker läuft beim ersten Versuch korrekt und enttäuscht danach auf zwei unauffällige Arten. Die Worker-Option, die steuert, wie viele Jobs gleichzeitig laufen, steht standardmäßig auf 1, also wird ein Rückstau an Lösungen einzeln abgearbeitet. Und sobald die Processor-Funktion den Event Loop blockiert, entscheidet BullMQ, dass der Job stalled ist, gibt ihn an einen anderen Worker weiter, und dasselbe Captcha wird zweimal gelöst. Keines von beiden ist ein Bug. Beides sind Standardwerte, und beides ändern Sie mit einer Zeile.
Was Sie brauchen
- Redis, dazu BullMQ installiert in dem Projekt, in dem Ihre Worker laufen.
- CapSkip läuft auf einer Windows-Maschine, mit dem Node-Client im selben Projekt.
- Der sitekey und die Seiten-URL kommen über die Job-Daten herein statt fest im Code zu stehen, damit eine Queue jedes Formular bedient.
- Ein Verbindungsmodus, den Sie festlegen, bevor Sie horizontal skalieren, denn Worker landen am Ende meist auf mehr Maschinen als der Löser.
# npm install capskip npm install bullmq capskip
Schritt 1: Wo der Worker läuft, und welchen Verbindungsmodus das erfordert
Das klären Sie zuerst, denn BullMQ selbst empfiehlt, eine Flotte von Workern über viele verschiedene Maschinen zu verteilen, und in dem Moment bedeutet Loopback nicht mehr das, was es auf Ihrem Laptop bedeutet hat.
Es gibt zwei Verbindungsmodi. Der Local-Modus bindet an 127.0.0.1 und antwortet nur diesem Gerät, und das ist richtig, wenn Ihre Automatisierung und der Löser sich eine Maschine teilen. Der Server-Modus bindet an Ihre Netzwerkadresse oder öffentliche IP, sodass ein anderer Rechner, ein VPS oder eine gehostete Plattform dieselbe Windows-Maschine über die API erreichen kann. Der Server-Modus ändert nur, auf welcher Adresse der Löser lauscht. Es ist weiterhin Ihre Hardware, und es wird weiterhin nicht pro Lösung abgerechnet. Beide Modi finden Sie unter Verbindungseinstellungen.
| Wo der Worker-Prozess läuft | Welcher Verbindungsmodus |
|---|---|
| Auf der CapSkip-Maschine, ein Prozess | Local-Modus. 127.0.0.1 ist hier wirklich richtig |
| Eine Flotte von Workern im eigenen Netzwerk | Server-Modus mit der LAN-Adresse des Solvers |
| Worker in Containern oder auf einem VPS | Server-Modus mit einer erreichbaren Adresse und einer Firewall-Regel |
Für einen Worker auf einem VPS lohnt sich eine statische öffentliche IP, damit die Adresse sich nicht unter einem laufenden Deployment verschiebt. Legen Sie die Adresse in eine Umgebungsvariable statt in den Quellcode, denn Ihr Laptop und Ihre Worker brauchen unterschiedliche Werte. Der Client liest von sich aus keinerlei Umgebungsvariablen, lesen Sie CAPSKIP_HOST also in Ihrem Code aus und übergeben Sie den Wert, wie es der Worker unten tut.
// 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,
});Schritt 2: concurrency steht standardmäßig auf einem Job gleichzeitig
Ein BullMQ-Worker verarbeitet einen Job nach dem anderen, solange Sie ihm nichts anderes sagen. Dieser Standardwert ist für CPU-Arbeit sinnvoll und für das Lösen von Captchas völlig falsch, denn eine Lösung besteht fast nur aus Warten. BullMQ sagt das direkt: concurrency ist nur möglich, wenn Worker asynchrone Operationen ausführen, etwa einen Aufruf an eine Datenbank oder an einen externen HTTP-Dienst. Einen Löser über HTTP zu pollen hat genau diese Form, der Event Loop bleibt also die ganze Zeit frei.
Die Rechnung lohnt sich einmal. Wenn eine reCAPTCHA-Lösung zwanzig Sekunden dauert, arbeitet ein Worker im Standard drei Jobs pro Minute ab. Derselbe Worker mit einer concurrency von zwanzig schafft sechzig, auf derselben Hardware, weil neunzehn dieser Jobs in einem Socket-Read hängen und nicht um die CPU konkurrieren.
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 });Bei einem Löser mit Abrechnung pro Lösung ist diese Zahl in Wahrheit eine Ausgabenbremse, und deshalb setzen so viele Beispiele sie sehr zaghaft an. Hier ist sie eine Kapazitätsfrage zu einer Maschine und dazu, wie schnell die Seite, an die Sie absenden, Anfragen annimmt. Das Zweite ist meist die engere Grenze.
Schritt 3: Wodurch ein Job stalled wird, und warum das zwei Lösungen bedeutet
Das ist der Fehler, der Leute einen Vormittag kostet, denn die Logs sehen aus, als würde die Queue arbeiten, und das eigene Log des Lösers sagt etwas anderes.
Wenn ein Job bei einem Worker ankommt, setzt BullMQ einen Lock darauf, damit nichts anderes ihn anfasst, und der Worker muss der Queue laufend melden, dass er noch arbeitet. Dieser Lock ist lockDuration, standardmäßig 30000 ms, und der Worker erneuert ihn im halben Abstand. Ein separater Durchlauf, stalledInterval, läuft alle 30000 ms und sucht nach Locks, die niemand erneuert hat. War der Worker zu beschäftigt, um rechtzeitig zu erneuern, wird der Job als stalled markiert, zurück in waiting verschoben und von einem anderen Worker erneut verarbeitet. Sobald er öfter stalled war, als maxStalledCount erlaubt, und das steht standardmäßig auf eins, landet er stattdessen im failed-Set.
Ein stalled Job ist also kein fehlgeschlagener Job, und der erneute Lauf ist kein Retry. Es ist die Queue, die zu Recht annimmt, dass der Worker mit diesem Job gestorben ist. Ihr Löser sieht zwei Einreichungen für ein Captcha, und benutzt wird am Ende nur das zweite Token.
| Worker-Einstellung | Standard | Was sie festlegt |
|---|---|---|
| "concurrency" | 1 | Wie viele Jobs ein Worker gleichzeitig abarbeitet |
| "lockDuration" | 30000 ms | Wie lange der Lock ohne Erneuerung überlebt |
| "lockRenewTime" | die Hälfte von lockDuration | Wie oft der Worker ihn erneuert |
| "stalledInterval" | 30000 ms | Wie oft nicht erneuerte Locks eingesammelt werden |
| "maxStalledCount" | 1 | Wie viele erneute Läufe, bevor der Job fehlschlägt |
Die Ursache ist immer dieselbe: Die Processor-Funktion hat die CPU belegt. Ein HTTP-Aufruf mit await tut das nicht, das Polling des Clients ist also unbedenklich. Eine selbst gebaute Schleife, die am Result-Endpunkt busy-waited, ist es nicht, und das synchrone Dekodieren eines großen Bildes vor dem Absenden ebenfalls nicht. Lassen Sie den Client pollen, denn er beginnt bei 250 ms und drosselt, statt in einem festen Intervall zu schlafen, und verschieben Sie jeden wirklich CPU-gebundenen Schritt aus der Processor-Funktion heraus oder in einen Sandboxed Processor.
Die Lock-Dauer hochzusetzen ist der falsche erste Schritt. Das behandelt das Symptom, und ein langer Lock auf einem Worker, der wirklich gestorben ist, lässt den Job dieses ganze Fenster über unangetastet.
Schritt 4: Retries, und das Token, das Sie nicht aufheben dürfen
BullMQ wiederholt standardmäßig nicht. Die Option attempts steht auf 1, also ein Versuch und dann das failed-Set. Für eine Lösung ist das meist richtig, denn die meisten Fehler hier sind entweder ein Parameterfehler, der auf ewig identisch scheitert, oder eine Seite, die sich weiterentwickelt hat. Ihren Platz verdienen Retries dort, wo ein Worker den Löser kurz nicht erreichen konnte, geben Sie ihnen also ein backoff und halten Sie die Zahl klein.
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 },
});Dazu gehören zwei Regeln. Tragen Sie ein Token nie über einen Versuch hinaus: Ein reCAPTCHA-Token ist etwa zwei Minuten lang gültig, lösen Sie also innerhalb des Versuchs, der es auch absendet. Und wenn Sie versucht sind, eines zwischenzuspeichern, lesen Sie den Leitfaden zum Token-Ablauf nach. Geben Sie das Token außerdem nicht als Rückgabewert des Jobs zurück. BullMQ behält abgeschlossene Jobs standardmäßig, dieser Rückgabewert landet also in Redis und bleibt dort. Geben Sie stattdessen das Ergebnis der Einreichung zurück, denn das ist das, was Sie sich später wirklich ansehen wollen.
Vollständiges lauffähiges Beispiel
Ein Producer, ein Worker, und die Lösung sitzt in derselben Processor-Funktion wie das, was sie verbraucht.
// 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 });Dieser Aufruf ist reCAPTCHA v2. Die anderen Typen haben dieselbe Form: Setzen Sie invisible oder enterprise auf 1, oder version auf v3 mit einer action, oder rufen Sie stattdessen turnstile oder geetest auf. Die vollständige Schnittstelle dokumentiert die Node.js-Captcha-Solver-Seite.
Häufige Fehler und was sie bedeuten
| Was Sie sehen | Ursache | Beheben |
|---|---|---|
| Die Queue wird viel langsamer abgearbeitet, als der Löser könnte | Die concurrency des Workers steht noch auf dem Standardwert eins | Erhöhen Sie sie. Die Lösung ist I/O mit await, keine CPU-Last |
| Zwei Lösungen für einen Job im Log, Sekunden auseinander | Der Lock wurde nicht erneuert, der Job galt also als stalled | Blockieren Sie den Event Loop in der Processor-Funktion nicht mehr |
| Ein Job landet im failed-Set, ohne dass ein Fehler geworfen wurde | Er war öfter stalled, als das Maximum erlaubt | Dieselbe blockierende Arbeit. Beheben Sie diese, nicht den Zähler |
| Läuft auf Ihrem Laptop, NetworkException auf der Worker-Maschine | 127.0.0.1 auf dieser Maschine ist diese Maschine | Server-Modus, und setzen Sie CAPSKIP_HOST für den Worker |
| Tokens werden nur beim zweiten Versuch abgelehnt | Ein Token aus dem ersten Versuch wurde mitgenommen | Lösen Sie innerhalb des Versuchs, der absendet |
| ERROR_WRONG_USER_KEY in einer ApiException | CAPSKIP_API_KEY ist in der Umgebung des Workers nicht gesetzt | Setzen Sie die Variable dort, wo der Worker läuft, und starten Sie diesen Worker dann neu |
| CAPCHA_NOT_READY bei einer selbst gebauten Polling-Schleife | Das Ergebnis wurde gelesen, bevor es fertig war | Lassen Sie den Client pollen. Er drosselt von selbst |
Diese letzte Antwort wird tatsächlich so geschrieben, und der fehlende Buchstabe ist kein Tippfehler auf unserer Seite, denn die API gibt sie wirklich so zurück. Ausführlich erklärt wird das im Leitfaden zu CAPCHA_NOT_READY.
FAQ
Können meine Worker auf anderen Maschinen laufen als der Löser?
Ja, und das ist der normale Aufbau, sobald Sie über einen Prozess hinaus skalieren. Stellen Sie CapSkip unter Verbindungseinstellungen auf den Server-Modus um, damit es auf Ihrer Netzwerkadresse statt auf Loopback lauscht, und setzen Sie dann CAPSKIP_HOST in der Umgebung jedes Workers. Im eigenen Netzwerk ist diese Adresse eine LAN-Adresse, und nichts muss zum Internet zeigen. Sitzt ein Worker auf einem VPS, verwenden Sie eine statische öffentliche IP mit einer Firewall-Regel, die nur die erwarteten Adressen zulässt.
Welche concurrency sollte ich tatsächlich setzen?
Beginnen Sie bei zehn und beobachten Sie zwei Dinge: die Löser-Maschine und die Reaktion der Seite, an die Sie absenden. Weil eine Lösung I/O mit await ist, ist der Worker-Prozess selbst selten die Grenze. Meist ist es die Seite, und sie sagt es Ihnen per Rate Limiting, lange bevor Node der Platz ausgeht. Hier wartet nichts hinter einem Guthaben, die Zahl ist also eine Kapazitätsentscheidung und keine Budgetentscheidung.
Warum wurde dasselbe Captcha zweimal gelöst, obwohl ich attempts nie gesetzt habe?
Weil dieser erneute Lauf kein Retry war. Ein stalled Job wird unabhängig von der Option attempts wieder eingereiht, in der Annahme, dass der Worker, der ihn hielt, gestorben ist. Auslöser ist ein Lock, der nicht rechtzeitig erneuert wurde, und das passiert, wenn die Processor-Funktion die CPU hält, statt auf I/O zu warten. Finden Sie die synchrone Arbeit in der Processor-Funktion und verlagern Sie sie, dann verschwindet die Dopplung.
Sollte das Lösen eine eigene Queue sein, die andere Jobs aufrufen?
Meistens nicht. Die Aufteilung bedeutet, dass das Token eine Queue-Grenze überquert und in Redis liegt, während der übergeordnete Job weiterläuft, und das ist der schnellste Weg, ein bereits abgelaufenes Token einzusetzen. Halten Sie das Lösen und alles, was es verbraucht, in derselben Processor-Funktion und geben Sie das Ergebnis zurück statt der Zugangsdaten. Eine eigene Queue ergibt nur dann Sinn, wenn das, was sie zurückgibt, gar kein Token ist.
Die Kurzfassung
Erhöhen Sie die concurrency des Workers, denn der Standard von einem Job gleichzeitig bremst eine Queue aus HTTP-Aufrufen mit await ohne Grund aus. Halten Sie die Processor-Funktion von der CPU fern, damit der Lock weiter erneuert wird, denn ein stalled Job wird von einem anderen Worker erneut ausgeführt, und das bezahlen Sie mit verschwendetem Durchsatz. Lassen Sie attempts niedrig und tragen Sie nie ein Token über einen Versuch hinaus. Legen Sie die Adresse in CAPSKIP_HOST und stellen Sie den Löser auf den Server-Modus um, sobald ein Worker woanders lebt.
- Die rohen Endpunkte hinter dem Client sind dokumentiert in der CapSkip-API-Dokumentation.
- Die Checkbox-Challenge selbst erklärt Ihnen die reCAPTCHA-v2-Solver-Seite.
Eines sollten Sie abwägen, bevor Sie diese concurrency-Zahl festlegen: CapSkip ist ein unbegrenzter Captcha-Löser und läuft auf Hardware, die Ihnen bereits gehört, sodass ein höherer Wert die Kapazität einer Maschine verbraucht und pro Lösung nichts kostet.
