DevOps & Scaling

CaptchaAI derrière un équilibreur de charge : modèles d'architecture

Pour passer de quelques centaines à plusieurs milliers de résolutions CAPTCHA par heure, la réponse tient en une phrase : placez plusieurs workers derrière un équilibreur de charge et laissez-le répartir le trafic. Un worker unique sature vite : il traite les tâches en série et une panne interrompt tout le pipeline.

Un équilibreur augmente le débit, assure le basculement et vous laisse monter en capacité horizontalement. Ce guide couvre le routage NGINX, le serveur API du worker en Python et Node.js, et l'équilibrage côté client.

Vue d'ensemble de l'architecture

[Scraper 1] ──┐                      ┌── [Worker 1] ──→ CaptchaAI API
[Scraper 2] ──┤── [Load Balancer] ──┤── [Worker 2] ──→ CaptchaAI API
[Scraper 3] ──┘                      └── [Worker 3] ──→ CaptchaAI API

Chaque worker expose la même API interne (/solve, /health) et relaie les tâches vers CaptchaAI. Côté facturation, le total des tâches simultanées de vos workers ne doit pas dépasser les threads de votre plan : un plan PREMIUM ($170/mois, 100 threads) couvre cinq workers plafonnés à 20 tâches.

Configurer NGINX comme équilibreur

NGINX est le point d'entrée le plus courant. Trois stratégies de répartition couvrent la quasi-totalité des cas.

Round-robin, la valeur par défaut

Sans directive particulière, NGINX distribue les requêtes à tour de rôle. Suffisant quand tous vos workers ont la même capacité.

upstream captcha_workers {
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
}

server {
    listen 80;
    server_name captcha.internal;

    location /solve {
        proxy_pass http://captcha_workers;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_connect_timeout 10s;
        proxy_read_timeout 300s;  # CAPTCHA solving can take minutes
    }

    location /health {
        proxy_pass http://captcha_workers;
        proxy_connect_timeout 5s;
        proxy_read_timeout 5s;
    }
}

least_conn, recommandé pour la résolution de CAPTCHA

La stratégie à privilégier ici : least_conn route chaque requête vers le worker le moins chargé, idéal quand la durée des tâches est imprévisible. On ajoute un poids pour les workers puissants et des contrôles de santé passifs.

upstream captcha_workers {
    least_conn;  # Route to worker with fewest active connections
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080 weight=2;  # Higher capacity worker

    # Health checks
    server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
}

Ajouter des workers de secours

Un worker marqué backup ne reçoit du trafic que lorsque les autres sont indisponibles — une capacité de réserve froide, non sollicitée en temps normal.

upstream captcha_workers {
    least_conn;
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080 backup;  # Only used when others are down
}

Le serveur API du worker

Chaque worker est un service HTTP qui plafonne sa concurrence et relaie la tâche vers CaptchaAI. Point clé : /health doit refléter la charge réelle, pas seulement le fait que le processus tourne — un worker à pleine capacité renvoie un 503 pour que l'équilibreur le retire de la rotation.

Python avec Flask

import os
import time
import threading
import requests
from flask import Flask, request, jsonify

API_KEY = os.environ["CAPTCHAAI_API_KEY"]
app = Flask(__name__)

# Track active tasks for load reporting
active_tasks = 0
tasks_lock = threading.Lock()
max_concurrent = int(os.environ.get("MAX_CONCURRENT", "20"))


@app.route("/solve", methods=["POST"])
def solve():
    global active_tasks
    with tasks_lock:
        if active_tasks >= max_concurrent:
            return jsonify({"error": "WORKER_AT_CAPACITY"}), 503
        active_tasks += 1

    try:
        data = request.json
        result = solve_captcha(data)
        return jsonify(result)
    finally:
        with tasks_lock:
            active_tasks -= 1


@app.route("/health")
def health():
    with tasks_lock:
        load = active_tasks / max_concurrent
    return jsonify({
        "status": "healthy" if load < 0.9 else "overloaded",
        "active_tasks": active_tasks,
        "max_concurrent": max_concurrent,
        "load_pct": round(load * 100, 1)
    }), 200 if load < 0.9 else 503


def solve_captcha(data):
    session = requests.Session()
    payload = {
        "key": API_KEY,
        "method": data.get("method", "userrecaptcha"),
        "googlekey": data.get("sitekey"),
        "pageurl": data.get("pageurl"),
        "json": 1
    }

    if data.get("proxy"):
        payload["proxy"] = data["proxy"]
        payload["proxytype"] = data.get("proxytype", "HTTP")

    resp = session.post("https://ocr.captchaai.com/in.php", data=payload)
    result = resp.json()
    if result.get("status") != 1:
        return {"error": result.get("request")}

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

    return {"error": "TIMEOUT"}


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, threaded=True)

JavaScript avec Express

Même logique côté Node.js : concurrence suivie, 503 à saturation, et polling de res.php jusqu'au token.

const express = require("express");
const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;
const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT || "20", 10);
const PORT = parseInt(process.env.PORT || "8080", 10);

let activeTasks = 0;
const app = express();
app.use(express.json());

app.post("/solve", async (req, res) => {
  if (activeTasks >= MAX_CONCURRENT) {
    return res.status(503).json({ error: "WORKER_AT_CAPACITY" });
  }
  activeTasks++;

  try {
    const result = await solveCaptcha(req.body);
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: err.message });
  } finally {
    activeTasks--;
  }
});

app.get("/health", (req, res) => {
  const load = activeTasks / MAX_CONCURRENT;
  const status = load < 0.9 ? "healthy" : "overloaded";
  res
    .status(load < 0.9 ? 200 : 503)
    .json({ status, activeTasks, maxConcurrent: MAX_CONCURRENT, loadPct: Math.round(load * 100) });
});

async function solveCaptcha(data) {
  const submitResp = await axios.post("https://ocr.captchaai.com/in.php", null, {
    params: {
      key: API_KEY,
      method: data.method || "userrecaptcha",
      googlekey: data.sitekey,
      pageurl: data.pageurl,
      json: 1,
    },
  });

  if (submitResp.data.status !== 1) {
    return { error: submitResp.data.request };
  }

  const captchaId = submitResp.data.request;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const pollResp = await axios.get("https://ocr.captchaai.com/res.php", {
      params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
    });

    if (pollResp.data.status === 1) {
      return { solution: pollResp.data.request, captchaId };
    }
    if (pollResp.data.request !== "CAPCHA_NOT_READY") {
      return { error: pollResp.data.request };
    }
  }
  return { error: "TIMEOUT" };
}

app.listen(PORT, () => console.log(`Worker listening on port ${PORT}`));

Comparer les stratégies de routage

Stratégie Fonctionnement Cas d'usage
Round-robin Rotation séquentielle Workers de capacité égale
least_conn Vers le worker le moins chargé Résolution de CAPTCHA (durée de tâche variable)
Pondérée (weight) Proportionnelle au poids attribué Workers de capacité mixte
Hachage IP Même client, même worker Affinité de session requise
Aléatoire Sélection au hasard Charge simple et bien répartie

Recommandation : retenez least_conn. Les durées de tâche s'étalent de 5 à 120 s ; le round-robin enverrait des requêtes à des workers déjà bloqués sur une tâche longue, d'où une charge déséquilibrée.

Équilibrage de charge côté client

Sans équilibreur externe — par exemple un worker OVHcloud ou Scaleway par région — implémentez le routage dans le client, qui reproduit least_conn : on choisit le worker le moins occupé et on bascule en cas d'échec.

import random
import requests

class ClientLoadBalancer:
    def __init__(self, workers):
        self.workers = [
            {"url": url, "healthy": True, "active": 0}
            for url in workers
        ]

    def get_worker(self):
        healthy = [w for w in self.workers if w["healthy"]]
        if not healthy:
            raise Exception("No healthy workers")
        return min(healthy, key=lambda w: w["active"])

    def solve(self, task):
        worker = self.get_worker()
        worker["active"] += 1
        try:
            resp = requests.post(
                f"{worker['url']}/solve",
                json=task,
                timeout=300
            )
            if resp.status_code == 503:
                worker["healthy"] = False
                return self.solve(task)  # Retry on another worker
            return resp.json()
        except requests.RequestException:
            worker["healthy"] = False
            return self.solve(task)
        finally:
            worker["active"] -= 1


lb = ClientLoadBalancer([
    "http://10.0.1.10:8080",
    "http://10.0.1.11:8080",
    "http://10.0.1.12:8080"
])
result = lb.solve({"sitekey": "6Le-wvkS...", "pageurl": "https://example.com"})

Ce schéma convient au multi-régions — un worker par région, eu-west-3 (Paris) pour l'Europe. Si vous journalisez les IP pour la supervision, pensez à vos obligations RGPD.

Dépannage

Problème Cause Correctif
502 Bad Gateway Le worker a planté ou n'a pas démarré Consultez les logs du worker ; vérifiez la liaison du port
Charge mal répartie Round-robin avec des durées de tâche variables Passez à least_conn
Contrôle de santé faux positif Le check passe mais le worker est à pleine capacité Renvoyez le pourcentage de charge dans la réponse /health
Délai de connexion dépassé proxy_read_timeout trop court Réglez-le à 300 s ou plus pour la résolution de CAPTCHA

FAQ

Quelle stratégie de routage privilégier pour la résolution de CAPTCHA ?

least_conn. Comme la durée d'une résolution varie fortement, c'est la seule stratégie qui tient compte de la charge réelle de chaque worker au lieu de router à l'aveugle.

Pourquoi faut-il régler proxy_read_timeout à 300 s ?

Parce qu'une résolution prend plusieurs dizaines de secondes : le worker interroge res.php en boucle jusqu'au token. Avec la valeur par défaut de NGINX, l'équilibreur couperait la connexion avant la réponse et provoquerait des timeouts côté client.

Faut-il activer des sessions persistantes (sticky sessions) ?

Non. Les requêtes de résolution sont sans état : n'importe quel worker traite n'importe quelle tâche. Des sessions persistantes forceraient un client toujours vers le même worker et recréeraient le déséquilibre de charge que l'on cherche à éviter.

Articles connexes

Prochaines étapes

Montez en débit : récupérez votre clé API CaptchaAI et déployez vos workers derrière un équilibreur de charge.

Guides associés :

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