DevOps & Skalierung

CAPTCHA-Worker horizontal skalieren: wann und wie

Mehr Worker lösen genau ein Problem: zu wenig Durchsatz. Wächst Ihre Warteschlange schneller, als sie abgearbeitet wird, ist horizontales Skalieren die richtige Antwort. Braucht dagegen jede einzelne Aufgabe zu lange, verteilen zusätzliche Container nur dieselbe Wartezeit auf mehr Prozesse – die Serverrechnung steigt, der Durchsatz nicht.

Dieser Leitfaden klärt beides: welche Messwerte eine Skalierung auslösen dürfen, wie viele Worker Ihr Thread-Budget bei CaptchaAI trägt, und wie ein Auto-Scaler in Python sowie ein Docker-Scaler in Node.js Replikate anhand der Warteschlangentiefe hoch- und wieder herunterfahren – mit fester Obergrenze, damit eine Lastspitze nicht zum Kostenproblem wird.

Vertikal oder horizontal: die Vorentscheidung

Ein größerer Server hilft bei prozessgebundenen Engpässen: Bildvorverarbeitung, die einen CPU-Kern auslastet, oder mehrere Headless-Browser, die Arbeitsspeicher belegen. Horizontal skalieren Sie, wenn mehr Aufgaben gleichzeitig anstehen, als ein Prozess betreuen kann – der typische Fall bei CAPTCHA-Workloads, denn ein Worker wartet meist auf die API, statt zu rechnen.

Die Faustregel für die Diagnose: Beobachten Sie CPU und Arbeitsspeicher eines einzelnen Workers, während die Warteschlange wächst. Liegen beide Werte deutlich unter der Auslastungsgrenze, ist die Nebenläufigkeit der Engpass, nicht die Maschine – dann bringen zusätzliche Worker linearen Zuwachs, ein doppelt so großer Server fast nichts.

Wie viele Worker Ihr Thread-Budget trägt

CaptchaAI rechnet pro gleichzeitigem Thread ab, nicht pro Lösung: Ein Thread ist eine Aufgabe in Bearbeitung, danach nimmt derselbe Thread die nächste, und die Zahl der Lösungen pro Thread ist im Abrechnungsmonat nicht gedeckelt. Die Thread-Zahl Ihres Plans ist damit die Obergrenze der Parallelität – unabhängig davon, wie viele Container laufen. Zur Orientierung (Preise in US-Dollar): BASIC mit 5 Threads für 15 $/Monat, ADVANCE mit 50 Threads für 90 $/Monat, PREMIUM mit 100 Threads für 170 $/Monat.

Die nötige Worker-Zahl rechnen Sie vor dem Deployment aus: Worker = Spitzenaufgaben pro Minute × Lösungszeit in Sekunden / 60 / Aufgaben pro Worker. Bei 100 Aufgaben pro Minute, 30 Sekunden Lösungszeit und 10 gleichzeitigen Aufgaben je Worker ergibt das 100 × 30 / 60 / 10 = 5 Worker, die zusammen bis zu 50 Aufgaben offen halten. Der Plan braucht also mindestens 50 Threads, sonst laufen die zusätzlichen Worker in Fehlerantworten statt in Lösungen.

Signale, die eine Skalierung auslösen dürfen

Skalieren Sie nach Warteschlange und Fehlerbild. Fünf Messwerte reichen:

Signal Schwelle Reaktion
Warteschlange wächst kontinuierlich > 50 offene Aufgaben Worker hinzufügen
Durchschnittliche Lösungslatenz > 45 Sekunden Worker hinzufügen – nicht die API ist der Engpass
CPU-Auslastung eines Workers dauerhaft > 70 % Worker hinzufügen statt Nebenläufigkeit pro Prozess erhöhen
Fehlerquote > 5 % mit ERROR_NO_SLOT_AVAILABLE zu viele gleichzeitige Aufgaben – erst das Thread-Budget prüfen
Warteschlange leert sich zu langsam < 80 % des Durchsatzziels Worker hinzufügen oder Nebenläufigkeit anheben

Architektur: Monitor, Scaler und Kostenbremse

Drei Bausteine genügen: Ein Monitor beobachtet die Warteschlange, ein Scaler startet und beendet Worker, eine Kostenbremse deckelt die Flottengröße. Wichtig ist die Trennung – der Monitor entscheidet nichts, der Scaler misst nichts.

[Queue Monitor] ──watches──→ [Task Queue]
       │                         ↕
       │ scale signal       [Worker 1]
       ↓                    [Worker 2]
[Auto Scaler] ──adds──→    [Worker 3]
       │                    [Worker N...]
       ↓
[Cost Manager] ──caps──→ max workers

Auto-Scaling-Controller in Python

Der Controller kombiniert Warteschlangentiefe, geschätzte Abarbeitungsdauer und Fehlerquote. Steigt die Fehlerquote über 10 %, skaliert er bewusst nicht weiter hoch – mehr Last auf eine gestörte Strecke verschlimmert das Bild nur. Der Cooldown von 120 Sekunden verhindert das Pendeln.

import os
import time
import math
import threading
import subprocess
import requests

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


class ScalingMetrics:
    """Collect metrics that drive scaling decisions."""

    def __init__(self):
        self.queue_depth = 0
        self.active_workers = 0
        self.tasks_per_minute = 0
        self.avg_solve_time = 30  # seconds
        self.error_rate = 0.0
        self.lock = threading.Lock()

    def update(self, queue_depth, active_workers, tasks_per_minute,
               avg_solve_time, error_rate):
        with self.lock:
            self.queue_depth = queue_depth
            self.active_workers = active_workers
            self.tasks_per_minute = tasks_per_minute
            self.avg_solve_time = avg_solve_time
            self.error_rate = error_rate

    @property
    def snapshot(self):
        with self.lock:
            return {
                "queue_depth": self.queue_depth,
                "active_workers": self.active_workers,
                "tasks_per_minute": self.tasks_per_minute,
                "avg_solve_time": self.avg_solve_time,
                "error_rate": self.error_rate,
            }


class HorizontalAutoScaler:
    def __init__(self, min_workers=2, max_workers=20,
                 tasks_per_worker=10, cooldown=120):
        self.min_workers = min_workers
        self.max_workers = max_workers
        self.tasks_per_worker = tasks_per_worker
        self.cooldown = cooldown
        self.current_workers = min_workers
        self.last_scale_time = 0
        self.metrics = ScalingMetrics()

    def calculate_desired_workers(self):
        snapshot = self.metrics.snapshot

        # Method 1: Queue-based scaling
        queue_based = math.ceil(
            snapshot["queue_depth"] / self.tasks_per_worker
        )

        # Method 2: Throughput-based scaling
        if snapshot["tasks_per_minute"] > 0 and snapshot["queue_depth"] > 0:
            drain_time = snapshot["queue_depth"] / snapshot["tasks_per_minute"]
            if drain_time > 5:  # More than 5 minutes to drain
                throughput_based = self.current_workers + 2
            else:
                throughput_based = self.current_workers
        else:
            throughput_based = self.current_workers

        # Method 3: Error-rate scaling (reduce if errors are high)
        if snapshot["error_rate"] > 0.1:
            error_based = max(
                self.min_workers,
                self.current_workers - 1
            )
        else:
            error_based = self.current_workers

        # Take the maximum of queue and throughput based, limited by error
        desired = max(queue_based, throughput_based)
        if snapshot["error_rate"] > 0.1:
            desired = min(desired, error_based)

        # Clamp to bounds
        return max(self.min_workers, min(self.max_workers, desired))

    def should_scale(self, desired):
        if desired == self.current_workers:
            return False
        if time.time() - self.last_scale_time < self.cooldown:
            return False
        return True

    def scale(self, desired):
        if not self.should_scale(desired):
            return

        direction = "up" if desired > self.current_workers else "down"
        diff = abs(desired - self.current_workers)

        print(f"Scaling {direction}: {self.current_workers} → {desired} "
              f"(+{diff if direction == 'up' else -diff})")

        if direction == "up":
            self._add_workers(diff)
        else:
            self._remove_workers(diff)

        self.current_workers = desired
        self.last_scale_time = time.time()

    def _add_workers(self, count):
        """Launch new worker containers."""
        for i in range(count):
            worker_id = f"captcha-worker-{self.current_workers + i}"
            # In production: use Docker API, K8s API, or cloud SDK
            print(f"  Launching {worker_id}")

    def _remove_workers(self, count):
        """Drain and stop workers."""
        for i in range(count):
            worker_id = f"captcha-worker-{self.current_workers - 1 - i}"
            print(f"  Draining and removing {worker_id}")

    def run_loop(self, interval=30):
        """Main auto-scaling loop."""
        print(f"Auto-scaler started: min={self.min_workers}, "
              f"max={self.max_workers}")
        while True:
            desired = self.calculate_desired_workers()
            self.scale(desired)

            snapshot = self.metrics.snapshot
            print(f"  Workers: {self.current_workers}, "
                  f"Queue: {snapshot['queue_depth']}, "
                  f"TPM: {snapshot['tasks_per_minute']}, "
                  f"Errors: {snapshot['error_rate']:.1%}")
            time.sleep(interval)


# Start auto-scaler
scaler = HorizontalAutoScaler(
    min_workers=2,
    max_workers=20,
    tasks_per_worker=10,
    cooldown=120  # 2-minute cooldown between scaling
)

# Run in background
scaling_thread = threading.Thread(target=scaler.run_loop, daemon=True)
scaling_thread.start()

Docker-Replikate mit Node.js steuern

Ohne Kubernetes kommt man mit Docker Compose weit: docker compose up -d --scale ändert die Replikatzahl im laufenden Betrieb, ohne bestehende Container neu zu erstellen. Der Scaler unten arbeitet mit Hysterese – hoch ab 50 offenen Aufgaben, herunter erst unter 10 – bei gleichem Cooldown.

const { exec } = require("child_process");
const { promisify } = require("util");
const execAsync = promisify(exec);

class DockerHorizontalScaler {
  constructor(options = {}) {
    this.serviceName = options.serviceName || "captcha-worker";
    this.minReplicas = options.minReplicas || 2;
    this.maxReplicas = options.maxReplicas || 15;
    this.currentReplicas = this.minReplicas;
    this.scaleUpThreshold = options.scaleUpThreshold || 50;
    this.scaleDownThreshold = options.scaleDownThreshold || 10;
    this.cooldownMs = options.cooldownMs || 120000;
    this.lastScaleTime = 0;
  }

  async evaluate(metrics) {
    const now = Date.now();
    if (now - this.lastScaleTime < this.cooldownMs) {
      return { action: "cooldown", current: this.currentReplicas };
    }

    let desired = this.currentReplicas;

    // Scale up: queue growing
    if (metrics.queueDepth > this.scaleUpThreshold) {
      const needed = Math.ceil(metrics.queueDepth / 10);
      desired = Math.min(this.maxReplicas, Math.max(desired, needed));
    }

    // Scale down: queue mostly empty
    if (
      metrics.queueDepth < this.scaleDownThreshold &&
      this.currentReplicas > this.minReplicas
    ) {
      desired = Math.max(this.minReplicas, this.currentReplicas - 1);
    }

    if (desired !== this.currentReplicas) {
      await this.scaleTo(desired);
      return { action: "scaled", from: this.currentReplicas, to: desired };
    }

    return { action: "no_change", current: this.currentReplicas };
  }

  async scaleTo(replicas) {
    const clamped = Math.max(
      this.minReplicas,
      Math.min(this.maxReplicas, replicas)
    );

    console.log(`Scaling ${this.serviceName}: ${this.currentReplicas} → ${clamped}`);

    try {
      // Docker Compose scaling
      await execAsync(
        `docker compose up -d --scale ${this.serviceName}=${clamped} --no-recreate`
      );
      this.currentReplicas = clamped;
      this.lastScaleTime = Date.now();
    } catch (err) {
      console.error(`Scale failed: ${err.message}`);
    }
  }

  status() {
    return {
      service: this.serviceName,
      current: this.currentReplicas,
      min: this.minReplicas,
      max: this.maxReplicas,
      lastScale: new Date(this.lastScaleTime).toISOString(),
    };
  }
}

// Monitor loop
const scaler = new DockerHorizontalScaler({
  serviceName: "captcha-worker",
  minReplicas: 2,
  maxReplicas: 15,
  cooldownMs: 120000,
});

async function monitorAndScale() {
  // In production, fetch from your queue/monitoring system
  const metrics = {
    queueDepth: 75, // Example
    errorRate: 0.02,
    avgSolveTime: 25,
  };

  const result = await scaler.evaluate(metrics);
  console.log("Scale decision:", result);
  console.log("Status:", scaler.status());
}

setInterval(monitorAndScale, 30000);

Die Kostenbremse gehört in den Scaler

Eine Obergrenze in Worker-Zahlen ist gut, eine in Geld ist besser. Die abgeleitete Klasse deckelt die Flotte über ein Stundenbudget: Bei 0,05 $ je Worker-Stunde und 2,00 $ pro Stunde laufen höchstens 40 Worker, egal was die Warteschlange fordert. Gemeint sind die Kosten Ihrer eigenen Infrastruktur – die CaptchaAI-Seite bleibt der feste Monatspreis des Plans.

class CostAwareScaler(HorizontalAutoScaler):
    def __init__(self, hourly_cost_per_worker=0.05, budget_per_hour=2.0,
                 **kwargs):
        super().__init__(**kwargs)
        self.hourly_cost = hourly_cost_per_worker
        self.budget = budget_per_hour

    def calculate_desired_workers(self):
        desired = super().calculate_desired_workers()

        # Cap by budget
        max_affordable = int(self.budget / self.hourly_cost)
        if desired > max_affordable:
            print(f"  Budget cap: wanted {desired}, "
                  f"can afford {max_affordable}")
            desired = max_affordable

        return desired

Praxisbeispiel: das nächtliche Zeitfenster

Ein Team in Berlin gleicht nachts Produktdaten der eigenen Shopware-Instanzen ab. Tagsüber genügen zwei Worker auf einem kleinen VPS bei Hetzner; zwischen 2 und 5 Uhr stapeln sich rund 1.200 Aufgaben, weil die GitLab-CI-Jobs aller Standorte gleichzeitig starten. Statt die Flotte dauerhaft auf Spitzenlast auszulegen, übernimmt der Scaler dieses Zeitfenster: hoch bis an das Thread-Budget, nach dem Leerlaufen zurück auf zwei.

Ein DACH-typischer Zusatzpunkt: Prüfen Sie, was Ihre Worker protokollieren. IP-Adressen und Session-Kennungen gelten in der DSGVO als personenbezogene Daten – ein Log, das mit jedem Skalierungsereignis mitwächst, wird schnell zum Aufbewahrungsproblem.

Checkliste vor dem Hochskalieren

Bereich Worauf es ankommt
Warteschlange persistent (Redis, SQS) – niemals nur im Arbeitsspeicher
Worker zustandslos: jeder Worker kann jede Aufgabe übernehmen
Health-Checks der Load Balancer erkennt, welche Worker tatsächlich arbeiten
Draining laufende Aufgaben zu Ende führen, bevor ein Container stoppt
Monitoring Warteschlangentiefe, Latenz und Fehlerquote sind sichtbar
Threads die Plan-Threads begrenzen die Parallelität, nicht die Container-Zahl
Kosten max_workers und Stundenbudget sind gesetzt

Typische Fehlerbilder beim Skalieren

Symptom Ursache Vorgehen
Scaler pendelt im Minutentakt Schwellen liegen zu eng beieinander Hysterese: hoch ab 50, herunter unter 10; Cooldown ≥ 120 Sekunden
Neue Worker bringen keinen Durchsatz Thread-Budget ausgereizt, nicht die Rechenleistung Anteil ERROR_NO_SLOT_AVAILABLE messen, Nebenläufigkeit senken oder Plan-Threads erhöhen
Frische Worker laufen leer Warteschlange war vor dem Start bereits abgearbeitet Cooldown verkürzen, in kleineren Schritten skalieren
Kosten steigen sprunghaft keine Obergrenze im Scaler max_workers und Budgetgrenze setzen, Alarm auf die Flottengröße legen
Fehlerquote steigt nach dem Rollout neue Version ändert Retry- oder Session-Verhalten Läufe vor und nach dem Deployment vergleichen, im Zweifel zurückrollen

Häufige Fragen

Was bedeutet ERROR_NO_SLOT_AVAILABLE beim Hochskalieren?

Es sind mehr Aufgaben gleichzeitig offen, als der Plan Threads bereitstellt – ein Kapazitäts-, kein Infrastruktursignal. Weitere Container beheben ihn nicht; senken Sie die Nebenläufigkeit pro Worker oder erhöhen Sie das Thread-Budget.

Wie verhindere ich, dass der Scaler ständig hin- und herschaltet?

Mit zwei getrennten Schwellen und einem Cooldown: hoch ab 50 offenen Aufgaben, herunter erst unter 10, dazwischen mindestens 120 Sekunden Ruhe. Ohne diese Hysterese reagiert der Scaler auf normale Schwankungen.

Müssen CAPTCHA-Worker wirklich zustandslos sein?

Ja, sonst funktioniert horizontales Skalieren nicht sauber: Jeder Worker muss jede Aufgabe aus der gemeinsamen Warteschlange übernehmen können. Aufgabenstatus, Zwischenergebnisse und Zähler gehören deshalb nach Redis oder in die Datenbank, nicht in den Prozessspeicher.

Lohnt sich Autoscaling auch bei gleichmäßiger Last?

Selten. Bei konstantem Volumen ist eine feste Worker-Zahl einfacher zu betreiben. Autoscaling lohnt sich, wenn Spitzen und Ruhephasen um den Faktor drei oder mehr auseinanderliegen – Nachtläufe, Kampagnenwochen, saisonale Peaks.

Nächste Schritte

Rechnen Sie zuerst das Thread-Budget, setzen Sie dann Ober- und Untergrenze im Scaler, und schalten Sie die Automatik erst danach scharf. Den API-Schlüssel dafür erhalten Sie auf captchaai.com.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.