DevOps & Skalierung

Hochverfügbare CAPTCHA-Lösung: Failover und Redundanz

Hochverfügbarkeit entsteht in einer CAPTCHA-Pipeline nicht durch mehr Threads, sondern durch vier Schichten, die unabhängig voneinander greifen: redundante Worker mit Supervisor, ein Circuit Breaker vor der API, ein aussagekräftiger Health-Check und definierte Degradationsstufen. Fehlt eine davon, kippt der ganze Lauf, sobald ein Prozess stirbt oder das Netz zwei Minuten wackelt.

Der Auslöser ist selten spektakulär: Ein Batch-Job startet nachts um 3 Uhr, ein Worker beendet sich still, und am Morgen fehlen 40 % der Datensätze – ohne Alarm.

Was in einer CAPTCHA-Pipeline tatsächlich ausfällt

Redundanz lohnt sich nur gegen Fehlerbilder, die im Betrieb wirklich auftreten:

  • Der Worker stirbt oder hängt. Der zweite Fall ist der gefährlichere: Der Prozess lebt, steckt im Polling fest und gilt jeder oberflächlichen Prüfung als gesund.
  • Eine gemeinsame Abhängigkeit fällt aus. DNS, Egress-Gateway oder Netzwerkroute treffen alle Worker gleichzeitig – Redundanz auf einer einzigen Maschine hilft dann nicht.
  • Die API liefert Fehlerserien. Anfragen laufen ins Timeout und blockieren genau die Threads, die gesunde Aufgaben bräuchten.
  • Tokens verfallen. Ein reCAPTCHA-Token gilt rund 120 Sekunden; was während der Störung in der Warteschlange liegen bleibt, ist beim Absenden wertlos.
  • Das eigene Deployment ist die Störung. Eine neue Version ändert Session- oder Retry-Verhalten, und die Fehlerquote steigt ohne externen Anlass.

Für die Kalkulation wichtig: CaptchaAI rechnet pro gleichzeitigem Thread ab, nicht pro Lösung – BASIC 15 $ im Monat mit 5 Threads, ADVANCE 90 $ mit 50 Threads, ENTERPRISE 300 $ mit 200 Threads, jeweils mit unbegrenzten Lösungen pro Thread. Redundanz kostet also keine zusätzlichen Lösungen – ein Reserve-Worker belegt einen Thread nur, während er wirklich arbeitet. (Preise in US-Dollar.)

Hochverfügbarkeit in vier Schichten

[Health Checker] ──── monitors ────→ [Worker Pool A]
       ↓                                    ↓ (primary)
[Circuit Breaker]                    [CaptchaAI API]
       ↓                                    ↑ (fallback)
[Failover Router] ── redirects ──→ [Worker Pool B]
       ↓
[Dead Letter Queue] ← unrecoverable failures

Der Health-Checker beobachtet den Pool, der Circuit Breaker schützt die API-Verbindung, der Failover-Router verschiebt Last auf Pool B – und was endgültig scheitert, landet in der Dead-Letter-Queue.

Schicht 1: Redundante Worker mit Supervisor

Betreiben Sie mehr Worker, als der Normalbetrieb braucht. Entscheidend ist die Untergrenze: Unterhalb von min_workers gilt der Pool nicht mehr als gesund – genau dieser Wert gehört in den Health-Check aus Schicht 3.

Zur Dimensionierung hilft die Lösungsdauer je Typ: Cloudflare Turnstile liegt laut SLA unter 10 Sekunden, reCAPTCHA v2 unter 60 Sekunden. Wer überwiegend reCAPTCHA v2 löst, belegt seine Threads länger – planen Sie entlang des Typ-Mixes, nicht der Aufgabenzahl.

Python: überwachter Worker-Pool mit Heartbeat

import os
import time
import threading
import queue
import requests

API_KEY = os.environ["CAPTCHAAI_API_KEY"]
task_queue = queue.Queue(maxsize=200)
results = {}


class SupervisedWorkerPool:
    def __init__(self, worker_count, min_workers=2):
        self.worker_count = worker_count
        self.min_workers = min_workers
        self.workers = {}
        self.lock = threading.Lock()

    def start(self):
        """Launch workers and supervisor."""
        for i in range(self.worker_count):
            self._launch_worker(i)

        # Supervisor thread monitors worker health
        supervisor = threading.Thread(target=self._supervise, daemon=True)
        supervisor.start()

    def _launch_worker(self, worker_id):
        t = threading.Thread(
            target=self._worker_loop,
            args=(worker_id,),
            daemon=True
        )
        t.start()
        with self.lock:
            self.workers[worker_id] = {
                "thread": t,
                "alive": True,
                "last_heartbeat": time.time(),
                "tasks_completed": 0
            }

    def _worker_loop(self, worker_id):
        session = requests.Session()
        while True:
            try:
                task = task_queue.get(timeout=30)
                result = solve_captcha(session, task)
                results[task["task_id"]] = result

                with self.lock:
                    self.workers[worker_id]["last_heartbeat"] = time.time()
                    self.workers[worker_id]["tasks_completed"] += 1

                task_queue.task_done()
            except queue.Empty:
                # Heartbeat even when idle
                with self.lock:
                    self.workers[worker_id]["last_heartbeat"] = time.time()
            except Exception as e:
                print(f"Worker {worker_id} error: {e}")
                with self.lock:
                    self.workers[worker_id]["last_heartbeat"] = time.time()

    def _supervise(self):
        """Restart dead workers."""
        while True:
            time.sleep(15)
            with self.lock:
                now = time.time()
                for wid, info in list(self.workers.items()):
                    if not info["thread"].is_alive():
                        print(f"Worker {wid} died — restarting")
                        self._launch_worker(wid)
                    elif now - info["last_heartbeat"] > 120:
                        print(f"Worker {wid} stalled — replacing")
                        self._launch_worker(wid)

    @property
    def status(self):
        with self.lock:
            alive = sum(1 for w in self.workers.values()
                       if w["thread"].is_alive())
            return {
                "alive": alive,
                "total": len(self.workers),
                "healthy": alive >= self.min_workers
            }


def solve_captcha(session, task):
    resp = session.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": task.get("method", "userrecaptcha"),
        "googlekey": task["sitekey"],
        "pageurl": task["pageurl"],
        "json": 1
    })
    data = resp.json()

    if data.get("status") != 1:
        return {"error": data.get("request")}

    captcha_id = data["request"]
    for _ in range(60):
        time.sleep(5)
        result = session.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get", "id": captcha_id, "json": 1
        }).json()
        if result.get("status") == 1:
            return {"solution": result["request"]}
        if result.get("request") != "CAPCHA_NOT_READY":
            return {"error": result.get("request")}
    return {"error": "TIMEOUT"}


# Start pool with 8 workers, minimum 3 healthy
pool = SupervisedWorkerPool(worker_count=8, min_workers=3)
pool.start()

Der Supervisor deckt beide Ausfallarten ab: tote Threads und solche, deren Heartbeat älter als 120 Sekunden ist. Wichtig ist der Heartbeat im Leerlauf – sonst gilt eine leere Warteschlange als Stillstand und der Pool startet endlos neu.

Schicht 2: Circuit Breaker gegen teure Fehlversuche

Ohne Circuit Breaker schickt jeder Worker während einer Störung weiter Anfragen los, die ins Timeout laufen. Das kostet keine Lösungen, blockiert aber Threads und verlängert die Wiederherstellung.

JavaScript: Breaker mit Half-Open-Phase

class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 60000; // 1 minute
    this.failures = 0;
    this.lastFailure = 0;
    this.state = "closed"; // closed, open, half-open
    this.successesInHalfOpen = 0;
  }

  async execute(fn) {
    if (this.state === "open") {
      if (Date.now() - this.lastFailure > this.resetTimeout) {
        this.state = "half-open";
        this.successesInHalfOpen = 0;
      } else {
        throw new Error("Circuit breaker is OPEN — requests blocked");
      }
    }

    try {
      const result = await fn();
      this._onSuccess();
      return result;
    } catch (err) {
      this._onFailure();
      throw err;
    }
  }

  _onSuccess() {
    if (this.state === "half-open") {
      this.successesInHalfOpen++;
      if (this.successesInHalfOpen >= 3) {
        this.state = "closed";
        this.failures = 0;
        console.log("Circuit breaker CLOSED — service recovered");
      }
    } else {
      this.failures = 0;
    }
  }

  _onFailure() {
    this.failures++;
    this.lastFailure = Date.now();
    if (this.failures >= this.failureThreshold) {
      this.state = "open";
      console.log(
        `Circuit breaker OPEN — ${this.failures} consecutive failures`
      );
    }
  }
}

// Usage
const breaker = new CircuitBreaker({
  failureThreshold: 5,
  resetTimeout: 60000,
});

async function solveCaptchaWithBreaker(sitekey, pageurl) {
  return breaker.execute(() => solveCaptcha(sitekey, pageurl));
}

Die Zustände sind bewusst asymmetrisch: Fünf Fehler in Folge öffnen den Breaker sofort, drei Erfolge in der Half-Open-Phase schließen ihn wieder. So trifft die volle Last nicht erneut auf eine noch instabile Strecke.

Nicht jeder Fehler gehört in den Zähler: ERROR_ZERO_BALANCE ist ein Kontoproblem – das sollte alarmieren, aber den Breaker nicht öffnen.

Schicht 3: Health-Check-Endpunkt, der etwas aussagt

Ein Endpunkt, der pauschal 200 OK liefert, hält den Load Balancer bei Laune, während der Pool längst leer ist. Aussagekräftig wird die Prüfung erst mit Pool-Zustand und Warteschlangentiefe.

Python (Flask)

from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/health")
def health_check():
    pool_status = pool.status
    queue_depth = task_queue.qsize()

    health = {
        "status": "healthy" if pool_status["healthy"] else "degraded",
        "workers_alive": pool_status["alive"],
        "workers_total": pool_status["total"],
        "queue_depth": queue_depth,
        "queue_capacity": task_queue.maxsize
    }

    code = 200 if health["status"] == "healthy" else 503
    return jsonify(health), code


@app.route("/health/ready")
def readiness_check():
    """Readiness probe — is this instance ready to receive tasks?"""
    if pool.status["alive"] > 0 and task_queue.qsize() < task_queue.maxsize:
        return "ready", 200
    return "not ready", 503

Trennen Sie Liveness und Readiness: /health sagt, ob die Instanz arbeitsfähig ist, /health/ready, ob sie neue Aufgaben annehmen kann. Der Statuscode 503 nimmt die Instanz automatisch aus der Rotation – in HAProxy, nginx und der readinessProbe von Kubernetes gleichermaßen.

Schicht 4: Kontrollierte Degradation statt Totalausfall

Wenn Kapazität fehlt, ist die Frage nicht, ob etwas ausfällt, sondern was zuerst. Diese Entscheidung gehört in den Code, nicht in den Zufall der Warteschlange.

class GracefulDegradation:
    def __init__(self):
        self.mode = "normal"  # normal, degraded, emergency

    def set_mode(self, error_rate, queue_depth, workers_alive):
        if workers_alive == 0 or error_rate > 0.5:
            self.mode = "emergency"
        elif error_rate > 0.2 or queue_depth > 150:
            self.mode = "degraded"
        else:
            self.mode = "normal"

    def should_accept_task(self, priority):
        if self.mode == "normal":
            return True
        if self.mode == "degraded":
            return priority in ("high", "critical")
        return priority == "critical"  # Emergency: critical only

    @property
    def status(self):
        return {
            "mode": self.mode,
            "accepting": {
                "normal": self.mode == "normal",
                "degraded": self.mode in ("normal", "degraded"),
                "emergency": True
            }
        }

Ab 20 % Fehlerrate oder 150 wartenden Aufgaben nimmt die Pipeline nur noch high und critical an, ab 50 % ausschließlich critical. Jeder Moduswechsel gehört ins Monitoring: Der Sprung von normal auf degraded ist das früheste verlässliche Warnsignal.

Praxisbeispiel: Failover zwischen zwei Standorten in Deutschland

Ein Händler in Nordrhein-Westfalen prüft nachts die Produktdaten seiner eigenen Shopware-Instanzen und autorisierter Partnerfeeds. Die Worker laufen auf zwei Hetzner-VPS in Falkenstein und Nürnberg, ausgerollt über GitLab CI, mit sechs Workern je Standort und min_workers=3.

Fällt ein Standort aus, übernimmt der andere – allerdings ohne Mehrdurchsatz: Beide teilen sich dasselbe Thread-Kontingent des Plans, im Beispiel ADVANCE mit 50 Threads für 90 $ im Monat. Der zweite Standort erhöht die Ausfallsicherheit, nicht die Kapazität.

Ein Punkt, der in DACH-Projekten oft zu spät kommt: IP-Adressen gelten in der EU als personenbezogene Daten – prüfen Sie Rechtsgrundlage und Speicherdauer Ihrer Logs.

HA-Checkliste vor dem Go-live

Baustein Prüfpunkt
Worker-Redundanz (N+1) und Supervisor Prozess hart beenden: bleibt der Pool über min_workers, kommt der Ersatz?
Circuit Breaker Fehlerserie simulieren, Zustandswechsel und Reset beobachten
Health-Check-Endpunkt 503 erzwingen, LB-Verhalten prüfen
Degradationsstufen Modi mit synthetischer Fehlerrate durchschalten
Dead-Letter-Queue und Fallback-Polling Callback abschalten: kommt das Ergebnis trotzdem an?
Alarmierung Testalarm an PagerDuty, Slack oder E-Mail auslösen

Typische Störungsbilder

Symptom Ursache Vorgehen
Worker läuft, verarbeitet nichts leere Queue oder falscher API-Schlüssel Queue-Tiefe, Guthaben und Fehlerrate je Worker gemeinsam prüfen
Breaker bleibt offen Reset-Timeout zu lang oder Störung besteht fort Timeout senken, Fehlerantworten der API auswerten
Health-Check grün, Aufgaben scheitern Prüfung misst nur den Prozess Erfolgsquote der letzten Aufgaben aufnehmen
Failover pendelt hin und her instabiles Netz Hysterese: erst nach mehreren Fehlern in Folge umschalten
Fehlerrate steigt nach dem Rollout Retry-Verhalten geändert Läufe vor und nach dem Deployment vergleichen

Häufige Fragen

Wie viele Worker brauche ich bei 50 Threads?

So viele, wie Sie parallel auslasten wollen, plus Reserve – aber nie mehr, als der Plan an Threads hergibt. 30 bis 40 Worker sind ein realistischer Startpunkt; messen Sie die Belegung eine Woche lang und korrigieren Sie danach.

Was passiert mit laufenden Aufgaben, wenn der Breaker öffnet?

Sie laufen zu Ende – der Breaker blockiert nur neue Aufrufe. Vorsicht gilt der Warteschlange: Ein gelöstes Token altert während der Störung weiter und ist nach rund 120 Sekunden nicht mehr verwendbar.

Reicht ein einfacher Health-Check für den Load Balancer?

Nein. Eine reine Prozessprüfung meldet gesund, solange der Dienst antwortet – auch wenn kein Worker mehr arbeitet. Nehmen Sie lebende Worker, Warteschlangentiefe und die Erfolgsquote der letzten Aufgaben in die Antwort auf.

Wie teste ich Failover, ohne die Produktion zu gefährden?

In einer Staging-Umgebung mit identischer Konfiguration: Worker-Prozesse hart beenden, mit tc netem Latenz und Paketverluste erzeugen, Fehlerantworten der API mocken. Erst wenn alle vier Schichten dort reagieren, lohnt ein Test in der Produktion.

Nächste Schritte

Bauen Sie die vier Schichten von Anfang an ein, statt sie nach dem ersten nächtlichen Ausfall nachzurüsten. Supervisor und Health-Check sind an einem Nachmittag umgesetzt; den API-Schlüssel dafür erhalten Sie auf captchaai.com.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.