DevOps & Scaling

Mises à jour progressives d'une flotte de workers CAPTCHA

Une flotte de workers qui résout des CAPTCHA n'a pas de fenêtre de maintenance : au moment où vous déployez, des tâches sont déjà parties vers in.php et attendent leur résultat. La mise à jour progressive traite donc les workers un par un — couper l'arrivée de nouvelles tâches, laisser finir celles qui sont en vol, déployer, vérifier, puis passer au suivant. Si le contrôle échoue sur le premier worker, vous revenez en arrière avant que la moitié de la flotte ne tourne dans une version inconnue.

Quelle stratégie de déploiement pour une flotte de workers ?

Choisissez d'abord la stratégie adaptée à la taille de votre flotte et à votre tolérance au risque.

Stratégie Interruption Vitesse de rollback Complexité Cas d'usage typique
Rolling (progressive) Aucune Modérée Faible La majorité des déploiements
Blue-Green Aucune Immédiate Moyenne Services critiques
Canary Aucune Rapide Élevée Grandes flottes (50 workers et plus)
Recreate Brève Sans objet Minimale Environnements de développement

La mise à jour progressive est le compromis par défaut : pas d'infrastructure à doubler, et la flotte continue de servir le trafic. Quand un rollback doit être instantané, le déploiement blue-green reste plus adapté, au prix d'une capacité dupliquée.

Les trois règles à respecter pendant l'opération

Drainer avant d'arrêter

Un worker en plein polling détient un captcha_id et interroge res.php toutes les cinq secondes. Tuer le process n'annule pas la tâche : elle continue d'occuper un thread côté API, et le résultat est perdu. Drainer, c'est refuser toute nouvelle tâche puis attendre que le compteur de tâches actives retombe à zéro.

Vérifier avant de continuer

Un health check qui répond « le process est démarré » ne prouve rien. Faites résoudre un vrai défi CAPTCHA par le worker fraîchement déployé, ou validez au minimum la clé API et le solde.

Revenir en arrière sans état mixte

Le pire scénario n'est pas l'échec du déploiement, c'est le rollback partiel : trois workers en 1.3.0, cinq en 1.2.0, et personne au courant. Gardez la liste des workers déjà migrés et restaurez-les tous.

Le déroulé, worker par worker

Workers: [W1-old] [W2-old] [W3-old] [W4-old]

Step 1:  [W1-drain] [W2-old]  [W3-old]  [W4-old]
Step 2:  [W1-NEW✓]  [W2-old]  [W3-old]  [W4-old]
Step 3:  [W1-NEW✓]  [W2-drain] [W3-old]  [W4-old]
Step 4:  [W1-NEW✓]  [W2-NEW✓]  [W3-old]  [W4-old]
  ...until all updated

Orchestrateur Python : drain, déploiement, health check

Chaque worker porte un état explicite (RUNNING, DRAINING, UPDATING, STOPPED) : le routeur n'envoie des tâches qu'aux workers réellement disponibles.

import os
import time
import signal
import threading
import requests
from dataclasses import dataclass, field
from enum import Enum

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


class WorkerState(Enum):
    RUNNING = "running"
    DRAINING = "draining"
    STOPPED = "stopped"
    UPDATING = "updating"


@dataclass
class Worker:
    worker_id: str
    version: str
    state: WorkerState = WorkerState.RUNNING
    active_tasks: int = 0
    tasks_completed: int = 0
    session: requests.Session = field(default_factory=requests.Session)

    def solve(self, task):
        if self.state != WorkerState.RUNNING:
            return {"error": "WORKER_NOT_ACCEPTING"}

        self.active_tasks += 1
        try:
            result = self._do_solve(task)
            self.tasks_completed += 1
            return result
        finally:
            self.active_tasks -= 1

    def _do_solve(self, task):
        resp = self.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 = self.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"}

    def drain(self, timeout=120):
        """Stop accepting tasks and wait for active tasks to complete."""
        self.state = WorkerState.DRAINING
        start = time.time()
        while self.active_tasks > 0:
            if time.time() - start > timeout:
                print(f"Worker {self.worker_id}: drain timeout with "
                      f"{self.active_tasks} tasks remaining")
                break
            time.sleep(1)
        self.state = WorkerState.STOPPED

    @property
    def is_healthy(self):
        return self.state == WorkerState.RUNNING


class RollingUpdateOrchestrator:
    def __init__(self, workers):
        self.workers = {w.worker_id: w for w in workers}
        self.lock = threading.Lock()

    def get_available_worker(self):
        """Route tasks only to RUNNING workers."""
        with self.lock:
            for worker in self.workers.values():
                if worker.state == WorkerState.RUNNING:
                    return worker
        return None

    def rolling_update(self, new_version, health_check_fn=None,
                       max_unavailable=1, drain_timeout=120):
        """Update workers one at a time with health gates."""
        worker_ids = list(self.workers.keys())
        updated = []
        failed = []

        for i in range(0, len(worker_ids), max_unavailable):
            batch = worker_ids[i:i + max_unavailable]

            for wid in batch:
                worker = self.workers[wid]
                print(f"[{wid}] Draining (v{worker.version})...")

                # Step 1: Drain active tasks
                worker.drain(timeout=drain_timeout)

                # Step 2: "Deploy" new version
                print(f"[{wid}] Deploying v{new_version}...")
                worker.state = WorkerState.UPDATING
                worker.version = new_version
                time.sleep(2)  # Simulate deployment

                # Step 3: Start and health check
                worker.state = WorkerState.RUNNING
                if health_check_fn:
                    healthy = health_check_fn(worker)
                    if not healthy:
                        print(f"[{wid}] Health check FAILED — rolling back")
                        failed.append(wid)
                        self._rollback(updated)
                        return {
                            "status": "rolled_back",
                            "failed_at": wid,
                            "updated": updated,
                        }

                updated.append(wid)
                print(f"[{wid}] Updated to v{new_version} ✓")

        return {"status": "complete", "updated": updated, "failed": failed}

    def _rollback(self, updated_ids):
        """Roll back already-updated workers."""
        for wid in updated_ids:
            worker = self.workers[wid]
            print(f"[{wid}] Rolling back...")
            worker.state = WorkerState.STOPPED
            time.sleep(1)
            worker.version = "rollback"
            worker.state = WorkerState.RUNNING

    @property
    def status(self):
        return {
            wid: {
                "version": w.version,
                "state": w.state.value,
                "active_tasks": w.active_tasks,
            }
            for wid, w in self.workers.items()
        }


# Create fleet
workers = [Worker(f"w{i}", "1.2.0") for i in range(6)]
orchestrator = RollingUpdateOrchestrator(workers)


def health_check(worker):
    """Verify worker can solve a test CAPTCHA."""
    # In production, send a real test task
    return worker.state == WorkerState.RUNNING


# Execute rolling update
result = orchestrator.rolling_update(
    new_version="1.3.0",
    health_check_fn=health_check,
    max_unavailable=1,
    drain_timeout=60
)
print(f"Rolling update result: {result}")

Trois paramètres pilotent tout : max_unavailable, drain_timeout et health_check_fn. En production, remplacez le health check factice par une résolution de test réelle — c'est le seul contrôle qui détecte une clé API mal injectée.

Node.js : suivre l'avancement et savoir s'arrêter

La version Node.js ajoute ce qui manque aux scripts maison : un compteur d'avancement et un seuil d'abandon. Au-delà de 25 % de workers en échec, l'orchestrateur arrête les frais.

const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;

class RollingUpdater {
  constructor(workerCount, currentVersion) {
    this.workers = Array.from({ length: workerCount }, (_, i) => ({
      id: `worker-${i}`,
      version: currentVersion,
      state: "running",
      activeTasks: 0,
    }));
    this.progress = { total: workerCount, completed: 0, failed: 0 };
  }

  async update(newVersion, options = {}) {
    const {
      maxUnavailable = 1,
      drainTimeout = 60000,
      healthCheckRetries = 3,
    } = options;

    console.log(
      `Starting rolling update: v${this.workers[0].version} → v${newVersion}`
    );

    for (let i = 0; i < this.workers.length; i += maxUnavailable) {
      const batch = this.workers.slice(i, i + maxUnavailable);

      for (const worker of batch) {
        try {
          // Drain
          console.log(`[${worker.id}] Draining...`);
          worker.state = "draining";
          await this.waitForDrain(worker, drainTimeout);

          // Deploy
          console.log(`[${worker.id}] Deploying v${newVersion}...`);
          worker.state = "updating";
          worker.version = newVersion;

          // Health check
          worker.state = "running";
          const healthy = await this.healthCheck(worker, healthCheckRetries);

          if (!healthy) {
            worker.state = "failed";
            this.progress.failed++;
            console.log(`[${worker.id}] FAILED health check`);

            if (this.progress.failed > Math.floor(this.workers.length * 0.25)) {
              console.log("Too many failures — aborting rolling update");
              return { status: "aborted", progress: this.progress };
            }
            continue;
          }

          this.progress.completed++;
          console.log(
            `[${worker.id}] Updated ✓ (${this.progress.completed}/${this.progress.total})`
          );
        } catch (err) {
          console.error(`[${worker.id}] Error: ${err.message}`);
          this.progress.failed++;
        }
      }
    }

    return { status: "complete", progress: this.progress };
  }

  async waitForDrain(worker, timeout) {
    const start = Date.now();
    while (worker.activeTasks > 0 && Date.now() - start < timeout) {
      await new Promise((r) => setTimeout(r, 1000));
    }
  }

  async healthCheck(worker, retries) {
    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        const resp = await axios.get("https://ocr.captchaai.com/res.php", {
          params: { key: API_KEY, action: "getbalance", json: 1 },
          timeout: 10000,
        });
        if (resp.data.status === 1) return true;
      } catch {
        // Retry
      }
      await new Promise((r) => setTimeout(r, 5000));
    }
    return false;
  }
}

// Execute
const updater = new RollingUpdater(8, "1.2.0");
updater
  .update("1.3.0", { maxUnavailable: 2, drainTimeout: 30000 })
  .then((result) => console.log("Result:", JSON.stringify(result, null, 2)));

Le health check interroge ici res.php avec action=getbalance : un contrôle d'identifiants et de connectivité, pas de résolution. Gardez-le comme premier filtre, et ajoutez une résolution réelle pour les workers critiques.

Régler drain_timeout et maxUnavailable selon vos types de CAPTCHA

Le délai de drain doit couvrir la tâche la plus lente en cours, plus une marge. Les plafonds publiés par type donnent la base ; les temps observés varient selon l'environnement et le volume.

Type de CAPTCHA Plafond de résolution drain_timeout conseillé
reCAPTCHA v3 < 4 s 30 s
Cloudflare Turnstile < 10 s 30 s
GeeTest v3 < 12 s 45 s
Cloudflare Challenge < 15 s 45 s
reCAPTCHA v2 < 60 s 120 s

Pour maxUnavailable, la règle est capacitaire : sous 10 workers, un seul à la fois ; au-delà, 10 à 25 % de la flotte, jamais plus de la moitié.

Autre point souvent mal compris : les threads CaptchaAI sont un budget de concurrence au niveau du compte, pas une ressource attachée à un worker. Avec BASIC ($15/mois, 5 threads), six workers se disputent déjà les mêmes threads. Avec ADVANCE ($90/mois, 50 threads), la flotte redevient le facteur limitant.

Exemple : huit workers de QA e-commerce chez un hébergeur européen

Une équipe QA valide des parcours de paiement avec huit workers sur des instances OVHcloud à Gravelines, plus deux machines de secours chez Scaleway. Le déploiement est planifié le mardi matin, avant la montée de trafic de midi.

Le réglage retenu : maxUnavailable = 2, drain_timeout = 120 parce que la file contient encore du reCAPTCHA v2, et un health check qui résout un défi de test en préproduction. La flotte tombe alors à six workers actifs pendant trois minutes par lot.

Côté conformité, gardez les logs de déploiement limités aux identifiants de worker, aux versions et aux durées : aucune donnée personnelle des parcours testés ne doit y transiter, ce qui évite d'élargir le périmètre de données que vous avez à documenter au titre du RGPD.

Dépannage

Problème Cause probable Correctif
Tâches perdues pendant la mise à jour Drain plus court que la résolution la plus lente Alignez drain_timeout sur le type le plus lent
Health check toujours en échec Régression, ou clé API absente du nouvel environnement Rollback, puis rejeu en préproduction
Déploiement interminable maxUnavailable trop bas pour la flotte 2 ou 3 workers par lot au-delà de vingt machines
Versions mélangées Rollback partiel, sans suivi des workers migrés Restaurez tous les workers déjà migrés
Le drain n'atteint jamais zéro Compteur non décrémenté en cas d'exception Décrémentez dans un bloc finally

FAQ

Une mise à jour progressive consomme-t-elle des threads supplémentaires ?

Non. Les threads correspondent aux résolutions en cours, pas aux machines : retirer un worker réduit votre concurrence côté flotte, jamais votre allocation côté API.

Que devient une tâche déjà envoyée à in.php quand le worker s'arrête ?

Elle continue d'être traitée. Si vous persistez le captcha_id dans un stockage partagé avant l'arrêt, un autre worker reprend le polling sur res.php et récupère le token. Sinon, le résultat est perdu.

Comment fixer le délai de drain sans bloquer le déploiement ?

Partez du plafond du type le plus lent, ajoutez 50 % de marge, et journalisez chaque drain qui atteint le timeout. Validez la valeur en préproduction avant de la pousser en production.

Les types en bêta changent-ils quelque chose à la procédure ?

La mécanique est identique, mais restez prudent : CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta) n'ont pas de temps de résolution publiés. Mesurez les vôtres avant d'en déduire un drain_timeout.

Prochaines étapes

Mettez à jour votre flotte sans coupure : récupérez votre clé API CaptchaAI et faites un premier passage sur deux machines de test.

Guides associés :

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