DevOps & Skalierung

CAPTCHA-Lösung mit Kubernetes-Job-Queues skalieren

Sobald Ihr Automatisierungs-Workload von einigen hundert auf mehrere zehntausend CAPTCHAs pro Stunde wächst, wird ein einzelner Solver-Prozess zum Flaschenhals. Die saubere Antwort: Sie entkoppeln das Einreichen der Aufgaben vom eigentlichen Lösen und überlassen Kubernetes das Hoch- und Herunterfahren der Worker. Eine Redis-Queue nimmt die Aufgaben entgegen, ein Deployment aus Worker-Pods zieht sie ab und schickt jede Abfrage an die CaptchaAI-API – der Horizontal Pod Autoscaler passt die Zahl der Pods automatisch an die Warteschlangentiefe an. Dieser Leitfaden zeigt den kompletten Aufbau Schritt für Schritt: Deployment, Secret, Redis, Worker-Code, HPA und Producer.


Architektur im Überblick

Producer → Redis Queue → Worker Pods (auto-scaled) → CaptchaAI API
                              ↓
                       Results Store (Redis)

Der Aufbau trennt drei Verantwortlichkeiten sauber voneinander: Ein Producer reiht die Aufgaben in die Redis-Queue ein, die Worker-Pods arbeiten sie parallel ab, und ein Ergebnis-Store – ebenfalls in Redis – hält die gelösten Tokens zur Abholung bereit. Weil die Worker zustandslos sind, kann Kubernetes sie jederzeit neu starten, ersetzen oder duplizieren, ohne dass eine laufende Aufgabe verloren geht: Eine noch nicht bestätigte Abfrage bleibt in der Queue und wird vom nächsten freien Pod übernommen.

Wichtig für die Kapazitätsplanung ist ein Punkt, den viele erst im Betrieb bemerken: Nicht die Zahl der Pods bestimmt Ihren Durchsatz, sondern Ihr Thread-Kontingent bei CaptchaAI. CaptchaAI rechnet pro gleichzeitigem Thread ab – nicht pro Lösung – und jeder Plan enthält unbegrenzt viele Lösungen pro Thread. Der Tarif ADVANCE (90 $/Monat, 50 Threads) erlaubt also 50 parallele Abfragen. Verteilen Sie diese auf zehn Pods, verarbeitet jeder Pod im Schnitt fünf gleichzeitig. Skalieren Sie die Pod-Zahl darüber hinaus, wächst nur die Queue, nicht der tatsächliche Durchsatz.


Worker-Deployment

Das Deployment beschreibt die Worker als austauschbare, horizontal skalierbare Einheit. Drei Replikate sind ein pragmatischer Startwert; die konkrete Zahl übernimmt später der HPA. Der API-Schlüssel kommt nicht als Klartext ins Manifest, sondern über einen secretKeyRef aus einem Kubernetes-Secret – so landet er weder im Image noch in Ihrem Git-Repository. Die resources-Blöcke setzen bewusst niedrige Requests und Limits, weil die Worker die meiste Zeit auf die API-Antwort warten und kaum CPU verbrauchen:

# k8s/worker-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: captcha-worker
  labels:
    app: captcha-worker
spec:
  replicas: 3
  selector:
    matchLabels:
      app: captcha-worker
  template:
    metadata:
      labels:
        app: captcha-worker
    spec:
      containers:

        - name: worker
          image: your-registry/captcha-worker:latest
          env:

            - name: CAPTCHAAI_KEY
              valueFrom:
                secretKeyRef:
                  name: captchaai-secret
                  key: api-key

            - name: REDIS_URL
              value: "redis://redis-service:6379"
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "256Mi"
              cpu: "250m"

Kubernetes-Secret für den API-Schlüssel

Legen Sie den CaptchaAI-API-Schlüssel einmalig als Secret an. Der Worker liest ihn zur Laufzeit über die Umgebungsvariable CAPTCHAAI_KEY ein:

kubectl create secret generic captchaai-secret \
  --from-literal=api-key=YOUR_API_KEY

In produktiven Umgebungen sollten Sie das Secret nicht per Kommandozeile, sondern über einen versionierten, verschlüsselten Weg verwalten – etwa Sealed Secrets oder einen External-Secrets-Operator, der den Wert aus einem Vault zieht. In deutschen Enterprise-Setups läuft dieser Schritt häufig als Stage in einer GitLab-CI-Pipeline.


Redis als Queue und Ergebnis-Store

Redis übernimmt hier zwei Rollen gleichzeitig: die Aufgaben-Queue (captcha:queue als Liste) und den Ergebnis-Store (captcha:results als Hash). Für erste Tests genügt ein einzelnes Redis-Deployment mit einem passenden Service. Das folgende Manifest deployt beides zusammen:

# k8s/redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:

        - name: redis
          image: redis:7-alpine
          ports:

            - containerPort: 6379
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
spec:
  selector:
    app: redis
  ports:

    - port: 6379

Für den Dauerbetrieb empfiehlt sich statt eines einzelnen Pods eine hochverfügbare Variante – etwa Redis mit Persistenz und Replikation oder ein gemanagter Redis-Dienst Ihres Providers. Ob Sie den Cluster bei Hetzner, IONOS oder einem Hyperscaler betreiben, spielt dabei keine Rolle: Redis lässt sich wahlweise im Cluster oder als externer Managed-Dienst anbinden.


Worker-Code

Der Worker ist eine schlichte Endlosschleife: Er zieht per blpop blockierend eine Aufgabe aus der Queue, ruft die CaptchaAI-API auf und schreibt Token oder Fehler zurück in den Ergebnis-Store. Die Methode _solve übermittelt die Abfrage an in.php, fragt anschließend den Status per Polling an res.php ab und bricht nach einem Timeout kontrolliert ab. Nach jeder Aufgabe aktualisiert der Worker zusätzlich die Metrik captcha:queue_length, an der sich später das Autoscaling orientiert:

# worker.py
import os
import json
import time
import redis
import requests


class CaptchaWorker:
    """Kubernetes worker that processes CAPTCHA tasks from Redis."""

    def __init__(self):
        self.api_key = os.environ["CAPTCHAAI_KEY"]
        self.redis = redis.from_url(
            os.environ.get("REDIS_URL", "redis://localhost:6379"),
        )
        self.base = "https://ocr.captchaai.com"

    def run(self):
        """Main worker loop."""
        hostname = os.environ.get("HOSTNAME", "unknown")
        print(f"Worker {hostname} started")

        while True:
            result = self.redis.blpop("captcha:queue", timeout=30)
            if result is None:
                continue

            _, raw = result
            task = json.loads(raw)
            task_id = task.get("id", "unknown")

            print(f"[{hostname}] Processing {task_id}")
            start = time.time()

            try:
                token = self._solve(task["method"], task["params"])
                duration = time.time() - start
                self.redis.hset("captcha:results", task_id, json.dumps({
                    "status": "success",
                    "token": token,
                    "duration": f"{duration:.1f}s",
                    "worker": hostname,
                }))
                print(f"[{hostname}] {task_id} solved in {duration:.1f}s")

            except Exception as e:
                self.redis.hset("captcha:results", task_id, json.dumps({
                    "status": "error",
                    "error": str(e),
                    "worker": hostname,
                }))
                print(f"[{hostname}] {task_id} failed: {e}")

            # Update queue length metric
            queue_len = self.redis.llen("captcha:queue")
            self.redis.set("captcha:queue_length", queue_len)

    def _solve(self, method, params, timeout=120):
        resp = requests.post(f"{self.base}/in.php", data={
            "key": self.api_key,
            "method": method,
            "json": 1,
            **params,
        }, timeout=30)
        result = resp.json()

        if result.get("status") != 1:
            raise RuntimeError(result.get("request"))

        captcha_id = result["request"]

        start = time.time()
        while time.time() - start < timeout:
            time.sleep(5)
            resp = requests.get(f"{self.base}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": captcha_id,
                "json": 1,
            }, timeout=15)
            data = resp.json()
            if data["request"] != "CAPCHA_NOT_READY":
                if data.get("status") == 1:
                    return data["request"]
                raise RuntimeError(data["request"])

        raise TimeoutError("Solve timeout")


if __name__ == "__main__":
    CaptchaWorker().run()

Autoscaling mit dem Horizontal Pod Autoscaler

Weil jeder Pod seinen eigenen HOSTNAME protokolliert, lassen sich Durchsatz und Fehler pro Pod im Log-Stack nachvollziehen. Für den Produktivbetrieb lohnt es sich, die print-Ausgaben durch strukturiertes Logging und ein sauberes Signal-Handling zu ersetzen, damit ein Pod bei SIGTERM seine laufende Abfrage noch zu Ende bringt, bevor Kubernetes ihn entfernt.

Der HPA skaliert die Worker nicht nach CPU, sondern nach der Länge der Redis-Queue. Wächst die Warteschlange, kommen Pods hinzu; leert sie sich, fährt Kubernetes sie wieder herunter. Der Zielwert averageValue: "10" bedeutet, dass pro Pod im Schnitt höchstens zehn wartende Aufgaben liegen sollen:

# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: captcha-worker-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: captcha-worker
  minReplicas: 2
  maxReplicas: 20
  metrics:

    - type: External
      external:
        metric:
          name: redis_queue_length
          selector:
            matchLabels:
              queue: captcha
        target:
          type: AverageValue
          averageValue: "10"

Damit der HPA eine externe Metrik wie redis_queue_length überhaupt sieht, benötigen Sie einen Metrik-Adapter. In der Praxis ist KEDA die komfortabelste Lösung: Der redis-Scaler von KEDA liest die Listenlänge direkt aus und kann die Worker sogar bis auf null herunterskalieren, wenn keine Aufgaben anliegen – das spart in lastarmen Nachtstunden spürbar Ressourcen.


Task-Producer: Aufgaben einreihen

Auf der anderen Seite steht der Producer. Er erzeugt für jede Aufgabe eine kurze ID, legt sie per rpush in die Queue und sammelt die Ergebnisse später wieder aus dem Hash ein. So bleibt das Einreichen vollständig vom Lösen entkoppelt – Ihr Anwendungscode wartet nie synchron auf ein einzelnes CAPTCHA:

import json
import uuid
import redis


def submit_tasks(redis_url, tasks):
    """Submit CAPTCHA tasks to the queue."""
    r = redis.from_url(redis_url)
    task_ids = []

    for task in tasks:
        task_id = str(uuid.uuid4())[:8]
        task["id"] = task_id
        r.rpush("captcha:queue", json.dumps(task))
        task_ids.append(task_id)

    return task_ids


def get_results(redis_url, task_ids, timeout=180):
    """Wait for and collect results."""
    r = redis.from_url(redis_url)
    results = {}
    deadline = time.time() + timeout

    while len(results) < len(task_ids) and time.time() < deadline:
        for tid in task_ids:
            if tid in results:
                continue
            raw = r.hget("captcha:results", tid)
            if raw:
                results[tid] = json.loads(raw)
        time.sleep(1)

    return results

Fehlerbehebung

Die häufigsten Probleme im Betrieb lassen sich meist an wenigen Symptomen festmachen:

Problem Ursache Lösung
Worker-Pods starten nicht Secret fehlt Secret mit kubectl create secret anlegen
Pods in CrashLoopBackOff Umgebungsvariablen fehlen oder Redis ist nicht erreichbar Logs mit kubectl logs prüfen
HPA skaliert nicht Externe Metrik nicht konfiguriert Metrik-Adapter (KEDA) installieren
Queue wächst, aber nichts wird verarbeitet Worker im Leerlauf oder abgestürzt Pod-Status prüfen und Pods neu starten

Häufige Fragen

Wie viele Worker-Pods brauche ich zum Start?

Drei Replikate sind ein guter Startwert; den Rest übernimmt der HPA anhand der Queue-Länge. Wie viele Pods am Ende sinnvoll sind, begrenzt Ihr Thread-Kontingent – mehr Pods als Threads bringen keinen zusätzlichen Durchsatz.

Wie hängen die Threads meines CaptchaAI-Plans mit den Pods zusammen?

Ein Thread ist eine gleichzeitig laufende Abfrage. CaptchaAI rechnet pro Thread ab, nicht pro Lösung, und jeder Plan enthält unbegrenzt viele Lösungen pro Thread. STANDARD (30 $/Monat) bietet 15 Threads, ADVANCE (90 $/Monat) 50 und PREMIUM (170 $/Monat) 100. Ihre Pods teilen sich dieses Kontingent an paralleler Kapazität.

Was passiert, wenn ein Token in der Queue abläuft?

Lösen Sie das CAPTCHA erst unmittelbar vor der Übermittlung. Tokens verfallen je nach Typ nach etwa 120 Sekunden, deshalb sollte der Producer die Ergebnisse zeitnah abholen und nicht auf Vorrat sammeln.

Welche CAPTCHA-Typen kann ich über die Worker lösen?

Über dieselbe method-Logik löst CaptchaAI reCAPTCHA v2/v3, Cloudflare Turnstile und Challenge, GeeTest v3 sowie Bild-, OCR- und Grid-CAPTCHAs und BLS. CaptchaFox, Friendly Captcha und Lemin befinden sich in der Beta. hCaptcha und FunCaptcha werden nicht unterstützt.

KEDA oder HPA – was skaliert besser auf die Queue?

Beide funktionieren. KEDA unterstützt die Redis-Listenlänge nativ als Auslöser, ist einfacher zu konfigurieren als ein eigener Metrik-Adapter und kann bis auf null herunterskalieren – für queue-getriebene Worker meist die bessere Wahl.


Verwandte Leitfäden


Skalieren Sie auf zehntausende CAPTCHAs pro Stunde – starten Sie mit CaptchaAI in Ihrem Kubernetes-Cluster.

Kommentare sind für diesen Artikel deaktiviert.