DevOps & Scaling

Mise à l'échelle horizontale des workers de résolution CAPTCHA

Passez à la mise à l'échelle horizontale dès que votre file de tâches CAPTCHA grossit plus vite que vos workers ne la vident : ajoutez des workers, pas un serveur plus gros. C'est le seul levier qui fait croître votre débit de résolution de façon quasi linéaire avec la charge, sans réécrire votre code. Toute la difficulté est de savoir quand déclencher l'ajout et comment l'automatiser sans payer des machines inactives.

Avant même de parler d'auto-scaling, un point que les tutoriels oublient souvent : passé un certain nombre de workers, ce n'est plus votre infrastructure mais votre allocation de threads CaptchaAI qui plafonne la concurrence. Autant l'établir tout de suite.

La règle d'or : ajoutez des workers tant que votre file grossit, mais jamais au-delà de vos threads CaptchaAI. C'est ce plafond de concurrence, et non vos machines, qui borne le débit réel.

Vos workers et vos threads CaptchaAI

CaptchaAI facture au thread, pas à la résolution. Un thread correspond à un CAPTCHA en cours ; dès qu'une résolution se termine, le thread reprend la tâche suivante, avec des résolutions illimitées dans le mois. Votre nombre de threads est donc le vrai plafond de concurrence, quel que soit le nombre de workers déployés.

Concrètement, lancer 20 workers sur un plan BASIC ($15/mois, 5 threads) ne sert à rien : seules 5 résolutions peuvent être en vol en même temps, les 15 workers restants attendent. Alignez la capacité sur votre besoin réel : STANDARD ($30/mois, 15 threads) ou ADVANCE ($90/mois, 50 threads) pour un pool plus large. La facturation reste en dollars US, indépendamment de votre région d'hébergement. Règle simple : workers utiles ≈ threads du plan / tâches simultanées par worker.

Quand passer à l'échelle horizontale des workers

Ne réagissez pas à un pic isolé. Surveillez ces signaux et ajoutez des workers seulement lorsqu'un seuil est franchi de façon soutenue :

Signal Seuil Action
Profondeur de la file en hausse > 50 tâches en attente Ajoutez des workers
Latence de résolution moyenne > 45 secondes Ajoutez des workers (l'API n'est pas le goulot)
Charge CPU par worker > 70 % en continu Ajoutez des workers
Taux d'erreur > 5 % de ERROR_NO_SLOT_AVAILABLE Trop de tâches simultanées par worker
File qui se vide lentement < 80 % de l'objectif de débit Ajoutez des workers ou augmentez la concurrence

La ligne ERROR_NO_SLOT_AVAILABLE est le piège classique : elle ne dit pas « ajoutez des machines », mais « vous lancez plus de résolutions simultanées que vos threads ne l'autorisent ». Traitez-la comme un signal de plan, pas de capacité serveur.

Architecture d'un pool de workers scalable

Le schéma reste volontairement simple. Un moniteur observe la file, un auto-scaler ajuste le nombre de workers, un gestionnaire de coûts pose un plafond. Les workers sont sans état : n'importe lequel traite n'importe quelle tâche, ce qui permet d'en ajouter ou d'en retirer à chaud.

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

Contrôleur d'auto-scaling en Python

Ce contrôleur collecte les métriques, puis calcule le nombre de workers souhaité en combinant trois méthodes de décision :

  1. File — un worker pour chaque tranche de tâches en attente.
  2. Débit — ajout de workers si vider la file prendrait trop longtemps au rythme actuel.
  3. Taux d'erreur — réduction du pool si les erreurs de saturation grimpent.

Il retient la valeur la plus prudente, la borne entre min_workers et max_workers, puis applique un cooldown de deux minutes pour éviter que l'auto-scaler ne fasse du yo-yo à chaque cycle.

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()

Auto-scaling basé sur Docker en JavaScript

Sur une machine unique, pas besoin d'orchestrateur : docker compose --scale suffit. Ce scaler Node.js ajuste le nombre de réplicas du service worker selon la profondeur de la file, avec des seuils d'augmentation et de réduction distincts — l'hystérésis qui évite l'oscillation.

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);

Plafonner les coûts avec un scaler budget-aware

Sur OVHcloud, Scaleway ou une région AWS proche comme eu-west-3 (Paris), chaque worker a un coût horaire. Sans garde-fou, un pic de trafic peut faire exploser la facture d'infrastructure avant même que vous ne le remarquiez. Cette sous-classe plafonne le nombre de workers selon un budget horaire, en plus des bornes min_workers / max_workers.

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

Checklist de mise en production

Avant d'activer l'auto-scaling en production, validez chaque point :

  • File d'attente — persistante (Redis, SQS), jamais en mémoire.
  • Workers — sans état, pour que n'importe lequel traite n'importe quelle tâche.
  • Bilans de santé — l'équilibreur de charge sait quels workers sont sains.
  • Drainage — les workers terminent leurs tâches en vol avant l'arrêt.
  • Supervision — profondeur de file, latence et taux d'erreur visibles.
  • Coût — des plafonds budgétaires bloquent l'emballement.

Dépannage

Problème Cause Correctif
Oscillation de l'échelle Seuils trop proches de la charge courante Ajoutez de l'hystérésis : montée à 50, descente à 10
Les nouveaux workers n'aident pas L'API CaptchaAI est le goulot (threads saturés) Vérifiez votre nombre de threads et les limites de débit ; ajustez la concurrence par worker
Workers inactifs après montée File vidée avant que les workers soient prêts Réduisez le cooldown ; montez par petits incréments
Flambée des coûts Aucun plafond max_workers Fixez toujours max_workers et un budget horaire

Questions fréquentes

Combien de threads CaptchaAI faut-il pour mes workers ?

Autant que de résolutions que vous voulez traiter en parallèle. Si chaque worker garde en moyenne deux résolutions en vol et que vous visez 10 workers, il vous faut environ 20 threads — soit le plan STANDARD ($30/mois, 15 threads) ou ADVANCE ($90/mois, 50 threads). Au-delà de vos threads, ajouter des workers n'accélère plus rien.

La mise à l'échelle horizontale réduit-elle le temps de résolution d'un CAPTCHA ?

Non. Le temps de résolution d'un CAPTCHA dépend de l'API et du type de défi, pas du nombre de workers. Le scaling horizontal augmente le débit (plus de résolutions en parallèle), pas la vitesse unitaire. Pour réduire la latence par tâche, regardez plutôt le type de CAPTCHA et votre proximité réseau.

Comment éviter l'oscillation de l'auto-scaler ?

Utilisez deux seuils distincts (hystérésis) : montez à 50 tâches en file, descendez seulement sous 10. Ajoutez un cooldown de quelques minutes entre deux ajustements et montez par petits incréments. C'est exactement ce que font les seuils scaleUpThreshold et scaleDownThreshold du scaler Docker ci-dessus.

La mise à l'échelle horizontale augmente-t-elle ma facture CaptchaAI ?

Non directement. CaptchaAI facture les threads, pas les workers ni les résolutions. Ajouter des workers augmente votre coût d'infrastructure (machines), mais votre facture CaptchaAI ne bouge que si vous changez de plan pour obtenir plus de threads.

Pour aller plus loin

Adaptez votre résolution CAPTCHA à n'importe quel débit — récupérez votre clé API CaptchaAI et déployez un auto-scaling horizontal aligné sur vos threads.

Guides associés :

Les commentaires sont désactivés pour cet article.