So lösen Sie Captchas auf Google Cloud Run ohne 504-Fehler

cloud run captcha - How to Solve CAPTCHAs on Google Cloud Run Without a 504

Ein Captcha-Lösen auf Cloud Run kann an drei Stellen sterben, und an zwei davon sieht Ihr Code nie einen Fehler. Das Python-Buildpack startet Ihre App mit den Standardeinstellungen von gunicorn, und gunicorn beendet einen Worker, der 30 Sekunden lang beschäftigt bleibt, was bei einem reCAPTCHA-Lösen oft vorkommt. Das eigene Anfrage-Timeout von Cloud Run steht standardmäßig auf 300 Sekunden, genau wie das reCAPTCHA-Polling-Timeout des SDK, und so gewinnt die 504 dieses Rennen jedes Mal. Und 127.0.0.1 im Container ist der Container selbst. CapSkip läuft auf einem Windows-Rechner, der Ihnen gehört, und der Dienst ist nur der Client. Hier ist das Deployment, das alle drei Probleme beseitigt.

Was Sie brauchen

  • CapSkip auf einem Windows-Rechner, den Sie kontrollieren. Es ist eine Desktop-Anwendung und läuft nicht in Cloud Run. Der Dienst ruft es über HTTP auf, mehr nicht.
  • Der Server-Modus muss eingeschaltet sein. Der Local-Modus antwortet auf 127.0.0.1 und nur für dieses Gerät, was einem Container im Netz von Google nichts nützt. Der Server-Modus lauscht auf Ihrer Netzwerkadresse oder öffentlichen IP, damit der Dienst ihn über dieselbe API erreichen kann, und beide finden Sie unter Verbindungseinstellungen. Eine statische öffentliche IP ist zu empfehlen, dazu eine Firewall-Regel für die eine Adresse, von der Google ankommen wird.
  • Ein Python-Dienst, der aus dem Quellcode deployt wird, mit einer requirements.txt, die capskip, flask und gunicorn aufführt. Das CapSkip-Paket braucht Python 3.10 oder neuer.
  • Die gcloud CLI sowie ein VPC-Netzwerk mit einem Subnetz in der Region des Dienstes für Schritt 3.

Warum drei Timeouts über ein Captcha-Deployment auf Cloud Run entscheiden

Über jedem Lösen laufen drei Uhren, und bei einem Standard-Deployment löst die falsche zuerst aus.

UhrStandardWas beim Auslösen passiert
Worker-Timeout von gunicorn, aus dem Standard-Entrypoint des Buildpacks30 SekundenDer Worker wird mitten im Lösen beendet und neu gestartet, und der Aufrufer bekommt einen Serverfehler
Anfrage-Timeout von Cloud Run300 Sekunden, erhöhbar auf 3600Der Aufrufer bekommt eine 504, während der Container die Anfrage weiter bearbeitet
reCAPTCHA-Polling-Timeout von CapSkip300 SekundenDer Client wirft eine TimeoutException, die Ihr Code behandeln kann

Fangen Sie mit gunicorn an. Bei einem Python-Deployment aus dem Quellcode ist der Standard-Entrypoint des Buildpacks gunicorn, gebunden an Port 8080, ohne jede weitere Einstellung, und das heißt: ein Worker, ein Thread und das Worker-Timeout von gunicorn mit 30 Sekunden. Gunicorn beendet einen Worker, der über dieses Limit hinaus still bleibt, und startet ihn neu, und ein synchroner Worker, der auf ein einziges langsames Lösen wartet, ist still. Ein reCAPTCHA, das 40 Sekunden braucht, kommt also nie zurück.

Dann der Gleichstand. Cloud Run schließt die Verbindung nach 300 Sekunden und gibt eine 504 zurück, und die Dokumentation von Google weist darauf hin, dass die Instanz dabei nicht beendet wird, sodass Ihr Code womöglich eine Anfrage weiterbearbeitet, auf die niemand mehr wartet. Das Timeout des SDK beträgt ebenfalls 300 Sekunden, aber seine Uhr startet später, nachdem die Anfrage angekommen und der Auftrag übermittelt ist. Die Uhr von Cloud Run löst immer zuerst aus, und die TimeoutException, die Ihnen gesagt hätte, was passiert ist, erreicht den Aufrufer nie. Im Leitfaden zum Anfrage-Timeout rät Google, das Limit über Ihre erwartete Ausführungszeit zu setzen und auch das eigene Timeout Ihres Frameworks zu prüfen. Gunicorn ist dieses Framework-Timeout.

Schritt 1: Den Dienst schreiben

Eine kleine Flask-App mit einer Route, gespeichert als main.py. Bauen Sie den Client einmal auf, beim Import. Er hält nur seine Einstellungen, daher können sich alle Threads im Worker ihn teilen.

# pip install capskip flask gunicorn
import os
from flask import Flask, jsonify, request
from capskip import CapSkip

app = Flask(__name__)

# The client does not read CAPSKIP_HOST by itself: pass it in.
solver = CapSkip(
    host=os.environ["CAPSKIP_HOST"],       # Server mode address
    port=int(os.environ.get("CAPSKIP_PORT", "8080")),
    apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
)

@app.post("/solve")
def solve():
    job = request.get_json(force=True)
    result = solver.recaptcha(sitekey=job["sitekey"], url=job["pageurl"])
    return jsonify(token=result["code"])   # use it straight away

Den Host mit einem harten Lookup statt mit einem Standardwert zu lesen, ist Absicht. Fehlt die Variable, scheitert der Import, gunicorn kann keinen Worker starten, und die neue Revision nimmt nie den Betrieb auf. Das ist ein viel klarerer Fehler als eine Revision, die sauber deployt wird und dann bei der ersten echten Anfrage eine NetworkException gegen Loopback wirft.

Schritt 2: Mit dem richtigen Entrypoint, Timeout und der richtigen Concurrency deployen

Jede Korrektur aus der Tabelle oben, die ein Captcha-Dienst auf Cloud Run braucht, gehört in den Deploy-Befehl, nichts davon steht also im Code.

# Run from the folder holding main.py and requirements.txt
gcloud run deploy solve-captcha \
  --source . \
  --region us-central1 \
  --no-allow-unauthenticated \
  --set-build-env-vars GOOGLE_ENTRYPOINT="gunicorn --bind :8080 --workers 1 --threads 8 --timeout 0 main:app" \
  --timeout 400 \
  --concurrency 8 \
  --set-env-vars CAPSKIP_HOST=203.0.113.10,CAPSKIP_PORT=8080,CAPSKIP_API_KEY=YOUR_API_KEY

Ersetzen Sie in Windows PowerShell jeden abschließenden Backslash durch einen Backtick. Das bewirkt jede Zeile:

  • Die Entrypoint-Zeile ist die Korrektur für gunicorn. Ein Timeout von 0 schaltet das Worker-Timeout von gunicorn ab und überlässt das Timing Cloud Run, und acht Threads lassen eine Instanz acht Lösungen gleichzeitig ausführen. Das sind die Einstellungen, die die Buildpack-Dokumentation von Google selbst als Beispiel verwendet, und gunicorn muss in requirements.txt aufgeführt sein, wenn Sie den Standard überschreiben. Der Port steht als 8080 da und nicht als Variable PORT, weil Ihre eigene Shell diese Variable expandieren würde, und zwar zu nichts, bevor gcloud sie überhaupt zu sehen bekommt. Cloud Run schickt den Traffic an 8080, sofern Sie nichts anderes angeben.
  • Das Anfrage-Timeout von 400 Sekunden liegt mit reichlich Luft über den 300 des Clients. Die Uhr des Clients startet erst, wenn der Auftrag übermittelt ist, und auf einer frischen Instanz hinter Cloud NAT kann diese erste Verbindung eine Minute dauern.
  • Die Concurrency-Zeile verhindert, dass sich Anfragen dort stauen, wo Cloud Run sie nicht sieht. Ein mit gcloud deployter Dienst nimmt standardmäßig bis zu 80 gleichzeitige Anfragen pro vCPU an. Bei acht Threads stauen sich die Anfragen neun bis achtzig in gunicorn, während die Uhr von Cloud Run schon läuft und die Instanz aussieht, als hätte sie reichlich Luft. Wenn Sie die Concurrency an die Threads anpassen, startet Cloud Run stattdessen eine weitere Instanz.
  • Die Umgebungsvariablen enthalten die Server-Modus-Adresse Ihres CapSkip-Rechners und seinen API-Schlüssel, die main.py ausdrücklich ausliest. Sobald das funktioniert, sind Secret Manager und das Flag set-secrets ein saubererer Ort für den Schlüssel.
  • Die Zeile no-allow-unauthenticated hält den Endpunkt privat, sodass nur Aufrufer mit der Berechtigung, den Dienst aufzurufen, über ihn die Zeit Ihres Solvers verbrauchen können.

Schritt 3: Dem Dienst eine statische ausgehende IP geben

Standardmäßig erreicht ein Cloud Run-Dienst das Internet aus einem dynamischen Pool von Google-Adressen, Ihre Firewall hat also keine einzelne IP, die sie freigeben könnte. Die dokumentierte Lösung ist, den Egress des Dienstes über ein VPC-Netzwerk mit einem Cloud NAT-Gateway zu leiten, das eine reservierte statische Adresse hält.

# Reserve one address and put Cloud NAT in front of the subnet
gcloud compute routers create capskip-router \
  --network default --region us-central1
gcloud compute addresses create capskip-egress --region us-central1
gcloud compute routers nats create capskip-nat \
  --router capskip-router --region us-central1 \
  --nat-custom-subnet-ip-ranges default \
  --nat-external-ip-pool capskip-egress

# Send ALL of the service's outbound traffic through that VPC
gcloud run services update solve-captcha --region us-central1 \
  --network default --subnet default --vpc-egress all-traffic

Das letzte Flag übersehen viele. Die Standard-Einstellung für den Egress ist private-ranges-only, und damit geht nur Traffic an private Adressen durch das VPC. Ihr Solver sitzt auf einer öffentlichen IP, ohne all-traffic gehen die Lösungsanfragen also trotzdem vom dynamischen Pool aus, und die NAT-Adresse taucht nie in Ihrem Firewall-Log auf. Google beschreibt die gesamte Einrichtung Schritt für Schritt in seinem Leitfaden zu statischen ausgehenden IP-Adressen.

Sobald die reservierte Adresse steht, geben Sie sie in der Firewall des Windows-Rechners für den Port des Solvers frei, und sonst nichts. Ein Cloud VPN-Tunnel in Ihr eigenes Netz erledigt dieselbe Aufgabe, ohne den Port überhaupt ins Internet zu stellen. So oder so ist der Server-Modus weiterhin Ihre Hardware und weiterhin ohne Abrechnung pro Lösung. Er ändert nur, wo der Solver lauscht, damit ihn etwas anderes als derselbe Desktop aufrufen kann.

Warum es nicht funktioniert, das Lösen nach der Antwort fertigzustellen

Der naheliegende Workaround für ein langsames Lösen ist, dem Aufrufer sofort zu antworten und die Arbeit in einem Hintergrund-Thread zu Ende zu bringen. Bei der standardmäßigen anfragebasierten Abrechnung von Cloud Run wird CPU nur zugewiesen, während die Instanz Anfragen verarbeitet. Ein Thread, der nach der Antwort noch pollt, bekommt nur dann CPU, wenn gerade eine andere Anfrage auf dieser Instanz läuft, und eine untätige Instanz kann jederzeit heruntergefahren werden. Die Arbeit stockt oder verschwindet, und nichts sagt Ihnen, was davon passiert ist.

Wenn der Aufrufer wirklich nicht warten kann, verlagern Sie die Arbeit stattdessen in einen Cloud Run-Job. Auf einen Job wartet keine HTTP-Anfrage, jeder Task darf standardmäßig 10 Minuten und höchstens 168 Stunden laufen, und Jobs nehmen dieselben Netzwerk- und Egress-Flags an wie Dienste, ein Job mit diesen Flags geht also über dieselbe NAT-Adresse hinaus.

Vollständiges lauffähiges Beispiel

Derselbe Dienst, der dem Aufrufer jetzt sagt, bei welchen Fehlern sich ein erneuter Versuch lohnt.

# pip install capskip flask gunicorn
import os
from flask import Flask, jsonify, request
from capskip import (CapSkip, ApiException, NetworkException,
                     TimeoutException, ValidationException)

app = Flask(__name__)

solver = CapSkip(
    host=os.environ["CAPSKIP_HOST"],
    port=int(os.environ.get("CAPSKIP_PORT", "8080")),
    apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
    recaptchaTimeout=300,   # keep it below the Cloud Run --timeout
)

@app.post("/solve")
def solve():
    job = request.get_json(force=True)
    try:
        result = solver.recaptcha(sitekey=job["sitekey"], url=job["pageurl"])
    except NetworkException as exc:
        # Worth retrying. Log the detail, but do not echo the
        # solver's address back to the caller.
        app.logger.warning("solver unreachable: %s", exc)
        return jsonify(error="solver unreachable"), 503
    except TimeoutException:
        return jsonify(error="no answer inside 300 seconds"), 504
    except (ApiException, ValidationException) as exc:
        # Same input, same failure: do not retry.
        return jsonify(error=str(exc)), 422
    return jsonify(token=result["code"])

Die Statuscodes sind so gewählt, dass ein Aufrufer darauf reagieren kann, ohne die Meldung zu lesen. Eine 503 bedeutet, dass der Solver nicht erreichbar war, um den Auftrag anzunehmen, und ein erneuter Versuch kann klappen. Eine 504 aus Ihrem eigenen Code, die kurz nach 300 Sekunden eintrifft, bedeutet, dass keine Antwort rechtzeitig zurückkam, weil der Solver langsam war oder nach der Annahme des Auftrags ausgefallen ist. Eine 422 bedeutet, dass CapSkip den Auftrag abgelehnt hat, meist weil der sitekey, die URL oder der API-Schlüssel falsch ist, und ein direkter erneuter Versuch bekommt in der Regel dieselbe Antwort. Alle vier Exceptions leiten sich von CapSkipError ab, wenn Sie lieber eine einzige Sache fangen.

Wer den Token erhält, sollte ihn sofort verwenden. Ein reCAPTCHA-Token hält etwa zwei Minuten, und die Einzelheiten zu diesem Zeitfenster stehen im Leitfaden zum reCAPTCHA-v2-Löser. Dieselbe Form funktioniert für die anderen Typen, die andere Argumente annehmen und andere Felder zurückgeben; jede Methode, die das Python-Paket bereitstellt, finden Sie auf der Python-Captcha-Solver-Seite.

Häufige Fehler und was sie bedeuten

Was Sie sehenUrsacheBeheben
WORKER TIMEOUT in den Logs und ein Serverfehler etwa 30 Sekunden nach Beginn eines LösensDer Standard-Entrypoint des Buildpacks, der gunicorn mit seinem Worker-Timeout von 30 Sekunden ausführtSetzen Sie GOOGLE_ENTRYPOINT mit einem Timeout von 0 und ein paar Threads
Eine 504 nach 300 Sekunden, und Logzeilen desselben Lösens treffen danach immer noch einDas Anfrage-Timeout von Cloud Run ist gleich dem Polling-Timeout des Clients, und die Uhr von Cloud Run ist zuerst gestartetDeployen Sie mit einem Timeout von 400 Sekunden
Latenz, die unter Last steigt, während die Zahl der Instanzen gleich bleibtCloud Run schickt bis zu 80 Anfragen pro vCPU an einen Server mit weit weniger ThreadsSetzen Sie die Concurrency auf die Zahl der gunicorn-Threads
Eine NetworkException mit der Meldung bad response: 404Der Client wurde ohne Host erstellt und hat deshalb 127.0.0.1 auf Port 8080 aufgerufen, und das ist im Container Ihr eigener Dienst. Er liest CAPSKIP_HOST nicht von selbstÜbergeben Sie den Host aus der Umgebung, so wie main.py es tut
Die neue Revision wird nach einem Deployment nie bereitCAPSKIP_HOST ist nicht gesetzt, oder gunicorn fehlt in requirements.txtSetzen Sie die Variable am Dienst und führen Sie gunicorn bei Ihren anderen Abhängigkeiten auf
Lösungen, die von Ihrem Laptop aus funktionieren und vom Dienst aus scheiternIhre Adresse zu Hause ist in der Firewall freigegeben, die Adressen von Google nichtGeben Sie dem Dienst eine statische ausgehende IP und geben Sie genau diese frei
Lösungen, die bis zum Timeout hängen, nachdem Sie das VPC angebunden habenDer gesamte Traffic läuft über ein VPC ohne Cloud NAT-Gateway und hat damit keinen Weg nach draußenErstellen Sie das NAT-Gateway für das Subnetz des Dienstes
Ihr Firewall-Log zeigt eine Google-Adresse, die Sie nicht reserviert habenDie Egress-Einstellung ist noch private-ranges-only, öffentlicher Traffic umgeht also das NATAktualisieren Sie den Dienst mit dem Egress all-traffic

FAQ

Kann CapSkip selbst auf Cloud Run laufen?

Nein, und das muss es auch nicht. CapSkip ist eine Windows-Anwendung, die auf Hardware läuft, die Ihnen gehört, und das Python-Paket in Ihrem Container ist ein schlanker Client dafür über HTTP. Schalten Sie den Server-Modus ein, geben Sie dem Dienst die Adresse, und er ruft den Solver genauso auf, wie es ein Skript auf demselben Schreibtisch täte. Das Lösen bleibt auf Ihrem Rechner, und deshalb zählt auch niemand ab, wie viele Captchas Sie lösen.

Passt ein Dienst oder ein Job besser zum Lösen von Captchas?

Ein Dienst, wenn etwas auf den Token wartet und ihn sofort verwenden wird, etwa ein Scraper, der mitten im Crawl Ihren Endpunkt aufruft. Ein Job, wenn die Arbeit ein Batch ist, auf den niemand wartet, denn ein Job hat überhaupt kein Anfrage-Timeout, und sein Task-Timeout reicht weit über eine Stunde hinaus. So oder so muss der Token von dem Prozess verwendet werden, der ihn hält, und zwar innerhalb weniger Minuten. Ein Job, der hundert Captchas löst und die Tokens für später irgendwo ablegt, hat hundert Lösungen umsonst erledigt. Dieselbe Abwägung taucht bei AWS auf, und der Leitfaden zu AWS Lambda behandelt sie mit einer vorgeschalteten Queue.

Wie lasse ich nur meinen Dienst an den Solver?

Leiten Sie den gesamten Egress des Dienstes über ein VPC mit einem Cloud NAT-Gateway, das eine reservierte Adresse hält, und geben Sie dann genau diese eine Adresse in Ihrer Firewall für den Port des Solvers frei. Ein Cloud VPN-Tunnel erreicht einen Solver in Ihrem eigenen Netz, ohne den Port überhaupt zum Internet hin zu öffnen. Halten Sie den Port für alles andere geschlossen und behandeln Sie den API-Schlüssel als zweites Schloss und nicht als einziges.

Kostet eine Anfrage, die auf ein Lösen wartet, viel?

Cloud Run rechnet eine Instanz ab, solange sie Anfragen verarbeitet, und eine Anfrage, die auf das Netz wartet, zählt dazu. Die Concurrency hält das im vernünftigen Rahmen. Acht Lösungen, die nebeneinander auf einer Instanz warten, verbrauchen die abgerechnete Zeit einer einzigen Instanz, während eine Anfrage pro Instanz acht Instanzen starten würde. Das ist die andere Hälfte der Begründung dafür, gunicorn acht Threads statt einem zu geben. Das Lösen selbst kostet pro Captcha nichts, weil es auf Ihrem eigenen Rechner läuft.

Die Kurzfassung

Ein Captcha-Deployment auf Cloud Run braucht vier Einstellungen, und drei davon stehen im Deploy-Befehl statt in Ihrem Code. Ersetzen Sie den Standard-Entrypoint des Buildpacks, damit gunicorn aufhört, Worker nach 30 Sekunden zu beenden, und geben Sie ihm Threads. Setzen Sie das Anfrage-Timeout über die 300 Sekunden des Clients, damit Ihr eigener Fehler vor der 504 von Cloud Run ankommt. Passen Sie die Concurrency an diese Threads an, damit zusätzliche Last neue Instanzen startet, statt sich zu stauen. Und übergeben Sie die Server-Modus-Adresse selbst an den Client und leiten Sie den Dienst dann über ein VPC mit Cloud NAT und dem Egress all-traffic, damit Ihre Firewall genau eine Adresse freigeben muss.

Noch ein letzter Punkt zur Wirtschaftlichkeit, denn er sorgt dafür, dass Sie entspannt nach den Statuscodes für Wiederholungen oben handeln können. Eine wiederholte Anfrage kostet Sie ein paar Cloud Run-Sekunden und sonst nichts: Der Captcha-Löser selbst läuft auf Hardware, die Sie bereits bezahlt haben, ein fehlgeschlagenes Lösen bringt also nie eine Gebühr pro Captcha von irgendjemandem mit sich.